diff --git a/src/locales/ca/translation.json b/src/locales/ca/translation.json index c756745a..9124af79 100644 --- a/src/locales/ca/translation.json +++ b/src/locales/ca/translation.json @@ -1,4 +1,7 @@ { + "app": { + "successfully_signed_in": "Has entrat correctament" + }, "home": { "featured": "Destacats", "trending": "Populars", @@ -14,7 +17,10 @@ "paused": "{{title}} (Pausat)", "downloading": "{{title}} ({{percentage}} - S'està baixant…)", "filter": "Filtra la biblioteca", - "home": "Inici" + "home": "Inici", + "queued": "{{title}} (En espera)", + "game_has_no_executable": "El joc encara no té un executable seleccionat", + "sign_in": "Entra" }, "header": { "search": "Cerca jocs", @@ -29,7 +35,9 @@ "bottom_panel": { "no_downloads_in_progress": "Cap baixada en curs", "downloading_metadata": "S'estan baixant les metadades de: {{title}}…", - "downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}" + "downloading": "S'està baixant: {{title}}… ({{percentage}} complet) - Finalització: {{eta}} - {{speed}}", + "calculating_eta": "Descarregant {{title}}… ({{percentage}} completat) - Calculant el temps restant…", + "checking_files": "Comprovant els fitxers de {{title}}… ({{percentage}} completat)" }, "catalogue": { "next_page": "Pàgina següent", @@ -47,12 +55,14 @@ "cancel": "Cancel·la", "remove": "Elimina", "space_left_on_disk": "{{space}} lliures al disc", - "eta": "Finalització: {{eta}}", + "eta": "Finalitza en: {{eta}}", + "calculating_eta": "Calculant temps estimat…", "downloading_metadata": "S'estan baixant les metadades…", "filter": "Filtra els reempaquetats", "requirements": "Requisits del sistema", "minimum": "Mínims", "recommended": "Recomanats", + "paused": "Paused", "release_date": "Publicat el {{date}}", "publisher": "Publicat per {{publisher}}", "hours": "hores", @@ -81,7 +91,29 @@ "previous_screenshot": "Captura anterior", "next_screenshot": "Captura següent", "screenshot": "Captura {{number}}", - "open_screenshot": "Obre la captura {{number}}" + "open_screenshot": "Obre la captura {{number}}", + "download_settings": "Configuració de descàrrega", + "downloader": "Descarregador", + "select_executable": "Selecciona", + "no_executable_selected": "No hi ha executable selccionat", + "open_folder": "Obre carpeta", + "open_download_location": "Visualitzar fitxers descarregats", + "create_shortcut": "Crear accés directe a l'escriptori", + "remove_files": "Elimina fitxers", + "remove_from_library_title": "Segur?", + "remove_from_library_description": "Això eliminarà el videojoc {{game}} del teu catàleg", + "options": "Opcions", + "executable_section_title": "Executable", + "executable_section_description": "Directori del fitxer des d'on s'executarà quan es cliqui a \"Executar\"", + "downloads_secion_title": "Descàrregues", + "downloads_section_description": "Comprova actualitzacions o altres versions del videojoc", + "danger_zone_section_title": "Zona de perill", + "danger_zone_section_description": "Elimina aquest videojoc del teu catàleg o els fitxers descarregats per Hydra", + "download_in_progress": "Descàrrega en progrés", + "download_paused": "Descàrrega en pausa", + "last_downloaded_option": "Opció de l'última descàrrega", + "create_shortcut_success": "Accés directe creat satisfactòriament", + "create_shortcut_error": "Error al crear l'accés directe" }, "activation": { "title": "Activa l'Hydra", @@ -98,6 +130,7 @@ "paused": "Pausada", "verifying": "S'està verificant…", "completed": "Completada", + "removed": "No descarregat", "cancel": "Cancel·la", "filter": "Filtra els jocs baixats", "remove": "Elimina", @@ -106,7 +139,14 @@ "delete": "Elimina l'instal·lador", "delete_modal_title": "N'estàs segur?", "delete_modal_description": "S'eliminaran de l'ordinador tots els fitxers d'instal·lació", - "install": "Instal·la" + "install": "Instal·la", + "download_in_progress": "En progrés", + "queued_downloads": "Descàrregues en espera", + "downloads_completed": "Completat", + "queued": "En espera", + "no_downloads_title": "Buit", + "no_downloads_description": "No has descarregat res amb Hydra encara, però mai és tard per començar a fer-ho.", + "checking_files": "Comprovant fitxers…" }, "settings": { "downloads_path": "Ruta de baixades", @@ -119,16 +159,49 @@ "launch_with_system": "Inicia l'Hydra quan s'iniciï el sistema", "general": "General", "behavior": "Comportament", + "download_sources": "Fonts de descàrrega", + "language": "Idioma", + "real_debrid_api_token": "Testimoni API", "enable_real_debrid": "Activa el Real Debrid", + "real_debrid_description": "Real-Debrid és un programa de descàrrega sense restriccions que us permet descarregar fitxers a l'instant i al màxim de la vostra velocitat d'Internet.", + "real_debrid_invalid_token": "Invalida el testimoni de l'API", "real_debrid_api_token_hint": "Pots obtenir la teva clau de l'API <0>aquí.", - "save_changes": "Desa els canvis" + "real_debrid_free_account_error": "L'usuari \"{{username}}\" és un compte gratuït. Si us plau subscriu-te a Real-Debrid", + "real_debrid_linked_message": "Compte \"{{username}}\" vinculat", + "save_changes": "Desa els canvis", + "changes_saved": "Els canvis s'han desat correctament", + "download_sources_description": "Hydra buscarà els enllaços de descàrrega d'aquestes fonts. L'URL d'origen ha de ser un enllaç directe a un fitxer .json que contingui els enllaços de descàrrega.", + "validate_download_source": "Valida", + "remove_download_source": "Elimina", + "add_download_source": "Afegeix font", + "download_count_zero": "No hi ha baixades a la llista", + "download_count_one": "{{countFormatted}} a la llista de baixades", + "download_count_other": "{{countFormatted}} baixades a la llista", + "download_options_zero": "No hi ha cap descàrrega disponible", + "download_options_one": "{{countFormatted}} descàrrega disponible", + "download_options_other": "{{countFormatted}} baixades disponibles", + "download_source_url": "Descarrega l'URL de la font", + "add_download_source_description": "Inseriu la URL que conté el fitxer .json", + "download_source_up_to_date": "Actualitzat", + "download_source_errored": "S'ha produït un error", + "sync_download_sources": "Sincronitza fonts", + "removed_download_source": "S'ha eliminat la font de descàrrega", + "added_download_source": "Added download source", + "download_sources_synced": "Totes les fonts de descàrrega estan sincronitzades", + "insert_valid_json_url": "Insereix una URL JSON vàlida", + "found_download_option_zero": "No s'ha trobat cap opció de descàrrega", + "found_download_option_one": "S'ha trobat l'opció de baixada de {{countFormatted}}", + "found_download_option_other": "S'han trobat {{countFormatted}} opcions de baixada", + "import": "Import" }, "notifications": { "download_complete": "La baixada ha finalitzat", "game_ready_to_install": "{{title}} ja es pot instal·lar", "repack_list_updated": "S'ha actualitzat la llista de reempaquetats", "repack_count_one": "S'ha afegit {{count}} reempaquetat", - "repack_count_other": "S'han afegit {{count}} reempaquetats" + "repack_count_other": "S'han afegit {{count}} reempaquetats", + "new_update_available": "Versió {{version}} disponible", + "restart_to_install_update": "Reinicieu Hydra per instal·lar l'actualització" }, "system_tray": { "open": "Obre l'Hydra", @@ -144,5 +217,39 @@ }, "modal": { "close": "Botó de tancar" + }, + "forms": { + "toggle_password_visibility": "Commuta la visibilitat de la contrasenya" + }, + "user_profile": { + "amount_hours": "{{amount}} hores", + "amount_minutes": "{{amount}} minuts", + "last_time_played": "Última partida {{period}}", + "activity": "Activitat recent", + "library": "Biblioteca", + "total_play_time": "Temps total de joc:{{amount}}", + "no_recent_activity_title": "Hmmm… encara no res", + "no_recent_activity_description": "No has jugat a cap joc recentment. És el moment de canviar-ho!", + "display_name": "Nom de visualització", + "saving": "Desant", + "save": "Desa", + "edit_profile": "Edita el Perfil", + "saved_successfully": "S'ha desat correctament", + "try_again": "Siusplau torna-ho a provar", + "sign_out_modal_title": "Segur?", + "cancel": "Cancel·la", + "successfully_signed_out": "S'ha tancat la sessió correctament", + "sign_out": "Tanca sessió", + "playing_for": "Jugant per {{amount}}", + "sign_out_modal_text": "La vostra biblioteca està enllaçada amb el vostre compte actual. Quan tanqueu la sessió, la vostra biblioteca ja no serà visible i cap progrés no es desarà. Voleu continuar amb tancar la sessió?", + "add_friends": "Afegeix amics", + "add": "Afegeix", + "friend_code": "Codi de l'amic", + "see_profile": "Veure Perfil", + "sending": "Enviant", + "friend_request_sent": "Sol·licitud d'amistat enviada", + "friends": "Amistats", + "friends_list": "Llista d'amistats", + "user_not_found": "Usuari no trobat" } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3f1bcdcd..b24509d3 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -250,6 +250,17 @@ "friend_request_sent": "Friend request sent", "friends": "Friends", "friends_list": "Friends list", - "user_not_found": "User not found" + "user_not_found": "User not found", + "block_user": "Block user", + "add_friend": "Add friend", + "request_sent": "Request sent", + "request_received": "Request received", + "accept_request": "Accept request", + "ignore_request": "Ignore request", + "cancel_request": "Cancel request", + "undo_friendship": "Undo friendship", + "request_accepted": "Request accepted", + "user_blocked_successfully": "User blocked successfully", + "user_block_modal_text": "This will block {{displayName}}" } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 568116f8..ef94c31f 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -250,6 +250,17 @@ "add": "Adicionar", "sending": "Enviando", "friends_list": "Lista de amigos", - "user_not_found": "Usuário não encontrado" + "user_not_found": "Usuário não encontrado", + "block_user": "Bloquear", + "add_friend": "Adicionar amigo", + "request_sent": "Pedido enviado", + "request_received": "Pedido recebido", + "accept_request": "Aceitar pedido", + "ignore_request": "Ignorar pedido", + "cancel_request": "Cancelar pedido", + "undo_friendship": "Desfazer amizade", + "request_accepted": "Pedido de amizade aceito", + "user_blocked_successfully": "Usuário bloqueado com sucesso", + "user_block_modal_text": "Bloquear {{displayName}}" } } diff --git a/src/main/events/index.ts b/src/main/events/index.ts index dd5e3263..57daf51c 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -43,8 +43,12 @@ import "./auth/sign-out"; import "./auth/open-auth-window"; import "./auth/get-session-hash"; import "./user/get-user"; +import "./user/block-user"; +import "./user/unblock-user"; +import "./user/get-user-friends"; import "./profile/get-friend-requests"; import "./profile/get-me"; +import "./profile/undo-friendship"; import "./profile/update-friend-request"; import "./profile/update-profile"; import "./profile/send-friend-request"; diff --git a/src/main/events/profile/undo-friendship.ts b/src/main/events/profile/undo-friendship.ts new file mode 100644 index 00000000..371bc5cc --- /dev/null +++ b/src/main/events/profile/undo-friendship.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const undoFriendship = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + await HydraApi.delete(`/profile/friends/${userId}`); +}; + +registerEvent("undoFriendship", undoFriendship); diff --git a/src/main/events/user/block-user.ts b/src/main/events/user/block-user.ts new file mode 100644 index 00000000..8003f478 --- /dev/null +++ b/src/main/events/user/block-user.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const blockUser = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + await HydraApi.post(`/user/${userId}/block`); +}; + +registerEvent("blockUser", blockUser); diff --git a/src/main/events/user/get-user-friends.ts b/src/main/events/user/get-user-friends.ts new file mode 100644 index 00000000..28783459 --- /dev/null +++ b/src/main/events/user/get-user-friends.ts @@ -0,0 +1,29 @@ +import { userAuthRepository } from "@main/repository"; +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; +import { UserFriends } from "@types"; + +export const getUserFriends = async ( + userId: string, + take: number, + skip: number +): Promise => { + const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } }); + + if (loggedUser?.userId === userId) { + return HydraApi.get(`/profile/friends`, { take, skip }); + } + + return HydraApi.get(`/user/${userId}/friends`, { take, skip }); +}; + +const getUserFriendsEvent = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string, + take: number, + skip: number +) => { + return getUserFriends(userId, take, skip); +}; + +registerEvent("getUserFriends", getUserFriendsEvent); diff --git a/src/main/events/user/get-user.ts b/src/main/events/user/get-user.ts index 7b4c0aa8..eb4f0619 100644 --- a/src/main/events/user/get-user.ts +++ b/src/main/events/user/get-user.ts @@ -4,13 +4,19 @@ import { steamGamesWorker } from "@main/workers"; import { UserProfile } from "@types"; import { convertSteamGameToCatalogueEntry } from "../helpers/search-games"; import { getSteamAppAsset } from "@main/helpers"; +import { getUserFriends } from "./get-user-friends"; const getUser = async ( _event: Electron.IpcMainInvokeEvent, userId: string ): Promise => { try { - const profile = await HydraApi.get(`/user/${userId}`); + const [profile, friends] = await Promise.all([ + HydraApi.get(`/user/${userId}`), + getUserFriends(userId, 12, 0).catch(() => { + return { totalFriends: 0, friends: [] }; + }), + ]); const recentGames = await Promise.all( profile.recentGames.map(async (game) => { @@ -46,7 +52,13 @@ const getUser = async ( }) ); - return { ...profile, libraryGames, recentGames }; + return { + ...profile, + libraryGames, + recentGames, + friends: friends.friends, + totalFriends: friends.totalFriends, + }; } catch (err) { return null; } diff --git a/src/main/events/user/unblock-user.ts b/src/main/events/user/unblock-user.ts new file mode 100644 index 00000000..ac678dbd --- /dev/null +++ b/src/main/events/user/unblock-user.ts @@ -0,0 +1,11 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const unblockUser = async ( + _event: Electron.IpcMainInvokeEvent, + userId: string +) => { + await HydraApi.post(`/user/${userId}/unblock`); +}; + +registerEvent("unblockUser", unblockUser); diff --git a/src/main/services/hydra-api.ts b/src/main/services/hydra-api.ts index 97517b5b..5365bd9e 100644 --- a/src/main/services/hydra-api.ts +++ b/src/main/services/hydra-api.ts @@ -72,7 +72,7 @@ export class HydraApi { this.instance.interceptors.request.use( (request) => { logger.log(" ---- REQUEST -----"); - logger.log(request.method, request.url, request.data); + logger.log(request.method, request.url, request.params, request.data); return request; }, (error) => { @@ -196,52 +196,52 @@ export class HydraApi { throw err; }; - static async get(url: string) { + static async get(url: string, params?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .get(url, this.getAxiosConfig()) + .get(url, { params, ...this.getAxiosConfig() }) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async post(url: string, data?: any) { + static async post(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .post(url, data, this.getAxiosConfig()) + .post(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async put(url: string, data?: any) { + static async put(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .put(url, data, this.getAxiosConfig()) + .put(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async patch(url: string, data?: any) { + static async patch(url: string, data?: any) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .patch(url, data, this.getAxiosConfig()) + .patch(url, data, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } - static async delete(url: string) { + static async delete(url: string) { if (!this.isLoggedIn()) throw new UserNotLoggedInError(); await this.revalidateAccessTokenIfExpired(); return this.instance - .delete(url, this.getAxiosConfig()) + .delete(url, this.getAxiosConfig()) .then((response) => response.data) .catch(this.handleUnauthorizedError); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 91722606..3350a340 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -135,6 +135,8 @@ contextBridge.exposeInMainWorld("electron", { /* Profile */ getMe: () => ipcRenderer.invoke("getMe"), + undoFriendship: (userId: string) => + ipcRenderer.invoke("undoFriendship", userId), updateProfile: (displayName: string, newProfileImagePath: string | null) => ipcRenderer.invoke("updateProfile", displayName, newProfileImagePath), getFriendRequests: () => ipcRenderer.invoke("getFriendRequests"), @@ -145,6 +147,10 @@ contextBridge.exposeInMainWorld("electron", { /* User */ getUser: (userId: string) => ipcRenderer.invoke("getUser", userId), + blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId), + unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId), + getUserFriends: (userId: string, take: number, skip: number) => + ipcRenderer.invoke("getUserFriends", userId, take, skip), /* Auth */ signOut: () => ipcRenderer.invoke("signOut"), diff --git a/src/renderer/index.html b/src/renderer/index.html index 52276268..543b85a9 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,7 +6,7 @@ Hydra diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 24c7bed6..8c6f7604 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -42,11 +42,12 @@ export function App() { const { isFriendsModalVisible, friendRequetsModalTab, - updateFriendRequests, + friendModalUserId, + fetchFriendRequests, hideFriendsModal, } = useUserDetails(); - const { fetchUserDetails, updateUserDetails, clearUserDetails } = + const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } = useUserDetails(); const dispatch = useAppDispatch(); @@ -104,7 +105,7 @@ export function App() { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); - updateFriendRequests(); + fetchFriendRequests(); } }); }, [fetchUserDetails, updateUserDetails, dispatch]); @@ -113,7 +114,7 @@ export function App() { fetchUserDetails().then((response) => { if (response) { updateUserDetails(response); - updateFriendRequests(); + fetchFriendRequests(); showSuccessToast(t("successfully_signed_in")); } }); @@ -218,11 +219,14 @@ export function App() { onClose={handleToastClose} /> - + {userDetails && ( + + )}
diff --git a/src/renderer/src/components/sidebar/sidebar-profile.tsx b/src/renderer/src/components/sidebar/sidebar-profile.tsx index 66c4d82d..81736e37 100644 --- a/src/renderer/src/components/sidebar/sidebar-profile.tsx +++ b/src/renderer/src/components/sidebar/sidebar-profile.tsx @@ -3,10 +3,11 @@ import { PersonAddIcon, PersonIcon } from "@primer/octicons-react"; import * as styles from "./sidebar-profile.css"; import { assignInlineVars } from "@vanilla-extract/dynamic"; import { useAppSelector, useUserDetails } from "@renderer/hooks"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { profileContainerBackground } from "./sidebar-profile.css"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; +import { FriendRequest } from "@types"; export function SidebarProfile() { const navigate = useNavigate(); @@ -16,6 +17,14 @@ export function SidebarProfile() { const { userDetails, profileBackground, friendRequests, showFriendsModal } = useUserDetails(); + const [receivedRequests, setReceivedRequests] = useState([]); + + useEffect(() => { + setReceivedRequests( + friendRequests.filter((request) => request.type === "RECEIVED") + ); + }, [friendRequests]); + const { gameRunning } = useAppSelector((state) => state.gameRunning); const handleButtonClick = () => { @@ -79,15 +88,17 @@ export function SidebarProfile() { )} - {userDetails && friendRequests.length > 0 && !gameRunning && ( + {userDetails && receivedRequests.length > 0 && !gameRunning && (
)} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index bb89f84e..e022cffe 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -16,6 +16,7 @@ import type { UserProfile, FriendRequest, FriendRequestAction, + UserFriends, } from "@types"; import type { DiskSpace } from "check-disk-space"; @@ -127,9 +128,17 @@ declare global { /* User */ getUser: (userId: string) => Promise; + blockUser: (userId: string) => Promise; + unblockUser: (userId: string) => Promise; + getUserFriends: ( + userId: string, + take: number, + skip: number + ) => Promise; /* Profile */ getMe: () => Promise; + undoFriendship: (userId: string) => Promise; updateProfile: ( displayName: string, newProfileImagePath: string | null diff --git a/src/renderer/src/features/user-details-slice.ts b/src/renderer/src/features/user-details-slice.ts index 0df88695..d559de09 100644 --- a/src/renderer/src/features/user-details-slice.ts +++ b/src/renderer/src/features/user-details-slice.ts @@ -8,6 +8,7 @@ export interface UserDetailsState { friendRequests: FriendRequest[]; isFriendsModalVisible: boolean; friendRequetsModalTab: UserFriendModalTab | null; + friendModalUserId: string; } const initialState: UserDetailsState = { @@ -16,6 +17,7 @@ const initialState: UserDetailsState = { friendRequests: [], isFriendsModalVisible: false, friendRequetsModalTab: null, + friendModalUserId: "", }; export const userDetailsSlice = createSlice({ @@ -33,10 +35,11 @@ export const userDetailsSlice = createSlice({ }, setFriendsModalVisible: ( state, - action: PayloadAction + action: PayloadAction<{ initialTab: UserFriendModalTab; userId: string }> ) => { state.isFriendsModalVisible = true; - state.friendRequetsModalTab = action.payload; + state.friendRequetsModalTab = action.payload.initialTab; + state.friendModalUserId = action.payload.userId; }, setFriendsModalHidden: (state) => { state.isFriendsModalVisible = false; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index d37612d4..182aef25 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -1,6 +1,7 @@ import type { GameShop } from "@types"; import Color from "color"; +import { average } from "color.js"; export const steamUrlBuilder = { library: (objectID: string) => @@ -45,3 +46,14 @@ export const buildGameDetailsPath = ( export const darkenColor = (color: string, amount: number, alpha: number = 1) => new Color(color).darken(amount).alpha(alpha).toString(); + +export const profileBackgroundFromProfileImage = async ( + profileImageUrl: string +) => { + const output = await average(profileImageUrl, { + amount: 1, + format: "hex", + }); + + return `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; +}; diff --git a/src/renderer/src/hooks/use-user-details.ts b/src/renderer/src/hooks/use-user-details.ts index a0da950a..21690e7e 100644 --- a/src/renderer/src/hooks/use-user-details.ts +++ b/src/renderer/src/hooks/use-user-details.ts @@ -1,6 +1,4 @@ import { useCallback } from "react"; -import { average } from "color.js"; - import { useAppDispatch, useAppSelector } from "./redux"; import { setProfileBackground, @@ -9,7 +7,7 @@ import { setFriendsModalVisible, setFriendsModalHidden, } from "@renderer/features"; -import { darkenColor } from "@renderer/helpers"; +import { profileBackgroundFromProfileImage } from "@renderer/helpers"; import { FriendRequestAction, UserDetails } from "@types"; import { UserFriendModalTab } from "@renderer/pages/shared-modals/user-friend-modal"; @@ -21,6 +19,7 @@ export function useUserDetails() { profileBackground, friendRequests, isFriendsModalVisible, + friendModalUserId, friendRequetsModalTab, } = useAppSelector((state) => state.userDetails); @@ -42,12 +41,9 @@ export function useUserDetails() { dispatch(setUserDetails(userDetails)); if (userDetails.profileImageUrl) { - const output = await average(userDetails.profileImageUrl, { - amount: 1, - format: "hex", - }); - - const profileBackground = `linear-gradient(135deg, ${darkenColor(output as string, 0.6)}, ${darkenColor(output as string, 0.8, 0.7)})`; + const profileBackground = await profileBackgroundFromProfileImage( + userDetails.profileImageUrl + ); dispatch(setProfileBackground(profileBackground)); window.localStorage.setItem( @@ -89,15 +85,19 @@ export function useUserDetails() { [updateUserDetails] ); - const updateFriendRequests = useCallback(async () => { - const friendRequests = await window.electron.getFriendRequests(); - dispatch(setFriendRequests(friendRequests)); + const fetchFriendRequests = useCallback(() => { + return window.electron + .getFriendRequests() + .then((friendRequests) => { + dispatch(setFriendRequests(friendRequests)); + }) + .catch(() => {}); }, [dispatch]); const showFriendsModal = useCallback( - (tab: UserFriendModalTab) => { - dispatch(setFriendsModalVisible(tab)); - updateFriendRequests(); + (initialTab: UserFriendModalTab, userId: string) => { + dispatch(setFriendsModalVisible({ initialTab, userId })); + fetchFriendRequests(); }, [dispatch] ); @@ -110,26 +110,39 @@ export function useUserDetails() { async (userId: string) => { return window.electron .sendFriendRequest(userId) - .then(() => updateFriendRequests()); + .then(() => fetchFriendRequests()); }, - [updateFriendRequests] + [fetchFriendRequests] ); const updateFriendRequestState = useCallback( async (userId: string, action: FriendRequestAction) => { return window.electron .updateFriendRequest(userId, action) - .then(() => updateFriendRequests()); + .then(() => fetchFriendRequests()); }, - [updateFriendRequests] + [fetchFriendRequests] ); + const undoFriendship = (userId: string) => { + return window.electron.undoFriendship(userId); + }; + + const blockUser = (userId: string) => { + return window.electron.blockUser(userId); + }; + + const unblockUser = (userId: string) => { + return window.electron.unblockUser(userId); + }; + return { userDetails, profileBackground, friendRequests, friendRequetsModalTab, isFriendsModalVisible, + friendModalUserId, showFriendsModal, hideFriendsModal, fetchUserDetails, @@ -138,7 +151,10 @@ export function useUserDetails() { updateUserDetails, patchUser, sendFriendRequest, - updateFriendRequests, + fetchFriendRequests, updateFriendRequestState, + blockUser, + unblockUser, + undoFriendship, }; } diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx new file mode 100644 index 00000000..7c34d040 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-item.tsx @@ -0,0 +1,136 @@ +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"; +import { useTranslation } from "react-i18next"; + +export type UserFriendItemProps = { + userId: string; + profileImageUrl: string | null; + displayName: string; + onClickItem: (userId: string) => void; +} & ( + | { type: "ACCEPTED"; onClickUndoFriendship: (userId: string) => void } + | { + type: "SENT" | "RECEIVED"; + onClickCancelRequest: (userId: string) => void; + onClickAcceptRequest: (userId: string) => void; + onClickRefuseRequest: (userId: string) => void; + } + | { type: null } +); + +export const UserFriendItem = (props: UserFriendItemProps) => { + const { t } = useTranslation("user_profile"); + const { userId, profileImageUrl, displayName, type, onClickItem } = props; + + const getRequestDescription = () => { + if (type === "ACCEPTED" || type === null) return null; + + return ( + + {type == "SENT" ? t("request_sent") : t("request_received")} + + ); + }; + + const getRequestActions = () => { + if (type === null) return null; + + if (type === "SENT") { + return ( + + ); + } + + if (type === "RECEIVED") { + return ( + <> + + + + ); + } + + if (type === "ACCEPTED") { + return ( + + ); + } + + return null; + }; + + return ( +
+ + +
+ {getRequestActions()} +
+
+ ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx index bf4879b2..0725674e 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-add-friend.tsx @@ -4,7 +4,7 @@ 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"; +import { UserFriendItem } from "./user-friend-item"; export interface UserFriendModalAddFriendProps { closeModal: () => void; @@ -23,7 +23,7 @@ export const UserFriendModalAddFriend = ({ const { sendFriendRequest, updateFriendRequestState, friendRequests } = useUserDetails(); - const { showErrorToast } = useToast(); + const { showSuccessToast, showErrorToast } = useToast(); const handleClickAddFriend = () => { setIsAddingFriend(true); @@ -56,21 +56,25 @@ export const UserFriendModalAddFriend = ({ navigate(`/user/${friendCode}`); }; - const handleClickCancelFriendRequest = (userId: string) => { + const handleCancelFriendRequest = (userId: string) => { updateFriendRequestState(userId, "CANCEL").catch(() => { - showErrorToast("Falha ao cancelar convite"); + showErrorToast(t("try_again")); }); }; - const handleClickAcceptFriendRequest = (userId: string) => { - updateFriendRequestState(userId, "ACCEPTED").catch(() => { - showErrorToast("Falha ao aceitar convite"); - }); + const handleAcceptFriendRequest = (userId: string) => { + updateFriendRequestState(userId, "ACCEPTED") + .then(() => { + showSuccessToast(t("request_accepted")); + }) + .catch(() => { + showErrorToast(t("try_again")); + }); }; - const handleClickRefuseFriendRequest = (userId: string) => { + const handleRefuseFriendRequest = (userId: string) => { updateFriendRequestState(userId, "REFUSED").catch(() => { - showErrorToast("Falha ao recusar convite"); + showErrorToast(t("try_again")); }); }; @@ -121,16 +125,16 @@ export const UserFriendModalAddFriend = ({

Pendentes

{friendRequests.map((request) => { return ( - ); })} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx new file mode 100644 index 00000000..52e646e0 --- /dev/null +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal-list.tsx @@ -0,0 +1,95 @@ +import { SPACING_UNIT } from "@renderer/theme.css"; +import { UserFriend } from "@types"; +import { useEffect, useState } from "react"; +import { UserFriendItem } from "./user-friend-item"; +import { useNavigate } from "react-router-dom"; +import { useToast, useUserDetails } from "@renderer/hooks"; +import { useTranslation } from "react-i18next"; + +export interface UserFriendModalListProps { + userId: string; + closeModal: () => void; +} + +const pageSize = 12; + +export const UserFriendModalList = ({ + userId, + closeModal, +}: UserFriendModalListProps) => { + const { t } = useTranslation("user_profile"); + const { showErrorToast } = useToast(); + const navigate = useNavigate(); + + const [page, setPage] = useState(0); + const [maxPage, setMaxPage] = useState(0); + const [friends, setFriends] = useState([]); + + const { userDetails, undoFriendship } = useUserDetails(); + const isMe = userDetails?.id == userId; + + const loadNextPage = () => { + if (page > maxPage) return; + window.electron + .getUserFriends(userId, pageSize, page * pageSize) + .then((newPage) => { + if (page === 0) { + setMaxPage(newPage.totalFriends / pageSize); + } + + setFriends([...friends, ...newPage.friends]); + setPage(page + 1); + }) + .catch(() => {}); + }; + + const reloadList = () => { + setPage(0); + setMaxPage(0); + setFriends([]); + loadNextPage(); + }; + + useEffect(() => { + reloadList(); + }, [userId]); + + const handleClickFriend = (userId: string) => { + closeModal(); + navigate(`/user/${userId}`); + }; + + const handleUndoFriendship = (userId: string) => { + undoFriendship(userId) + .then(() => { + reloadList(); + }) + .catch(() => { + showErrorToast(t("try_again")); + }); + }; + + return ( +
+ {friends.map((friend) => { + return ( + + ); + })} +
+ ); +}; diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx index 88cf4a6c..abc26270 100644 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx +++ b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-modal.tsx @@ -3,23 +3,27 @@ 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"; +import { useUserDetails } from "@renderer/hooks"; +import { UserFriendModalList } from "./user-friend-modal-list"; export enum UserFriendModalTab { FriendsList, AddFriend, } -export interface UserAddFriendsModalProps { +export interface UserFriendsModalProps { visible: boolean; onClose: () => void; initialTab: UserFriendModalTab | null; + userId: string; } export const UserFriendModal = ({ visible, onClose, initialTab, -}: UserAddFriendsModalProps) => { + userId, +}: UserFriendsModalProps) => { const { t } = useTranslation("user_profile"); const tabs = [t("friends_list"), t("add_friends")]; @@ -28,6 +32,9 @@ export const UserFriendModal = ({ initialTab || UserFriendModalTab.FriendsList ); + const { userDetails } = useUserDetails(); + const isMe = userDetails?.id == userId; + useEffect(() => { if (initialTab != null) { setCurrentTab(initialTab); @@ -36,7 +43,7 @@ export const UserFriendModal = ({ const renderTab = () => { if (currentTab == UserFriendModalTab.FriendsList) { - return <>; + return ; } if (currentTab == UserFriendModalTab.AddFriend) { @@ -56,20 +63,21 @@ export const UserFriendModal = ({ gap: `${SPACING_UNIT * 2}px`, }} > -
- {tabs.map((tab, index) => { - return ( - - ); - })} -
-

{tabs[currentTab]}

+ {isMe && ( +
+ {tabs.map((tab, index) => { + return ( + + ); + })} +
+ )} {renderTab()} diff --git a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx b/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx deleted file mode 100644 index 022807d5..00000000 --- a/src/renderer/src/pages/shared-modals/user-friend-modal/user-friend-request.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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 ( -
- - -
- {isRequestSent ? ( - - ) : ( - <> - - - - )} -
-
- ); -}; diff --git a/src/renderer/src/pages/user/user-block-modal.tsx b/src/renderer/src/pages/user/user-block-modal.tsx new file mode 100644 index 00000000..e179e4da --- /dev/null +++ b/src/renderer/src/pages/user/user-block-modal.tsx @@ -0,0 +1,44 @@ +import { Button, Modal } from "@renderer/components"; +import * as styles from "./user.css"; +import { useTranslation } from "react-i18next"; + +export interface UserBlockModalProps { + visible: boolean; + displayName: string; + onConfirm: () => void; + onClose: () => void; +} + +export const UserBlockModal = ({ + visible, + displayName, + onConfirm, + onClose, +}: UserBlockModalProps) => { + const { t } = useTranslation("user_profile"); + + return ( + <> + +
+

+ {t("user_block_modal_text", { displayName })} +

+
+ + + +
+
+
+ + ); +}; diff --git a/src/renderer/src/pages/user/user-content.tsx b/src/renderer/src/pages/user/user-content.tsx index e70335d8..81db119d 100644 --- a/src/renderer/src/pages/user/user-content.tsx +++ b/src/renderer/src/pages/user/user-content.tsx @@ -1,4 +1,4 @@ -import { UserGame, UserProfile } from "@types"; +import { FriendRequestAction, UserGame, UserProfile } from "@types"; import cn from "classnames"; import * as styles from "./user.css"; import { SPACING_UNIT, vars } from "@renderer/theme.css"; @@ -12,12 +12,23 @@ import { useUserDetails, } from "@renderer/hooks"; import { useNavigate } from "react-router-dom"; -import { buildGameDetailsPath, steamUrlBuilder } from "@renderer/helpers"; -import { PersonIcon, PlusIcon, TelescopeIcon } from "@primer/octicons-react"; +import { + buildGameDetailsPath, + profileBackgroundFromProfileImage, + steamUrlBuilder, +} from "@renderer/helpers"; +import { + CheckCircleIcon, + PersonIcon, + PlusIcon, + TelescopeIcon, + XCircleIcon, +} from "@primer/octicons-react"; import { Button, Link } from "@renderer/components"; import { UserEditProfileModal } from "./user-edit-modal"; -import { UserSignOutModal } from "./user-signout-modal"; +import { UserSignOutModal } from "./user-sign-out-modal"; import { UserFriendModalTab } from "../shared-modals/user-friend-modal"; +import { UserBlockModal } from "./user-block-modal"; const MAX_MINUTES_TO_SHOW_IN_PLAYTIME = 120; @@ -26,6 +37,8 @@ export interface ProfileContentProps { updateUserProfile: () => Promise; } +type FriendAction = FriendRequestAction | ("BLOCK" | "UNDO" | "SEND"); + export function UserContent({ userProfile, updateUserProfile, @@ -36,13 +49,20 @@ export function UserContent({ userDetails, profileBackground, signOut, - updateFriendRequests, + sendFriendRequest, + fetchFriendRequests, showFriendsModal, + updateFriendRequestState, + undoFriendship, + blockUser, } = useUserDetails(); - const { showSuccessToast } = useToast(); + const { showSuccessToast, showErrorToast } = useToast(); + const [profileContentBoxBackground, setProfileContentBoxBackground] = + useState(); const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [showSignOutModal, setShowSignOutModal] = useState(false); + const [showUserBlockModal, setShowUserBlockModal] = useState(false); const { gameRunning } = useAppSelector((state) => state.gameRunning); @@ -93,14 +113,141 @@ export function UserContent({ const isMe = userDetails?.id == userProfile.id; useEffect(() => { - if (isMe) updateFriendRequests(); + if (isMe) fetchFriendRequests(); }, [isMe]); - const profileContentBoxBackground = useMemo(() => { - if (profileBackground) return profileBackground; - /* TODO: Render background colors for other users */ - return undefined; - }, [profileBackground]); + useEffect(() => { + if (isMe && profileBackground) { + setProfileContentBoxBackground(profileBackground); + } + + if (userProfile.profileImageUrl) { + profileBackgroundFromProfileImage(userProfile.profileImageUrl).then( + (profileBackground) => { + setProfileContentBoxBackground(profileBackground); + } + ); + } + }, [profileBackground, isMe]); + + const handleFriendAction = (userId: string, action: FriendAction) => { + try { + if (action === "UNDO") { + undoFriendship(userId).then(updateUserProfile); + return; + } + + if (action === "BLOCK") { + blockUser(userId).then(() => { + setShowUserBlockModal(false); + showSuccessToast(t("user_blocked_successfully")); + navigate(-1); + }); + + return; + } + + if (action === "SEND") { + sendFriendRequest(userProfile.id).then(updateUserProfile); + return; + } + + updateFriendRequestState(userId, action).then(updateUserProfile); + } catch (err) { + showErrorToast(t("try_again")); + } + }; + + const showFriends = isMe || userProfile.totalFriends > 0; + + const getProfileActions = () => { + if (isMe) { + return ( + <> + + + + + ); + } + + if (userProfile.relation == null) { + return ( + <> + + + + + ); + } + + if (userProfile.relation.status === "ACCEPTED") { + const userId = + userProfile.relation.AId === userDetails?.id + ? userProfile.relation.BId + : userProfile.relation.AId; + + return ( + <> + + + ); + } + + if (userProfile.relation.BId === userProfile.id) { + return ( + + ); + } + + return ( + <> + + + + ); + }; return ( <> @@ -117,6 +264,13 @@ export function UserContent({ onConfirm={handleConfirmSignout} /> + setShowUserBlockModal(false)} + onConfirm={() => handleFriendAction(userProfile.id, "BLOCK")} + displayName={userProfile.displayName} + /> +
- {isMe && ( +
-
- <> - - - - -
+ {getProfileActions()}
- )} +
@@ -327,12 +468,16 @@ export function UserContent({
- {(isMe || - (userProfile.friends && userProfile.friends.length > 0)) && ( + {showFriends && (
@@ -388,7 +533,10 @@ export function UserContent({