Merge branch 'main' into feature/adding-generic-http-downloads

This commit is contained in:
Zamitto 2024-07-26 13:38:48 -03:00 committed by GitHub
commit c0198b49ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1006 additions and 171 deletions

View File

@ -7,7 +7,7 @@
<h1 align="center">Hydra Launcher</h1> <h1 align="center">Hydra Launcher</h1>
<p align="center"> <p align="center">
<strong>Hydra is a game launcher with its own embedded bittorrent client and a self-managed repack scraper.</strong> <strong>Hydra is a game launcher with its own embedded bittorrent client.</strong>
</p> </p>
[![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions) [![build](https://img.shields.io/github/actions/workflow/status/hydralauncher/hydra/build.yml)](https://github.com/hydralauncher/hydra/actions)
@ -50,17 +50,15 @@
## About ## About
**Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client** and a **self-managed repack scraper**. **Hydra** is a **Game Launcher** with its own embedded **BitTorrent Client**.
<br> <br>
The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using libtorrent. The launcher is written in TypeScript (Electron) and Python, which handles the torrenting system by using libtorrent.
## Features ## Features
- Self-Managed repack scraper among all the most reliable websites on the [Megathread]("https://www.reddit.com/r/Piracy/wiki/megathread/")
- Own embedded bittorrent client - Own embedded bittorrent client
- How Long To Beat (HLTB) integration on game page - How Long To Beat (HLTB) integration on game page
- Downloads path customization - Downloads path customization
- Repack list update notifications
- Windows and Linux support - Windows and Linux support
- Constantly updated - Constantly updated
- And more ... - And more ...
@ -134,9 +132,8 @@ pip install -r requirements.txt
## Environment variables ## Environment variables
You'll need an SteamGridDB API Key in order to fetch the game icons on installation. You'll need an SteamGridDB API Key in order to fetch the game icons on installation.
If you want to have onlinefix as a repacker you'll need to add your credentials to the .env
Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`, `ONLINEFIX_USERNAME`, `ONLINEFIX_PASSWORD`. Once you have it, you can copy or rename the `.env.example` file to `.env` and put it on`STEAMGRIDDB_API_KEY`.
## Running ## Running

View File

@ -40,6 +40,7 @@
"@reduxjs/toolkit": "^2.2.3", "@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0", "@sentry/electron": "^5.1.0",
"@vanilla-extract/css": "^1.14.2", "@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/dynamic": "^2.1.1",
"@vanilla-extract/recipes": "^0.5.2", "@vanilla-extract/recipes": "^0.5.2",
"aria2": "^4.1.2", "aria2": "^4.1.2",
"auto-launch": "^5.0.6", "auto-launch": "^5.0.6",

View File

@ -241,6 +241,15 @@
"successfully_signed_out": "Successfully signed out", "successfully_signed_out": "Successfully signed out",
"sign_out": "Sign out", "sign_out": "Sign out",
"playing_for": "Playing for {{amount}}", "playing_for": "Playing for {{amount}}",
"sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?" "sign_out_modal_text": "Your library is linked with your current account. When signing out, your library will not be visible anymore, and any progress will not be saved. Continue with sign out?",
"add_friends": "Add Friends",
"add": "Add",
"friend_code": "Friend code",
"see_profile": "See profile",
"sending": "Sending",
"friend_request_sent": "Friend request sent",
"friends": "Friends",
"friends_list": "Friends list",
"user_not_found": "User not found"
} }
} }

View File

@ -48,7 +48,7 @@
"download_options_zero": "No hay opciones de descargas disponibles", "download_options_zero": "No hay opciones de descargas disponibles",
"download_options_one": "{{count}} opción de descarga", "download_options_one": "{{count}} opción de descarga",
"download_options_other": "{{count}} opciones de descargas", "download_options_other": "{{count}} opciones de descargas",
"updated_at": "Actualizado el {{updated_at}}", "updated_at": "Actualizado el: {{updated_at}}",
"install": "Instalar", "install": "Instalar",
"resume": "Continuar", "resume": "Continuar",
"pause": "Pausa", "pause": "Pausa",
@ -74,7 +74,7 @@
"remove_from_library": "Eliminar de la biblioteca", "remove_from_library": "Eliminar de la biblioteca",
"no_downloads": "No hay descargas disponibles", "no_downloads": "No hay descargas disponibles",
"play_time": "Jugado por {{amount}}", "play_time": "Jugado por {{amount}}",
"last_time_played": "Jugado por última vez {{period}}", "last_time_played": "Jugado por última vez: {{period}}",
"not_played_yet": "Aún no has jugado a {{title}}", "not_played_yet": "Aún no has jugado a {{title}}",
"next_suggestion": "Siguiente sugerencia", "next_suggestion": "Siguiente sugerencia",
"play": "Jugar", "play": "Jugar",
@ -107,8 +107,8 @@
"executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"", "executable_section_description": "Ruta del archivo que se ejecutará cuando se presione \"Jugar\"",
"downloads_secion_title": "Descargas", "downloads_secion_title": "Descargas",
"downloads_section_description": "Buscar actualizaciones u otras versiones de este juego", "downloads_section_description": "Buscar actualizaciones u otras versiones de este juego",
"danger_zone_section_title": "Zona de Peligro", "danger_zone_section_title": "Opciones Avanzadas",
"danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra", "danger_zone_section_description": "Eliminar este juego de tu librería o los archivos descargados por Hydra (Esto solo eliminará los archivos de instalación y no el juego instalado)",
"download_in_progress": "Descarga en progreso", "download_in_progress": "Descarga en progreso",
"download_paused": "Descarga pausada", "download_paused": "Descarga pausada",
"last_downloaded_option": "Última opción descargada", "last_downloaded_option": "Última opción descargada",
@ -138,7 +138,7 @@
"deleting": "Eliminando instalador…", "deleting": "Eliminando instalador…",
"delete": "Eliminar instalador", "delete": "Eliminar instalador",
"delete_modal_title": "¿Estás seguro?", "delete_modal_title": "¿Estás seguro?",
"delete_modal_description": "Esto eliminará todos los archivos de instalación de tu computadora.", "delete_modal_description": "Esto eliminará todos los archivos de la instalación del repack del juego de tu computadora. (Si ya instalaste el juego, puedes eliminar esto, no afectará al juego)",
"install": "Instalar", "install": "Instalar",
"download_in_progress": "En progreso", "download_in_progress": "En progreso",
"queued_downloads": "Descargas en cola", "queued_downloads": "Descargas en cola",
@ -200,7 +200,8 @@
"repack_list_updated": "Lista de repacks actualizadas", "repack_list_updated": "Lista de repacks actualizadas",
"repack_count_one": "{{count}} repack ha sido añadido", "repack_count_one": "{{count}} repack ha sido añadido",
"repack_count_other": "{{count}} repacks añadidos", "repack_count_other": "{{count}} repacks añadidos",
"new_update_available": "Version {{version}} disponible" "new_update_available": "Version {{version}} disponible",
"restart_to_install_update": "Reinicia Hydra para instalar la actualización"
}, },
"system_tray": { "system_tray": {
"open": "Abrir Hydra", "open": "Abrir Hydra",
@ -223,13 +224,13 @@
"user_profile": { "user_profile": {
"amount_hours": "{{amount}} horas", "amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos", "amount_minutes": "{{amount}} minutos",
"last_time_played": "Última vez jugado {{period}}", "last_time_played": "Última vez jugado: {{period}}",
"activity": "Actividad reciente", "activity": "Actividad reciente",
"library": "Biblioteca", "library": "Biblioteca",
"total_play_time": "Total de tiempo jugado: {{amount}}", "total_play_time": "Total de tiempo jugado: {{amount}}",
"no_recent_activity_title": "Que raro, no hay nada por acá, ¿que tal si jugamos algo para empezar?", "no_recent_activity_title": "Que raro, no hay nada por acá...",
"no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!", "no_recent_activity_description": "No has jugado ningún juego recientemente, ¡vamos a cambiar eso ahora!",
"display_name": "Nombre a mostrar", "display_name": "Nombre en pantalla",
"saving": "Guardando", "saving": "Guardando",
"save": "Guardar", "save": "Guardar",
"edit_profile": "Editar perfil", "edit_profile": "Editar perfil",

View File

@ -12,11 +12,11 @@
"catalogue": "Catálogo", "catalogue": "Catálogo",
"downloads": "Downloads", "downloads": "Downloads",
"settings": "Ajustes", "settings": "Ajustes",
"my_library": "Minha biblioteca", "my_library": "Biblioteca",
"downloading_metadata": "{{title}} (Baixando metadados…)", "downloading_metadata": "{{title}} (Baixando metadados…)",
"paused": "{{title}} (Pausado)", "paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Baixando…)", "downloading": "{{title}} ({{percentage}} - Baixando…)",
"filter": "Filtrar biblioteca", "filter": "Buscar",
"home": "Início", "home": "Início",
"queued": "{{title}} (Na fila)", "queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado", "game_has_no_executable": "Jogo não possui executável selecionado",
@ -45,7 +45,7 @@
"download_options_one": "{{count}} opção de download", "download_options_one": "{{count}} opção de download",
"download_options_other": "{{count}} opções de download", "download_options_other": "{{count}} opções de download",
"updated_at": "Atualizado {{updated_at}}", "updated_at": "Atualizado {{updated_at}}",
"resume": "Resumir", "resume": "Retomar",
"pause": "Pausar", "pause": "Pausar",
"cancel": "Cancelar", "cancel": "Cancelar",
"remove": "Remover", "remove": "Remover",
@ -54,7 +54,7 @@
"calculating_eta": "Calculando tempo restante…", "calculating_eta": "Calculando tempo restante…",
"downloading_metadata": "Baixando metadados…", "downloading_metadata": "Baixando metadados…",
"filter": "Filtrar repacks", "filter": "Filtrar repacks",
"requirements": "Requisitos do sistema", "requirements": "Requisitos de sistema",
"minimum": "Mínimos", "minimum": "Mínimos",
"recommended": "Recomendados", "recommended": "Recomendados",
"paused": "Pausado", "paused": "Pausado",
@ -68,16 +68,16 @@
"add_to_library": "Adicionar à biblioteca", "add_to_library": "Adicionar à biblioteca",
"remove_from_library": "Remover da biblioteca", "remove_from_library": "Remover da biblioteca",
"no_downloads": "Nenhum download disponível", "no_downloads": "Nenhum download disponível",
"play_time": "Jogado por {{amount}}", "play_time": "Jogou por {{amount}}",
"next_suggestion": "Próxima sugestão", "next_suggestion": "Próxima sugestão",
"install": "Instalar", "install": "Instalar",
"last_time_played": "Jogou por último {{period}}", "last_time_played": "Última sessão {{period}}",
"play": "Jogar", "play": "Jogar",
"not_played_yet": "Você ainda não jogou {{title}}", "not_played_yet": "Você ainda não jogou {{title}}",
"close": "Fechar", "close": "Fechar",
"deleting": "Excluindo instalador…", "deleting": "Excluindo instalador…",
"playing_now": "Jogando agora", "playing_now": "Jogando agora",
"change": "Mudar", "change": "Explorar",
"repacks_modal_description": "Escolha o repack do jogo que deseja baixar", "repacks_modal_description": "Escolha o repack do jogo que deseja baixar",
"select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>", "select_folder_hint": "Para trocar o diretório padrão, acesse a <0>Tela de Ajustes</0>",
"download_now": "Iniciar download", "download_now": "Iniciar download",
@ -90,13 +90,13 @@
"open_screenshot": "Ver captura de tela {{number}}", "open_screenshot": "Ver captura de tela {{number}}",
"download_settings": "Ajustes do download", "download_settings": "Ajustes do download",
"downloader": "Downloader", "downloader": "Downloader",
"select_executable": "Selecionar", "select_executable": "Explorar",
"no_executable_selected": "Nenhum executável selecionado", "no_executable_selected": "Nenhum executável selecionado",
"open_folder": "Abrir pasta", "open_folder": "Abrir pasta",
"open_download_location": "Ver arquivos baixados", "open_download_location": "Ver arquivos baixados",
"create_shortcut": "Criar atalho na área de trabalho", "create_shortcut": "Criar atalho na área de trabalho",
"remove_files": "Remover arquivos", "remove_files": "Remover arquivos",
"options": "Opções", "options": "Gerenciar",
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca", "remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
"remove_from_library_title": "Tem certeza?", "remove_from_library_title": "Tem certeza?",
"executable_section_title": "Executável", "executable_section_title": "Executável",
@ -120,7 +120,7 @@
"loading": "Carregando…" "loading": "Carregando…"
}, },
"downloads": { "downloads": {
"resume": "Resumir", "resume": "Retomar",
"pause": "Pausar", "pause": "Pausar",
"eta": "Conclusão {{eta}}", "eta": "Conclusão {{eta}}",
"paused": "Pausado", "paused": "Pausado",
@ -146,12 +146,12 @@
}, },
"settings": { "settings": {
"downloads_path": "Diretório dos downloads", "downloads_path": "Diretório dos downloads",
"change": "Mudar", "change": "Explorar...",
"notifications": "Notificações", "notifications": "Notificações",
"enable_download_notifications": "Quando um download for concluído", "enable_download_notifications": "Quando um download for concluído",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada", "enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"real_debrid_api_token_label": "Token de API do Real-Debrid", "real_debrid_api_token_label": "Token de API do Real-Debrid",
"quit_app_instead_hiding": "Encerrar o Hydra ao invés de minimizá-lo ao fechar", "quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar.",
"launch_with_system": "Iniciar o Hydra junto com o sistema", "launch_with_system": "Iniciar o Hydra junto com o sistema",
"general": "Geral", "general": "Geral",
"behavior": "Comportamento", "behavior": "Comportamento",
@ -208,7 +208,7 @@
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "Programas não instalados", "title": "Programas não instalados",
"description": "Não foram encontrados no seu sistema os executáveis do Wine ou Lutris", "description": "Os executáveis do Wine ou Lutris não foram encontrados em seu sistema.",
"instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo" "instructions": "Verifique a forma correta de instalar algum deles no seu distro Linux, garantindo assim a execução normal do jogo"
}, },
"catalogue": { "catalogue": {
@ -224,8 +224,8 @@
"user_profile": { "user_profile": {
"amount_hours": "{{amount}} horas", "amount_hours": "{{amount}} horas",
"amount_minutes": "{{amount}} minutos", "amount_minutes": "{{amount}} minutos",
"last_time_played": "Jogou {{period}}", "last_time_played": "Última sessão {{period}}",
"activity": "Atividade recente", "activity": "Atividades recentes",
"library": "Biblioteca", "library": "Biblioteca",
"total_play_time": "Tempo total de jogo: {{amount}}", "total_play_time": "Tempo total de jogo: {{amount}}",
"no_recent_activity_title": "Hmmm… nada por aqui", "no_recent_activity_title": "Hmmm… nada por aqui",
@ -233,7 +233,7 @@
"display_name": "Nome de exibição", "display_name": "Nome de exibição",
"saving": "Salvando…", "saving": "Salvando…",
"save": "Salvar", "save": "Salvar",
"edit_profile": "Editar Perfil", "edit_profile": "Editar perfil",
"saved_successfully": "Salvo com sucesso", "saved_successfully": "Salvo com sucesso",
"try_again": "Por favor, tente novamente", "try_again": "Por favor, tente novamente",
"cancel": "Cancelar", "cancel": "Cancelar",
@ -241,6 +241,15 @@
"sign_out": "Sair da conta", "sign_out": "Sair da conta",
"sign_out_modal_title": "Tem certeza?", "sign_out_modal_title": "Tem certeza?",
"playing_for": "Jogando por {{amount}}", "playing_for": "Jogando por {{amount}}",
"sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?" "sign_out_modal_text": "Sua biblioteca de jogos está associada com a sua conta atual. Ao sair, sua biblioteca não aparecerá mais no Hydra e qualquer progresso não será salvo. Deseja continuar?",
"add_friends": "Adicionar Amigos",
"friend_code": "Código de amigo",
"see_profile": "Ver perfil",
"friend_request_sent": "Pedido de amizade enviado",
"friends": "Amigos",
"add": "Adicionar",
"sending": "Enviando",
"friends_list": "Lista de amigos",
"user_not_found": "Usuário não encontrado"
} }
} }

View File

@ -36,7 +36,8 @@
"no_downloads_in_progress": "Нет активных загрузок", "no_downloads_in_progress": "Нет активных загрузок",
"downloading_metadata": "Загрузка метаданных {{title}}…", "downloading_metadata": "Загрузка метаданных {{title}}…",
"downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}", "downloading": "Загрузка {{title}}… ({{percentage}} завершено) - Окончание {{eta}} - {{speed}}",
"calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…" "calculating_eta": "Загрузка {{title}}… ({{percentage}} завершено) - Подсчёт оставшегося времени…",
"checking_files": "Проверка файлов {{title}}… ({{percentage}} завершено)"
}, },
"catalogue": { "catalogue": {
"next_page": "Следующая страница", "next_page": "Следующая страница",
@ -144,7 +145,8 @@
"downloads_completed": "Завершено", "downloads_completed": "Завершено",
"queued": "В очереди", "queued": "В очереди",
"no_downloads_title": "Здесь так пусто...", "no_downloads_title": "Здесь так пусто...",
"no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать." "no_downloads_description": "Вы ещё ничего не скачали через Hydra, но никогда не поздно начать.",
"checking_files": "Проверка файлов…"
}, },
"settings": { "settings": {
"downloads_path": "Путь загрузок", "downloads_path": "Путь загрузок",
@ -198,7 +200,8 @@
"repack_list_updated": "Список репаков обновлен", "repack_list_updated": "Список репаков обновлен",
"repack_count_one": "{{count}} репак добавлен", "repack_count_one": "{{count}} репак добавлен",
"repack_count_other": "{{count}} репаков добавлено", "repack_count_other": "{{count}} репаков добавлено",
"new_update_available": "Доступна версия {{version}}" "new_update_available": "Доступна версия {{version}}",
"restart_to_install_update": "Перезапустите Hydra для установки обновления"
}, },
"system_tray": { "system_tray": {
"open": "Открыть Hydra", "open": "Открыть Hydra",

View File

@ -43,8 +43,11 @@ import "./auth/sign-out";
import "./auth/open-auth-window"; import "./auth/open-auth-window";
import "./auth/get-session-hash"; import "./auth/get-session-hash";
import "./user/get-user"; import "./user/get-user";
import "./profile/get-friend-requests";
import "./profile/get-me"; import "./profile/get-me";
import "./profile/update-friend-request";
import "./profile/update-profile"; import "./profile/update-profile";
import "./profile/send-friend-request";
ipcMain.handle("ping", () => "pong"); ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion()); ipcMain.handle("getVersion", () => app.getVersion());

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { FriendRequest } from "@types";
const getFriendRequests = async (
_event: Electron.IpcMainInvokeEvent
): Promise<FriendRequest[]> => {
return HydraApi.get(`/profile/friend-requests`).catch(() => []);
};
registerEvent("getFriendRequests", getFriendRequests);

View File

@ -9,9 +9,7 @@ const getMe = async (
_event: Electron.IpcMainInvokeEvent _event: Electron.IpcMainInvokeEvent
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
return HydraApi.get(`/profile/me`) return HydraApi.get(`/profile/me`)
.then((response) => { .then((me) => {
const me = response.data;
userAuthRepository.upsert( userAuthRepository.upsert(
{ {
id: 1, id: 1,
@ -26,12 +24,18 @@ const getMe = async (
return me; return me;
}) })
.catch((err) => { .catch(async (err) => {
if (err instanceof UserNotLoggedInError) { if (err instanceof UserNotLoggedInError) {
return null; return null;
} }
return userAuthRepository.findOne({ where: { id: 1 } }); const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
if (loggedUser) {
return { ...loggedUser, id: loggedUser.userId };
}
return null;
}); });
}; };

View File

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
const sendFriendRequest = async (
_event: Electron.IpcMainInvokeEvent,
userId: string
) => {
return HydraApi.post("/profile/friend-requests", { friendCode: userId });
};
registerEvent("sendFriendRequest", sendFriendRequest);

View File

@ -0,0 +1,19 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { FriendRequestAction } from "@types";
const updateFriendRequest = async (
_event: Electron.IpcMainInvokeEvent,
userId: string,
action: FriendRequestAction
) => {
if (action == "CANCEL") {
return HydraApi.delete(`/profile/friend-requests/${userId}`);
}
return HydraApi.patch(`/profile/friend-requests/${userId}`, {
requestState: action,
});
};
registerEvent("updateFriendRequest", updateFriendRequest);

View File

@ -26,11 +26,9 @@ const updateProfile = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
displayName: string, displayName: string,
newProfileImagePath: string | null newProfileImagePath: string | null
) => { ): Promise<UserProfile> => {
if (!newProfileImagePath) { if (!newProfileImagePath) {
return patchUserProfile(displayName).then( return patchUserProfile(displayName);
(response) => response.data as UserProfile
);
} }
const stats = fs.statSync(newProfileImagePath); const stats = fs.statSync(newProfileImagePath);
@ -42,7 +40,7 @@ const updateProfile = async (
imageLength: fileSizeInBytes, imageLength: fileSizeInBytes,
}) })
.then(async (preSignedResponse) => { .then(async (preSignedResponse) => {
const { presignedUrl, profileImageUrl } = preSignedResponse.data; const { presignedUrl, profileImageUrl } = preSignedResponse;
const mimeType = await fileTypeFromFile(newProfileImagePath); const mimeType = await fileTypeFromFile(newProfileImagePath);
@ -51,13 +49,11 @@ const updateProfile = async (
"Content-Type": mimeType?.mime, "Content-Type": mimeType?.mime,
}, },
}); });
return profileImageUrl; return profileImageUrl as string;
}) })
.catch(() => undefined); .catch(() => undefined);
return patchUserProfile(displayName, profileImageUrl).then( return patchUserProfile(displayName, profileImageUrl);
(response) => response.data as UserProfile
);
}; };
registerEvent("updateProfile", updateProfile); registerEvent("updateProfile", updateProfile);

View File

@ -10,8 +10,7 @@ const getUser = async (
userId: string userId: string
): Promise<UserProfile | null> => { ): Promise<UserProfile | null> => {
try { try {
const response = await HydraApi.get(`/user/${userId}`); const profile = await HydraApi.get(`/user/${userId}`);
const profile = response.data;
const recentGames = await Promise.all( const recentGames = await Promise.all(
profile.recentGames.map(async (game) => { profile.recentGames.map(async (game) => {

View File

@ -20,6 +20,8 @@ autoUpdater.setFeedURL({
autoUpdater.logger = logger; autoUpdater.logger = logger;
logger.log("Init Hydra");
const gotTheLock = app.requestSingleInstanceLock(); const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit(); if (!gotTheLock) app.quit();
@ -121,6 +123,7 @@ app.on("window-all-closed", () => {
app.on("before-quit", () => { app.on("before-quit", () => {
/* Disconnects libtorrent */ /* Disconnects libtorrent */
PythonInstance.kill(); PythonInstance.kill();
logger.log("Quit Hydra");
}); });
app.on("activate", () => { app.on("activate", () => {

View File

@ -10,7 +10,7 @@ import { UserNotLoggedInError } from "@shared";
export class HydraApi { export class HydraApi {
private static instance: AxiosInstance; private static instance: AxiosInstance;
private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; private static readonly EXPIRATION_OFFSET_IN_MS = 1000 * 60 * 5; // 5 minutes
private static secondsToMilliseconds = (seconds: number) => seconds * 1000; private static secondsToMilliseconds = (seconds: number) => seconds * 1000;
@ -45,6 +45,8 @@ export class HydraApi {
expirationTimestamp: tokenExpirationTimestamp, expirationTimestamp: tokenExpirationTimestamp,
}; };
logger.log("Sign in received", this.userAuth);
await userAuthRepository.upsert( await userAuthRepository.upsert(
{ {
id: 1, id: 1,
@ -74,7 +76,7 @@ export class HydraApi {
return request; return request;
}, },
(error) => { (error) => {
logger.log("request error", error); logger.error("request error", error);
return Promise.reject(error); return Promise.reject(error);
} }
); );
@ -95,12 +97,18 @@ export class HydraApi {
const { config } = error; const { config } = error;
logger.error(config.method, config.baseURL, config.url, config.headers); logger.error(
config.method,
config.baseURL,
config.url,
config.headers,
config.data
);
if (error.response) { if (error.response) {
logger.error(error.response.status, error.response.data); logger.error("Response", error.response.status, error.response.data);
} else if (error.request) { } else if (error.request) {
logger.error(error.request); logger.error("Request", error.request);
} else { } else {
logger.error("Error", error.message); logger.error("Error", error.message);
} }
@ -146,6 +154,8 @@ export class HydraApi {
this.userAuth.authToken = accessToken; this.userAuth.authToken = accessToken;
this.userAuth.expirationTimestamp = tokenExpirationTimestamp; this.userAuth.expirationTimestamp = tokenExpirationTimestamp;
logger.log("Token refreshed", this.userAuth);
userAuthRepository.upsert( userAuthRepository.upsert(
{ {
id: 1, id: 1,
@ -170,6 +180,8 @@ export class HydraApi {
private static handleUnauthorizedError = (err) => { private static handleUnauthorizedError = (err) => {
if (err instanceof AxiosError && err.response?.status === 401) { if (err instanceof AxiosError && err.response?.status === 401) {
logger.error("401 - Current credentials:", this.userAuth);
this.userAuth = { this.userAuth = {
authToken: "", authToken: "",
expirationTimestamp: 0, expirationTimestamp: 0,
@ -190,6 +202,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.get(url, this.getAxiosConfig()) .get(url, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@ -199,6 +212,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.post(url, data, this.getAxiosConfig()) .post(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@ -208,6 +222,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.put(url, data, this.getAxiosConfig()) .put(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@ -217,6 +232,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.patch(url, data, this.getAxiosConfig()) .patch(url, data, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
@ -226,6 +242,7 @@ export class HydraApi {
await this.revalidateAccessTokenIfExpired(); await this.revalidateAccessTokenIfExpired();
return this.instance return this.instance
.delete(url, this.getAxiosConfig()) .delete(url, this.getAxiosConfig())
.then((response) => response.data)
.catch(this.handleUnauthorizedError); .catch(this.handleUnauthorizedError);
} }
} }

View File

@ -10,11 +10,7 @@ export const createGame = async (game: Game) => {
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,
}) })
.then((response) => { .then((response) => {
const { const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
id: remoteId,
playTimeInMilliseconds,
lastTimePlayed,
} = response.data;
gameRepository.update( gameRepository.update(
{ objectID: game.objectID }, { objectID: game.objectID },

View File

@ -6,7 +6,7 @@ import { getSteamAppAsset } from "@main/helpers";
export const mergeWithRemoteGames = async () => { export const mergeWithRemoteGames = async () => {
return HydraApi.get("/games") return HydraApi.get("/games")
.then(async (response) => { .then(async (response) => {
for (const game of response.data) { for (const game of response) {
const localGame = await gameRepository.findOne({ const localGame = await gameRepository.findOne({
where: { where: {
objectID: game.objectId, objectID: game.objectId,

View File

@ -9,6 +9,7 @@ import type {
AppUpdaterEvent, AppUpdaterEvent,
StartGameDownloadPayload, StartGameDownloadPayload,
GameRunning, GameRunning,
FriendRequestAction,
} from "@types"; } from "@types";
contextBridge.exposeInMainWorld("electron", { contextBridge.exposeInMainWorld("electron", {
@ -136,6 +137,11 @@ contextBridge.exposeInMainWorld("electron", {
getMe: () => ipcRenderer.invoke("getMe"), getMe: () => ipcRenderer.invoke("getMe"),
updateProfile: (displayName: string, newProfileImagePath: string | null) => updateProfile: (displayName: string, newProfileImagePath: string | null) =>
ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath),
getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"),
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
sendFriendRequest: (userId: string) =>
ipcRenderer.invoke("sendFriendRequest", userId),
/* User */ /* User */
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),

View File

@ -6,7 +6,7 @@
<title>Hydra</title> <title>Hydra</title>
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: https://cdn.discordapp.com https://*.cloudfront.net https://*.s3.amazonaws.com https://steamcdn-a.akamaihd.net https://shared.akamai.steamstatic.com https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com; media-src 'self' local: data: https://steamcdn-a.akamaihd.net https://cdn.cloudflare.steamstatic.com https://cdn2.steamgriddb.com https://cdn.akamai.steamstatic.com https://shared.akamai.steamstatic.com;"
/> />
</head> </head>
<body style="background-color: #1c1c1c"> <body style="background-color: #1c1c1c">

View File

@ -25,6 +25,7 @@ import {
setGameRunning, setGameRunning,
} from "@renderer/features"; } from "@renderer/features";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
export interface AppProps { export interface AppProps {
children: React.ReactNode; children: React.ReactNode;
@ -38,6 +39,13 @@ export function App() {
const { clearDownload, setLastPacket } = useDownload(); const { clearDownload, setLastPacket } = useDownload();
const {
isFriendsModalVisible,
friendRequetsModalTab,
updateFriendRequests,
hideFriendsModal,
} = useUserDetails();
const { fetchUserDetails, updateUserDetails, clearUserDetails } = const { fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails(); useUserDetails();
@ -94,7 +102,10 @@ export function App() {
} }
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) updateUserDetails(response); if (response) {
updateUserDetails(response);
updateFriendRequests();
}
}); });
}, [fetchUserDetails, updateUserDetails, dispatch]); }, [fetchUserDetails, updateUserDetails, dispatch]);
@ -102,6 +113,7 @@ export function App() {
fetchUserDetails().then((response) => { fetchUserDetails().then((response) => {
if (response) { if (response) {
updateUserDetails(response); updateUserDetails(response);
updateFriendRequests();
showSuccessToast(t("successfully_signed_in")); showSuccessToast(t("successfully_signed_in"));
} }
}); });
@ -206,6 +218,12 @@ export function App() {
onClose={handleToastClose} onClose={handleToastClose}
/> />
<UserFriendModal
visible={isFriendsModalVisible}
initialTab={friendRequetsModalTab}
onClose={hideFriendsModal}
/>
<main> <main>
<Sidebar /> <Sidebar />

View File

@ -1,7 +1,18 @@
import { style } from "@vanilla-extract/css"; import { createVar, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css"; import { SPACING_UNIT, vars } from "../../theme.css";
export const profileContainerBackground = createVar();
export const profileContainer = style({
background: profileContainerBackground,
position: "relative",
cursor: "pointer",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const profileButton = style({ export const profileButton = style({
display: "flex", display: "flex",
cursor: "pointer", cursor: "pointer",
@ -10,9 +21,8 @@ export const profileButton = style({
color: vars.color.muted, color: vars.color.muted,
borderBottom: `solid 1px ${vars.color.border}`, borderBottom: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)", boxShadow: "0px 0px 15px 0px rgb(0 0 0 / 70%)",
":hover": { width: "100%",
backgroundColor: "rgba(255, 255, 255, 0.15)", zIndex: "10",
},
}); });
export const profileButtonContent = style({ export const profileButtonContent = style({
@ -64,3 +74,25 @@ export const profileButtonTitle = style({
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}); });
export const friendRequestContainer = style({
position: "absolute",
padding: "8px",
right: `${SPACING_UNIT}px`,
display: "flex",
top: 0,
bottom: 0,
alignItems: "center",
});
export const friendRequestButton = style({
color: vars.color.success,
cursor: "pointer",
borderRadius: "50%",
overflow: "hidden",
width: "40px",
height: "40px",
":hover": {
color: vars.color.muted,
},
});

View File

@ -1,17 +1,20 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { PersonIcon } from "@primer/octicons-react"; import { PersonAddIcon, PersonIcon } from "@primer/octicons-react";
import * as styles from "./sidebar-profile.css"; import * as styles from "./sidebar-profile.css";
import { assignInlineVars } from "@vanilla-extract/dynamic";
import { useAppSelector, useUserDetails } from "@renderer/hooks"; import { useAppSelector, useUserDetails } from "@renderer/hooks";
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { profileContainerBackground } from "./sidebar-profile.css";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function SidebarProfile() { export function SidebarProfile() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("sidebar"); const { t } = useTranslation("sidebar");
const { userDetails, profileBackground } = useUserDetails(); const { userDetails, profileBackground, friendRequests, showFriendsModal } =
useUserDetails();
const { gameRunning } = useAppSelector((state) => state.gameRunning); const { gameRunning } = useAppSelector((state) => state.gameRunning);
@ -30,46 +33,64 @@ export function SidebarProfile() {
}, [profileBackground]); }, [profileBackground]);
return ( return (
<button <div
type="button" className={styles.profileContainer}
className={styles.profileButton} style={assignInlineVars({
style={{ background: profileButtonBackground }} [profileContainerBackground]: profileButtonBackground,
onClick={handleButtonClick} })}
> >
<div className={styles.profileButtonContent}> <button
<div className={styles.profileAvatar}> type="button"
{userDetails?.profileImageUrl ? ( className={styles.profileButton}
<img onClick={handleButtonClick}
className={styles.profileAvatar} >
src={userDetails.profileImageUrl} <div className={styles.profileButtonContent}>
alt={userDetails.displayName} <div className={styles.profileAvatar}>
/> {userDetails?.profileImageUrl ? (
) : ( <img
<PersonIcon /> className={styles.profileAvatar}
)} src={userDetails.profileImageUrl}
</div> alt={userDetails.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div className={styles.profileButtonInformation}> <div className={styles.profileButtonInformation}>
<p className={styles.profileButtonTitle}> <p className={styles.profileButtonTitle}>
{userDetails ? userDetails.displayName : t("sign_in")} {userDetails ? userDetails.displayName : t("sign_in")}
</p> </p>
{userDetails && gameRunning && (
<div>
<small>{gameRunning.title}</small>
</div>
)}
</div>
{userDetails && gameRunning && ( {userDetails && gameRunning && (
<div> <img
<small>{gameRunning.title}</small> alt={gameRunning.title}
</div> width={24}
style={{ borderRadius: 4 }}
src={gameRunning.iconUrl}
/>
)} )}
</div> </div>
</button>
{userDetails && gameRunning && ( {userDetails && friendRequests.length > 0 && !gameRunning && (
<img <div className={styles.friendRequestContainer}>
alt={gameRunning.title} <button
width={24} type="button"
style={{ borderRadius: 4 }} className={styles.friendRequestButton}
src={gameRunning.iconUrl} onClick={() => showFriendsModal(UserFriendModalTab.AddFriend)}
/> >
)} <PersonAddIcon size={24} />
</div> {friendRequests.length}
</button> </button>
</div>
)}
</div>
); );
} }

View File

@ -14,6 +14,8 @@ import type {
RealDebridUser, RealDebridUser,
DownloadSource, DownloadSource,
UserProfile, UserProfile,
FriendRequest,
FriendRequestAction,
} from "@types"; } from "@types";
import type { DiskSpace } from "check-disk-space"; import type { DiskSpace } from "check-disk-space";
@ -132,6 +134,12 @@ declare global {
displayName: string, displayName: string,
newProfileImagePath: string | null newProfileImagePath: string | null
) => Promise<UserProfile>; ) => Promise<UserProfile>;
getFriendRequests: () => Promise<FriendRequest[]>;
updateFriendRequest: (
userId: string,
action: FriendRequestAction
) => Promise<void>;
sendFriendRequest: (userId: string) => Promise<void>;
} }
interface Window { interface Window {

View File

@ -1,14 +1,21 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"; import { PayloadAction, createSlice } from "@reduxjs/toolkit";
import type { UserDetails } from "@types"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
import type { FriendRequest, UserDetails } from "@types";
export interface UserDetailsState { export interface UserDetailsState {
userDetails: UserDetails | null; userDetails: UserDetails | null;
profileBackground: null | string; profileBackground: null | string;
friendRequests: FriendRequest[];
isFriendsModalVisible: boolean;
friendRequetsModalTab: UserFriendModalTab | null;
} }
const initialState: UserDetailsState = { const initialState: UserDetailsState = {
userDetails: null, userDetails: null,
profileBackground: null, profileBackground: null,
friendRequests: [],
isFriendsModalVisible: false,
friendRequetsModalTab: null,
}; };
export const userDetailsSlice = createSlice({ export const userDetailsSlice = createSlice({
@ -21,8 +28,27 @@ export const userDetailsSlice = createSlice({
setProfileBackground: (state, action: PayloadAction<string | null>) => { setProfileBackground: (state, action: PayloadAction<string | null>) => {
state.profileBackground = action.payload; state.profileBackground = action.payload;
}, },
setFriendRequests: (state, action: PayloadAction<FriendRequest[]>) => {
state.friendRequests = action.payload;
},
setFriendsModalVisible: (
state,
action: PayloadAction<UserFriendModalTab>
) => {
state.isFriendsModalVisible = true;
state.friendRequetsModalTab = action.payload;
},
setFriendsModalHidden: (state) => {
state.isFriendsModalVisible = false;
state.friendRequetsModalTab = null;
},
}, },
}); });
export const { setUserDetails, setProfileBackground } = export const {
userDetailsSlice.actions; setUserDetails,
setProfileBackground,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} = userDetailsSlice.actions;

View File

@ -2,16 +2,27 @@ import { useCallback } from "react";
import { average } from "color.js"; import { average } from "color.js";
import { useAppDispatch, useAppSelector } from "./redux"; import { useAppDispatch, useAppSelector } from "./redux";
import { setProfileBackground, setUserDetails } from "@renderer/features"; import {
setProfileBackground,
setUserDetails,
setFriendRequests,
setFriendsModalVisible,
setFriendsModalHidden,
} from "@renderer/features";
import { darkenColor } from "@renderer/helpers"; import { darkenColor } from "@renderer/helpers";
import { UserDetails } from "@types"; import { FriendRequestAction, UserDetails } from "@types";
import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal";
export function useUserDetails() { export function useUserDetails() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { userDetails, profileBackground } = useAppSelector( const {
(state) => state.userDetails userDetails,
); profileBackground,
friendRequests,
isFriendsModalVisible,
friendRequetsModalTab,
} = useAppSelector((state) => state.userDetails);
const clearUserDetails = useCallback(async () => { const clearUserDetails = useCallback(async () => {
dispatch(setUserDetails(null)); dispatch(setUserDetails(null));
@ -78,13 +89,56 @@ export function useUserDetails() {
[updateUserDetails] [updateUserDetails]
); );
const updateFriendRequests = useCallback(async () => {
const friendRequests = await window.electron.getFriendRequests();
dispatch(setFriendRequests(friendRequests));
}, [dispatch]);
const showFriendsModal = useCallback(
(tab: UserFriendModalTab) => {
dispatch(setFriendsModalVisible(tab));
updateFriendRequests();
},
[dispatch]
);
const hideFriendsModal = useCallback(() => {
dispatch(setFriendsModalHidden());
}, [dispatch]);
const sendFriendRequest = useCallback(
async (userId: string) => {
return window.electron
.sendFriendRequest(userId)
.then(() => updateFriendRequests());
},
[updateFriendRequests]
);
const updateFriendRequestState = useCallback(
async (userId: string, action: FriendRequestAction) => {
return window.electron
.updateFriendRequest(userId, action)
.then(() => updateFriendRequests());
},
[updateFriendRequests]
);
return { return {
userDetails, userDetails,
profileBackground,
friendRequests,
friendRequetsModalTab,
isFriendsModalVisible,
showFriendsModal,
hideFriendsModal,
fetchUserDetails, fetchUserDetails,
signOut, signOut,
clearUserDetails, clearUserDetails,
updateUserDetails, updateUserDetails,
patchUser, patchUser,
profileBackground, sendFriendRequest,
updateFriendRequests,
updateFriendRequestState,
}; };
} }

View File

@ -0,0 +1 @@
export * from "./user-friend-modal";

View File

@ -0,0 +1,140 @@
import { Button, TextField } from "@renderer/components";
import { useToast, useUserDetails } from "@renderer/hooks";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { UserFriendRequest } from "./user-friend-request";
export interface UserFriendModalAddFriendProps {
closeModal: () => void;
}
export const UserFriendModalAddFriend = ({
closeModal,
}: UserFriendModalAddFriendProps) => {
const { t } = useTranslation("user_profile");
const [friendCode, setFriendCode] = useState("");
const [isAddingFriend, setIsAddingFriend] = useState(false);
const navigate = useNavigate();
const { sendFriendRequest, updateFriendRequestState, friendRequests } =
useUserDetails();
const { showErrorToast } = useToast();
const handleClickAddFriend = () => {
setIsAddingFriend(true);
sendFriendRequest(friendCode)
.then(() => {
// TODO: add validation for this input?
setFriendCode("");
})
.catch(() => {
showErrorToast("Não foi possível enviar o pedido de amizade");
})
.finally(() => {
setIsAddingFriend(false);
});
};
const resetAndClose = () => {
setFriendCode("");
closeModal();
};
const handleClickRequest = (userId: string) => {
resetAndClose();
navigate(`/user/${userId}`);
};
const handleClickSeeProfile = () => {
resetAndClose();
// TODO: add validation for this input?
navigate(`/user/${friendCode}`);
};
const handleClickCancelFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "CANCEL").catch(() => {
showErrorToast("Falha ao cancelar convite");
});
};
const handleClickAcceptFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "ACCEPTED").catch(() => {
showErrorToast("Falha ao aceitar convite");
});
};
const handleClickRefuseFriendRequest = (userId: string) => {
updateFriendRequestState(userId, "REFUSED").catch(() => {
showErrorToast("Falha ao recusar convite");
});
};
return (
<>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
}}
>
<TextField
label={t("friend_code")}
value={friendCode}
minLength={8}
maxLength={8}
containerProps={{ style: { width: "100%" } }}
onChange={(e) => setFriendCode(e.target.value)}
/>
<Button
disabled={isAddingFriend}
style={{ alignSelf: "end" }}
type="button"
onClick={handleClickAddFriend}
>
{isAddingFriend ? t("sending") : t("add")}
</Button>
<Button
onClick={handleClickSeeProfile}
disabled={isAddingFriend}
style={{ alignSelf: "end" }}
type="button"
>
{t("see_profile")}
</Button>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h3>Pendentes</h3>
{friendRequests.map((request) => {
return (
<UserFriendRequest
key={request.id}
displayName={request.displayName}
isRequestSent={request.type === "SENT"}
profileImageUrl={request.profileImageUrl}
userId={request.id}
onClickAcceptRequest={handleClickAcceptFriendRequest}
onClickCancelRequest={handleClickCancelFriendRequest}
onClickRefuseRequest={handleClickRefuseFriendRequest}
onClickRequest={handleClickRequest}
/>
);
})}
</div>
</>
);
};

View File

@ -0,0 +1,92 @@
import { SPACING_UNIT, vars } from "../../../theme.css";
import { style } from "@vanilla-extract/css";
export const profileContentBox = style({
display: "flex",
gap: `${SPACING_UNIT * 3}px`,
alignItems: "center",
borderRadius: "4px",
border: `solid 1px ${vars.color.border}`,
width: "100%",
boxShadow: "0px 0px 15px 0px rgba(0, 0, 0, 0.7)",
transition: "all ease 0.3s",
});
export const friendAvatarContainer = style({
width: "35px",
minWidth: "35px",
height: "35px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
});
export const friendListDisplayName = style({
fontWeight: "bold",
fontSize: vars.size.body,
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const profileAvatar = style({
height: "100%",
width: "100%",
objectFit: "cover",
});
export const friendListContainer = style({
width: "100%",
height: "54px",
transition: "all ease 0.2s",
position: "relative",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const friendListButton = style({
display: "flex",
alignItems: "center",
position: "absolute",
cursor: "pointer",
height: "100%",
width: "100%",
flexDirection: "row",
color: vars.color.body,
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
padding: `0 ${SPACING_UNIT}px`,
});
export const friendRequestItem = style({
color: vars.color.body,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const acceptRequestButton = style({
cursor: "pointer",
color: vars.color.body,
width: "28px",
height: "28px",
":hover": {
color: vars.color.success,
},
});
export const cancelRequestButton = style({
cursor: "pointer",
color: vars.color.body,
width: "28px",
height: "28px",
":hover": {
color: vars.color.danger,
},
});

View File

@ -0,0 +1,77 @@
import { Button, Modal } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { UserFriendModalAddFriend } from "./user-friend-modal-add-friend";
export enum UserFriendModalTab {
FriendsList,
AddFriend,
}
export interface UserAddFriendsModalProps {
visible: boolean;
onClose: () => void;
initialTab: UserFriendModalTab | null;
}
export const UserFriendModal = ({
visible,
onClose,
initialTab,
}: UserAddFriendsModalProps) => {
const { t } = useTranslation("user_profile");
const tabs = [t("friends_list"), t("add_friends")];
const [currentTab, setCurrentTab] = useState(
initialTab || UserFriendModalTab.FriendsList
);
useEffect(() => {
if (initialTab != null) {
setCurrentTab(initialTab);
}
}, [initialTab]);
const renderTab = () => {
if (currentTab == UserFriendModalTab.FriendsList) {
return <></>;
}
if (currentTab == UserFriendModalTab.AddFriend) {
return <UserFriendModalAddFriend closeModal={onClose} />;
}
return <></>;
};
return (
<Modal visible={visible} title={t("friends")} onClose={onClose}>
<div
style={{
display: "flex",
width: "500px",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<section style={{ display: "flex", gap: `${SPACING_UNIT}px` }}>
{tabs.map((tab, index) => {
return (
<Button
key={tab}
theme={index === currentTab ? "primary" : "outline"}
onClick={() => setCurrentTab(index)}
>
{tab}
</Button>
);
})}
</section>
<h2>{tabs[currentTab]}</h2>
{renderTab()}
</div>
</Modal>
);
};

View File

@ -0,0 +1,97 @@
import {
CheckCircleIcon,
PersonIcon,
XCircleIcon,
} from "@primer/octicons-react";
import * as styles from "./user-friend-modal.css";
import cn from "classnames";
import { SPACING_UNIT } from "@renderer/theme.css";
export interface UserFriendRequestProps {
userId: string;
profileImageUrl: string | null;
displayName: string;
isRequestSent: boolean;
onClickCancelRequest: (userId: string) => void;
onClickAcceptRequest: (userId: string) => void;
onClickRefuseRequest: (userId: string) => void;
onClickRequest: (userId: string) => void;
}
export const UserFriendRequest = ({
userId,
profileImageUrl,
displayName,
isRequestSent,
onClickCancelRequest,
onClickAcceptRequest,
onClickRefuseRequest,
onClickRequest,
}: UserFriendRequestProps) => {
return (
<div className={cn(styles.friendListContainer, styles.profileContentBox)}>
<button
type="button"
className={styles.friendListButton}
onClick={() => onClickRequest(userId)}
>
<div className={styles.friendAvatarContainer}>
{profileImageUrl ? (
<img
className={styles.profileAvatar}
alt={displayName}
src={profileImageUrl}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
flex: "1",
minWidth: 0,
}}
>
<p className={styles.friendListDisplayName}>{displayName}</p>
<small>{isRequestSent ? "Pedido enviado" : "Pedido recebido"}</small>
</div>
</button>
<div
style={{
position: "absolute",
right: "8px",
display: "flex",
gap: `${SPACING_UNIT}px`,
}}
>
{isRequestSent ? (
<button
className={styles.cancelRequestButton}
onClick={() => onClickCancelRequest(userId)}
>
<XCircleIcon size={28} />
</button>
) : (
<>
<button
className={styles.acceptRequestButton}
onClick={() => onClickAcceptRequest(userId)}
>
<CheckCircleIcon size={28} />
</button>
<button
className={styles.cancelRequestButton}
onClick={() => onClickRefuseRequest(userId)}
>
<XCircleIcon size={28} />
</button>
</>
)}
</div>
</div>
);
};

View File

@ -1,9 +1,8 @@
import { UserGame, UserProfile } from "@types"; import { UserGame, UserProfile } from "@types";
import cn from "classnames"; import cn from "classnames";
import * as styles from "./user.css"; import * as styles from "./user.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SteamLogo from "@renderer/assets/steam-logo.svg?react"; import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { import {
@ -14,10 +13,11 @@ import {
} from "@renderer/hooks"; } from "@renderer/hooks";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers";
import { PersonIcon, TelescopeIcon } from "@primer/octicons-react"; import { PersonIcon, PlusIcon, TelescopeIcon } from "@primer/octicons-react";
import { Button, Link } from "@renderer/components"; import { Button, Link } from "@renderer/components";
import { UserEditProfileModal } from "./user-edit-modal"; import { UserEditProfileModal } from "./user-edit-modal";
import { UserSignOutModal } from "./user-signout-modal"; import { UserSignOutModal } from "./user-signout-modal";
import { UserFriendModalTab } from "../shared-modals/user-friend-modal";
const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120;
@ -32,7 +32,13 @@ export function UserContent({
}: ProfileContentProps) { }: ProfileContentProps) {
const { t, i18n } = useTranslation("user_profile"); const { t, i18n } = useTranslation("user_profile");
const { userDetails, profileBackground, signOut } = useUserDetails(); const {
userDetails,
profileBackground,
signOut,
updateFriendRequests,
showFriendsModal,
} = useUserDetails();
const { showSuccessToast } = useToast(); const { showSuccessToast } = useToast();
const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showEditProfileModal, setShowEditProfileModal] = useState(false);
@ -72,6 +78,10 @@ export function UserContent({
setShowEditProfileModal(true); setShowEditProfileModal(true);
}; };
const handleOnClickFriend = (userId: string) => {
navigate(`/user/${userId}`);
};
const handleConfirmSignout = async () => { const handleConfirmSignout = async () => {
await signOut(); await signOut();
@ -82,6 +92,10 @@ export function UserContent({
const isMe = userDetails?.id == userProfile.id; const isMe = userDetails?.id == userProfile.id;
useEffect(() => {
if (isMe) updateFriendRequests();
}, [isMe]);
const profileContentBoxBackground = useMemo(() => { const profileContentBoxBackground = useMemo(() => {
if (profileBackground) return profileBackground; if (profileBackground) return profileBackground;
/* TODO: Render background colors for other users */ /* TODO: Render background colors for other users */
@ -216,9 +230,11 @@ export function UserContent({
<TelescopeIcon size={24} /> <TelescopeIcon size={24} />
</div> </div>
<h2>{t("no_recent_activity_title")}</h2> <h2>{t("no_recent_activity_title")}</h2>
<p style={{ fontFamily: "Fira Sans" }}> {isMe && (
{t("no_recent_activity_description")} <p style={{ fontFamily: "Fira Sans" }}>
</p> {t("no_recent_activity_description")}
</p>
)}
</div> </div>
) : ( ) : (
<div <div
@ -259,55 +275,128 @@ export function UserContent({
)} )}
</div> </div>
<div className={cn(styles.contentSidebar, styles.profileGameSection)}> <div className={styles.contentSidebar}>
<div <div className={styles.profileGameSection}>
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>{t("library")}</h2>
<div <div
style={{ style={{
flex: 1, display: "flex",
backgroundColor: vars.color.border, alignItems: "center",
height: "1px", justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
}} }}
/> >
<h3 style={{ fontWeight: "400" }}> <h2>{t("library")}</h2>
{userProfile.libraryGames.length}
</h3> <div
style={{
flex: 1,
backgroundColor: vars.color.border,
height: "1px",
}}
/>
<h3 style={{ fontWeight: "400" }}>
{userProfile.libraryGames.length}
</h3>
</div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button
key={game.objectID}
className={cn(styles.gameListItem, styles.profileContentBox)}
onClick={() => handleGameClick(game)}
title={game.title}
>
{game.iconUrl ? (
<img
className={styles.libraryGameIcon}
src={game.iconUrl}
alt={game.title}
/>
) : (
<SteamLogo className={styles.libraryGameIcon} />
)}
</button>
))}
</div>
</div> </div>
<small>{t("total_play_time", { amount: formatPlayTime() })}</small>
<div {(isMe ||
style={{ (userProfile.friends && userProfile.friends.length > 0)) && (
display: "grid", <div className={styles.friendsSection}>
gridTemplateColumns: "repeat(4, 1fr)",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.libraryGames.map((game) => (
<button <button
key={game.objectID} className={styles.friendsSectionHeader}
className={cn(styles.gameListItem, styles.profileContentBox)} onClick={() => showFriendsModal(UserFriendModalTab.FriendsList)}
onClick={() => handleGameClick(game)}
title={game.title}
> >
{game.iconUrl ? ( <h2>{t("friends")}</h2>
<img
className={styles.libraryGameIcon} <div
src={game.iconUrl} style={{
alt={game.title} flex: 1,
/> backgroundColor: vars.color.border,
) : ( height: "1px",
<SteamLogo className={styles.libraryGameIcon} /> }}
)} />
<h3 style={{ fontWeight: "400" }}>
{userProfile.friends.length}
</h3>
</button> </button>
))}
</div> <div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT}px`,
}}
>
{userProfile.friends.map((friend) => {
return (
<button
key={friend.id}
className={cn(
styles.profileContentBox,
styles.friendListContainer
)}
onClick={() => handleOnClickFriend(friend.id)}
>
<div className={styles.friendAvatarContainer}>
{friend.profileImageUrl ? (
<img
className={styles.friendProfileIcon}
src={friend.profileImageUrl}
alt={friend.displayName}
/>
) : (
<PersonIcon size={24} />
)}
</div>
<p className={styles.friendListDisplayName}>
{friend.displayName}
</p>
</button>
);
})}
{isMe && (
<Button
theme="outline"
onClick={() =>
showFriendsModal(UserFriendModalTab.AddFriend)
}
>
<PlusIcon /> {t("add")}
</Button>
)}
</div>
</div>
)}
</div> </div>
</div> </div>
</> </>

View File

@ -11,6 +11,7 @@ export const wrapper = style({
export const profileContentBox = style({ export const profileContentBox = style({
display: "flex", display: "flex",
cursor: "pointer",
gap: `${SPACING_UNIT * 3}px`, gap: `${SPACING_UNIT * 3}px`,
alignItems: "center", alignItems: "center",
borderRadius: "4px", borderRadius: "4px",
@ -35,6 +36,29 @@ export const profileAvatarContainer = style({
zIndex: 1, zIndex: 1,
}); });
export const friendAvatarContainer = style({
width: "35px",
minWidth: "35px",
height: "35px",
borderRadius: "50%",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
overflow: "hidden",
border: `solid 1px ${vars.color.border}`,
boxShadow: "0px 0px 5px 0px rgba(0, 0, 0, 0.7)",
});
export const friendListDisplayName = style({
fontWeight: "bold",
fontSize: vars.size.body,
textAlign: "left",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});
export const profileAvatarEditContainer = style({ export const profileAvatarEditContainer = style({
width: "128px", width: "128px",
height: "128px", height: "128px",
@ -53,8 +77,6 @@ export const profileAvatarEditContainer = style({
export const profileAvatar = style({ export const profileAvatar = style({
height: "100%", height: "100%",
width: "100%", width: "100%",
borderRadius: "50%",
overflow: "hidden",
objectFit: "cover", objectFit: "cover",
}); });
@ -86,14 +108,36 @@ export const profileContent = style({
export const profileGameSection = style({ export const profileGameSection = style({
width: "100%", width: "100%",
height: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
}); });
export const friendsSection = style({
width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
});
export const friendsSectionHeader = style({
fontSize: vars.size.body,
color: vars.color.body,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: `${SPACING_UNIT * 2}px`,
":hover": {
color: vars.color.muted,
},
});
export const contentSidebar = style({ export const contentSidebar = style({
width: "100%", width: "100%",
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 3}px`,
"@media": { "@media": {
"(min-width: 768px)": { "(min-width: 768px)": {
width: "100%", width: "100%",
@ -116,12 +160,17 @@ export const libraryGameIcon = style({
borderRadius: "4px", borderRadius: "4px",
}); });
export const friendProfileIcon = style({
height: "100%",
});
export const feedItem = style({ export const feedItem = style({
color: vars.color.body, color: vars.color.body,
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`, gap: `${SPACING_UNIT * 2}px`,
width: "100%", width: "100%",
overflow: "hidden",
height: "72px", height: "72px",
transition: "all ease 0.2s", transition: "all ease 0.2s",
cursor: "pointer", cursor: "pointer",
@ -143,6 +192,19 @@ export const gameListItem = style({
}, },
}); });
export const friendListContainer = style({
color: vars.color.body,
width: "100%",
height: "54px",
padding: `0 ${SPACING_UNIT}px`,
gap: `${SPACING_UNIT + SPACING_UNIT / 2}px`,
transition: "all ease 0.2s",
position: "relative",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const gameInformation = style({ export const gameInformation = style({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",

View File

@ -2,18 +2,23 @@ import { UserProfile } from "@types";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { setHeaderTitle } from "@renderer/features"; import { setHeaderTitle } from "@renderer/features";
import { useAppDispatch } from "@renderer/hooks"; import { useAppDispatch, useToast } from "@renderer/hooks";
import { UserSkeleton } from "./user-skeleton"; import { UserSkeleton } from "./user-skeleton";
import { UserContent } from "./user-content"; import { UserContent } from "./user-content";
import { SkeletonTheme } from "react-loading-skeleton"; import { SkeletonTheme } from "react-loading-skeleton";
import { vars } from "@renderer/theme.css"; import { vars } from "@renderer/theme.css";
import * as styles from "./user.css"; import * as styles from "./user.css";
import { useTranslation } from "react-i18next";
export const User = () => { export const User = () => {
const { userId } = useParams(); const { userId } = useParams();
const [userProfile, setUserProfile] = useState<UserProfile>(); const [userProfile, setUserProfile] = useState<UserProfile>();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("user_profile");
const { showErrorToast } = useToast();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const getUserProfile = useCallback(() => { const getUserProfile = useCallback(() => {
@ -22,10 +27,11 @@ export const User = () => {
dispatch(setHeaderTitle(userProfile.displayName)); dispatch(setHeaderTitle(userProfile.displayName));
setUserProfile(userProfile); setUserProfile(userProfile);
} else { } else {
showErrorToast(t("user_not_found"));
navigate(-1); navigate(-1);
} }
}); });
}, [dispatch, userId]); }, [dispatch, userId, t]);
useEffect(() => { useEffect(() => {
getUserProfile(); getUserProfile();

View File

@ -10,6 +10,8 @@ export type GameStatus =
export type GameShop = "steam" | "epic"; export type GameShop = "steam" | "epic";
export type FriendRequestAction = "ACCEPTED" | "REFUSED" | "CANCEL";
export interface SteamGenre { export interface SteamGenre {
id: string; id: string;
name: string; name: string;
@ -269,14 +271,27 @@ export interface UserDetails {
profileImageUrl: string | null; profileImageUrl: string | null;
} }
export interface UserFriend {
id: string;
displayName: string;
profileImageUrl: string | null;
}
export interface FriendRequest {
id: string;
displayName: string;
profileImageUrl: string | null;
type: "SENT" | "RECEIVED";
}
export interface UserProfile { export interface UserProfile {
id: string; id: string;
displayName: string; displayName: string;
username: string;
profileImageUrl: string | null; profileImageUrl: string | null;
totalPlayTimeInSeconds: number; totalPlayTimeInSeconds: number;
libraryGames: UserGame[]; libraryGames: UserGame[];
recentGames: UserGame[]; recentGames: UserGame[];
friends: UserFriend[];
} }
export interface DownloadSource { export interface DownloadSource {

View File

@ -2433,6 +2433,13 @@
modern-ahocorasick "^1.0.0" modern-ahocorasick "^1.0.0"
picocolors "^1.0.0" picocolors "^1.0.0"
"@vanilla-extract/dynamic@^2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@vanilla-extract/dynamic/-/dynamic-2.1.1.tgz#bc93a577b127a7dcb6f254973d13a863029a7faf"
integrity sha512-iqf736036ujEIKsIq28UsBEMaLC2vR2DhwKyrG3NDb/fRy9qL9FKl1TqTtBV4daU30Uh3saeik4vRzN8bzQMbw==
dependencies:
"@vanilla-extract/private" "^1.0.5"
"@vanilla-extract/integration@^7.1.3": "@vanilla-extract/integration@^7.1.3":
version "7.1.4" version "7.1.4"
resolved "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-7.1.4.tgz" resolved "https://registry.npmjs.org/@vanilla-extract/integration/-/integration-7.1.4.tgz"
@ -2456,6 +2463,11 @@
resolved "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.4.tgz" resolved "https://registry.npmjs.org/@vanilla-extract/private/-/private-1.0.4.tgz"
integrity sha512-8FGD6AejeC/nXcblgNCM5rnZb9KXa4WNkR03HCWtdJBpANjTgjHEglNLFnhuvdQ78tC6afaxBPI+g7F2NX3tgg== integrity sha512-8FGD6AejeC/nXcblgNCM5rnZb9KXa4WNkR03HCWtdJBpANjTgjHEglNLFnhuvdQ78tC6afaxBPI+g7F2NX3tgg==
"@vanilla-extract/private@^1.0.5":
version "1.0.5"
resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.5.tgz#8c08ac4851f4cc89a3dcdb858d8938e69b1481c4"
integrity sha512-6YXeOEKYTA3UV+RC8DeAjFk+/okoNz/h88R+McnzA2zpaVqTR/Ep+vszkWYlGBcMNO7vEkqbq5nT/JMMvhi+tw==
"@vanilla-extract/recipes@^0.5.2": "@vanilla-extract/recipes@^0.5.2":
version "0.5.2" version "0.5.2"
resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz" resolved "https://registry.npmjs.org/@vanilla-extract/recipes/-/recipes-0.5.2.tgz"