diff --git a/.env b/.env index 5ac52c4..ccfd09d 100644 --- a/.env +++ b/.env @@ -1,15 +1,15 @@ PORT = 3002 HTTPS = TRUE -TYPEORM_CONNECTION = sqlite -TYPEORM_HOST = localhost -TYPEORM_USERNAME = root -TYPEORM_PASSWORD = admin -TYPEORM_DATABASE = database/lol-stalker.db -TYPEORM_PORT = 3000 -TYPEORM_SYNCHRONIZE = false -TYPEORM_ENTITIES = **/entities/*.js -TYPEORM_MIGRATIONS_DIR = migration -TYPEORM_MIGRATIONS = migration/*.js +#TYPEORM_CONNECTION = sqlite +#TYPEORM_HOST = localhost +#TYPEORM_USERNAME = root +#TYPEORM_PASSWORD = admin +#TYPEORM_DATABASE = $Env:APPDATA/LoL-stalker/database/lol-stalker.dev.db +#TYPEORM_PORT = 3000 +#TYPEORM_SYNCHRONIZE = false +#TYPEORM_ENTITIES = **/entities/*.js +#TYPEORM_MIGRATIONS_DIR = electron/migration +#TYPEORM_MIGRATIONS = migration/*.js -TYPEORM_LOGGING = false \ No newline at end of file +#TYPEORM_LOGGING = false \ No newline at end of file diff --git a/electron/config.ts b/electron/config.ts deleted file mode 100644 index 41d9356..0000000 --- a/electron/config.ts +++ /dev/null @@ -1,35 +0,0 @@ -import fs from "fs/promises"; -import path from "path"; - -export const config: { current: Record | null } = { current: null }; - -const initialConfig = { - windowsNotifications: true, - dirname: __dirname, - defaultLossMessage: - "\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}", -}; -const configFilePath = path.join(__dirname, "config.json"); -export const loadConfig = async () => { - try { - const configArr = JSON.parse(await fs.readFile(configFilePath, "utf-8")); - config.current = configArr; - } catch (e) { - console.log("no config file found, creating it..."); - config.current = { ...initialConfig }; - await persistConfig(); - } finally { - return config.current; - } -}; - -export const editConfig = async (callback: () => void) => { - callback(); - persistConfig(); -}; - -export const persistConfig = () => - config.current && - //@ts-ignore - (console.log(config.current) || - fs.writeFile(configFilePath, JSON.stringify(config.current, null, 4))); diff --git a/electron/db.ts b/electron/db.ts index 16870e6..2ee3998 100644 --- a/electron/db.ts +++ b/electron/db.ts @@ -1,16 +1,36 @@ import isDev from "electron-is-dev"; import path from "path"; +import fs from "fs/promises"; import sqlite3 from "sqlite3"; import { createConnection } from "typeorm"; -const dbUrl = path.join(__dirname, "database", "lol-stalker.db"); -// export const db = new sqlite3.Database(dbUrl); +import { app } from "electron"; +const dbFolder = path.join(app.getPath("userData"), "database"); +const dbUrl = path.join(dbFolder, isDev ? "lol-stalker.dev.db" : "lol-stalker.db"); -export const makeDb = () => - isDev - ? createConnection() - : createConnection({ - type: "sqlite", - database: dbUrl, - entities: [path.join(__dirname, "entities/*")], - }); +export const makeDb = async () => { + try { + await fs.stat(dbFolder); + } catch (e) { + await fs.mkdir(dbFolder); + } + try { + await fs.stat(dbUrl); + } catch (e) { + await createDbFile(); + console.log(`db file ${dbUrl} does not exist, creating it`); + } + + return createConnection({ + type: "sqlite", + database: dbUrl, + entities: [path.join(__dirname, "entities/*")], + migrationsRun: true, + migrations: [path.join(__dirname, "migration/*")], + }); +}; + +const createDbFile = () => + new Promise((resolve, reject) => { + new sqlite3.Database(dbUrl, (err) => (err ? reject(err) : resolve(true))); + }); diff --git a/electron/entities/Friend.ts b/electron/entities/Friend.ts index 7db6009..ea9e610 100644 --- a/electron/entities/Friend.ts +++ b/electron/entities/Friend.ts @@ -35,6 +35,9 @@ export class Friend { @Column("datetime", { name: "createdAt", default: () => "CURRENT_TIMESTAMP" }) createdAt: Date; + @Column("text", { name: "subscription", nullable: true }) + subscription: string; + @Column("boolean", { name: "isCurrentSummoner", default: () => "false" }) isCurrentSummoner: boolean; diff --git a/electron/features/autoLaunch.ts b/electron/features/autoLaunch.ts new file mode 100644 index 0000000..eb0939c --- /dev/null +++ b/electron/features/autoLaunch.ts @@ -0,0 +1,39 @@ +import AutoLaunch from "auto-launch"; +import electronIsDev from "electron-is-dev"; +import { editStoreEntry, store } from "./store"; + +export const initAutoLauch = async () => { + const autoLaunch = new AutoLaunch({ name: "LoL Stalker" }); + await editStoreEntry("autoLaunch", autoLaunch); + + if (electronIsDev) return console.log("AutoLaunch doesn't work in development"); + const isEnabled = await autoLaunch.isEnabled(); + + if (store.config.autoLaunch && !isEnabled) autoLaunch.enable(); + else if (isEnabled) autoLaunch.disable(); +}; + +export const enableAutoLaunch = async () => { + console.log("enabling autolaunch..."); + if (electronIsDev) return console.log("AutoLaunch doesn't work in development"); + const autoLaunch = store.autoLaunch; + if (!autoLaunch) return; + + const isEnabled = await autoLaunch.isEnabled(); + + if (isEnabled) return; + autoLaunch.enable(); + console.log("autolaunch enabled"); +}; + +export const disableAutoLauch = async () => { + console.log("disabling autolaunch..."); + const autoLaunch = store.autoLaunch; + if (!autoLaunch) return; + + const isEnabled = await autoLaunch.isEnabled(); + + if (!isEnabled) return; + autoLaunch.disable(); + console.log("autolaunch disabled"); +}; diff --git a/electron/LCU/lcu.ts b/electron/features/lcu/lcu.ts similarity index 84% rename from electron/LCU/lcu.ts rename to electron/features/lcu/lcu.ts index 8ccc3a4..14de3e7 100644 --- a/electron/LCU/lcu.ts +++ b/electron/features/lcu/lcu.ts @@ -2,36 +2,37 @@ import { pick } from "@pastable/core"; import axios, { AxiosInstance } from "axios"; import https from "https"; import LCUConnector from "lcu-connector"; -import { Friend } from "../entities/Friend"; -import { sendInvalidate } from "../routes"; -import { addOrUpdateFriends, getSelectedFriends } from "../routes/friends"; -import { selectedFriends } from "../selection"; -import { sendToClient, Tier } from "../utils"; +import { Friend } from "../../entities/Friend"; +import { sendToClient, Tier } from "../../utils"; import { CurrentSummoner, FriendDto, MatchDto, Queue, RankedStats } from "./types"; +import { editStoreEntry, Locale, store } from "../store"; +import { addOrUpdateFriends } from "../routes/friends"; const httpsAgent = new https.Agent({ rejectUnauthorized: false }); export const connector = new LCUConnector(); -export const connectorStatus = { - current: null as any, - api: null as unknown as AxiosInstance, -}; -export const sendConnectorStatus = () => sendToClient("lcu/connection", connectorStatus.current); +export const sendConnectorStatus = () => sendToClient("lcu/connection", store.connectorStatus); connector.on("connect", async (data) => { - connectorStatus.current = data; + editStoreEntry("connectorStatus", data); const { protocol, username, password, address, port } = data; const baseURL = `${protocol}://${username}:${password}@${address}:${port}`; - connectorStatus.api = axios.create({ + store.lcu = axios.create({ baseURL, httpsAgent, headers: { Authorization: `Basic ${data.password}` }, }); console.log("connected to riot client"); + + try { + const locale = await getRegionLocale(); + await editStoreEntry("locale", locale); + } catch (e) { + console.log(e); + } }); connector.on("disconnect", () => { - connectorStatus.current = null; - sendInvalidate("lcuStatus"); + editStoreEntry("connectorStatus", null); }); export interface AuthData { @@ -57,13 +58,14 @@ export const compareFriends = async (oldFriends: FriendStats[], newFriends: Frie if ( newFriend.division !== oldFriend.division || newFriend.tier !== oldFriend.tier || - newFriend.leaguePoints !== oldFriend.leaguePoints + newFriend.leaguePoints !== oldFriend.leaguePoints || + newFriend.miniSeriesProgress !== oldFriend.miniSeriesProgress ) { changes.push({ ...newFriend, oldFriend, toNotify: !!oldFriend.division, - windowsNotification: selectedFriends.current?.has(newFriend.puuid), + windowsNotification: store.selectedFriends?.has(newFriend.puuid), }); } }); @@ -85,7 +87,7 @@ export const postMessage = (payload: { summonerName: string; message: string }) const url = `/lol-game-client-chat/v1/instant-messages?summonerName=${encodeURI( payload.summonerName )}&message=${encodeURI(payload.message)}`; - return connectorStatus.api.post(url); + return store.lcu?.post(url); }; export const getAllApexLeague = async () => { @@ -103,6 +105,7 @@ export const getApexLeague = (tier: RankedStats["queues"][0]["tier"]) => ///lol-ranked-stats/v1/stats/{summonerId} export const getHelp = () => request("/help?format=Console"); export const getBuild = () => request("/system/v1/builds"); +export const getRegionLocale = () => request("/riotclient/region-locale"); export const getCurrentSummoner = () => request("/lol-summoner/v1/current-summoner"); export const getFriends = () => request("/lol-chat/v1/friends"); @@ -153,4 +156,4 @@ export const getSwagger = () => request("/swagger/v2/swagger.json"); type AxiosMethod = "get" | "post" | "put" | "delete" | "patch"; export const request = async (uri: string, method: AxiosMethod = "get") => - (await connectorStatus.api[method](uri)).data as T; + (await store.lcu?.[method](uri))?.data as T; diff --git a/electron/LCU/types.ts b/electron/features/lcu/types.ts similarity index 99% rename from electron/LCU/types.ts rename to electron/features/lcu/types.ts index a49ec1d..f7613f9 100644 --- a/electron/LCU/types.ts +++ b/electron/features/lcu/types.ts @@ -1,4 +1,4 @@ -import { Friend } from "../entities/Friend"; +import { Friend } from "../../entities/Friend"; export interface FriendDto extends Friend { availability: string; diff --git a/electron/routes/friends.ts b/electron/features/routes/friends.ts similarity index 73% rename from electron/routes/friends.ts rename to electron/features/routes/friends.ts index 4b36850..5b76768 100644 --- a/electron/routes/friends.ts +++ b/electron/features/routes/friends.ts @@ -2,12 +2,11 @@ import { pick } from "@pastable/core"; import debug from "debug"; import { getManager } from "typeorm"; import { sendFriendList, sendInvalidate } from "."; -import { Friend } from "../entities/Friend"; -import { FriendName } from "../entities/FriendName"; -import { Ranking } from "../entities/Ranking"; -import { FriendDto } from "../LCU/types"; -import { editSelectedFriends, persistSelectedFriends, selectedFriends } from "../selection"; -import { sendToClient } from "../utils"; +import { Friend } from "../../entities/Friend"; +import { FriendName } from "../../entities/FriendName"; +import { Ranking } from "../../entities/Ranking"; +import { FriendDto } from "../lcu/types"; +import { editStoreEntry, store } from "../store"; const friendFields: (keyof FriendDto)[] = [ "gameName", @@ -44,10 +43,17 @@ export const getFriendsAndRankingsFromDb = () => { }; export const getFriendAndRankingsFromDb = (puuid: Friend["puuid"]) => - getManager().findOne(Friend, { - where: { puuid }, - relations: ["rankings", "friendNames", "notifications"], - }); + getManager() + .createQueryBuilder(Friend, "friend") + .leftJoinAndSelect("friend.rankings", "rankings") + .leftJoinAndSelect("friend.friendNames", "friendNames") + .leftJoinAndSelect("friend.notifications", "notifications") + .orderBy("rankings.createdAt", "DESC") + .orderBy("notifications.createdAt", "DESC") + .orderBy("friendNames.createdAt", "DESC") + .where("friend.puuid = :puuid", { puuid }) + .getOne(); + export const getFriendsAndLastRankingFromDb = async () => { const friends = await getFriendsAndRankingsFromDb(); return friends.map((friend) => { @@ -62,18 +68,19 @@ export const getFriendsAndLastRankingFromDb = async () => { }); }; -export const getSelectedFriends = async () => Array.from(selectedFriends.current!); -export const toggleSelectFriends = async (puuids: Friend["puuid"][], newState: boolean) => - editSelectedFriends(() => - puuids.forEach((puuid) => selectedFriends.current?.[newState ? "add" : "delete"](puuid)) - ); +export const toggleSelectFriends = async (puuids: Friend["puuid"][], newState: boolean) => { + const newSelectedFriends = new Set(store.selectedFriends); + + puuids.forEach((puuid) => newSelectedFriends?.[newState ? "add" : "delete"](puuid)); + await editStoreEntry("selectedFriends", newSelectedFriends); +}; + export const selectAllFriends = async (select: boolean) => { const friends = await getFriendsFromDb(); - return editSelectedFriends(() => - friends.forEach((friend) => - selectedFriends.current?.[select ? "add" : "delete"](friend.puuid) - ) - ); + const newSelectedFriends = new Set(store.selectedFriends); + + friends.forEach((friend) => newSelectedFriends?.[select ? "add" : "delete"](friend.puuid)); + await editStoreEntry("selectedFriends", newSelectedFriends); }; const friendDtoToFriend = (friendDto: FriendDto): Partial => ({ @@ -91,10 +98,6 @@ const friendDtoToFriend = (friendDto: FriendDto): Partial => ({ ]), }); -export const inGameFriends: { current: any[] } = { - current: null as any, -}; - export const addOrUpdateFriends = async (friends: FriendDto[]) => { const existingFriends = await getFriendsFromDb(); const manager = getManager(); @@ -103,13 +106,15 @@ export const addOrUpdateFriends = async (friends: FriendDto[]) => { for (const friend of friends) { if ( - friend.lol.gameStatus !== "outOfGame" && + !["outOfGame", "hosting_RANKED_SOLO_5x5", "spectating"].includes( + friend.lol.gameStatus + ) && friend.lol.gameQueueType === "RANKED_SOLO_5x5" ) { currentInGame.push({ - ...pick(friend, ["puuid", "gameName", "icon"]), + ...pick(friend, ["puuid", "gameName", "icon", "name"]), ...pick(friend.lol, ["championId", "timeStamp", "gameStatus"]), - }); // championId: friend.lol.championId}); + }); } const friendDto = pick(friend, friendFields); const existingFriend = existingFriends.find((ef) => ef.puuid === friend.puuid); @@ -138,7 +143,8 @@ export const addOrUpdateFriends = async (friends: FriendDto[]) => { await manager.save(manager.create(Friend, friendDtoToFriend(friendDto))); } } - inGameFriends.current = [...currentInGame]; + + await editStoreEntry("inGameFriends", [...currentInGame]); sendInvalidate("friendList/in-game"); sendFriendList(); debug("add or update ended"); @@ -159,7 +165,6 @@ export const friendsApi = { getFriendsAndRankingsFromDb, getFriendAndRankingsFromDb, getFriendsAndLastRankingFromDb, - getSelectedFriends, toggleSelectFriends, addOrUpdateFriends, addRanking, diff --git a/electron/routes/index.ts b/electron/features/routes/index.ts similarity index 82% rename from electron/routes/index.ts rename to electron/features/routes/index.ts index c1b9cef..19abaec 100644 --- a/electron/routes/index.ts +++ b/electron/features/routes/index.ts @@ -1,18 +1,16 @@ -import { config } from "../config"; -import { Friend } from "../entities/Friend"; -import { getAllApexLeague, getMatchHistoryBySummonerPuuid, postMessage } from "../LCU/lcu"; -import { sendToClient } from "../utils"; +import { Friend } from "../../entities/Friend"; +import { sendToClient } from "../../utils"; +import { getAllApexLeague, getMatchHistoryBySummonerPuuid, postMessage } from "../lcu/lcu"; +import { sendStoreEntry, store } from "../store"; import { getFriendAndRankingsFromDb, getFriendsAndLastRankingFromDb, getFriendsAndRankingsFromDb, - getSelectedFriends, selectAllFriends, toggleSelectFriends, } from "./friends"; import { getCursoredNotifications, - getFriendNotifications, getNbNewNotifications, NotificationFilters, setNotificationIsNew, @@ -49,14 +47,9 @@ export const sendNbNewNotifications = async ( sendToClient("notifications/nb-new", nb); }; -export const sendSelected = async () => { - const selected = await getSelectedFriends(); - sendToClient("friendList/selected", selected); -}; - export const sendSelectAllFriends = async (_: any, select: boolean) => { await selectAllFriends(select); - sendSelected(); + sendStoreEntry("selectedFriends"); }; export const sendMatches = async (_: any, puuid: Friend["puuid"]) => { @@ -76,7 +69,7 @@ export const receiveToggleSelectFriends = async ( const payload = Array.isArray(puuids) ? puuids : [puuids]; await toggleSelectFriends(payload, type === "add"); - sendSelected(); + sendStoreEntry("selectedFriends"); }; export const sendApex = async () => { @@ -85,7 +78,7 @@ export const sendApex = async () => { }; export const sendInstantMessage = async (_: any, { summonerName }: { summonerName: string }) => { - if (!config.current) return; - await postMessage({ summonerName, message: config.current.defaultLossMessage }); + if (!store.config) return; + await postMessage({ summonerName, message: store.config.defaultLossMessage }); sendToClient("friendList/message", "ok"); }; diff --git a/electron/features/routes/internal.ts b/electron/features/routes/internal.ts new file mode 100644 index 0000000..7b69e8e --- /dev/null +++ b/electron/features/routes/internal.ts @@ -0,0 +1,109 @@ +import { app, ipcMain, IpcMainEvent, shell } from "electron"; +import { + receiveToggleSelectFriends, + sendApex, + sendCursoredNotifications, + sendFriendList, + sendFriendListWithRankings, + sendFriendRank, + sendInstantMessage, + sendMatches, + sendNbNewNotifications, + sendSelectAllFriends, +} from "."; +import { sendToClient, getDbPath } from "../../utils"; +import { disableAutoLauch, enableAutoLaunch } from "../autoLaunch"; +import { getCurrentSummoner, sendConnectorStatus } from "../lcu/lcu"; +import { + DiscordUrls, + editStoreEntry, + emptyCache, + sendStore, + sendStoreEntry, + Store, + store, +} from "../store"; +import { makeSocketClient, sendWs } from "../ws/discord"; + +const getMe = async () => sendToClient("me", await getCurrentSummoner()); +const setConfig: InternalCallback = async (_, data) => { + const newConfig = { ...store.config }; + Object.entries(data).forEach(([key, val]) => (newConfig[key] = val)); + if (data["autoLaunch"] !== undefined) { + if (data["autoLaunch"]) await enableAutoLaunch(); + else await disableAutoLauch(); + } + + await editStoreEntry("config", newConfig); +}; +const setStore: InternalCallback = async (_, payload) => { + for (const [key, value] of Object.entries(payload)) { + await editStoreEntry(key as keyof Store, value as any); + } +}; +const passThrough = + (event: string, formatter?: (data: any) => any): InternalCallback => + (_, data) => + //@ts-ignore + console.log("sending ws", event) || sendWs(event, formatter?.(data) || data); + +const getDiscordUrls = async () => { + sendWs("discordUrls"); + store.backendSocket?.once("discordUrls", (data: DiscordUrls) => + editStoreEntry("discordUrls", data) + ); +}; + +const dlDb = () => { + const url = getDbPath(); + shell.showItemInFolder(url); + sendToClient("config/dl-db", "ok"); +}; + +const openExternal: InternalCallback = (_, url: string) => { + shell.openExternal(url); + sendToClient("config/open-external", "ok"); +}; + +const injectAccessToken = (obj?: any) => ({ + ...(obj || {}), + accessToken: store.discordAuth?.access_token, +}); + +type InternalCallback = (event: IpcMainEvent, data: any) => any; +const internalCallbacks: Record = { + "friendList/lastRank": sendFriendList, + "friendList/friend": sendFriendRank, + "friendList/ranks": sendFriendListWithRankings, + "friendList/select": receiveToggleSelectFriends, + "friendList/select-all": sendSelectAllFriends, + "friendList/selected": () => sendStoreEntry("selectedFriends"), + "friendList/in-game": () => sendToClient("friendList/in-game", store.inGameFriends), + "friendList/message": sendInstantMessage, + "notifications/all": sendCursoredNotifications, + "notifications/nb-new": sendNbNewNotifications, + "friend/matches": sendMatches, + "config/apex": sendApex, + "ws/reconnect": () => !store.backendSocket && makeSocketClient(), + config: () => sendToClient("config", store.config), + me: getMe, + "store/set": setStore, + "config/set": setConfig, + "discord/guilds": () => sendWs("guilds", { accessToken: store.discordAuth?.access_token }), + "discord/remove-friends": passThrough("removeSummoners", injectAccessToken), + "discord/add-friends": passThrough("addSummoners", injectAccessToken), + ws: (_, data) => passThrough(data.event, data.data)(_, data), + "config/discord-urls": getDiscordUrls, + "config/dl-db": dlDb, + "config/open-external": openExternal, + store: sendStore, +}; + +export const registerInternalRoutes = () => { + Object.entries(internalCallbacks).forEach(([route, cb]) => ipcMain.on(route, cb)); +}; + +ipcMain.on("close", () => { + window.close(); + app.exit(0); +}); diff --git a/electron/routes/notifications.ts b/electron/features/routes/notifications.ts similarity index 90% rename from electron/routes/notifications.ts rename to electron/features/routes/notifications.ts index b38f1ce..bca7c17 100644 --- a/electron/routes/notifications.ts +++ b/electron/features/routes/notifications.ts @@ -1,8 +1,8 @@ import { last } from "@pastable/core"; import { getManager, In, LessThan, MoreThan, SelectQueryBuilder } from "typeorm"; -import { Friend } from "../entities/Friend"; -import { Notification } from "../entities/Notification"; -import { selectedFriends } from "../selection"; +import { Friend } from "../../entities/Friend"; +import { Notification } from "../../entities/Notification"; +import { store } from "../store"; export const addNotification = (data: Partial) => getManager().save(getManager().create(Notification, data)); @@ -36,9 +36,9 @@ const applyFilters = (query: SelectQueryBuilder, filters: Notifica whereClauses.push("notification.id > :currentMaxId"); payload.currentMaxId = filters.currentMaxId; } - if (filters.selected && selectedFriends.current) { + if (filters.selected && store.selectedFriends) { whereClauses.push("friend.puuid IN (:...puuids)"); - payload.puuids = Array.from(selectedFriends.current?.values()); + payload.puuids = Array.from(store.selectedFriends?.values()); } if (filters.types?.length) { whereClauses.push("notification.type IN (:...types)"); diff --git a/electron/features/store.ts b/electron/features/store.ts new file mode 100644 index 0000000..57fac92 --- /dev/null +++ b/electron/features/store.ts @@ -0,0 +1,234 @@ +import fs from "fs/promises"; +import { AxiosInstance } from "axios"; +import { sendToClient } from "../utils"; +import path from "path"; +import electronIsDev from "electron-is-dev"; +import { CurrentSummoner } from "./lcu/types"; +import { app } from "electron"; +import AutoLaunch from "auto-launch"; +import { WebSocket } from "ws"; + +export const initialConfig = { + windowsNotifications: true, + dirname: path.join(__dirname, ".."), + defaultLossMessage: + "\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}\u{1F602}", +}; + +export interface ConnectorStatus { + address: string; + port: number; + username: string; + password: string; + protocol: string; +} +export interface DiscordAuth { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + token_type: string; +} +export type SocketStatus = + | "initial" + | "connecting" + | "connected" + | "error" + | "closed" + | "can't reach server"; +export interface DiscordUrls { + inviteUrl: string; + authUrl: string; +} + +export interface Locale { + locale: string; + region: string; + webLanguage: string; + webRegion: string; +} + +export interface Me { + id: string; + username: string; + avatar: string; + discriminator: string; + public_flags: number; + flags: number; + banner?: any; + banner_color?: any; + accent_color?: any; + locale: string; + mfa_enabled: boolean; + premium_type: number; +} +export interface Store { + config: Record; + selectedFriends: Set | null; + connectorStatus: null | ConnectorStatus; + lcu: null | AxiosInstance; + inGameFriends: null | any[]; + backendSocket: null | WebSocket; + userGuilds: null | string[]; + discordAuth: null | DiscordAuth; + friends: null | any[]; + socketStatus: SocketStatus; + discordUrls: null | DiscordUrls; + leagueSummoner: null | CurrentSummoner; + me: Me | null; + autoLaunch: AutoLaunch | null; + locale: Locale | null; +} + +interface StoreConfig { + persist?: boolean; + notifyOnChange?: boolean; + formatter?: (data: any) => any; + formatOnLoad?: (data: any) => any; +} +const initialStore: Store = { + config: initialConfig, + selectedFriends: null, + connectorStatus: null, + lcu: null, + inGameFriends: null, + backendSocket: null, + userGuilds: null, + discordAuth: null, + friends: null, + socketStatus: "initial", + discordUrls: null, + leagueSummoner: null, + me: null, + autoLaunch: null, + locale: null, +}; +const resetStore = () => + Object.entries(initialStore).forEach(([key, value]) => (store[key as keyof Store] = value)); +export const store: Store = { ...initialStore }; + +const storeConfig: Partial> = { + config: { + persist: true, + notifyOnChange: true, + }, + discordAuth: { + notifyOnChange: true, + persist: true, + }, + discordUrls: { + notifyOnChange: true, + }, + selectedFriends: { + persist: true, + notifyOnChange: true, + formatter: (data: Set) => data && Array.from(data), + formatOnLoad: (data: string[]) => data && new Set(data), + }, + inGameFriends: { + notifyOnChange: true, + }, + connectorStatus: { + notifyOnChange: true, + }, + socketStatus: { + notifyOnChange: true, + }, + me: { + notifyOnChange: true, + persist: true, + }, + leagueSummoner: { + notifyOnChange: true, + }, + locale: { + notifyOnChange: true, + persist: true, + }, +}; + +export const editStoreEntry = async ( + entryName: Entry, + value: Store[Entry] +) => { + const config = storeConfig[entryName]; + store[entryName] = value; + + const payload = config?.formatter?.(value) || value; + + if (config?.persist) { + await fs.writeFile(getJsonPath(entryName), JSON.stringify(payload, null, 4)); + } + + if (config?.notifyOnChange) { + sendStoreEntry(entryName, payload); + } +}; + +export const sendStoreEntry = (entryName: keyof Store, formattedValue?: any) => { + const payload = formattedValue || getValue(entryName); + sendToClient("store/update", { [entryName]: payload }); +}; + +export const getValue = (entryName: keyof Store) => + storeConfig[entryName]?.formatter?.(store[entryName]) || store[entryName]; + +export const loadStore = async () => { + resetStore(); + try { + await fs.stat(jsonFolderPath); + await fs.readdir(jsonFolderPath); + } catch (e) { + console.log(e); + await fs.mkdir(jsonFolderPath); + } + + const persisted = Object.entries(storeConfig) + .filter(([_, config]) => config.persist) + .map(([entryName]) => entryName as keyof Store); + for (const entryName of persisted) { + try { + const stored = JSON.parse(await fs.readFile(getJsonPath(entryName), "utf-8")); + if (stored) { + store[entryName] = storeConfig[entryName]?.formatOnLoad?.(stored) || stored; + } + } catch (e) { + console.log("Couldn't load ", entryName + ".json"); + console.error(e); + } + } +}; + +export const emptyCache = async () => { + const files = await fs.readdir(jsonFolderPath); + for (const file of files) { + try { + await fs.rm(file); + console.log("deleted", file); + } catch (e) { + console.log(e); + } + } + + await loadStore(); +}; + +const jsonFolderPath = path.join(app.getPath("userData"), "jsons"); +const getJsonPath = (name: string) => + path.join(jsonFolderPath, (electronIsDev ? "dev." : "") + name + ".json"); + +export const sendStore = () => { + const notified = Object.entries(storeConfig) + .filter(([_, config]) => config.notifyOnChange) + .map(([entryName]) => entryName as keyof Store); + + const payload = notified.reduce( + (acc, entryName) => ({ + ...acc, + [entryName]: storeConfig[entryName]?.formatter?.(store[entryName]) || store[entryName], + }), + {} + ); + + sendToClient("store", payload); +}; diff --git a/electron/features/ws/discord.ts b/electron/features/ws/discord.ts new file mode 100644 index 0000000..3b191ce --- /dev/null +++ b/electron/features/ws/discord.ts @@ -0,0 +1,107 @@ +import DiscordOauth2 from "discord-oauth2"; +import WebSocket from "ws"; +import { focusWindow } from "../.."; +import { sendToClient, wsUrl } from "../../utils"; +import { sendInvalidate } from "../routes"; +import { DiscordAuth, editStoreEntry, store } from "../store"; +export const makeSocketClient = async () => { + try { + if (store.backendSocket?.readyState === WebSocket.OPEN) { + return console.log("A ws connection alreay exists"); + } + + const params = { + ...(store.config?.socketId ? { id: store.config?.socketId } : {}), + ...(store.discordAuth ? store.discordAuth : {}), + }; + const search = new URLSearchParams( + Object.entries(params).reduce( + (acc, [key, value]) => ({ ...acc, ...(!!value ? { [key]: value } : {}) }), + {} + ) + ); + + let timeout = null as any as NodeJS.Timer; + const socket = new WebSocket(wsUrl + "?" + search.toString(), {}); + await editStoreEntry("socketStatus", "connecting"); + await editStoreEntry("backendSocket", socket); + + socket.onopen = async () => { + console.log("Connection opened"); + await editStoreEntry("socketStatus", "connected"); + + timeout = setInterval(() => { + socket.send(JSON.stringify({ event: "ping", data: null })); + }, 10000); + }; + socket.onerror = async (error) => { + console.error("WebSocket error"); + await editStoreEntry("socketStatus", "error"); + }; + + //@ts-ignore + socket.onmessage = (event) => onMessage({ event }); + socket.onclose = async () => { + console.log("WebSocket close"); + await editStoreEntry("socketStatus", "closed"); + console.log("Retrying in 3s..."); + clearTimeout(timeout); + setTimeout(() => makeSocketClient(), 3000); + }; + } catch (error) { + console.error(error); + } +}; + +async function onMessage({ event: msgEvent }: { event: MessageEvent }) { + const length = + msgEvent.data instanceof ArrayBuffer ? msgEvent.data.byteLength : msgEvent.data.length; + // Most likely a "pong" response from our "ping" message + if (!length) return; + + const message = await decode(msgEvent.data); + // Invalid message + if (!message) return; + + // console.log(message, typeof message); + const { event, data } = message; + console.log(event, data); + try { + makeCallback[event]?.(data); + } catch (e) { + console.log(e); + } +} +const decoder = new TextDecoder(); +export const decode = async (payload: ArrayBuffer | string): Promise => { + try { + const data = payload instanceof ArrayBuffer ? decoder.decode(payload) : payload; + return JSON.parse(data); + } catch (err) { + return null as any; + } +}; + +export const oauth = new DiscordOauth2(); +const makeCallback: Record void> = { + id: async (data: string) => { + await editStoreEntry("config", { ...store.config, socketId: data }); + }, + auth: async (data: DiscordAuth) => { + sendWs("guilds", { accessToken: data.access_token }); + focusWindow(); + await editStoreEntry("discordAuth", data); + }, + me: async (data) => { + await editStoreEntry("me", data); + }, + summoners: async (data) => console.log(data), + invalidateGuilds: () => sendInvalidate("guilds"), + discordUrls: async (data) => editStoreEntry("discordUrls", data), + invalidToken: () => editStoreEntry("discordAuth", null), + rateLimit: () => sendToClient("errorToast", "Rate limit error, try again later"), + error: async (data) => sendToClient("error", data), +}; + +export const sendWs = (event: string, data?: any) => + store.backendSocket?.send(JSON.stringify({ event, data })); diff --git a/electron/features/ws/wsUtils.ts b/electron/features/ws/wsUtils.ts new file mode 100644 index 0000000..503e686 --- /dev/null +++ b/electron/features/ws/wsUtils.ts @@ -0,0 +1,43 @@ +import { wsUrl } from "../../utils"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export const encode = (payload: Payload) => encoder.encode(JSON.stringify(payload)); + +// export const WS = isServer() ? require("ws") : WebSocket; + +export const serialize = (data: Record) => { + let payload: Record = {}; + for (const key in data) { + if (typeof data[key] === "object") { + payload[key] = JSON.stringify(data[key]); + } else { + payload[key] = data[key]; + } + } + + return payload; +}; + +export const getQueryString = (data: Record) => + new URLSearchParams(serialize(data)).toString(); + +export const getWebsocketURL = () => wsUrl; + +// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState +export enum SocketReadyState { + CONNECTING, + OPEN, + CLOSING, + CLOSED, +} + +export enum WsEvent { + Connecting = "_connecting_", + Open = "open", + Close = "close", + Error = "error", + Any = "_msg_", + Reconnected = "_reconnected_", +} diff --git a/electron/index.ts b/electron/index.ts index cc61593..8d71c60 100644 --- a/electron/index.ts +++ b/electron/index.ts @@ -1,35 +1,24 @@ import dotenv from "dotenv"; +import { app, BrowserWindow, dialog } from "electron"; dotenv.config(); -import { app, BrowserWindow, ipcMain, shell } from "electron"; import isDev from "electron-is-dev"; -import path, { join } from "path"; -import { config, loadConfig, persistConfig } from "./config"; +import path from "path"; import { makeDb } from "./db"; import { startCheckCurrentSummonerRank } from "./jobs/currentSummonerRank"; import { startCheckFriendListJob } from "./jobs/friendListJob"; -import { connector, sendConnectorStatus } from "./LCU/lcu"; -import { - receiveToggleSelectFriends, - sendApex, - sendCursoredNotifications, - sendFriendList, - sendFriendListWithRankings, - sendFriendRank, - sendInstantMessage, - sendMatches, - sendNbNewNotifications, - sendSelectAllFriends, - sendSelected, -} from "./routes"; -import { inGameFriends } from "./routes/friends"; -import { loadSelectedFriends } from "./selection"; -import { sendToClient } from "./utils"; +import { connector } from "./features/lcu/lcu"; +import { registerInternalRoutes } from "./features/routes/internal"; +import { makeSocketClient } from "./features/ws/discord"; +import { loadStore } from "./features/store"; +import { startUpdateApex } from "./jobs/updateApex"; +import { initAutoLauch } from "./features/autoLaunch"; const height = 600; const width = 1200; const baseBounds = { height, width }; let window: BrowserWindow; + export function makeWindow() { // Create the browser window. window = new BrowserWindow({ @@ -38,90 +27,81 @@ export function makeWindow() { show: true, resizable: true, autoHideMenuBar: true, + icon: path.join(__dirname, "../public/icon.ico"), fullscreenable: true, webPreferences: { - preload: join(__dirname, "preload.js"), + preload: path.join(__dirname, "preload.js"), webSecurity: false, allowRunningInsecureContent: true, nodeIntegration: true, }, }); const port = process.env.PORT || 3001; - const url = isDev ? `https://localhost:${port}` : join(__dirname, "../src/out/index.html"); + const url = isDev + ? `https://localhost:${port}` + : path.join(__dirname, "../../src/out/index.html"); // window.webContents.openDevTools(); - isDev ? window?.loadURL(url) : window?.loadFile(url); - + console.log(__dirname); return window; } -app.whenReady().then(async () => { - await makeDb(); - connector.start(); - await loadSelectedFriends(); - await loadConfig(); - makeWindow(); - startCheckFriendListJob(); - startCheckCurrentSummonerRank(); - app.on("activate", function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - if (BrowserWindow.getAllWindows().length === 0) makeWindow(); - }); - app.on("window-all-closed", () => { - app.quit(); - process.exit(0); - }); -}); -app.setAppUserModelId("LoL Stalker"); +const gotTheLock = app.requestSingleInstanceLock(); -ipcMain.on("lcu/connection", () => { - sendConnectorStatus(); -}); -ipcMain.on("friendList/lastRank", sendFriendList); -ipcMain.on("friendList/friend", sendFriendRank); -ipcMain.on("friendList/ranks", sendFriendListWithRankings); -ipcMain.on("friendList/select", receiveToggleSelectFriends); -ipcMain.on("friendList/select-all", sendSelectAllFriends); -ipcMain.on("friendList/selected", () => sendSelected()); -ipcMain.on("friendList/in-game", () => sendToClient("friendList/in-game", inGameFriends.current)); -ipcMain.on("friendList/message", sendInstantMessage); +export const focusWindow = () => { + window?.show(); + window?.focus(); +}; -ipcMain.on("notifications/all", sendCursoredNotifications); -ipcMain.on("notifications/nb-new", sendNbNewNotifications); -ipcMain.on("friend/matches", sendMatches); +if (!gotTheLock && !isDev) { + app.quit(); +} else { + if (!isDev) + app.on("second-instance", () => { + // Someone tried to run a second instance, we should focus our window. + if (window) { + if (window.isMinimized()) window.restore(); + window.focus(); + } + }); + // Create window, load the rest of the app, etc... + app.whenReady().then(async () => { + await loadStore(); + await initAutoLauch(); + await makeDb(); + connector.start(); + registerInternalRoutes(); + await makeSocketClient(); + makeWindow(); -ipcMain.on("config/apex", sendApex); + startCheckFriendListJob(); + startCheckCurrentSummonerRank(); + startUpdateApex(); -ipcMain.on("config", () => sendToClient("config", config.current)); -ipcMain.on("config/set", async (_, data) => { - Object.entries(data).forEach(([key, val]) => (config.current![key] = val)); - sendToClient("config/set", "ok"); - sendToClient("invalidate", "config"); - await persistConfig(); -}); - -ipcMain.on("config/dl-db", () => { - const url = path.join( - __dirname, - isDev ? "../database/lol-stalker.db" : "./database/lol-stalker.db" - ); - shell.showItemInFolder(url); - sendToClient("config/dl-db", "ok"); -}); + app.on("activate", function () { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (BrowserWindow.getAllWindows().length === 0) makeWindow(); + }); + app.on("window-all-closed", () => { + app.quit(); + process.exit(0); + }); + app.on("open-url", (_, url) => { + dialog.showErrorBox("Welcome Back", `You arrived from: ${url}`); + }); + }); -ipcMain.on("config/open-external", (_, url: string) => { - shell.openExternal(url); - sendToClient("config/open-external", "ok"); -}); + // Handle the protocol. In this case, we choose to show an Error Box. + app.on("open-url", (_, url) => { + dialog.showErrorBox("Welcome Back", `You arrived from: ${url}`); + }); -ipcMain.on("close", () => { - window.close(); - app.exit(0); -}); + app.setAppUserModelId("LoL Stalker"); -app.on("window-all-closed", function () { - if (process.platform !== "darwin") app.quit(); -}); + app.on("window-all-closed", function () { + if (process.platform !== "darwin") app.quit(); + }); -app.commandLine.appendSwitch("disable-site-isolation-trials"); -app.commandLine.appendSwitch("ignore-certificate-errors"); + app.commandLine.appendSwitch("disable-site-isolation-trials"); + app.commandLine.appendSwitch("ignore-certificate-errors"); +} diff --git a/electron/jobs/currentSummonerRank.ts b/electron/jobs/currentSummonerRank.ts index 54f5ae1..3af6636 100644 --- a/electron/jobs/currentSummonerRank.ts +++ b/electron/jobs/currentSummonerRank.ts @@ -1,55 +1,83 @@ import { pick } from "@pastable/core"; -import { getCurrentSummoner, getSoloQRankedStats } from "../LCU/lcu"; +import { getManager } from "typeorm"; +import { Friend } from "../entities/Friend"; +import { Ranking } from "../entities/Ranking"; +import { getCurrentSummoner, getSoloQRankedStats } from "../features/lcu/lcu"; +import { editStoreEntry, store } from "../features/store"; +import { sendWs } from "../features/ws/discord"; +import { getRankDifference } from "../utils"; export const startCheckCurrentSummonerRank = async () => { - // try { - // const currentSummonerFromLCU = await getCurrentSummoner(); - // const currentSummoner: Prisma.FriendCreateInput = { - // puuid: currentSummonerFromLCU.puuid, - // summonerId: currentSummonerFromLCU.summonerId, - // gameName: currentSummonerFromLCU.displayName, - // name: currentSummonerFromLCU.displayName, - // icon: currentSummonerFromLCU.profileIconId, - // isCurrentSummoner: true, - // }; - // const currentSummonerInDb = await prisma.friend.findUnique({ - // where: { puuid: currentSummoner.puuid }, - // }); - // if (!currentSummonerInDb) { - // console.log("Creating new current summoner in db"); - // await prisma.friend.create({ data: currentSummoner }); - // } - // const summonerRank = await getSoloQRankedStats(currentSummoner.puuid); - // if (!summonerRank) throw `Couldn't find last rank for summoner ${currentSummoner.name}`; - // const lastRankFromDb = await prisma.ranking.findFirst({ - // where: { puuid: currentSummoner.puuid }, - // orderBy: { createdAt: "desc" }, - // }); - // if ( - // !lastRankFromDb || - // lastRankFromDb.tier === summonerRank.tier || - // lastRankFromDb.division === summonerRank.division || - // lastRankFromDb.leaguePoints === summonerRank.leaguePoints - // ) { - // console.log("Last rank from db is different than the new one, inserting new rank..."); - // await prisma.ranking.create({ - // data: { - // ...pick(summonerRank, [ - // "division", - // "tier", - // "leaguePoints", - // "wins", - // "losses", - // "miniSeriesProgress", - // ]), - // puuid: currentSummoner.puuid, - // }, - // }); - // console.log("done!"); - // } else console.log("No change in current summoner rank"); - // setTimeout(() => startCheckCurrentSummonerRank(), 1000 * 60 * 15); - // } catch (e) { - // console.log("something went wrong, retrying in 5s"); - // setTimeout(() => startCheckCurrentSummonerRank(), 5000); - // } + try { + console.log("starting check current summoner"); + const currentSummonerFromLCU = await getCurrentSummoner(); + await editStoreEntry("leagueSummoner", currentSummonerFromLCU); + const summonerRank = await getSoloQRankedStats(currentSummonerFromLCU.puuid); + + if (!summonerRank) throw "no summoner rank found"; + const manager = getManager(); + const summonerInDb = await manager.findOne(Friend, { + where: { puuid: currentSummonerFromLCU.puuid }, + relations: ["rankings"], + }); + // return summonerInDb; + if (!summonerInDb) { + const friend = manager.create(Friend, { + puuid: currentSummonerFromLCU.puuid, + isCurrentSummoner: true, + name: currentSummonerFromLCU.displayName, + gameName: currentSummonerFromLCU.displayName, + icon: currentSummonerFromLCU.profileIconId, + summonerId: currentSummonerFromLCU.summonerId, + }); + await manager.save(friend); + } + const lastRanking = summonerInDb?.rankings.sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + )[0]; + const friend = new Friend(); + friend.puuid = currentSummonerFromLCU.puuid; + if ( + !lastRanking || + lastRanking.division !== summonerRank.division || + lastRanking.tier !== summonerRank.tier || + lastRanking.leaguePoints !== summonerRank.leaguePoints || + lastRanking.miniSeriesProgress !== summonerRank?.miniSeriesProgress + ) { + console.log("saving new ranking"); + const ranking = manager.create(Ranking, { + ...pick(summonerRank!, [ + "division", + "leaguePoints", + "tier", + "miniSeriesProgress", + "wins", + "losses", + ]), + friend, + }); + await manager.save(ranking); + if (lastRanking) { + const payload = { + region: store.locale?.region, + puuid: currentSummonerFromLCU.puuid, + name: currentSummonerFromLCU.displayName, + fromTier: lastRanking.tier, + fromDivision: lastRanking.division, + fromLeaguePoints: lastRanking.leaguePoints, + fromMiniSeriesProgress: lastRanking.miniSeriesProgress, + toTier: ranking.tier, + toDivision: ranking.division, + toLeaguePoints: ranking.leaguePoints, + toMiniSeriesProgress: ranking.miniSeriesProgress, + }; + sendWs("update", payload); + } + } + + setTimeout(() => startCheckCurrentSummonerRank(), 1000 * 60); + } catch (e) { + console.log("something went wrong, retrying in 5s", e); + setTimeout(() => startCheckCurrentSummonerRank(), 5000); + } }; diff --git a/electron/jobs/friendListJob.ts b/electron/jobs/friendListJob.ts index 478fbe0..09b56a3 100644 --- a/electron/jobs/friendListJob.ts +++ b/electron/jobs/friendListJob.ts @@ -1,29 +1,27 @@ import { Notification } from "electron"; -import { config } from "../config"; import { Friend } from "../entities/Friend"; -import { checkFriendList, compareFriends, connectorStatus } from "../LCU/lcu"; -import { addRanking, getFriendsAndLastRankingFromDb } from "../routes/friends"; -import { addNotification } from "../routes/notifications"; -import { getRankDifference, sendToClient } from "../utils"; - -export const friendsRef = { - current: null as any, -}; +import { checkFriendList, compareFriends, getAllApexLeague } from "../features/lcu/lcu"; +import { getRankDifference, sendToClient, Tier } from "../utils"; +import { getFriendsAndLastRankingFromDb, addRanking } from "../features/routes/friends"; +import { addNotification } from "../features/routes/notifications"; +import { sendWs } from "../features/ws/discord"; +import { editStoreEntry, store } from "../features/store"; +import { sendInvalidate } from "../features/routes"; export const startCheckFriendListJob = async () => { try { - if (!connectorStatus.current) throw "not connected to LCU"; + if (!store.connectorStatus) throw "not connected to LCU"; console.log("start checking friendlist"); - friendsRef.current = await getFriendsAndLastRankingFromDb(); - - if (!friendsRef.current?.length) { + await editStoreEntry("friends", await getFriendsAndLastRankingFromDb()); + if (!store.friends?.length) { const friendListStats = await checkFriendList(); - friendsRef.current = friendListStats; + await editStoreEntry("friends", friendListStats); } while (true) { const friendListStats = await checkFriendList(); - const changes = await compareFriends(friendsRef.current, friendListStats); + const changes = await compareFriends(store.friends!, friendListStats); + const apexFromLCU = await getAllApexLeague(); if (changes.length) { console.log( `${changes.length} change${changes.length > 1 ? "s" : ""} found in friendList` @@ -36,10 +34,31 @@ export const startCheckFriendListJob = async () => { change.oldFriend as any, change as any ); + + const apex = + apexFromLCU[change.oldFriend.tier as Tier] || + apexFromLCU[change.tier as Tier]; + + const payload = { + region: store.locale?.region, + fromDivision: change.oldFriend.division, + fromTier: change.oldFriend.tier, + fromLeaguePoints: change.oldFriend.leaguePoints, + fromMiniSeriesProgress: change.oldFriend.miniSeriesProgress, + toDivision: change.division, + toTier: change.tier, + toLeaguePoints: change.leaguePoints, + toMiniSeriesProgress: change.miniSeriesProgress, + puuid: change.puuid, + name: change.name, + apex, + }; + + sendWs("update", payload); + const friend = new Friend(); friend.puuid = change.puuid; - console.log(config.current); - if (config.current?.windowsNotifications && change.windowsNotification) + if (store.config?.windowsNotifications && change.windowsNotification) new Notification({ title: change.name, body: notification.content, @@ -47,12 +66,11 @@ export const startCheckFriendListJob = async () => { await addNotification({ ...notification, friend }); } } - sendToClient("invalidate", "notifications/nb-new"); + sendInvalidate("notifications/nb-new"); } else { console.log("no soloQ played by friends"); } - - friendsRef.current = friendListStats; + await editStoreEntry("friends", friendListStats); await new Promise((resolve) => setTimeout(resolve, 10000)); } diff --git a/electron/jobs/updateApex.ts b/electron/jobs/updateApex.ts new file mode 100644 index 0000000..4424d33 --- /dev/null +++ b/electron/jobs/updateApex.ts @@ -0,0 +1,16 @@ +import { getAllApexLeague } from "../features/lcu/lcu"; +import { sendWs } from "../features/ws/discord"; + +export const startUpdateApex = async () => { + try { + const apex = await getAllApexLeague(); + + sendWs("apex", apex); + + console.log("sent apex to ws backed"); + setTimeout(() => startUpdateApex(), 1000 * 60 * 15); + } catch (e) { + console.log("couldn't find apex, retrying in 5s"); + setTimeout(() => startUpdateApex(), 5000); + } +}; diff --git a/migration/1643628930993-dist.js b/electron/migration/1643628930993-dist.js similarity index 100% rename from migration/1643628930993-dist.js rename to electron/migration/1643628930993-dist.js diff --git a/electron/migration/1644156291716-dist.js b/electron/migration/1644156291716-dist.js new file mode 100644 index 0000000..eeb69ad --- /dev/null +++ b/electron/migration/1644156291716-dist.js @@ -0,0 +1,75 @@ +const { MigrationInterface, QueryRunner } = require("typeorm"); + +module.exports = class dist1644156291716 { + name = 'dist1644156291716' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "temporary_Friend" ("puuid" text PRIMARY KEY NOT NULL, "id" text, "gameName" text NOT NULL, "gameTag" text, "groupId" integer NOT NULL DEFAULT (0), "groupName" text NOT NULL DEFAULT ('NONE'), "name" text NOT NULL, "summonerId" integer NOT NULL, "icon" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "isCurrentSummoner" boolean NOT NULL DEFAULT (false), "subscription" text)`); + await queryRunner.query(`INSERT INTO "temporary_Friend"("puuid", "id", "gameName", "gameTag", "groupId", "groupName", "name", "summonerId", "icon", "createdAt", "isCurrentSummoner") SELECT "puuid", "id", "gameName", "gameTag", "groupId", "groupName", "name", "summonerId", "icon", "createdAt", "isCurrentSummoner" FROM "Friend"`); + await queryRunner.query(`DROP TABLE "Friend"`); + await queryRunner.query(`ALTER TABLE "temporary_Friend" RENAME TO "Friend"`); + await queryRunner.query(`CREATE TABLE "temporary_Ranking" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "division" text NOT NULL, "tier" text NOT NULL, "leaguePoints" integer NOT NULL, "wins" integer NOT NULL, "losses" integer NOT NULL, "miniSeriesProgress" text NOT NULL DEFAULT (''), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "puuid" text)`); + await queryRunner.query(`INSERT INTO "temporary_Ranking"("id", "division", "tier", "leaguePoints", "wins", "losses", "miniSeriesProgress", "createdAt", "puuid") SELECT "id", "division", "tier", "leaguePoints", "wins", "losses", "miniSeriesProgress", "createdAt", "puuid" FROM "Ranking"`); + await queryRunner.query(`DROP TABLE "Ranking"`); + await queryRunner.query(`ALTER TABLE "temporary_Ranking" RENAME TO "Ranking"`); + await queryRunner.query(`CREATE TABLE "temporary_FriendName" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" text NOT NULL DEFAULT (''), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "puuid" text)`); + await queryRunner.query(`INSERT INTO "temporary_FriendName"("id", "name", "createdAt", "puuid") SELECT "id", "name", "createdAt", "puuid" FROM "FriendName"`); + await queryRunner.query(`DROP TABLE "FriendName"`); + await queryRunner.query(`ALTER TABLE "temporary_FriendName" RENAME TO "FriendName"`); + await queryRunner.query(`CREATE TABLE "temporary_Notification" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" text NOT NULL DEFAULT (''), "from" text NOT NULL, "to" text NOT NULL, "content" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "isNew" boolean NOT NULL DEFAULT (true), "puuid" text)`); + await queryRunner.query(`INSERT INTO "temporary_Notification"("id", "type", "from", "to", "content", "createdAt", "isNew", "puuid") SELECT "id", "type", "from", "to", "content", "createdAt", "isNew", "puuid" FROM "Notification"`); + await queryRunner.query(`DROP TABLE "Notification"`); + await queryRunner.query(`ALTER TABLE "temporary_Notification" RENAME TO "Notification"`); + await queryRunner.query(`CREATE TABLE "temporary_Friend" ("puuid" text PRIMARY KEY NOT NULL, "id" text, "gameName" text NOT NULL, "gameTag" text, "groupId" integer NOT NULL DEFAULT (0), "groupName" text NOT NULL DEFAULT ('NONE'), "name" text NOT NULL, "summonerId" integer NOT NULL, "icon" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "isCurrentSummoner" boolean NOT NULL DEFAULT (false), "subscription" text, CONSTRAINT "UQ_e9500c8aa0065f96b5e95502506" UNIQUE ("puuid"))`); + await queryRunner.query(`INSERT INTO "temporary_Friend"("puuid", "id", "gameName", "gameTag", "groupId", "groupName", "name", "summonerId", "icon", "createdAt", "isCurrentSummoner", "subscription") SELECT "puuid", "id", "gameName", "gameTag", "groupId", "groupName", "name", "summonerId", "icon", "createdAt", "isCurrentSummoner", "subscription" FROM "Friend"`); + await queryRunner.query(`DROP TABLE "Friend"`); + await queryRunner.query(`ALTER TABLE "temporary_Friend" RENAME TO "Friend"`); + await queryRunner.query(`CREATE TABLE "temporary_Ranking" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "division" text NOT NULL, "tier" text NOT NULL, "leaguePoints" integer NOT NULL, "wins" integer NOT NULL, "losses" integer NOT NULL, "miniSeriesProgress" text NOT NULL DEFAULT (''), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "puuid" text, CONSTRAINT "FK_07dd2b585b204c4f5d9f7e458bf" FOREIGN KEY ("puuid") REFERENCES "Friend" ("puuid") ON DELETE RESTRICT ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "temporary_Ranking"("id", "division", "tier", "leaguePoints", "wins", "losses", "miniSeriesProgress", "createdAt", "puuid") SELECT "id", "division", "tier", "leaguePoints", "wins", "losses", "miniSeriesProgress", "createdAt", "puuid" FROM "Ranking"`); + await queryRunner.query(`DROP TABLE "Ranking"`); + await queryRunner.query(`ALTER TABLE "temporary_Ranking" RENAME TO "Ranking"`); + await queryRunner.query(`CREATE TABLE "temporary_FriendName" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" text NOT NULL DEFAULT (''), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "puuid" text, CONSTRAINT "FK_0a11f58ef016f91c3917d0620bb" FOREIGN KEY ("puuid") REFERENCES "Friend" ("puuid") ON DELETE RESTRICT ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "temporary_FriendName"("id", "name", "createdAt", "puuid") SELECT "id", "name", "createdAt", "puuid" FROM "FriendName"`); + await queryRunner.query(`DROP TABLE "FriendName"`); + await queryRunner.query(`ALTER TABLE "temporary_FriendName" RENAME TO "FriendName"`); + await queryRunner.query(`CREATE TABLE "temporary_Notification" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" text NOT NULL DEFAULT (''), "from" text NOT NULL, "to" text NOT NULL, "content" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "isNew" boolean NOT NULL DEFAULT (true), "puuid" text, CONSTRAINT "FK_a0f86a7dba20e7f0d9c708a65a5" FOREIGN KEY ("puuid") REFERENCES "Friend" ("puuid") ON DELETE RESTRICT ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "temporary_Notification"("id", "type", "from", "to", "content", "createdAt", "isNew", "puuid") SELECT "id", "type", "from", "to", "content", "createdAt", "isNew", "puuid" FROM "Notification"`); + await queryRunner.query(`DROP TABLE "Notification"`); + await queryRunner.query(`ALTER TABLE "temporary_Notification" RENAME TO "Notification"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "Notification" RENAME TO "temporary_Notification"`); + await queryRunner.query(`CREATE TABLE "Notification" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" text NOT NULL DEFAULT (''), "from" text NOT NULL, "to" text NOT NULL, "content" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "isNew" boolean NOT NULL DEFAULT (true), "puuid" text)`); + await queryRunner.query(`INSERT INTO "Notification"("id", "type", "from", "to", "content", "createdAt", "isNew", "puuid") SELECT "id", "type", "from", "to", "content", "createdAt", "isNew", "puuid" FROM "temporary_Notification"`); + await queryRunner.query(`DROP TABLE "temporary_Notification"`); + await queryRunner.query(`ALTER TABLE "FriendName" RENAME TO "temporary_FriendName"`); + await queryRunner.query(`CREATE TABLE "FriendName" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" text NOT NULL DEFAULT (''), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "puuid" text)`); + await queryRunner.query(`INSERT INTO "FriendName"("id", "name", "createdAt", "puuid") SELECT "id", "name", "createdAt", "puuid" FROM "temporary_FriendName"`); + await queryRunner.query(`DROP TABLE "temporary_FriendName"`); + await queryRunner.query(`ALTER TABLE "Ranking" RENAME TO "temporary_Ranking"`); + await queryRunner.query(`CREATE TABLE "Ranking" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "division" text NOT NULL, "tier" text NOT NULL, "leaguePoints" integer NOT NULL, "wins" integer NOT NULL, "losses" integer NOT NULL, "miniSeriesProgress" text NOT NULL DEFAULT (''), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "puuid" text)`); + await queryRunner.query(`INSERT INTO "Ranking"("id", "division", "tier", "leaguePoints", "wins", "losses", "miniSeriesProgress", "createdAt", "puuid") SELECT "id", "division", "tier", "leaguePoints", "wins", "losses", "miniSeriesProgress", "createdAt", "puuid" FROM "temporary_Ranking"`); + await queryRunner.query(`DROP TABLE "temporary_Ranking"`); + await queryRunner.query(`ALTER TABLE "Friend" RENAME TO "temporary_Friend"`); + await queryRunner.query(`CREATE TABLE "Friend" ("puuid" text PRIMARY KEY NOT NULL, "id" text, "gameName" text NOT NULL, "gameTag" text, "groupId" integer NOT NULL DEFAULT (0), "groupName" text NOT NULL DEFAULT ('NONE'), "name" text NOT NULL, "summonerId" integer NOT NULL, "icon" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "isCurrentSummoner" boolean NOT NULL DEFAULT (false), "subscription" text)`); + await queryRunner.query(`INSERT INTO "Friend"("puuid", "id", "gameName", "gameTag", "groupId", "groupName", "name", "summonerId", "icon", "createdAt", "isCurrentSummoner", "subscription") SELECT "puuid", "id", "gameName", "gameTag", "groupId", "groupName", "name", "summonerId", "icon", "createdAt", "isCurrentSummoner", "subscription" FROM "temporary_Friend"`); + await queryRunner.query(`DROP TABLE "temporary_Friend"`); + await queryRunner.query(`ALTER TABLE "Notification" RENAME TO "temporary_Notification"`); + await queryRunner.query(`CREATE TABLE "Notification" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "type" text NOT NULL DEFAULT (''), "from" text NOT NULL, "to" text NOT NULL, "content" text NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "isNew" boolean NOT NULL DEFAULT (true), "puuid" text, CONSTRAINT "FK_a0f86a7dba20e7f0d9c708a65a5" FOREIGN KEY ("puuid") REFERENCES "Friend" ("puuid") ON DELETE RESTRICT ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "Notification"("id", "type", "from", "to", "content", "createdAt", "isNew", "puuid") SELECT "id", "type", "from", "to", "content", "createdAt", "isNew", "puuid" FROM "temporary_Notification"`); + await queryRunner.query(`DROP TABLE "temporary_Notification"`); + await queryRunner.query(`ALTER TABLE "FriendName" RENAME TO "temporary_FriendName"`); + await queryRunner.query(`CREATE TABLE "FriendName" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" text NOT NULL DEFAULT (''), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "puuid" text, CONSTRAINT "FK_0a11f58ef016f91c3917d0620bb" FOREIGN KEY ("puuid") REFERENCES "Friend" ("puuid") ON DELETE RESTRICT ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "FriendName"("id", "name", "createdAt", "puuid") SELECT "id", "name", "createdAt", "puuid" FROM "temporary_FriendName"`); + await queryRunner.query(`DROP TABLE "temporary_FriendName"`); + await queryRunner.query(`ALTER TABLE "Ranking" RENAME TO "temporary_Ranking"`); + await queryRunner.query(`CREATE TABLE "Ranking" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "division" text NOT NULL, "tier" text NOT NULL, "leaguePoints" integer NOT NULL, "wins" integer NOT NULL, "losses" integer NOT NULL, "miniSeriesProgress" text NOT NULL DEFAULT (''), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "puuid" text, CONSTRAINT "FK_07dd2b585b204c4f5d9f7e458bf" FOREIGN KEY ("puuid") REFERENCES "Friend" ("puuid") ON DELETE RESTRICT ON UPDATE CASCADE)`); + await queryRunner.query(`INSERT INTO "Ranking"("id", "division", "tier", "leaguePoints", "wins", "losses", "miniSeriesProgress", "createdAt", "puuid") SELECT "id", "division", "tier", "leaguePoints", "wins", "losses", "miniSeriesProgress", "createdAt", "puuid" FROM "temporary_Ranking"`); + await queryRunner.query(`DROP TABLE "temporary_Ranking"`); + await queryRunner.query(`ALTER TABLE "Friend" RENAME TO "temporary_Friend"`); + await queryRunner.query(`CREATE TABLE "Friend" ("puuid" text PRIMARY KEY NOT NULL, "id" text, "gameName" text NOT NULL, "gameTag" text, "groupId" integer NOT NULL DEFAULT (0), "groupName" text NOT NULL DEFAULT ('NONE'), "name" text NOT NULL, "summonerId" integer NOT NULL, "icon" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "isCurrentSummoner" boolean NOT NULL DEFAULT (false))`); + await queryRunner.query(`INSERT INTO "Friend"("puuid", "id", "gameName", "gameTag", "groupId", "groupName", "name", "summonerId", "icon", "createdAt", "isCurrentSummoner") SELECT "puuid", "id", "gameName", "gameTag", "groupId", "groupName", "name", "summonerId", "icon", "createdAt", "isCurrentSummoner" FROM "temporary_Friend"`); + await queryRunner.query(`DROP TABLE "temporary_Friend"`); + } +} diff --git a/electron/selection.ts b/electron/selection.ts deleted file mode 100644 index 22ad504..0000000 --- a/electron/selection.ts +++ /dev/null @@ -1,31 +0,0 @@ -import fs from "fs/promises"; -import path from "path"; - -const selectedFriendsFilePath = path.join(__dirname, "selectedFriends.json"); -export const selectedFriends: { current: Set | null } = { - current: null, -}; -export const loadSelectedFriends = async () => { - try { - const selectedFriendsArr = JSON.parse(await fs.readFile(selectedFriendsFilePath, "utf-8")); - selectedFriends.current = new Set(selectedFriendsArr); - } catch (e) { - console.log("no selected friends file found, creating it..."); - selectedFriends.current = new Set(); - await persistSelectedFriends(); - } finally { - return selectedFriends.current; - } -}; - -export const editSelectedFriends = async (callback: () => void) => { - callback(); - persistSelectedFriends(); -}; - -export const persistSelectedFriends = () => - selectedFriends.current && - fs.writeFile( - selectedFriendsFilePath, - JSON.stringify(Array.from(selectedFriends.current), null, 4) - ); diff --git a/electron/tsconfig.json b/electron/tsconfig.json index 77d7f44..f272e10 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -22,5 +22,12 @@ "outDir": "../main" }, "exclude": ["node_modules"], - "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.prisma", "prismaClient/*"] + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.js", + "**/*.prisma", + "prismaClient/*", + "../ormconfig.js" + ] } diff --git a/electron/utils.ts b/electron/utils.ts index 6d57f86..1c383e7 100644 --- a/electron/utils.ts +++ b/electron/utils.ts @@ -1,17 +1,41 @@ import debug from "debug"; import { BrowserWindow } from "electron"; +import electronIsDev from "electron-is-dev"; +import path from "path"; import { Ranking } from "./entities/Ranking"; +const domain = electronIsDev ? "localhost:8080/" : "stalker.back.chainbreak.dev/"; +export const baseURL = (electronIsDev ? "http://" : "https://") + domain; +export const wsUrl = "ws://" + domain + "ws"; + export const sendToClient = (channel: string, ...args: any[]) => console.log(channel)! || BrowserWindow.getAllWindows()?.[0]?.webContents.send(channel, ...args); export const makeDataDragonUrl = (buildVersion: string) => `https://ddragon.leagueoflegends.com/cdn/dragontail-${buildVersion}.tgz`; -export const formatRank = (ranking: Pick) => - `${ranking.tier}${ranking.division !== "NA" ? ` ${ranking.division}` : ""} - ${ +export const formatRank = ( + ranking: Pick & { miniSeriesProgress?: string } +) => { + const isPromo = !!ranking.miniSeriesProgress && ranking.miniSeriesProgress !== "NNNNN"; + + return `${ranking.tier}${ranking.division !== "NA" ? ` ${ranking.division}` : ""} - ${ ranking.leaguePoints - } LPs`; + } LPs${isPromo ? " " + getPromosGames(ranking.miniSeriesProgress!) : ""}`; +}; + +const getPromosGames = (miniSeriesProgress: string) => { + const nbWin = Array.from(miniSeriesProgress).reduce( + (acc, current) => (current === "W" ? acc + 1 : acc), + 0 + ); + const nbLoss = Array.from(miniSeriesProgress).reduce( + (acc, current) => (current === "L" ? acc + 1 : acc), + 0 + ); + + return `(${nbWin} - ${nbLoss})`; +}; export const ranks: Rank[] = [ { @@ -56,12 +80,14 @@ export type Tier = | "CHALLENGER"; type Division = "I" | "II" | "III" | "IV"; interface Rank { - tier: Tier; - division: Division; + tier: string; + division: string; leaguePoints: number; + miniSeriesProgress?: string; } const tiers = [ "IRON", + "BRONZE", "SILVER", "GOLD", "PLATINUM", @@ -104,3 +130,11 @@ export const getRankDifference = (oldRank: Rank, newRank: Rank) => { content: `${hasLost ? "LOST" : "GAINED"} ${Math.abs(lpDifference)} LP`, }; }; + +export const getDbPath = () => + path.join( + process.env.APPDATA!, + "LoL Stalker", + "database", + electronIsDev ? "lol-stalker.dev.db" : "lol-stalker.db" + ); diff --git a/ormconfig.js b/ormconfig.js new file mode 100644 index 0000000..815a484 --- /dev/null +++ b/ormconfig.js @@ -0,0 +1,14 @@ +const dotenv = require("dotenv"); +const path = require("path"); +dotenv.config(); + +console.log(path.join(__dirname, "electron/migration/*.js")); +module.exports = { + type: "sqlite", + database: path.join(process.env.APPDATA, "LoL Stalker", "database", "lol-stalker.dev.db"), + migrations: [path.join(__dirname, "electron/migration/*.js")], + entities: [path.join(__dirname, "main/entities/*.js")], + cli: { + migrationsDir: "electron/migration", + }, +}; diff --git a/package.json b/package.json index b1ceb31..3e9990d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "lol-stalker", - "version": "0.0.2", + "version": "0.1.0", "license": "MIT", - "main": "main/index.js", + "main": "main/electron/index.js", "author": { "name": "Ledoux Martin" }, @@ -18,19 +18,19 @@ }, "productName": "LoL Stalker", "scripts": { - "dev": "yarn migration:up && concurrently \"yarn dev:vite --port 3002\" \" yarn dev:electron\"", + "dev": "concurrently \"yarn dev:vite --port 3002\" \" yarn dev:electron\"", "dev:vite": "vite --https", "dev:electron": "yarn build:electron && electron .", "build": "yarn clean && yarn init:db && yarn build:vite && yarn build:electron && yarn migration:up", "build:vite": "vite build", "build:electron": "tsc -p electron", - "dist": "yarn build && electron-builder", + "dist": "set NODE_ENV=PRODUCTION && yarn build && electron-builder", "pack": "yarn build && electron-builder --dir", "clean": "rimraf dist main src/out database", "type-check": "tsc", "init:db": "copyfiles ./base.db ./database && move-cli ./database/base.db ./database/lol-stalker.db", "migrate": "yarn migration:create && yarn migration:up", - "migration:create": "yarn typeorm migration:generate -o -n dist", + "migration:create": "yarn build:electron && yarn typeorm migration:generate -o -n dist", "migration:up": "yarn typeorm migration:run" }, "dependencies": { @@ -38,11 +38,12 @@ "@chakra-ui/react": "^1.6.7", "@emotion/react": "^11", "@emotion/styled": "^11", - "@pastable/core": "^0.1.14", + "@pastable/core": "^0.1.15", + "auto-launch": "^5.0.5", "axios": "^0.24.0", - "bull": "^4.2.1", "classnames": "^2.3.1", "debug": "^4.3.3", + "discord-oauth2": "^2.9.0", "dotenv": "^14.3.2", "electron-is-dev": "^2.0.0", "framer-motion": "^4", @@ -52,20 +53,27 @@ "react-dom": "^17.0.2", "react-hook-form": "^7.25.3", "react-icons": "^4.3.1", + "react-json-tree": "^0.16.1", "react-query": "^3.34.8", "react-router-dom": "6", "recharts": "^2.1.8", "sqlite3": "^5.0.2", "typeorm": "^0.2.41", + "websocket": "^1.0.34", + "ws": "^8.5.0", + "ws-client-js": "^1.0.5", + "xstate": "^4.29.0", "yenv": "^3.0.1" }, "devDependencies": { - "@types/bull": "^3.15.7", + "@types/auto-launch": "^5.0.2", "@types/node": "^16.3.3", "@types/react": "^17.0.3", "@types/react-dom": "^17.0.3", "@types/react-icons": "^3.0.0", "@types/sqlite3": "^3.1.8", + "@types/websocket": "^1.0.5", + "@types/ws": "^8.2.2", "@vitejs/plugin-react": "^1.1.4", "@vitejs/plugin-react-refresh": "^1.3.1", "autoprefixer": "^10.3.1", @@ -75,22 +83,29 @@ "electron": "^13.1.7", "electron-builder": "^22.10.5", "move-cli": "^2.0.0", + "path-exists-cli": "^2.0.0", "postcss": "^8.3.5", - "sqlite3-cli": "^1.0.0", "typescript": "^4.2.3", "vite": "^2.1.2" }, "build": { "asar": false, + "nsis": { + "oneClick": true, + "installerIcon": "public/icon.ico", + "installerHeaderIcon": "public/icon.ico" + }, + "win": { + "target": "nsis", + "icon": "public/icon.ico" + }, "files": [ "main", "src/out", + "resources/**/*", { - "from": "./database/", - "to": "main/database/", - "filter": [ - "*.db" - ] + "from": "public", + "to": "public" } ], "directories": { diff --git a/public/icon.ico b/public/icon.ico new file mode 100644 index 0000000..e0b68eb Binary files /dev/null and b/public/icon.ico differ diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..ed226d1 Binary files /dev/null and b/public/icon.png differ diff --git a/src/App.tsx b/src/App.tsx index 8f60c8d..27e1157 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { ChakraProvider } from "@chakra-ui/react"; import { QueryClient, QueryClientProvider } from "react-query"; import { HashRouter } from "react-router-dom"; import { LCUConnector } from "./components/LCUConnector"; +import { SocketStatus } from "./components/SocketStatus"; import { Home } from "./Home"; import theme from "./theme"; diff --git a/src/Home.tsx b/src/Home.tsx index 6848b56..d17ed33 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -1,13 +1,17 @@ -import { Box, Center, Icon, Spinner } from "@chakra-ui/react"; +import { Box, Center, Flex, Icon, Spinner } from "@chakra-ui/react"; import { useAtomValue } from "jotai/utils"; import { Route, Routes, useLocation } from "react-router-dom"; -import { lcuStatusAtom } from "./components/LCUConnector"; import { Navbar, navbarHeight } from "./components/Navbar"; import { FriendDetails } from "./features/FriendDetails/FriendDetails"; import { FriendList } from "./features/FriendList/FriendList"; import { Notifications } from "./features/Notifications/Notifications"; import { OptionsPage } from "./features/Options/OptionsPage"; import { BiRefresh } from "react-icons/bi"; +import { DevTools } from "./features/DevTools/DevTools"; +import { Discord } from "./features/Discord/Discord"; +import { lcuStatusAtom, Store } from "./components/LCUConnector"; +import { SocketStatus } from "./components/SocketStatus"; +import { CurrentSummoner } from "./features/CurrentSummoner/CurrentSummoner"; export const Home = () => { const lcuStatus = useAtomValue(lcuStatusAtom); @@ -27,25 +31,30 @@ export const Home = () => { - window.location.reload()} + alignItems="center" > - + - + cursor="pointer" + transition="transform .3s" + transitionProperty="transform" + _hover={{ transform: "rotate(90deg)" }} + onClick={() => window.location.reload()} + > + + + ); }; @@ -57,6 +66,9 @@ const AppRoutes = () => { } /> } /> } /> + } /> + {process.env.NODE_ENV === "development" && } />} + } /> ); }; diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..30caa45 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,16 @@ +import { isDev } from "@pastable/utils"; +import axios from "axios"; + +const baseURL = isDev() ? "http://localhost:8080" : `https://stalker.back.chainbreak.dev`; + +export const api = axios.create({ baseURL }); +api.interceptors.response.use( + (response) => response, + (error) => { + // If token has expired, force a full page refresh + if (error.response?.status === 401) { + // document.location.reload(); + } + throw error; + } +); diff --git a/src/components/LCUConnector.tsx b/src/components/LCUConnector.tsx index f8e664c..2f055f4 100644 --- a/src/components/LCUConnector.tsx +++ b/src/components/LCUConnector.tsx @@ -1,46 +1,121 @@ import axios from "axios"; import { atom } from "jotai"; -import { atomWithStorage, useUpdateAtom } from "jotai/utils"; +import { useUpdateAtom } from "jotai/utils"; import { useEffect } from "react"; import { useQuery, useQueryClient } from "react-query"; import { useChampionsList } from "../features/DataDragon/useChampionsList"; import { useItemsList } from "../features/DataDragon/useItemsList"; import { useSummonerSpellsList } from "../features/DataDragon/useSummonerSpellsList"; -import { friendsAtom, selectedFriendsAtom } from "../features/FriendList/useFriendList"; -import { AuthData, FriendDto, FriendLastRankDto } from "../types"; +import { friendsAtom } from "../features/FriendList/useFriendList"; +import { useApi } from "../features/hooks/useApi"; +import { CurrentSummoner, FriendLastRankDto } from "../types"; import { electronRequest, sendMessage } from "../utils"; +import { errorToast } from "./toasts"; -export const lcuStatusAtom = atom(null as unknown as AuthData); +export interface DiscordGuild { + channelId: string; + guildId: string; + channelName: string; + guildName: string; + nbStalkers: number; + summoners: { id: number; puuid: string; channelId: string; name: string; region: string }[]; + isRestricted: boolean; +} +export interface ConnectorStatus { + address: string; + port: number; + username: string; + password: string; + protocol: string; +} +export interface DiscordAuth { + access_token: string; + expires_in: number; + refresh_token: string; + scope: string; + token_type: string; +} + +export type SocketStatus = "initial" | "connecting" | "connected" | "error" | "closed"; +export interface DiscordUrls { + inviteUrl: string; + authUrl: string; +} +export interface Me { + id: string; + username: string; + avatar: string; + discriminator: string; + public_flags: number; + flags: number; + banner?: any; + banner_color?: any; + accent_color?: any; + locale: string; + mfa_enabled: boolean; + premium_type: number; +} -export enum LocalStorageKeys { - OpenGroups = "lol-stalker/openGroups", +export interface Locale { + locale: string; + region: string; + webLanguage: string; + webRegion: string; } -export const openGroupsAtom = atomWithStorage(LocalStorageKeys.OpenGroups, []); +export interface Store { + config: Record; + selectedFriends: Array | null; + connectorStatus: null | ConnectorStatus; + inGameFriends: null | any[]; + discordAuth: null | DiscordAuth; + socketStatus: SocketStatus; + discordUrls: null | DiscordUrls; + leagueSummoner: null | CurrentSummoner; + me: null | Me; + locale: null | Locale; +} + +export const storeAtom = atom(null); -const getLCUStatus = () => electronRequest("lcu/connection"); +export const lcuStatusAtom = atom((get) => get(storeAtom)?.connectorStatus); +export const configAtom = atom((get) => get(storeAtom)?.config); +export const socketStatusAtom = atom((get) => get(storeAtom)?.socketStatus); +export const selectedFriendsAtom = atom((get) => get(storeAtom)?.selectedFriends); +export const discordAuthAtom = atom((get) => get(storeAtom)?.discordAuth); +export const discordUrlsAtom = atom((get) => get(storeAtom)?.discordUrls); +export const meAtom = atom((get) => get(storeAtom)?.me); +export const leagueSummonerAtom = atom((get) => get(storeAtom)?.leagueSummoner); +export const regionAtom = atom((get) => get(storeAtom)?.locale?.region); export const LCUConnector = () => { const setFriends = useUpdateAtom(friendsAtom); - const setSelectedFriends = useUpdateAtom(selectedFriendsAtom); - const setLcuStatus = useUpdateAtom(lcuStatusAtom); + const setStore = useUpdateAtom(storeAtom); const queryClient = useQueryClient(); - useQuery("lcuStatus", getLCUStatus, { onSuccess: setLcuStatus }); + useQuery("store", () => electronRequest("store"), { + onSuccess: (store) => setStore(store), + }); + usePatchVersion(); useChampionsList(); useItemsList(); useSummonerSpellsList(); + useApi(); useEffect(() => { - window.ipcRenderer.send("lcu/connection"); window.Main.on("invalidate", (queryName: string) => queryClient.invalidateQueries(queryName) ); + window.Main.on("store/update", (data: Partial) => + setStore((store) => (store ? { ...store, ...data } : null)) + ); window.Main.on("friendList/lastRank", (data: FriendLastRankDto[]) => setFriends([...data])); - window.Main.on("friendList/selected", setSelectedFriends); + window.Main.on("error", (data: string) => + errorToast({ title: "An error has occured", description: data }) + ); + window.Main.on("errorToast", (data: string) => errorToast({ title: data })); sendMessage("friendList/lastRank"); - sendMessage("friendList/selected"); return () => window.Main.getEventNames().forEach((eventName) => diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 1dedb5d..e240f04 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -13,7 +13,6 @@ export const Navbar = (props: StackProps) => { const hasSubMenu = testRegex(location.pathname); const puuid = hasSubMenu && location.pathname.replace("/friend/", ""); - return ( <> { > Notifications Friendlist + My graph + Discord Options + {process.env.NODE_ENV === "development" && Dev tools} {hasSubMenu && (
diff --git a/src/components/SocketStatus.tsx b/src/components/SocketStatus.tsx new file mode 100644 index 0000000..9f26461 --- /dev/null +++ b/src/components/SocketStatus.tsx @@ -0,0 +1,8 @@ +import { Box, BoxProps } from "@chakra-ui/react"; +import { useAtomValue } from "jotai/utils"; +import { socketStatusAtom } from "./LCUConnector"; + +export const SocketStatus = (props: BoxProps) => { + const socketStatus = useAtomValue(socketStatusAtom); + return {socketStatus}; +}; diff --git a/src/components/toasts.ts b/src/components/toasts.ts new file mode 100644 index 0000000..b6072dd --- /dev/null +++ b/src/components/toasts.ts @@ -0,0 +1,57 @@ +import { ToastId, UseToastOptions, createStandaloneToast } from "@chakra-ui/react"; +import { getRandomString, isDev, parseStringAsBoolean } from "@pastable/core"; +import { AxiosError } from "axios"; +import theme from "../theme"; + +// Toasts +const toast = createStandaloneToast({ theme }); +const baseToastConfig = { duration: 3000, isClosable: true, unique: true }; + +type ToastStatus = Exclude | "default"; +export const toastConfigs: Record = { + default: { ...baseToastConfig }, + success: { ...baseToastConfig, status: "success" }, + error: { ...baseToastConfig, status: "error" }, + info: { ...baseToastConfig, status: "info" }, + warning: { ...baseToastConfig, status: "warning" }, +}; + +const toastMap = new Map(); +export type ToastOptions = UseToastOptions & UniqueToastOptions; + +export const makeToast = (options: ToastOptions) => { + if (options.uniqueId) { + options.id = getRandomString(10); + const prevToast = toastMap.get(options.uniqueId); + prevToast && toast.close(prevToast.id!); + toastMap.set(options.uniqueId, options); + } else if (options.unique) { + toast.closeAll(); + } + + return toast(options); +}; + +export const defaultToast = (options: ToastOptions) => + makeToast({ ...toastConfigs.default, ...options }); +export const successToast = (options: ToastOptions) => + makeToast({ ...toastConfigs.success, unique: false, ...options }); +export const errorToast = (options?: ToastOptions) => + makeToast({ title: "Une erreur est survenue", ...toastConfigs.error, ...options }); +export const infoToast = (options: ToastOptions) => makeToast({ ...toastConfigs.info, ...options }); +export const warningToast = (options: ToastOptions) => + makeToast({ ...toastConfigs.warning, ...options }); + +// Errors +export const onError = (description: string) => errorToast({ description }); +export const onAxiosError = (err: AxiosError) => { + if (isDev()) console.error(err); + onError(err.response?.data?.error) as any; +}; + +interface UniqueToastOptions { + /** When provided, will close previous toasts with the same id */ + uniqueId?: ToastId; + /** When true, will close all other toasts */ + unique?: boolean; +} diff --git a/src/features/CurrentSummoner/CurrentSummoner.tsx b/src/features/CurrentSummoner/CurrentSummoner.tsx new file mode 100644 index 0000000..e7ffa6b --- /dev/null +++ b/src/features/CurrentSummoner/CurrentSummoner.tsx @@ -0,0 +1,38 @@ +import { Box, chakra, Flex, Spinner, Stack } from "@chakra-ui/react"; +import { useAtomValue } from "jotai/utils"; +import { useQuery } from "react-query"; +import { leagueSummonerAtom } from "../../components/LCUConnector"; +import { ProfileIcon } from "../DataDragon/Profileicon"; +import { getFriendRanks } from "../FriendDetails/FriendDetails"; +import { FriendRankingGraph } from "../FriendDetails/FriendRankingGraph"; + +export const CurrentSummoner = () => { + const currentSummoner = useAtomValue(leagueSummonerAtom); + const friendQuery = useQuery( + ["friend", currentSummoner!.puuid], + () => getFriendRanks(currentSummoner!.puuid!), + { enabled: !!currentSummoner } + ); + + if (friendQuery.isLoading) return ; + if (!friendQuery.data) return null; + + if (!currentSummoner) return ; + + return ( + + {currentSummoner && ( + + + + Connected as + {currentSummoner?.displayName} + + + )} + + + + + ); +}; diff --git a/src/features/DevTools/DevTools.tsx b/src/features/DevTools/DevTools.tsx new file mode 100644 index 0000000..8bfebc3 --- /dev/null +++ b/src/features/DevTools/DevTools.tsx @@ -0,0 +1,29 @@ +import { Button, Center, Input, Stack, Textarea } from "@chakra-ui/react"; +import { useAtomValue } from "jotai/utils"; +import { useForm } from "react-hook-form"; +import { JSONTree } from "react-json-tree"; +import { storeAtom } from "../../components/LCUConnector"; +import { electronRequest } from "../../utils"; + +export const DevTools = () => { + const store = useAtomValue(storeAtom); + const { handleSubmit, register } = useForm(); + const onSubmit = (data: any) => electronRequest("ws", JSON.parse(data)); + return ( +
+ +
+
+ Event: + + Data: + + +
+
+ + +
+
+ ); +}; diff --git a/src/features/Discord/AddSummonerButton.tsx b/src/features/Discord/AddSummonerButton.tsx new file mode 100644 index 0000000..2a506da --- /dev/null +++ b/src/features/Discord/AddSummonerButton.tsx @@ -0,0 +1,52 @@ +import { Center, chakra, Modal, useDisclosure } from "@chakra-ui/react"; +import { BiMinusCircle, BiPlusCircle } from "react-icons/bi"; +import { DiscordGuild } from "../../components/LCUConnector"; +import { AddSummonerModal, useRemoveSummonersMutation } from "./AddSummonerModal"; + +export const AddSummonerButton = ({ + guildId, + channelId, + summoners, + guildName, + isRestricted, +}: DiscordGuild) => { + const disclosure = useDisclosure(); + const removeSummonersMutation = useRemoveSummonersMutation(); + return ( + <> +
+
disclosure.onOpen()} userSelect="none"> + + Add summoner +
+ {summoners?.length !== 0 && ( +
{ + removeSummonersMutation.mutate({ + channelId: channelId, + guildId: guildId, + summoners: summoners.map((summoner) => summoner.id), + }); + }} + userSelect="none" + > + + Remove all +
+ )} +
+ + + + + ); +}; diff --git a/src/features/Discord/AddSummonerModal.tsx b/src/features/Discord/AddSummonerModal.tsx new file mode 100644 index 0000000..87f0ea1 --- /dev/null +++ b/src/features/Discord/AddSummonerModal.tsx @@ -0,0 +1,222 @@ +import { + Accordion, + AccordionButton, + AccordionItem, + AccordionPanel, + Box, + Button, + Center, + Flex, + Icon, + ModalCloseButton, + ModalContent, + Stack, +} from "@chakra-ui/react"; +import { useSelection } from "@pastable/core"; +import { useAtomValue } from "jotai/utils"; +import { useState } from "react"; +import { BiFolder } from "react-icons/bi"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { api } from "../../api"; +import { DiscordGuild, regionAtom, storeAtom } from "../../components/LCUConnector"; +import { FriendLastRankDto } from "../../types"; +import { electronRequest } from "../../utils"; +import { useSearchFriendlist } from "../FriendList/FriendList"; +import { SearchFriendlist } from "../FriendList/SearchFriendList"; +import { useFriendList } from "../FriendList/useFriendList"; +import { getBgColor } from "./discordUtils"; + +type SummonerSelection = Omit; +export const useRemoveSummonersMutation = () => { + const queryClient = useQueryClient(); + return useMutation((payload: any) => api.post("/remove-summoners", payload), { + onSuccess: () => queryClient.invalidateQueries("guilds"), + }); +}; + +export const AddSummonerModal = ({ + summoners, + guildName, + guildId, + channelId, + onClose, + isRestricted, +}: { + summoners: DiscordGuild["summoners"]; + guildName: string; + guildId: string; + channelId: string; + onClose: () => void; + isRestricted?: boolean; +}) => { + const { friendGroups } = useFriendList(); + const [selection, selectionApi] = useSelection({ + getId: (item) => item.puuid, + }); + + const meQuery = useQuery("me", () => electronRequest("me")); + const queryClient = useQueryClient(); + const addSummonersMutation = useMutation( + (payload: any) => api.post("/add-summoners", payload), + { onSuccess: () => queryClient.invalidateQueries("guilds") } + ); + const removeSummonersMutation = useRemoveSummonersMutation(); + const region = useAtomValue(regionAtom); + const [search, setSearch] = useState(""); + + const searchFriendlist = useSearchFriendlist(friendGroups, search); + + if (!friendGroups?.length) + return ( +
+ No friend. You can try refreshing the page (CTRL-R) +
+ ); + + const summonersIds = summoners.map((summoner) => summoner.puuid); + const selectionIds = selection.map((selected) => selected.puuid); + const { toAdd, toRemove } = selection.reduce( + (acc, current) => + summoners.find((summoner) => summoner.puuid === current.puuid) + ? { ...acc, toRemove: [...acc.toRemove, current] } + : { ...acc, toAdd: [...acc.toAdd, current] }, + { toAdd: [] as SummonerSelection[], toRemove: [] as SummonerSelection[] } + ); + const currentNbSelected = summoners.length + toAdd.length - toRemove.length; + + const onClick = () => { + if (toRemove.length) { + console.log("toremove", toRemove); + removeSummonersMutation.mutate({ + channelId, + guildId, + summoners: toRemove.map((summoner) => summoner.id), + }); + } + if (toAdd.length) { + console.log(toAdd); + const payload = { + channelId, + guildId, + summoners: toAdd.map((summoner) => ({ ...summoner, region })), + }; + addSummonersMutation.mutate(payload); + } + onClose(); + }; + + return ( + + + {selection.length !== 0 && ( +
+ +
+ )} + + Add or remove summoners to {guildName} stalking list + + {isRestricted && ( + + Stalker summoners: {currentNbSelected}/10 + + )} + + {search && + searchFriendlist.map((friend) => ( + selectionApi.toggle(friend)} + /> + ))} + {!search && ( + + {meQuery.isSuccess && ( + + selectionApi.toggle({ + name: meQuery.data.displayName, + puuid: meQuery.data.puuid, + id: 0, + }) + } + > + Me ({meQuery.data.displayName}) + + )} + + {friendGroups.map((group) => ( + + + + + {group.groupName} + + + + + {group.friends.map((friend) => ( + selectionApi.toggle(friend)} + /> + ))} + + + + ))} + + + )} +
+ ); +}; + +const FriendRow = ({ + summonersIds, + selectionIds, + onClick, + ...friend +}: FriendLastRankDto & { summonersIds: string[]; selectionIds: string[]; onClick: () => void }) => { + return ( + + + {friend.name} + + + ); +}; diff --git a/src/features/Discord/BotInfos.tsx b/src/features/Discord/BotInfos.tsx new file mode 100644 index 0000000..534a7a3 --- /dev/null +++ b/src/features/Discord/BotInfos.tsx @@ -0,0 +1,81 @@ +import { + Box, + BoxProps, + Center, + chakra, + Divider, + Flex, + Icon, + IconButton, + ListItem, + Spinner, + UnorderedList, +} from "@chakra-ui/react"; +import { useAtomValue } from "jotai/utils"; +import { BiLogOut } from "react-icons/bi"; +import { FaDiscord } from "react-icons/fa"; +import { meAtom } from "../../components/LCUConnector"; +import { electronMutation } from "../../utils"; +import { BotInvitation } from "./BotInvitation"; +import { useGuildsQuery } from "./DiscordGuildList"; + +export const BotInfos = (props: BoxProps) => { + const guildsQuery = useGuildsQuery(); + const me = useAtomValue(meAtom); + if (!me) + return ( +
+ +
+ ); + + const guilds = guildsQuery.data; + + return ( +
+ + {me && ( + + + + Connected as + + {me.username} #{me.discriminator} + + + electronMutation("store/set", { discordAuth: null })} + icon={} + aria-label="Logout" + /> + + )} + + + The bot is active on {guilds?.length || 0} of your servers + + + + Invite the bot to your Discord server + + Send !stalker init in the + channel you want the bot to send messages in + + + Add stalked summoners to the list + + + + +
+ ); +}; diff --git a/src/features/Discord/BotInvitation.tsx b/src/features/Discord/BotInvitation.tsx new file mode 100644 index 0000000..6bbd1d6 --- /dev/null +++ b/src/features/Discord/BotInvitation.tsx @@ -0,0 +1,23 @@ +import { ExternalLinkIcon } from "@chakra-ui/icons"; +import { Box, Button, Center, CenterProps } from "@chakra-ui/react"; +import { useAtomValue } from "jotai/utils"; +import { discordUrlsAtom } from "../../components/LCUConnector"; +import { electronRequest } from "../../utils"; + +export const BotInvitation = (props: CenterProps) => { + const discordUrls = useAtomValue(discordUrlsAtom); + + if (!discordUrls) return ; + + return ( +
+ + +
+ ); +}; diff --git a/src/features/Discord/Discord.tsx b/src/features/Discord/Discord.tsx new file mode 100644 index 0000000..b894bd3 --- /dev/null +++ b/src/features/Discord/Discord.tsx @@ -0,0 +1,38 @@ +import { Box, Center, Divider, Flex, Stack } from "@chakra-ui/react"; +import { useAtomValue } from "jotai/utils"; +import { BiTrash } from "react-icons/bi"; +import { discordAuthAtom, socketStatusAtom } from "../../components/LCUConnector"; +import { electronMutation } from "../../utils"; +import { BotInfos } from "./BotInfos"; +import { DiscordGuildList } from "./DiscordGuildList"; +import { DiscordLoginButton } from "./DiscordLoginButton"; + +export const refreshGuilds = () => electronMutation("discord/guilds"); +export const Discord = () => { + const discordAuth = useAtomValue(discordAuthAtom); + const socketStatus = useAtomValue(socketStatusAtom); + + if (socketStatus !== "connected") { + return ( +
+ Not connected to WS backend, try again later +
+ ); + } + + if (!discordAuth) + return ( +
+ +
+ ); + return ( + + + + + + + + ); +}; diff --git a/src/features/Discord/DiscordGuildList.tsx b/src/features/Discord/DiscordGuildList.tsx new file mode 100644 index 0000000..76f324a --- /dev/null +++ b/src/features/Discord/DiscordGuildList.tsx @@ -0,0 +1,111 @@ +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Box, + BoxProps, + Center, + chakra, + Flex, + IconButton, + Spinner, + Stack, +} from "@chakra-ui/react"; +import { BiRefresh } from "react-icons/bi"; +import { useQuery } from "react-query"; +import { api } from "../../api"; +import { DiscordGuild } from "../../components/LCUConnector"; +import { useFriendList } from "../FriendList/useFriendList"; +import { AddSummonerButton } from "./AddSummonerButton"; +import { SummonerPanel } from "./SummonerPanel"; + +const getGuilds = async () => (await api.get("/guilds")).data; +export const useGuildsQuery = () => + useQuery("guilds", getGuilds, { staleTime: 1000 * 60 * 5 }); +export const DiscordGuildList = (props: BoxProps) => { + const guildsQuery = useGuildsQuery(); + const { friends } = useFriendList(); + if (guildsQuery.isLoading) + return ( +
+ +
+ ); + if (guildsQuery.isError) + return ( +
+ An error has occured +
+ ); + + const guilds = guildsQuery.data!; + console.log(guilds); + return ( + + + + Stalked summoners + + guildsQuery.refetch()} + icon={} + aria-label="Refresh guilds" + /> + + + {/* {true && ( +
+ +
+ )} */} + {!guilds?.length ? ( + No guild + ) : ( + guilds.map((guild) => ( + + + + + {guild.guildName} - {guild.channelName}{" "} + + ({guild.summoners.length}/ + {guild.isRestricted ? 10 : "*"}) + + + + + {guild.nbStalkers} active stalker + {guild.nbStalkers > 1 ? "s" : ""} + + + + + {guild.summoners.map((summoner) => ( + friend.puuid === summoner.puuid + )} + {...guild} + /> + ))} + + + + )) + )} +
+
+ ); +}; diff --git a/src/features/Discord/DiscordLoginButton.tsx b/src/features/Discord/DiscordLoginButton.tsx new file mode 100644 index 0000000..e17f4a2 --- /dev/null +++ b/src/features/Discord/DiscordLoginButton.tsx @@ -0,0 +1,26 @@ +import { Button, Center, CenterProps, Icon } from "@chakra-ui/react"; +import { useAtomValue } from "jotai/utils"; +import { useEffect } from "react"; +import { FaDiscord } from "react-icons/fa"; +import { discordUrlsAtom } from "../../components/LCUConnector"; +import { electronRequest } from "../../utils"; + +export const DiscordLoginButton = (props: CenterProps) => { + const discordUrls = useAtomValue(discordUrlsAtom); + + useEffect(() => { + if (!discordUrls) electronRequest("config/discord-urls"); + }, [discordUrls]); + + return ( +
+ + +
+ ); +}; diff --git a/src/features/Discord/SummonerPanel.tsx b/src/features/Discord/SummonerPanel.tsx new file mode 100644 index 0000000..6467ddb --- /dev/null +++ b/src/features/Discord/SummonerPanel.tsx @@ -0,0 +1,47 @@ +import { CloseIcon } from "@chakra-ui/icons"; +import { Box, chakra, Flex, Icon, Tooltip } from "@chakra-ui/react"; +import { FaUserFriends } from "react-icons/fa"; +import { DiscordGuild } from "../../components/LCUConnector"; +import { useRemoveSummonersMutation } from "./AddSummonerModal"; + +export const SummonerPanel = ({ + summoner, + guildId, + channelId, + isInFriendList, +}: { + summoner: DiscordGuild["summoners"][0]; + guildId: string; + channelId: string; + isInFriendList?: boolean; +}) => { + const removeSummonersMutation = useRemoveSummonersMutation(); + return ( + + + + {summoner.name} + {summoner.region} + + {isInFriendList && ( + + + + + + )} + + + removeSummonersMutation.mutate({ + guildId, + channelId, + summoners: [summoner.id], + }) + } + /> + + ); +}; diff --git a/src/features/Discord/discordUtils.tsx b/src/features/Discord/discordUtils.tsx new file mode 100644 index 0000000..d83410a --- /dev/null +++ b/src/features/Discord/discordUtils.tsx @@ -0,0 +1,6 @@ +export const getBgColor = (initial: string[], selection: string[], puuid: string) => { + if (initial.includes(puuid) && selection.includes(puuid)) return "red.400"; + if (!initial.includes(puuid) && !selection.includes(puuid)) return "initial"; + if (initial.includes(puuid) && !selection.includes(puuid)) return "blue.400"; + if (!initial.includes(puuid) && selection.includes(puuid)) return "green.400"; +}; diff --git a/src/features/FriendDetails/FriendDetails.tsx b/src/features/FriendDetails/FriendDetails.tsx index 473d52f..a535d1f 100644 --- a/src/features/FriendDetails/FriendDetails.tsx +++ b/src/features/FriendDetails/FriendDetails.tsx @@ -24,7 +24,7 @@ export const formatRank = (ranking: Pick +export const getFriendRanks = (puuid: FriendDto["puuid"]) => electronRequest("friendList/friend", puuid); type FriendDetailsState = "notifications" | "match-history" | "graph" | "old-names"; @@ -72,7 +72,7 @@ export const FriendDetails = () => { setState={setState as (state: string) => void} state={state} /> - + {renderComponentByState[state](friend)} diff --git a/src/features/FriendDetails/FriendMatches.tsx b/src/features/FriendDetails/FriendMatches.tsx index 22f1e73..ea2a7a7 100644 --- a/src/features/FriendDetails/FriendMatches.tsx +++ b/src/features/FriendDetails/FriendMatches.tsx @@ -32,7 +32,7 @@ export const FriendMatches = ({ puuid }: Pick) => { const games = matchObj?.games?.games; return ( -
+ {games.length ? ( {games.map((game) => ( @@ -42,7 +42,7 @@ export const FriendMatches = ({ puuid }: Pick) => { ) : ( <>No game )} -
+
); }; @@ -58,7 +58,7 @@ export const GameRow = ({ game }: { game: Game }) => { const items = useItemsDataByIds(makeArrayOf(7).map((_, index) => stats["item" + index])); return ( - + {champion ? : }
{ const data = useMemo(() => { if (!query.data) return []; tierDataRef.current = makeTierData(query.data); - return friend.rankings.map((rank) => ({ + return friend?.rankings?.map((rank) => ({ ...rank, totalLp: getTotalLpFromRank(rank, tierDataRef.current), })); @@ -51,10 +51,11 @@ export const FriendRankingGraph = ({ friend }: { friend: FriendDto }) => {
); } + return ( - + { - const lastRanking = last(friend.rankings); + if (!friend?.rankings) return null; + const lastRanking = last(friend?.rankings); return ( diff --git a/src/features/FriendList/FriendGroup.tsx b/src/features/FriendList/FriendGroup.tsx index 4c03078..448f192 100644 --- a/src/features/FriendList/FriendGroup.tsx +++ b/src/features/FriendList/FriendGroup.tsx @@ -1,4 +1,3 @@ -import { ChevronDownIcon, ChevronRightIcon } from "@chakra-ui/icons"; import { AccordionButton, AccordionIcon, @@ -8,46 +7,23 @@ import { Checkbox, Flex, FlexProps, - Stack, - useDisclosure, } from "@chakra-ui/react"; -import { useAtom } from "jotai"; import { useAtomValue } from "jotai/utils"; import { ChangeEvent, useMemo } from "react"; import { useNavigate } from "react-router-dom"; -import { openGroupsAtom } from "../../components/LCUConnector"; +import { selectedFriendsAtom } from "../../components/LCUConnector"; import { FriendDto, FriendGroup } from "../../types"; import { sendMessage } from "../../utils"; import { ProfileIcon } from "../DataDragon/Profileicon"; -import { selectedFriendsAtom } from "./useFriendList"; export const FriendGroupRow = ({ group }: { group: FriendGroup }) => { - const [openGroups, setOpenGroups] = useAtom(openGroupsAtom); - const defaultIsOpen = useMemo(() => openGroups.includes(group.groupId), []); const selectedFriends = useAtomValue(selectedFriendsAtom); - - const { isOpen, onToggle } = useDisclosure({ - onOpen: () => { - setOpenGroups((openGroups) => { - if (openGroups.includes(group.groupId)) return openGroups; - return [...openGroups, group.groupId]; - }); - }, - onClose: () => { - setOpenGroups((openGroups) => { - if (!openGroups.includes(group.groupId)) return openGroups; - return openGroups.filter((groupId) => groupId !== group.groupId); - }); - }, - defaultIsOpen, - }); - const isChecked = useMemo( - () => group.friends.every((friend) => selectedFriends.includes(friend.puuid)), + () => group.friends.every((friend) => selectedFriends?.includes(friend.puuid)), [group, selectedFriends] ); const isIndeterminate = useMemo( - () => !isChecked && group.friends.some((friend) => selectedFriends.includes(friend.puuid)), + () => !isChecked && group.friends.some((friend) => selectedFriends?.includes(friend.puuid)), [group, selectedFriends] ); @@ -58,7 +34,7 @@ export const FriendGroupRow = ({ group }: { group: FriendGroup }) => { }; return ( - + { ))} diff --git a/src/features/FriendList/FriendList.tsx b/src/features/FriendList/FriendList.tsx index f6aa2f3..83046b8 100644 --- a/src/features/FriendList/FriendList.tsx +++ b/src/features/FriendList/FriendList.tsx @@ -1,10 +1,48 @@ -import { Accordion, Box, Button, Center, Flex, Spinner, Stack } from "@chakra-ui/react"; -import { FriendGroupRow } from "./FriendGroup"; +import { SearchIcon } from "@chakra-ui/icons"; +import { + Accordion, + Box, + Button, + Center, + chakra, + Divider, + Flex, + Input, + ListItem, + Stack, + UnorderedList, +} from "@chakra-ui/react"; +import { useAtomValue } from "jotai/utils"; +import { useState } from "react"; +import { leagueSummonerAtom, selectedFriendsAtom } from "../../components/LCUConnector"; +import { FriendGroup, FriendLastRankDto } from "../../types"; +import { ProfileIcon } from "../DataDragon/Profileicon"; +import { FriendGroupRow, FriendRow } from "./FriendGroup"; +import { SearchFriendlist } from "./SearchFriendList"; import { useFriendList } from "./useFriendList"; +export const useSearchFriendlist = (friendGroups: FriendGroup[], search?: string) => { + const matchingFriends: FriendGroup["friends"] = []; + if (!search) return matchingFriends; + + friendGroups.forEach((group) => + group.friends.forEach((friend) => { + if (friend.name.toLowerCase().includes(search.toLowerCase())) + matchingFriends.push(friend); + }) + ); + + return matchingFriends; +}; + export const FriendList = () => { const { friendGroups } = useFriendList(); - console.log(friendGroups); + const leagueSummoner = useAtomValue(leagueSummonerAtom); + const [search, setSearch] = useState(""); + + const searchFriendlist = useSearchFriendlist(friendGroups, search); + const selectedFriends = useAtomValue(selectedFriendsAtom); + if (!friendGroups?.length) return (
@@ -12,27 +50,88 @@ export const FriendList = () => {
); return ( - - - - + + + + {leagueSummoner && ( + <> + + + Connected as + + {leagueSummoner?.displayName} + + + + )} + + + + + This is your LoL friendlist + + + Select the friends you want to receive Windows notifications from + + + You can also filter using the "Show all" checkbox on the Notifications + page + + + + + + + + + Friendlist + + + + {!search && ( + + + + + )} + {search && ( + + {searchFriendlist.map((friend) => ( + + ))} + + )} + {!search && ( + + {friendGroups?.map((group) => ( + + ))} + + )} - - {friendGroups?.map((group) => ( - - ))} - ); }; diff --git a/src/features/FriendList/SearchFriendList.tsx b/src/features/FriendList/SearchFriendList.tsx new file mode 100644 index 0000000..41579c5 --- /dev/null +++ b/src/features/FriendList/SearchFriendList.tsx @@ -0,0 +1,23 @@ +import { SearchIcon } from "@chakra-ui/icons"; +import { Box, Input } from "@chakra-ui/react"; +import { SetState } from "@pastable/core"; + +export const SearchFriendlist = ({ + setSearch, + search, +}: { + setSearch: SetState; + search: string; +}) => { + return ( + + setSearch(e.target.value)} + value={search} + /> + + + ); +}; diff --git a/src/features/FriendList/useFriendList.tsx b/src/features/FriendList/useFriendList.tsx index b0d698b..c06bbd6 100644 --- a/src/features/FriendList/useFriendList.tsx +++ b/src/features/FriendList/useFriendList.tsx @@ -1,11 +1,11 @@ import { pick } from "@pastable/core"; import { atom } from "jotai"; import { useAtomValue } from "jotai/utils"; +import { selectedFriendsAtom } from "../../components/LCUConnector"; import { FriendDto, FriendGroup, FriendLastRankDto } from "../../types"; export const friendsAtom = atom([]); export const groupsAtom = atom((get) => getFriendListFilteredByGroups(get(friendsAtom))); -export const selectedFriendsAtom = atom([]); export interface FriendUpdate extends Partial { puuid: FriendDto["puuid"]; } @@ -19,7 +19,7 @@ export const useFriendList = () => { export const useIsFriendSelected = (puuid: FriendDto["puuid"]) => { const selectedFriends = useAtomValue(selectedFriendsAtom); - return selectedFriends.includes(puuid); + return selectedFriends?.includes(puuid); }; export const getFriendListFilteredByGroups = (friends: FriendLastRankDto[]) => { diff --git a/src/features/Notifications/InGameFriends.tsx b/src/features/Notifications/InGameFriends.tsx index 5fc945d..3252c83 100644 --- a/src/features/Notifications/InGameFriends.tsx +++ b/src/features/Notifications/InGameFriends.tsx @@ -1,4 +1,14 @@ -import { Box, BoxProps, Center, chakra, Flex, Spinner, Stack, useInterval } from "@chakra-ui/react"; +import { + Box, + BoxProps, + Center, + chakra, + Divider, + Flex, + Spinner, + Stack, + useInterval, +} from "@chakra-ui/react"; import { useState } from "react"; import { useQuery } from "react-query"; import { useNavigate } from "react-router-dom"; @@ -24,30 +34,28 @@ export const InGameFriends = () => { An error has occured
); - if (!query.data) - return ( -
- No friend activity -
- ); - const inGameFriends = query.data; return ( - - + + Friend activity - {inGameFriends - .sort((a, b) => a.timeStamp - b.timeStamp) - .sort( - (a, b) => - gameStatusOrder.findIndex((item) => item === a.gameStatus) - - gameStatusOrder.findIndex((item) => item === b.gameStatus) - ) - .map((friend) => ( - - ))} + + {!inGameFriends ? ( +
+ No friend activity +
+ ) : ( + inGameFriends + .sort((a, b) => a.timeStamp - b.timeStamp) + .sort( + (a, b) => + gameStatusOrder.findIndex((item) => item === a.gameStatus) - + gameStatusOrder.findIndex((item) => item === b.gameStatus) + ) + .map((friend) => ) + )}
); }; @@ -55,14 +63,14 @@ const InGameFriendRow = ({ friend }: { friend: InGameFriend }) => { const champion = useChampionDataById(friend.championId); const navigate = useNavigate(); return ( - + navigate(`/friend/${friend.puuid}`)} cursor="pointer" > - {friend.gameName} + {friend.name} @@ -105,15 +113,9 @@ const CountDown = ({ timeStamp, ...props }: { timeStamp: number } & BoxProps) =>
); }; -type GameStatus = "hosting_RANKED_SOLO_5x5" | "inQueue" | "inGame" | "championSelect"; -const gameStatusOrder: GameStatus[] = [ - "inGame", - "championSelect", - "inQueue", - "hosting_RANKED_SOLO_5x5", -]; +type GameStatus = "inQueue" | "inGame" | "championSelect"; +const gameStatusOrder: GameStatus[] = ["inGame", "championSelect", "inQueue"]; const gameStatusLabelMap: Record = { - hosting_RANKED_SOLO_5x5: "In Lobby", inQueue: "In queue", inGame: "In game", championSelect: "In champion select", diff --git a/src/features/Notifications/NotificationItem.tsx b/src/features/Notifications/NotificationItem.tsx index da2c934..8c49f03 100644 --- a/src/features/Notifications/NotificationItem.tsx +++ b/src/features/Notifications/NotificationItem.tsx @@ -44,7 +44,7 @@ export const NotificationItem = ({ {formatNotificationContent(notification)} - {["DEMOTION", "LOSS"].includes(notification.type) && ( + {hasMessageButton(notification) && ( { - if (notification.from.slice(0, 4) === "NONE") { - return ( - - CAME BACK FROM - - - ); - } - if (notification.to.slice(0, 4) === "NONE") { - return ( - - WENT TO - - - ); - } +const hasMessageButton = (notification: NotificationDto) => { + return ( + ["DEMOTION", "LOSS"].includes(notification.type) || + (notification.to.includes(" 99 LPs") && + ["MASTER", "GRANDMASTER", "CHALLENGER"].every( + (tier) => !notification.to.includes(tier) + )) + ); +}; +const formatNotificationContent = (notification: NotificationDto) => { return notification.content.replace(" NA ", " "); }; diff --git a/src/features/Notifications/Notifications.tsx b/src/features/Notifications/Notifications.tsx index e4b43da..2a96c3f 100644 --- a/src/features/Notifications/Notifications.tsx +++ b/src/features/Notifications/Notifications.tsx @@ -1,4 +1,4 @@ -import { Box, Center, Flex, Heading, Spinner, Stack } from "@chakra-ui/react"; +import { Box, Center, Divider, Flex, Heading, Spinner, Stack } from "@chakra-ui/react"; import { useCallback, useEffect, useRef } from "react"; import { NotificationDto } from "../../types"; import { InGameFriends } from "./InGameFriends"; @@ -9,29 +9,37 @@ import { useNotificationsQueries } from "./useNotificationsQueries"; export const Notifications = () => { const { notificationsQuery, nbNewNotifications } = useNotificationsQueries(); if (notificationsQuery.isError) return An error has occured; - const notificationPages = notificationsQuery.data?.pages; const hasData = notificationPages?.some((arr) => !!arr.nextCursor); return ( - + + + Filters + + {notificationsQuery.isLoading ? (
) : ( - - {nbNewNotifications && nbNewNotifications > 0 && ( + + + Recent notifications + + + + {!!nbNewNotifications && nbNewNotifications > 0 && ( notificationsQuery.refetch()} w="100%" textAlign="center" bgColor="blue.500" py="10px" - borderRadius="10px 10px 0 0" + m="0" fontWeight="medium" cursor="pointer" > @@ -43,8 +51,9 @@ export const Notifications = () => { notificationPages={notificationPages!} fetchNextPage={notificationsQuery.fetchNextPage} /> - +
)} + @@ -58,6 +67,7 @@ export interface InGameFriend { gameStatus: string; timeStamp: number; puuid: string; + name: string; } export const NotificationContent = ({ diff --git a/src/features/Options/OptionsPage.tsx b/src/features/Options/OptionsPage.tsx index 508dd88..20db307 100644 --- a/src/features/Options/OptionsPage.tsx +++ b/src/features/Options/OptionsPage.tsx @@ -1,5 +1,5 @@ +import { CopyIcon } from "@chakra-ui/icons"; import { - Box, Button, Center, CenterProps, @@ -10,12 +10,13 @@ import { Spinner, Stack, } from "@chakra-ui/react"; -// import { shell } from "electron"; -import { useMutation, useQuery } from "react-query"; -import { electronRequest } from "../../utils"; -import { AiFillGithub, AiFillTwitterCircle } from "react-icons/ai"; -import { CopyIcon } from "@chakra-ui/icons"; +import { useAtomValue } from "jotai/utils"; import { useForm } from "react-hook-form"; +import { AiFillGithub, AiFillTwitterCircle } from "react-icons/ai"; +// import { shell } from "electron"; +import { useMutation } from "react-query"; +import { configAtom } from "../../components/LCUConnector"; +import { electronMutation, electronRequest } from "../../utils"; export const OptionsPage = () => { const dlDbMutation = useMutation(() => electronRequest("config/dl-db")); const openExternalBrowserMutation = useMutation((url: string) => @@ -32,6 +33,7 @@ export const OptionsPage = () => {