feat: add friends window and review replies (#2301)

* fix: make onInAppAchievementUnlocked optional in renderer declaration and add runtime check for safe handling

* feat: add friends window and review replies

* style: format friends window styles

* feat: refine friends window and review UI

* feat: add friends window review replies

* style: format friend request event files

* chore: sync proto submodule

---------

Co-authored-by: Hachi-R <drive8hachi@gmail.com>
Co-authored-by: Chubby Granny Chaser <hydralauncher@proton.me>
Co-authored-by: Chubby Granny Chaser <hydralauncher@proton.me>
This commit is contained in:
Chubby Granny Chaser 2026-06-12 18:04:13 +01:00 committed by GitHub
parent 0e89f082ad
commit e40173119f
96 changed files with 4826 additions and 402 deletions

View file

@ -91,12 +91,117 @@ jobs:
BUILDS_URL: ${{ secrets.BUILDS_URL }}
BUILD_WEBHOOK_URL: ${{ secrets.BUILD_WEBHOOK_URL }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_ACTOR_ID: ${{ github.actor_id }}
BUILD_FLAVOR: staging
run: node scripts/upload-build.cjs
- name: Create artifact
uses: actions/upload-artifact@v4
with:
name: Build-${{ matrix.os }}
name: Build-staging-${{ matrix.os }}
path: |
dist/*-portable.exe
dist/*.zip
dist/*.dmg
dist/*.deb
dist/*.rpm
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
dist/*.AppImage
build-production:
if: github.event_name == 'push'
strategy:
matrix:
os: [windows-2022, ubuntu-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22.21.0
cache: "yarn"
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Install Python dependencies
run: pip install -r requirements.txt
- name: Build Python RPC with cx_Freeze
run: python python_rpc/setup.py build
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Build Windows
if: matrix.os == 'windows-2022'
run: yarn build:win
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_WS_URL: ${{ vars.MAIN_VITE_WS_URL }}
MAIN_VITE_NIMBUS_API_URL: ${{ vars.MAIN_VITE_NIMBUS_API_URL }}
RENDERER_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
MAIN_VITE_EXTERNAL_RESOURCES_URL: ${{ vars.EXTERNAL_RESOURCES_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
RENDERER_VITE_SENTRY_DSN: ${{ vars.SENTRY_DSN }}
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID: ${{ vars.RENDERER_VITE_REAL_DEBRID_REFERRAL_ID }}
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
MAIN_VITE_LAUNCHER_SUBDOMAIN: ${{ vars.MAIN_VITE_LAUNCHER_SUBDOMAIN }}
- name: Upload build
env:
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
S3_BUILDS_BUCKET_NAME: ${{ secrets.S3_BUILDS_BUCKET_NAME }}
BUILDS_URL: ${{ secrets.BUILDS_URL }}
BUILD_WEBHOOK_URL: ${{ secrets.BUILD_WEBHOOK_URL }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_ACTOR_ID: ${{ github.actor_id }}
BUILD_FLAVOR: production
run: node scripts/upload-build.cjs
- name: Create artifact
uses: actions/upload-artifact@v4
with:
name: Build-production-${{ matrix.os }}
path: |
dist/*-portable.exe
dist/*.zip

View file

@ -106,6 +106,8 @@ jobs:
BUILDS_URL: ${{ secrets.BUILDS_URL }}
BUILD_WEBHOOK_URL: ${{ secrets.BUILD_WEBHOOK_URL }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_ACTOR_ID: ${{ github.actor_id }}
BUILD_FLAVOR: production
run: node scripts/upload-build.cjs
- name: Release

View file

@ -278,6 +278,7 @@ dependencies = [
"napi-build",
"napi-derive",
"sysinfo",
"tokio",
"uuid",
]

View file

@ -12,6 +12,7 @@ mime_guess = "2.0.5"
napi = { version = "3.5.2", default-features = false, features = ["napi8", "tokio_rt"] }
napi-derive = "3.3.2"
sysinfo = "0.37.2"
tokio = { version = "1.50.0", features = ["rt"] }
uuid = { version = "1.11.0", features = ["v4"] }
[build-dependencies]

View file

@ -1,12 +1,13 @@
use std::fs::File;
use std::io::BufReader;
use std::io::{BufReader, BufWriter};
use std::path::{Path, PathBuf};
use std::{cmp::Ordering, collections::HashMap};
use image::codecs::gif::GifDecoder;
use image::codecs::gif::{GifDecoder, GifEncoder, Repeat};
use image::codecs::png::PngDecoder;
use image::codecs::webp::WebPDecoder;
use image::{AnimationDecoder, ImageFormat, ImageReader};
use image::imageops::{crop_imm, resize, FilterType};
use image::{AnimationDecoder, Frame, ImageFormat, ImageReader, ImageResult, RgbaImage};
use napi::bindgen_prelude::Error;
use napi_derive::napi;
use sysinfo::{ProcessesToUpdate, System};
@ -18,6 +19,13 @@ pub struct ProcessedImageData {
pub mime_type: String,
}
#[napi(object)]
pub struct ProcessedFriendImageData {
pub image_path: String,
pub mime_type: String,
pub is_animated: bool,
}
#[napi(object)]
pub struct NativeProcessPayload {
pub exe: Option<String>,
@ -72,6 +80,68 @@ pub fn process_profile_image(
})
}
#[napi]
pub async fn process_friend_image(
image_path: String,
output_path_base: String,
width: u32,
height: u32,
preserve_animation: bool,
) -> napi::Result<ProcessedFriendImageData> {
tokio::task::spawn_blocking(move || {
process_friend_image_sync(
image_path,
output_path_base,
width,
height,
preserve_animation,
)
})
.await
.map_err(|err| Error::from_reason(err.to_string()))?
}
fn process_friend_image_sync(
image_path: String,
output_path_base: String,
width: u32,
height: u32,
preserve_animation: bool,
) -> napi::Result<ProcessedFriendImageData> {
if width == 0 || height == 0 {
return Err(Error::from_reason("Invalid output dimensions"));
}
let input_path = PathBuf::from(image_path);
if !input_path.exists() {
return Err(Error::from_reason("Image file not found"));
}
let format = detect_image_format(&input_path)?;
let is_animated = preserve_animation && is_animated_image(&input_path, format)?;
if is_animated {
let output_path = with_extension(&output_path_base, "gif");
resize_animated_image(&input_path, format, &output_path, width, height)?;
return Ok(ProcessedFriendImageData {
image_path: output_path.to_string_lossy().to_string(),
mime_type: "image/gif".to_string(),
is_animated: true,
});
}
let output_path = with_extension(&output_path_base, "webp");
resize_static_image(&input_path, &output_path, width, height)?;
Ok(ProcessedFriendImageData {
image_path: output_path.to_string_lossy().to_string(),
mime_type: "image/webp".to_string(),
is_animated: false,
})
}
#[napi]
pub fn list_processes() -> Vec<NativeProcessPayload> {
let mut system = System::new_all();
@ -183,6 +253,122 @@ fn is_apng(path: &Path) -> napi::Result<bool> {
.map_err(|err| Error::from_reason(err.to_string()))
}
fn resize_animated_image(
input_path: &Path,
format: Option<ImageFormat>,
output_path: &Path,
width: u32,
height: u32,
) -> napi::Result<()> {
match format {
Some(ImageFormat::Gif) => {
let input_file =
File::open(input_path).map_err(|err| Error::from_reason(err.to_string()))?;
let decoder = GifDecoder::new(BufReader::new(input_file))
.map_err(|err| Error::from_reason(err.to_string()))?;
encode_animation_frames_to_gif(decoder.into_frames(), output_path, width, height)
}
Some(ImageFormat::WebP) => {
let input_file =
File::open(input_path).map_err(|err| Error::from_reason(err.to_string()))?;
let decoder = WebPDecoder::new(BufReader::new(input_file))
.map_err(|err| Error::from_reason(err.to_string()))?;
encode_animation_frames_to_gif(decoder.into_frames(), output_path, width, height)
}
Some(ImageFormat::Png) => {
let input_file =
File::open(input_path).map_err(|err| Error::from_reason(err.to_string()))?;
let decoder = PngDecoder::new(BufReader::new(input_file))
.map_err(|err| Error::from_reason(err.to_string()))?
.apng()
.map_err(|err| Error::from_reason(err.to_string()))?;
encode_animation_frames_to_gif(decoder.into_frames(), output_path, width, height)
}
_ => Err(Error::from_reason("Unsupported animated image format")),
}
}
fn encode_animation_frames_to_gif<I>(
frames: I,
output_path: &Path,
width: u32,
height: u32,
) -> napi::Result<()>
where
I: IntoIterator<Item = ImageResult<Frame>>,
{
let output_file = File::create(output_path).map_err(|err| Error::from_reason(err.to_string()))?;
let mut encoder = GifEncoder::new(BufWriter::new(output_file));
encoder
.set_repeat(Repeat::Infinite)
.map_err(|err| Error::from_reason(err.to_string()))?;
for frame in frames {
let frame = frame.map_err(|err| Error::from_reason(err.to_string()))?;
let delay = frame.delay().clone();
let resized = resize_cover_rgba(&frame.into_buffer(), width, height)?;
encoder
.encode_frame(Frame::from_parts(resized, 0, 0, delay))
.map_err(|err| Error::from_reason(err.to_string()))?;
}
Ok(())
}
fn resize_static_image(
input_path: &Path,
output_path: &Path,
width: u32,
height: u32,
) -> napi::Result<()> {
let image = ImageReader::open(input_path)
.map_err(|err| Error::from_reason(err.to_string()))?
.with_guessed_format()
.map_err(|err| Error::from_reason(err.to_string()))?
.decode()
.map_err(|err| Error::from_reason(err.to_string()))?
.to_rgba8();
let resized = resize_cover_rgba(&image, width, height)?;
resized
.save_with_format(output_path, ImageFormat::WebP)
.map_err(|err| Error::from_reason(err.to_string()))
}
fn resize_cover_rgba(image: &RgbaImage, width: u32, height: u32) -> napi::Result<RgbaImage> {
let source_width = image.width();
let source_height = image.height();
if source_width == 0 || source_height == 0 {
return Err(Error::from_reason("Could not read source image dimensions"));
}
let width_scale = width as f32 / source_width as f32;
let height_scale = height as f32 / source_height as f32;
let scale = width_scale.max(height_scale);
let resized_width = ((source_width as f32 * scale).ceil() as u32).max(width);
let resized_height = ((source_height as f32 * scale).ceil() as u32).max(height);
let resized = resize(
image,
resized_width,
resized_height,
FilterType::Lanczos3,
);
let left = (resized_width.saturating_sub(width)) / 2;
let top = (resized_height.saturating_sub(height)) / 2;
Ok(crop_imm(&resized, left, top, width, height).to_image())
}
fn with_extension(output_path_base: &str, extension: &str) -> PathBuf {
let mut output_path = PathBuf::from(output_path_base);
output_path.set_extension(extension);
output_path
}
fn output_format_from_extension(extension: &str) -> napi::Result<ImageFormat> {
match extension {
"png" => Ok(ImageFormat::Png),

2
proto

@ -1 +1 @@
Subproject commit 6f11c99c572420a282ba5149b6866e39b8a4569c
Subproject commit 97a0cbd3601c18fc6e60f90eab0cf15c07eaaf99

View file

@ -8,6 +8,9 @@ if (!process.env.BUILD_WEBHOOK_URL) {
process.exit(0);
}
const flavor = process.env.BUILD_FLAVOR || "staging";
const isProduction = flavor === "production";
const s3 = new S3Client({
region: "auto",
endpoint: process.env.S3_ENDPOINT,
@ -30,7 +33,7 @@ fs.readdir(dist, async (err, files) => {
.filter((file) => extensionsToUpload.includes(path.extname(file)))
.map(async (file) => {
console.log(`⌛️ Uploading ${file}...`);
const fileName = `${new Date().getTime()}-${file}`;
const fileName = `${flavor}-${new Date().getTime()}-${file}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUILDS_BUCKET_NAME,
@ -60,8 +63,8 @@ fs.readdir(dist, async (err, files) => {
embeds: [
{
description: process.env.BRANCH_NAME,
color: 5814783,
title: `🔥 Nova build do Hydra (versão ${packageJson.version})`,
color: isProduction ? 5763719 : 5814783,
title: `🔥 Nova build do Hydra [${flavor.toUpperCase()}] (versão ${packageJson.version})`,
fields: [
{
name: "",

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (في قائمة الانتظار)",
"game_has_no_executable": "اللعبة لا تحتوي على ملف تشغيل",
"sign_in": "تسجيل الدخول",
"friends": "الأصدقاء",
"friends": "Friends",
"need_help": "تحتاج مساعدة؟",
"favorites": "المفضلة",
"library": "Library",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (В опашката)",
"game_has_no_executable": "Няма избран изпълним файл за играта",
"sign_in": "Вход",
"friends": "Приятели",
"friends": "Friends",
"need_help": "Нужда от помощ?",
"favorites": "Любими",
"library": "Library",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (V řadě)",
"game_has_no_executable": "Hra nemá zvolen žádný spustitelný soubor",
"sign_in": "Přihlásit se",
"friends": "Přátelé",
"friends": "Friends",
"need_help": "Potřebujete pomoc?",
"favorites": "Oblíbené",
"library": "Library",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (I køen)",
"game_has_no_executable": "Spillet har ikke nogen eksekverbar fil valgt",
"sign_in": "Log ind",
"friends": "Venner",
"friends": "Friends",
"favorites": "Favoritter",
"library": "Library",
"collections": "Collections",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (In Warteschlange)",
"game_has_no_executable": "Spiel hat keine ausführbare Datei gewählt",
"sign_in": "Anmelden",
"friends": "Freunde",
"friends": "Friends",
"need_help": "Brauchst du Hilfe?",
"favorites": "Favoriten",
"playable_button_title": "Nur Spiele anzeigen, die du jetzt spielen kannst",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -413,6 +413,21 @@
"loading_reviews": "Loading reviews...",
"loading_more_reviews": "Loading more reviews...",
"load_more_reviews": "Load more reviews",
"reply": "Reply",
"view_all_replies": "View all replies ({{count}})",
"load_more_replies": "Load more replies",
"hide_replies": "Hide replies",
"loading_replies": "Loading replies...",
"replies_load_failed": "Failed to load replies. Please try again.",
"write_reply_placeholder": "Write a reply...",
"post_reply": "Reply",
"posting_reply": "Posting...",
"reply_submitted_successfully": "Reply posted successfully!",
"reply_submission_failed": "Failed to post reply. Please try again.",
"reply_cannot_be_empty": "The reply cannot be empty.",
"reply_deleted_successfully": "Reply deleted successfully.",
"reply_deletion_failed": "Failed to delete reply. Please try again.",
"delete_reply": "Delete reply",
"you_seemed_to_enjoy_this_game": "You seem to have enjoyed this game",
"would_you_recommend_this_game": "Would you like to leave a review for this game?",
"yes": "Yes",
@ -1443,6 +1458,8 @@
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Your review for {{gameTitle}} got upvotes!",
"review_upvote_description": "Your review received {{count}} new upvotes",
"review_answer_title": "New reply to your review for {{gameTitle}}",
"review_answer_description": "{{displayName}} replied to your review",
"marked_all_as_read": "All notifications marked as read",
"failed_to_mark_as_read": "Failed to mark notifications as read",
"cleared_all": "All notifications cleared",
@ -1451,5 +1468,27 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"clear_search": "Clear search",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!",
"minimize": "Minimize",
"close": "Close"
}
}

View file

@ -463,6 +463,21 @@
"hide_original": "Ocultar original",
"leave_a_review": "Crear una reseña",
"load_more_reviews": "Cargar más reseñas",
"reply": "Responder",
"view_all_replies": "Ver todas las respuestas ({{count}})",
"load_more_replies": "Cargar más respuestas",
"hide_replies": "Ocultar respuestas",
"loading_replies": "Cargando respuestas...",
"replies_load_failed": "Error al cargar las respuestas. Por favor intentá de nuevo.",
"write_reply_placeholder": "Escribe una respuesta...",
"post_reply": "Responder",
"posting_reply": "Publicando...",
"reply_submitted_successfully": "¡Respuesta publicada correctamente!",
"reply_submission_failed": "Error al publicar la respuesta. Por favor intentá de nuevo.",
"reply_cannot_be_empty": "La respuesta no puede estar vacía.",
"reply_deleted_successfully": "Respuesta eliminada correctamente.",
"reply_deletion_failed": "Error al eliminar la respuesta. Por favor intentá de nuevo.",
"delete_reply": "Eliminar respuesta",
"loading_more_reviews": "Cargando más reseñas...",
"loading_reviews": "Cargando reseñas...",
"maybe_later": "Tal vez después",
@ -1114,6 +1129,8 @@
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "¡Tu reseña de {{gameTitle}} recibió votos!",
"review_upvote_description": "Tu reseña recibió {{count}} nuevos votos",
"review_answer_title": "Nueva respuesta a tu reseña de {{gameTitle}}",
"review_answer_description": "{{displayName}} respondió a tu reseña",
"marked_all_as_read": "Todas las notificaciones marcadas como leídas",
"failed_to_mark_as_read": "Error al marcar las notificaciones como leídas",
"cleared_all": "Todas las notificaciones eliminadas",
@ -1136,5 +1153,25 @@
"preflight_downloading": "Descargando dependencias",
"preflight_installing": "Instalando dependencias",
"preflight_installing_detail": "{{detail}}"
},
"friends_window": {
"title": "Amigos",
"online": "En línea",
"offline": "Desconectado",
"friend_code_copied": "Código de amigo copiado",
"copy_friend_code": "Copiar código de amigo",
"your_friend_code": "Tu código de amigo:",
"add_friend": "Añadir amigo",
"search_placeholder": "Buscar amigos",
"clear_search": "Borrar búsqueda",
"friend_requests": "Solicitudes de amistad",
"accept": "Aceptar",
"refuse": "Rechazar",
"playing": "Jugando a {{game}}",
"remove_friend": "Eliminar amigo",
"no_results": "No se encontraron amigos",
"no_friends": "No hay amigos en línea",
"try_again": "Algo salió mal. Inténtalo de nuevo.",
"copied": "¡Copiado!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (Järjekorras)",
"game_has_no_executable": "Mängul pole käivitusfaili valitud",
"sign_in": "Logi sisse",
"friends": "Sõbrad",
"friends": "Friends",
"favorites": "Lemmikud",
"library": "Library",
"collections": "Collections",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (Jonossa)",
"game_has_no_executable": "Pelin käynnistystiedostoa ei ole valittu",
"sign_in": "Kirjaudu sisään",
"friends": "Kaverit",
"friends": "Friends",
"need_help": "Tarvitsetko apua?",
"favorites": "Suosikit",
"playable_button_title": "Näytä vain asennetut pelit.",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (En file d'attente)",
"game_has_no_executable": "Aucun exécutable sélectionné pour ce jeu",
"sign_in": "Se connecter",
"friends": "Amis",
"friends": "Friends",
"need_help": "Besoin d'aide ?",
"favorites": "Favoris",
"playable_button_title": "Afficher uniquement les jeux que vous pouvez jouer maintenant",
@ -1099,5 +1099,24 @@
"preflight_downloading": "Téléchargement des dépendances",
"preflight_installing": "Installation des dépendances",
"preflight_installing_detail": "{{detail}}"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -27,7 +27,7 @@
"queued": "{{title}} (Várakozásban)",
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
"sign_in": "Bejelentkezés",
"friends": "Barátok",
"friends": "Friends",
"notifications": "Értesítések",
"need_help": "Elakadtál?",
"favorites": "Kedvenc Játékaim",
@ -1079,5 +1079,24 @@
"failed_to_dismiss": "Értesítés eltüntetése nem sikerült",
"friend_request_accepted": "Barátfelkérés elfogadva",
"friend_request_refused": "Barátfelkérés elutasítva"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -28,7 +28,7 @@
"queued": "{{title}} (Dalam antrean)",
"game_has_no_executable": "Game tidak memiliki executable yang dipilih",
"sign_in": "Masuk",
"friends": "Teman",
"friends": "Friends",
"notifications": "Notifikasi",
"need_help": "Butuh bantuan?",
"favorites": "Favorit",
@ -1159,5 +1159,24 @@
"failed_to_dismiss": "Gagal mengabaikan notifikasi",
"friend_request_accepted": "Permintaan pertemanan diterima",
"friend_request_refused": "Permintaan pertemanan ditolak"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -25,7 +25,7 @@
"queued": "{{title}} (In coda)",
"game_has_no_executable": "Nessun eseguibile selezionato per il gioco",
"sign_in": "Accedi",
"friends": "Amici",
"friends": "Friends",
"notifications": "Notifiche",
"need_help": "Hai bisogno di aiuto?",
"favorites": "Preferiti",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Impossibile ignorare la notifica",
"friend_request_accepted": "Richiesta di amicizia accettata",
"friend_request_refused": "Richiesta di amicizia rifiutata"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -27,7 +27,7 @@
"queued": "{{title}}(待機中)",
"game_has_no_executable": "実行ファイルが選択されていません",
"sign_in": "サインイン",
"friends": "フレンド",
"friends": "Friends",
"notifications": "通知",
"need_help": "ヘルプが必要ですか?",
"favorites": "お気に入り",
@ -1081,5 +1081,24 @@
"failed_to_dismiss": "通知の閉じるに失敗しました",
"friend_request_accepted": "フレンドリクエストを承認しました",
"friend_request_refused": "フレンドリクエストを拒否しました"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (Rindā)",
"game_has_no_executable": "Spēles palaišanas fails nav izvēlēts",
"sign_in": "Pieteikties",
"friends": "Draugi",
"friends": "Friends",
"need_help": "Nepieciešama palīdzība?",
"favorites": "Izlase",
"playable_button_title": "Rādīt tikai instalētās spēles.",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (I køen)",
"game_has_no_executable": "Spillet har ikke noen kjørbar fil valgt",
"sign_in": "Logge inn",
"friends": "Venner",
"friends": "Friends",
"favorites": "Favoritter",
"library": "Library",
"collections": "Collections",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -27,7 +27,7 @@
"queued": "{{title}} (W kolejce)",
"game_has_no_executable": "Gra nie ma wybranego pliku wykonywalnego",
"sign_in": "Zaloguj się",
"friends": "Znajomi",
"friends": "Friends",
"notifications": "Powiadomienia",
"need_help": "Potrzebujesz pomocy?",
"favorites": "Ulubione",
@ -1164,5 +1164,24 @@
"system_tray": {
"open": "Otwórz Hydrę",
"quit": "Zamknij Hydrę"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -466,6 +466,21 @@
"loading_reviews": "Carregando avaliações...",
"loading_more_reviews": "Carregando mais avaliações...",
"load_more_reviews": "Carregar mais avaliações",
"reply": "Responder",
"view_all_replies": "Ver todas as respostas ({{count}})",
"load_more_replies": "Carregar mais respostas",
"hide_replies": "Ocultar respostas",
"loading_replies": "Carregando respostas...",
"replies_load_failed": "Falha ao carregar as respostas. Por favor, tente novamente.",
"write_reply_placeholder": "Escreva uma resposta...",
"post_reply": "Responder",
"posting_reply": "Publicando...",
"reply_submitted_successfully": "Resposta publicada com sucesso!",
"reply_submission_failed": "Falha ao publicar a resposta. Por favor, tente novamente.",
"reply_cannot_be_empty": "A resposta não pode estar vazia.",
"reply_deleted_successfully": "Resposta excluída com sucesso.",
"reply_deletion_failed": "Falha ao excluir a resposta. Por favor, tente novamente.",
"delete_reply": "Excluir resposta",
"you_seemed_to_enjoy_this_game": "Parece que você gostou deste jogo",
"would_you_recommend_this_game": "Gostaria de deixar uma avaliação para este jogo?",
"yes": "Sim",
@ -1108,6 +1123,8 @@
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Sua avaliação de {{gameTitle}} recebeu votos!",
"review_upvote_description": "Sua avaliação recebeu {{count}} novos votos",
"review_answer_title": "Nova resposta à sua avaliação de {{gameTitle}}",
"review_answer_description": "{{displayName}} respondeu à sua avaliação",
"marked_all_as_read": "Todas as notificações marcadas como lidas",
"failed_to_mark_as_read": "Falha ao marcar notificações como lidas",
"cleared_all": "Todas as notificações limpas",
@ -1130,5 +1147,25 @@
"preflight_downloading": "Baixando dependências",
"preflight_installing": "Instalando dependências",
"preflight_installing_detail": "{{detail}}"
},
"friends_window": {
"title": "Amigos",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Código de amigo copiado",
"copy_friend_code": "Copiar código de amigo",
"your_friend_code": "Seu código de amigo:",
"add_friend": "Adicionar amigo",
"search_placeholder": "Buscar amigos",
"clear_search": "Limpar busca",
"friend_requests": "Solicitações de amizade",
"accept": "Aceitar",
"refuse": "Recusar",
"playing": "Jogando {{game}}",
"remove_friend": "Remover amigo",
"no_results": "Nenhum amigo encontrado",
"no_friends": "Nenhum amigo online",
"try_again": "Algo deu errado. Tente novamente.",
"copied": "Copiado!"
}
}

View file

@ -276,6 +276,21 @@
"loading_reviews": "A carregar avaliações...",
"loading_more_reviews": "A carregar mais avaliações...",
"load_more_reviews": "Carregar mais avaliações",
"reply": "Responder",
"view_all_replies": "Ver todas as respostas ({{count}})",
"load_more_replies": "Carregar mais respostas",
"hide_replies": "Ocultar respostas",
"loading_replies": "A carregar respostas...",
"replies_load_failed": "Falha ao carregar as respostas. Por favor, tenta novamente.",
"write_reply_placeholder": "Escreve uma resposta...",
"post_reply": "Responder",
"posting_reply": "A publicar...",
"reply_submitted_successfully": "Resposta publicada com sucesso!",
"reply_submission_failed": "Falha ao publicar a resposta. Por favor, tenta novamente.",
"reply_cannot_be_empty": "A resposta não pode estar vazia.",
"reply_deleted_successfully": "Resposta eliminada com sucesso.",
"reply_deletion_failed": "Falha ao eliminar a resposta. Por favor, tenta novamente.",
"delete_reply": "Eliminar resposta",
"you_seemed_to_enjoy_this_game": "Parece que gostaste deste jogo",
"would_you_recommend_this_game": "Gostarias de deixar uma avaliação para este jogo?",
"yes": "Sim",
@ -980,6 +995,8 @@
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "A tua avaliação de {{gameTitle}} recebeu votos positivos!",
"review_upvote_description": "A tua avaliação recebeu {{count}} novos votos positivos",
"review_answer_title": "Nova resposta à tua avaliação de {{gameTitle}}",
"review_answer_description": "{{displayName}} respondeu à tua avaliação",
"marked_all_as_read": "Todas as notificações marcadas como lidas",
"failed_to_mark_as_read": "Falha ao marcar notificações como lidas",
"cleared_all": "Todas as notificações limpas",
@ -1002,5 +1019,25 @@
"preflight_downloading": "A transferir dependências",
"preflight_installing": "A instalar dependências",
"preflight_installing_detail": "{{detail}}"
},
"friends_window": {
"title": "Amigos",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Código de amigo copiado",
"copy_friend_code": "Copiar código de amigo",
"your_friend_code": "O teu código de amigo:",
"add_friend": "Adicionar amigo",
"search_placeholder": "Procurar amigos",
"clear_search": "Limpar pesquisa",
"friend_requests": "Pedidos de amizade",
"accept": "Aceitar",
"refuse": "Recusar",
"playing": "A jogar {{game}}",
"remove_friend": "Remover amigo",
"no_results": "Nenhum amigo encontrado",
"no_friends": "Nenhum amigo online",
"try_again": "Algo correu mal. Tenta novamente.",
"copied": "Copiado!"
}
}

View file

@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -372,6 +372,21 @@
"loading_reviews": "Загрузка отзывов...",
"loading_more_reviews": "Загрузка дополнительных отзывов...",
"load_more_reviews": "Загрузить больше отзывов",
"reply": "Ответить",
"view_all_replies": "Показать все ответы ({{count}})",
"load_more_replies": "Загрузить больше ответов",
"hide_replies": "Скрыть ответы",
"loading_replies": "Загрузка ответов...",
"replies_load_failed": "Не удалось загрузить ответы. Пожалуйста, попробуйте снова.",
"write_reply_placeholder": "Напишите ответ...",
"post_reply": "Ответить",
"posting_reply": "Публикация...",
"reply_submitted_successfully": "Ответ успешно опубликован!",
"reply_submission_failed": "Не удалось опубликовать ответ. Пожалуйста, попробуйте снова.",
"reply_cannot_be_empty": "Ответ не может быть пустым.",
"reply_deleted_successfully": "Ответ успешно удалён.",
"reply_deletion_failed": "Не удалось удалить ответ. Пожалуйста, попробуйте снова.",
"delete_reply": "Удалить ответ",
"you_seemed_to_enjoy_this_game": "Похоже, вам понравилась эта игра",
"would_you_recommend_this_game": "Хотите оставить отзыв об этой игре?",
"yes": "Да",
@ -1121,6 +1136,8 @@
"badge_received_description": "{{badgeName}}",
"review_upvote_title": "Ваш отзыв на {{gameTitle}} получил голоса!",
"review_upvote_description": "Ваш отзыв получил {{count}} новых голосов",
"review_answer_title": "Новый ответ на ваш отзыв об игре {{gameTitle}}",
"review_answer_description": "{{displayName}} ответил на ваш отзыв",
"marked_all_as_read": "Все уведомления отмечены как прочитанные",
"failed_to_mark_as_read": "Не удалось отметить уведомления как прочитанные",
"cleared_all": "Все уведомления очищены",
@ -1143,5 +1160,25 @@
"preflight_downloading": "Загрузка зависимостей",
"preflight_installing": "Установка зависимостей",
"preflight_installing_detail": "{{detail}}"
},
"friends_window": {
"title": "Друзья",
"online": "В сети",
"offline": "Не в сети",
"friend_code_copied": "Код друга скопирован",
"copy_friend_code": "Скопировать код друга",
"your_friend_code": "Ваш код друга:",
"add_friend": "Добавить друга",
"search_placeholder": "Поиск друзей",
"clear_search": "Очистить поиск",
"friend_requests": "Запросы в друзья",
"accept": "Принять",
"refuse": "Отклонить",
"playing": "Играет в {{game}}",
"remove_friend": "Удалить из друзей",
"no_results": "Друзья не найдены",
"no_friends": "Нет друзей в сети",
"try_again": "Что-то пошло не так. Попробуйте снова.",
"copied": "Скопировано!"
}
}

View file

@ -25,7 +25,7 @@
"queued": "{{title}} (V čakalni vrsti)",
"game_has_no_executable": "Igra nima izbrane izvršljive datoteke",
"sign_in": "Prijavite se",
"friends": "Prijatelji",
"friends": "Friends",
"notifications": "Obvestila",
"need_help": "Potrebujete pomoč?",
"favorites": "Priljubljene",
@ -1292,5 +1292,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (I kö)",
"game_has_no_executable": "Spelet har ingen vald körbar fil",
"sign_in": "Logga in",
"friends": "Vänner",
"friends": "Friends",
"need_help": "Behöver du hjälp?",
"favorites": "Favoriter",
"library": "Library",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -25,7 +25,7 @@
"queued": "{{title}} (Sırada)",
"game_has_no_executable": "Bu oyun için çalıştırılabilir dosya seçilmedi",
"sign_in": "Giriş Yap",
"friends": "Arkadaşlar",
"friends": "Friends",
"need_help": "Yardıma mı ihtiyacınız var?",
"favorites": "Favoriler",
"playable_button_title": "Şu anda oynayabileceğin oyunları göster",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -25,7 +25,7 @@
"queued": "{{title}} в черзі",
"sign_in": "Увійти",
"favorites": "Улюблені",
"friends": "Друзі",
"friends": "Friends",
"need_help": "Потрібна допомога?",
"playable_button_title": "Показати лише ігри, які можна грати зараз",
"add_custom_game_tooltip": "Додати власну гру",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (Navbatda)",
"game_has_no_executable": "Oʻyinni ishga tushirish fayli tanlanmagan",
"sign_in": "Kirish",
"friends": "Do'stlar",
"friends": "Friends",
"need_help": "Yordam kerakmi?",
"favorites": "Sevimlilar",
"library": "Library",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -24,7 +24,7 @@
"queued": "{{title}} (已加入下载队列)",
"game_has_no_executable": "未选择游戏的可执行文件",
"sign_in": "登入",
"friends": "好友",
"friends": "Friends",
"favorites": "收藏",
"need_help": "需要帮助?",
"playable_button_title": "仅显示现在可以游玩的游戏",
@ -1056,5 +1056,24 @@
"failed_to_dismiss": "Failed to dismiss notification",
"friend_request_accepted": "Friend request accepted",
"friend_request_refused": "Friend request refused"
},
"friends_window": {
"title": "Friends",
"online": "Online",
"offline": "Offline",
"friend_code_copied": "Friend code copied",
"copy_friend_code": "Copy friend code",
"your_friend_code": "Your friend code:",
"add_friend": "Add friend",
"search_placeholder": "Search friends",
"friend_requests": "Friend requests",
"accept": "Accept",
"refuse": "Refuse",
"playing": "Playing {{game}}",
"remove_friend": "Remove friend",
"no_results": "No friends found",
"no_friends": "No friends online",
"try_again": "Something went wrong. Please try again.",
"copied": "Copied!"
}
}

View file

@ -3,6 +3,7 @@ import {
DownloadManager,
HydraApi,
WSClient,
WindowManager,
gamesPlaytime,
} from "@main/services";
import {
@ -41,6 +42,9 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
HydraApi.handleSignOut();
/* The friends window is only meaningful while signed in */
WindowManager.closeFriendsWindow();
await Promise.all([
databaseOperations,
HydraApi.post("/auth/logout").catch(() => {}),

View file

@ -0,0 +1,10 @@
import { WindowManager } from "@main/services";
import { ipcMain } from "electron";
ipcMain.handle("minimizeFriendsWindow", () => {
WindowManager.minimizeFriendsWindow();
});
ipcMain.handle("closeFriendsWindow", () => {
WindowManager.closeFriendsWindow();
});

View file

@ -0,0 +1,162 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { net } from "electron";
import { registerEvent } from "../register-event";
import { logger, NativeAddon } from "@main/services";
import { SystemPath } from "@main/services/system-path";
type FriendImageOptions = {
width: number;
height: number;
preserveAnimation?: boolean;
};
const MAX_DIMENSION = 2048;
const CACHE_VERSION = 2;
const inFlight = new Map<string, Promise<string>>();
const isHttpUrl = (url: string) =>
url.startsWith("http://") || url.startsWith("https://");
const validateOptions = ({ width, height }: FriendImageOptions) => {
return (
Number.isInteger(width) &&
Number.isInteger(height) &&
width > 0 &&
height > 0 &&
width <= MAX_DIMENSION &&
height <= MAX_DIMENSION
);
};
const getCacheDir = () => {
return path.join(SystemPath.getPath("userData"), "image-cache", "friends");
};
const getCacheKey = (
imageUrl: string,
options: Required<FriendImageOptions>
) => {
return crypto
.createHash("sha256")
.update(
JSON.stringify({
imageUrl,
version: CACHE_VERSION,
width: options.width,
height: options.height,
preserveAnimation: options.preserveAnimation,
})
)
.digest("hex");
};
const findCachedImage = async (outputBase: string) => {
for (const extension of ["gif", "webp"]) {
const imagePath = `${outputBase}.${extension}`;
if (fs.existsSync(imagePath)) {
return imagePath;
}
}
return null;
};
const downloadImage = async (imageUrl: string, outputPath: string) => {
const response = await net.fetch(imageUrl);
if (!response.ok) {
throw new Error(`Image download failed with status ${response.status}`);
}
const contentType = response.headers.get("content-type")?.split(";")[0];
if (contentType && !contentType.startsWith("image/")) {
throw new Error(`Unexpected image content type: ${contentType}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
await fs.promises.writeFile(outputPath, buffer);
};
const processAndCacheImage = async (
imageUrl: string,
options: Required<FriendImageOptions>
) => {
const cacheDir = getCacheDir();
await fs.promises.mkdir(cacheDir, { recursive: true });
const cacheKey = getCacheKey(imageUrl, options);
const outputBase = path.join(cacheDir, cacheKey);
const cachedImage = await findCachedImage(outputBase);
if (cachedImage) return `local:${cachedImage}`;
const tempBase = path.join(
cacheDir,
`${cacheKey}-${process.pid}-${Date.now()}-tmp`
);
const downloadPath = `${tempBase}.input`;
try {
await downloadImage(imageUrl, downloadPath);
const processedImage = await NativeAddon.processFriendImage(
downloadPath,
tempBase,
options.width,
options.height,
options.preserveAnimation
);
const extension = path.extname(processedImage.imagePath);
const finalPath = `${outputBase}${extension}`;
await fs.promises.rename(processedImage.imagePath, finalPath);
return `local:${finalPath}`;
} finally {
await Promise.allSettled([
fs.promises.unlink(downloadPath),
fs.promises.unlink(`${tempBase}.gif`),
fs.promises.unlink(`${tempBase}.webp`),
]);
}
};
const getProcessedFriendImage = async (
_event: Electron.IpcMainInvokeEvent,
imageUrl: string | null,
options: FriendImageOptions
): Promise<string | null> => {
if (!imageUrl || !isHttpUrl(imageUrl)) return imageUrl;
if (!validateOptions(options)) return imageUrl;
const normalizedOptions = {
width: options.width,
height: options.height,
preserveAnimation: options.preserveAnimation ?? true,
};
const sourceUrl = imageUrl;
const inFlightKey = getCacheKey(sourceUrl, normalizedOptions);
try {
const existingRequest = inFlight.get(inFlightKey);
if (existingRequest) return await existingRequest;
const request = processAndCacheImage(sourceUrl, normalizedOptions);
inFlight.set(inFlightKey, request);
return await request;
} catch (error) {
logger.error("Failed to process friend image", { imageUrl, error });
return imageUrl;
} finally {
inFlight.delete(inFlightKey);
}
};
registerEvent("getProcessedFriendImage", getProcessedFriendImage);

View file

@ -0,0 +1,6 @@
import "./open-friends-window";
import "./open-friend-profile";
import "./open-add-friend-modal";
import "./friends-window-controls";
import "./get-processed-friend-image";
import "./sync-friend-requests";

View file

@ -0,0 +1,6 @@
import { WindowManager } from "@main/services";
import { ipcMain } from "electron";
ipcMain.handle("openAddFriendModalInMainWindow", () => {
WindowManager.openAddFriendModalInMainWindow();
});

View file

@ -0,0 +1,6 @@
import { WindowManager } from "@main/services";
import { ipcMain } from "electron";
ipcMain.handle("openFriendProfileInMainWindow", (_event, userId: string) => {
WindowManager.focusMainWindowAndNavigate(`/profile/${userId}`);
});

View file

@ -0,0 +1,6 @@
import { WindowManager } from "@main/services";
import { ipcMain } from "electron";
ipcMain.handle("openFriendsWindow", () => {
WindowManager.openFriendsWindow();
});

View file

@ -0,0 +1,13 @@
import { WindowManager } from "@main/services";
import { ipcMain } from "electron";
// Friend-request mutations (accept/refuse/cancel/send) go straight through the
// HTTP API from whichever renderer the user is in. The WS push only reaches the
// *other* user, so the acting user's own windows never learn the request was
// handled. This lets the acting renderer fan the change out to every app window
// (main, big picture, friends) so notification lists and badges stay in sync.
ipcMain.handle("syncFriendRequests", (_event, friendRequestCount: number) => {
WindowManager.sendToAppWindows("on-sync-friend-requests", {
friendRequestCount,
});
});

View file

@ -7,6 +7,7 @@ import "./big-picture";
import "./catalogue";
import "./cloud-save";
import "./download-sources";
import "./friends";
import "./hardware";
import "./library";
import "./leveldb";
@ -27,3 +28,6 @@ ipcMain.handle("getVersion", () => appVersion);
ipcMain.handle("isStaging", () => isStaging);
ipcMain.handle("isPortableVersion", () => isPortableVersion());
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);
ipcMain.handle("getCloudIframeUrl", () =>
new URL("/cloud", import.meta.env.MAIN_VITE_CHECKOUT_URL).toString()
);

View file

@ -1,5 +1,5 @@
import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
import { HydraApi, WindowManager } from "@main/services";
import fs from "node:fs";
import path from "node:path";
import type { UpdateProfileRequest, UserProfile } from "@types";
@ -77,7 +77,13 @@ const updateProfile = async (
}
}
return patchUserProfile(payload);
const updatedProfile = await patchUserProfile(payload);
// Notify every window (e.g. the friends window, which has its own store) so
// they can re-fetch the signed-in user's details after a profile change.
WindowManager.sendToAppWindows("on-profile-updated");
return updatedProfile;
};
registerEvent("updateProfile", updateProfile);

View file

@ -1,11 +0,0 @@
import { HydraDebridClient } from "@main/services/download/hydra-debrid";
import { registerEvent } from "../register-event";
const checkDebridAvailability = async (
_event: Electron.IpcMainInvokeEvent,
magnets: string[]
) => {
return HydraDebridClient.getAvailableMagnets(magnets);
};
registerEvent("checkDebridAvailability", checkDebridAvailability);

View file

@ -1,6 +1,5 @@
import "./add-game-to-queue";
import "./cancel-game-download";
import "./check-debrid-availability";
import "./pause-game-download";
import "./pause-game-seed";
import "./resume-game-download";

View file

@ -37,9 +37,6 @@ const startGameDownload = async (
`[Downloads] Start requested for ${gameKey} (downloader=${downloader})`
);
await prepareGameEntry({ gameKey, title, objectId, shop });
await DownloadManager.cancelDownload(gameKey);
const download: Download = {
shop,
objectId,
@ -63,6 +60,9 @@ const startGameDownload = async (
};
try {
await DownloadManager.validateDownloadUrl(download);
await prepareGameEntry({ gameKey, title, objectId, shop });
await DownloadManager.cancelDownload(gameKey);
await downloadsSublevel.put(gameKey, download);
await DownloadOrchestrator.startPreparedDownload(download);

View file

@ -49,6 +49,19 @@ export interface Notification {
*/
notificationCount: number;
}
/**
* @generated from protobuf message FriendPresence
*/
export interface FriendPresence {
/**
* @generated from protobuf field: string friend_id = 1
*/
friendId: string;
/**
* @generated from protobuf field: bool is_online = 2
*/
isOnline: boolean;
}
/**
* @generated from protobuf message Envelope
*/
@ -78,6 +91,13 @@ export interface Envelope {
*/
notification: Notification;
}
| {
oneofKind: "friendPresence";
/**
* @generated from protobuf field: FriendPresence friend_presence = 4
*/
friendPresence: FriendPresence;
}
| {
oneofKind: undefined;
};
@ -329,6 +349,83 @@ class Notification$Type extends MessageType<Notification> {
*/
export const Notification = new Notification$Type();
// @generated message type with reflection information, may provide speed optimized methods
class FriendPresence$Type extends MessageType<FriendPresence> {
constructor() {
super("FriendPresence", [
{ no: 1, name: "friend_id", kind: "scalar", T: 9 /*ScalarType.STRING*/ },
{ no: 2, name: "is_online", kind: "scalar", T: 8 /*ScalarType.BOOL*/ },
]);
}
create(value?: PartialMessage<FriendPresence>): FriendPresence {
const message = globalThis.Object.create(this.messagePrototype!);
message.friendId = "";
message.isOnline = false;
if (value !== undefined)
reflectionMergePartial<FriendPresence>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: FriendPresence
): FriendPresence {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string friend_id */ 1:
message.friendId = reader.string();
break;
case /* bool is_online */ 2:
message.isOnline = reader.bool();
break;
default:
let u = options.readUnknownField;
if (u === "throw")
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: FriendPresence,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* string friend_id = 1; */
if (message.friendId !== "")
writer.tag(1, WireType.LengthDelimited).string(message.friendId);
/* bool is_online = 2; */
if (message.isOnline !== false)
writer.tag(2, WireType.Varint).bool(message.isOnline);
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(
this.typeName,
message,
writer
);
return writer;
}
}
/**
* @generated MessageType for protobuf message FriendPresence
*/
export const FriendPresence = new FriendPresence$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Envelope$Type extends MessageType<Envelope> {
constructor() {
super("Envelope", [
@ -353,6 +450,13 @@ class Envelope$Type extends MessageType<Envelope> {
oneof: "payload",
T: () => Notification,
},
{
no: 4,
name: "friend_presence",
kind: "message",
oneof: "payload",
T: () => FriendPresence,
},
]);
}
create(value?: PartialMessage<Envelope>): Envelope {
@ -406,6 +510,17 @@ class Envelope$Type extends MessageType<Envelope> {
),
};
break;
case /* FriendPresence friend_presence */ 4:
message.payload = {
oneofKind: "friendPresence",
friendPresence: FriendPresence.internalBinaryRead(
reader,
reader.uint32(),
options,
(message.payload as any).friendPresence
),
};
break;
default:
let u = options.readUnknownField;
if (u === "throw")
@ -451,6 +566,13 @@ class Envelope$Type extends MessageType<Envelope> {
writer.tag(3, WireType.LengthDelimited).fork(),
options
).join();
/* FriendPresence friend_presence = 4; */
if (message.payload.oneofKind === "friendPresence")
FriendPresence.internalBinaryWrite(
message.payload.friendPresence,
writer.tag(4, WireType.LengthDelimited).fork(),
options
).join();
let u = options.writeUnknownFields;
if (u !== false)
(u == true ? UnknownFieldHandler.onWrite : u)(

View file

@ -78,12 +78,10 @@ export class DownloadOrchestrator {
}
private static async activateDownload(download: Download) {
await DownloadManager.resumeDownload(download);
const activeDownload: Download = {
...download,
status: "active",
queued: true,
queued: false,
pinnedToHero: false,
extracting: false,
extractionProgress: 0,
@ -92,6 +90,31 @@ export class DownloadOrchestrator {
await downloadsSublevel.put(getGameKey(download), activeDownload);
try {
await DownloadManager.resumeDownload(activeDownload);
} catch (error) {
const downloadId = getDownloadId(download);
await downloadsSublevel.put(getGameKey(download), {
...activeDownload,
status: "error",
queued: false,
});
const downloads = await this.getAllDownloads();
const layoutState = await getNormalizedDownloadLayoutState(downloads);
await setDownloadLayoutQueues(
downloads,
layoutState.queueOrder.filter((id) => id !== downloadId),
[
downloadId,
...layoutState.pausedOrder.filter((id) => id !== downloadId),
]
);
WindowManager.sendDownloadsUpdated();
throw error;
}
return activeDownload;
}

View file

@ -25,7 +25,6 @@ import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { TorBoxClient } from "./torbox";
import { GameFilesManager } from "../game-files-manager";
import { HydraDebridClient } from "./hydra-debrid";
import { PremiumizeClient } from "./premiumize";
import { AllDebridClient } from "./all-debrid";
import { JsHttpDownloader } from "./js-http-downloader";
@ -33,6 +32,7 @@ import { getDirectorySize } from "@main/events/helpers/get-directory-size";
import {
getDownloadLayoutStateRecord,
getNextQueuedDownloadFromLayout,
setDownloadLayoutQueues,
} from "../download-layout-state";
import { shouldFinalizeDownload } from "./download-completion";
@ -727,7 +727,27 @@ export class DownloadManager {
);
if (nextItemOnQueue) {
this.resumeDownload(nextItemOnQueue);
const nextDownloadId = levelKeys.game(
nextItemOnQueue.shop,
nextItemOnQueue.objectId
);
const activeDownload: Download = {
...nextItemOnQueue,
status: "active",
queued: false,
pinnedToHero: false,
extracting: false,
extractionProgress: 0,
};
await downloadsSublevel.put(nextDownloadId, activeDownload);
WindowManager.sendDownloadsUpdated();
try {
await this.resumeDownload(activeDownload);
} catch (error) {
await this.handleRuntimeDownloadError(nextDownloadId, error);
}
} else {
this.downloadingGameId = null;
this.usingJsDownloader = false;
@ -736,6 +756,76 @@ export class DownloadManager {
}
}
private static getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try {
return JSON.stringify(error) ?? "Unknown error";
} catch {
return "Unknown error";
}
}
private static async handleRuntimeDownloadError(
downloadId: string,
error: unknown
) {
if (this.downloadingGameId && this.downloadingGameId !== downloadId) {
const message = this.getErrorMessage(error);
logger.warn(
`[DownloadManager] Ignoring stale download error for ${downloadId}: ${message}`
);
return;
}
const message = this.getErrorMessage(error);
logger.error(
`[DownloadManager] Download failed for ${downloadId}: ${message}`,
error
);
this.downloadingGameId = null;
this.isPreparingDownload = false;
this.usingJsDownloader = false;
this.jsDownloader = null;
this.allDebridBatch = null;
WindowManager.mainWindow?.setProgressBar(-1);
WindowManager.sendToAppWindows("on-download-progress", null);
try {
const download = await downloadsSublevel.get(downloadId);
if (download) {
await downloadsSublevel.put(downloadId, {
...download,
status: "error",
queued: false,
pinnedToHero: false,
extracting: false,
});
const downloads = await downloadsSublevel.values().all();
const layoutState = await getDownloadLayoutStateRecord();
await setDownloadLayoutQueues(
downloads,
layoutState.queueOrder.filter((id) => id !== downloadId),
[
downloadId,
...layoutState.pausedOrder.filter((id) => id !== downloadId),
]
);
}
} catch (persistError) {
logger.error(
`[DownloadManager] Failed to persist download error for ${downloadId}`,
persistError
);
}
WindowManager.sendDownloadsUpdated();
await this.processNextQueuedDownload();
}
public static async getSeedStatus() {
let seedStatus: LibtorrentPayload[] = [];
@ -883,7 +973,7 @@ export class DownloadManager {
case Downloader.TorBox:
return this.getTorBoxDownloadOptions(download, resumingFilename);
case Downloader.Hydra:
return this.getHydraDownloadOptions(download, resumingFilename);
throw new Error(DownloadError.NotCachedOnHydra);
case Downloader.VikingFile:
return this.getVikingFileDownloadOptions(download, resumingFilename);
case Downloader.Rootz:
@ -999,21 +1089,46 @@ export class DownloadManager {
throw new Error("Invalid gofile URL");
}
const { url, headers } = await this.resolveGofileDownload(id, password);
const filename = this.resolveFilename(resumingFilename, download.uri, url);
return this.buildDownloadOptions(
url,
download.downloadPath,
filename,
headers
);
}
private static async resolveGofileDownload(
id: string,
password?: string
): Promise<{
url: string;
headers?: Record<string, string>;
}> {
const alternateCdnDownloadLink =
await GofileApi.getAlternateCdnDownloadLinkIfAvailable(id);
if (alternateCdnDownloadLink) {
logger.log(
`[DownloadManager] GoFile download ${id} will use alternate CDN`
);
return { url: alternateCdnDownloadLink };
}
logger.log(
`[DownloadManager] GoFile download ${id} will use official GoFile fallback`
);
const downloadLink = await GofileApi.getDownloadLink(id, password);
await GofileApi.checkDownloadUrl(downloadLink);
const token = await GofileApi.authorize();
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadLink
);
return this.buildDownloadOptions(
downloadLink,
download.downloadPath,
filename,
{ Cookie: `accountToken=${token}` }
);
return {
url: downloadLink,
headers: { Cookie: `accountToken=${token}` },
};
}
private static async getPixelDrainDownloadOptions(
@ -1154,24 +1269,6 @@ export class DownloadManager {
);
}
private static async getHydraDownloadOptions(
download: Download,
resumingFilename?: string
) {
const downloadUrl = await HydraDebridClient.getDownloadUrl(download.uri);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
const filename = this.resolveFilename(
resumingFilename,
download.uri,
downloadUrl
);
return this.buildDownloadOptions(
downloadUrl,
download.downloadPath,
filename
);
}
private static async getVikingFileDownloadOptions(
download: Download,
resumingFilename?: string
@ -1219,19 +1316,24 @@ export class DownloadManager {
throw new Error("Invalid gofile URL");
}
const downloadLink = await GofileApi.getDownloadLink(id, password);
await GofileApi.checkDownloadUrl(downloadLink);
const token = await GofileApi.authorize();
return {
action: "start",
const { url, headers } = await this.resolveGofileDownload(id, password);
const payload = {
action: "start" as const,
game_id: downloadId,
url: downloadLink,
url,
save_path: download.downloadPath,
header: `Cookie: accountToken=${token}`,
allow_multiple_connections: true,
connections_limit: 8,
};
if (headers?.Cookie) {
return {
...payload,
header: `Cookie: ${headers.Cookie}`,
};
}
return payload;
}
case Downloader.PixelDrain: {
const downloadUrl = await PixelDrainApi.unlock(download.uri);
@ -1354,18 +1456,7 @@ export class DownloadManager {
};
}
case Downloader.Hydra: {
const downloadUrl = await HydraDebridClient.getDownloadUrl(
download.uri
);
if (!downloadUrl) throw new Error(DownloadError.NotCachedOnHydra);
return {
action: "start",
game_id: downloadId,
url: downloadUrl,
save_path: download.downloadPath,
allow_multiple_connections: true,
};
throw new Error(DownloadError.NotCachedOnHydra);
}
case Downloader.VikingFile: {
logger.log(
@ -1401,6 +1492,55 @@ export class DownloadManager {
if (!options) {
throw new Error("Failed to validate download URL");
}
await this.validateJsDownloadResponse(options);
}
}
private static async validateJsDownloadResponse(options: {
url: string;
headers?: Record<string, string>;
}) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(options.url, {
method: "GET",
headers: options.headers,
signal: controller.signal,
});
const contentType = response.headers.get("content-type") ?? "unknown";
const contentLength = response.headers.get("content-length") ?? "unknown";
logger.log(
`[DownloadManager] Preflight response status=${response.status} content-type=${contentType} content-length=${contentLength}`
);
await response.body?.cancel().catch(() => undefined);
if (response.status >= 400) {
throw new Error(
`The download link is not available (HTTP ${response.status}).`
);
}
if (
contentType.includes("text/html") ||
contentType.includes("application/xhtml")
) {
throw new Error(
"The download link returned a web page instead of a file. It may have expired or be invalid."
);
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Download URL validation timed out");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
@ -1470,10 +1610,14 @@ export class DownloadManager {
this.logResolvedUrl(options.url);
this.jsDownloader.startDownload(options).catch((err) => {
logger.error("[DownloadManager] JS download error:", err);
this.usingJsDownloader = false;
this.jsDownloader = null;
this.allDebridBatch = null;
void this.handleRuntimeDownloadError(downloadId, err).catch(
(error) => {
logger.error(
`[DownloadManager] Failed to handle download error for ${downloadId}`,
error
);
}
);
});
}
} catch (err) {

View file

@ -1,27 +0,0 @@
import { HydraApi } from "../hydra-api";
export class HydraDebridClient {
public static getAvailableMagnets(
magnets: string[]
): Promise<Record<string, boolean>> {
return HydraApi.put(
"/debrid/check-availability",
{
magnets,
},
{ needsAuth: false }
);
}
public static async getDownloadUrl(magnet: string) {
try {
const response = await HydraApi.post("/debrid/request-file", {
magnet,
});
return response.downloadUrl;
} catch (error) {
return null;
}
}
}

View file

@ -44,6 +44,13 @@ const RETRYABLE_ERROR_CODES = new Set([
"ERR_STREAM_PREMATURE_CLOSE",
]);
class HttpDownloadStatusError extends Error {
constructor(public readonly statusCode: number) {
super(`The download link is not available (HTTP ${statusCode}).`);
this.name = "HttpDownloadStatusError";
}
}
export class JsHttpDownloader {
private abortController: AbortController | null = null;
private writeStream: fs.WriteStream | null = null;
@ -397,6 +404,10 @@ export class JsHttpDownloader {
);
}
if (response.status >= 400) {
throw new HttpDownloadStatusError(response.status);
}
if (!response.ok && response.status !== 206) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@ -407,7 +418,7 @@ export class JsHttpDownloader {
contentType.includes("application/xhtml")
) {
throw new Error(
`Unexpected HTML response (content-type: ${contentType}). The download URL may have expired or is invalid.`
`The download link returned a web page instead of a file. It may have expired or be invalid.`
);
}

View file

@ -56,6 +56,7 @@ export class GofileApi {
private static readonly timeoutMs = 15000;
private static readonly websiteTokenScriptUrl =
"https://gofile.io/dist/js/wt.obf.js";
private static readonly alternateCdnBaseUrl = "https://gofilecdn.eu.cc";
private static readonly pageSize = 1000;
private static readonly cacheVersion = 1;
private static token?: string;
@ -413,6 +414,34 @@ export class GofileApi {
return link;
}
public static async getAlternateCdnDownloadLinkIfAvailable(id: string) {
const downloadUrl = `${this.alternateCdnBaseUrl}/${encodeURIComponent(id)}`;
logger.log(`[Gofile] Checking alternate CDN downloader for ${id}`);
try {
const response = await this.fetchWithTimeout(downloadUrl, {
method: "OPTIONS",
});
if (!response.ok) {
logger.log(
`[Gofile] Alternate CDN unavailable for ${id}: ${response.status}`
);
return null;
}
logger.log(`[Gofile] Using alternate CDN downloader for ${id}`);
return downloadUrl;
} catch (error) {
logger.warn(
`[Gofile] Alternate CDN check failed for ${id}; falling back to official downloader`,
error
);
return null;
}
}
private static async parseLinksRecursively(
id: string,
accountToken: string,

View file

@ -15,11 +15,23 @@ type NativeProcessProfileImageResponse = {
mime_type?: string;
};
type NativeProcessFriendImageResponse = NativeProcessProfileImageResponse & {
isAnimated?: boolean;
is_animated?: boolean;
};
type HydraNativeModule = {
processProfileImage: (
imagePath: string,
targetExtension?: string
) => NativeProcessProfileImageResponse;
processFriendImage: (
imagePath: string,
outputPathBase: string,
width: number,
height: number,
preserveAnimation: boolean
) => Promise<NativeProcessFriendImageResponse>;
listProcesses: () => ProcessPayload[];
};
@ -219,6 +231,42 @@ export class NativeAddon {
}
}
public static async processFriendImage(
imagePath: string,
outputPathBase: string,
width: number,
height: number,
preserveAnimation: boolean
) {
try {
const response = await this.load().processFriendImage(
imagePath,
outputPathBase,
width,
height,
preserveAnimation
);
const normalizedImagePath = response.imagePath ?? response.image_path;
const normalizedMimeType = response.mimeType ?? response.mime_type;
const normalizedIsAnimated =
response.isAnimated ?? response.is_animated ?? false;
if (!normalizedImagePath || !normalizedMimeType) {
throw new Error("Hydra native addon returned an invalid payload");
}
return {
imagePath: normalizedImagePath,
mimeType: normalizedMimeType,
isAnimated: normalizedIsAnimated,
};
} catch (error) {
logger.error("Failed to process friend image via native addon", error);
throw error;
}
}
private static drainResolvers() {
const drained = this.pendingResolvers.splice(0);
for (const pending of drained) {

View file

@ -33,6 +33,7 @@ export class WindowManager {
public static notificationWindow: Electron.BrowserWindow | null = null;
public static gameLauncherWindow: Electron.BrowserWindow | null = null;
private static bigPicture: Electron.BrowserWindow | null = null;
private static friendsWindow: Electron.BrowserWindow | null = null;
private static deferredMainMaximize = false;
private static readonly editorWindows: Map<string, BrowserWindow> = new Map();
@ -118,7 +119,7 @@ export class WindowManager {
}
public static sendToAppWindows(channel: string, ...args: unknown[]) {
const windows = [this.mainWindow, this.bigPicture];
const windows = [this.mainWindow, this.bigPicture, this.friendsWindow];
for (const window of windows) {
if (!window || window.isDestroyed()) continue;
@ -374,6 +375,83 @@ export class WindowManager {
});
}
public static openFriendsWindow() {
if (this.friendsWindow) {
if (this.friendsWindow.isMinimized()) {
this.friendsWindow.restore();
}
this.friendsWindow.focus();
return;
}
this.friendsWindow = new BrowserWindow({
width: 420,
height: 780,
minWidth: 420,
maxWidth: 420,
minHeight: 560,
maximizable: false,
backgroundColor: "#1c1c1c",
// No native frame/controls — the renderer draws its own minimize and
// close buttons in the title bar (see friends-window.tsx).
frame: false,
icon,
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
show: false,
});
this.friendsWindow.removeMenu();
this.loadWindowURL(this.friendsWindow, "friends-window");
this.friendsWindow.once("ready-to-show", () => {
this.friendsWindow?.show();
if (!app.isPackaged || isStaging) {
this.friendsWindow?.webContents.openDevTools();
}
});
this.friendsWindow.on("closed", () => {
this.friendsWindow = null;
});
}
public static minimizeFriendsWindow() {
if (this.friendsWindow && !this.friendsWindow.isDestroyed()) {
this.friendsWindow.minimize();
}
}
public static closeFriendsWindow() {
if (this.friendsWindow && !this.friendsWindow.isDestroyed()) {
this.friendsWindow.close();
}
this.friendsWindow = null;
}
private static focusMainWindow() {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
if (this.mainWindow.isMinimized()) this.mainWindow.restore();
this.mainWindow.show();
this.mainWindow.focus();
} else {
this.createMainWindow();
}
}
public static focusMainWindowAndNavigate(path: string) {
this.focusMainWindow();
this.mainWindow?.webContents.send("on-navigate", path);
}
public static openAddFriendModalInMainWindow() {
this.focusMainWindow();
this.mainWindow?.webContents.send("on-open-add-friend-modal");
}
public static openAuthWindow(page: AuthPage, searchParams: URLSearchParams) {
const parentWindow =
this.bigPicture && !this.bigPicture.isDestroyed()

View file

@ -1,10 +1,13 @@
import type { FriendGameSession } from "@main/generated/envelope";
import { db, levelKeys } from "@main/level";
import { HydraApi } from "@main/services/hydra-api";
import { WindowManager } from "@main/services/window-manager";
import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications";
import type { UserPreferences, UserProfile } from "@types";
export const friendGameSessionEvent = async (payload: FriendGameSession) => {
WindowManager.sendToAppWindows("on-friends-updated");
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{

View file

@ -0,0 +1,10 @@
import type { FriendPresence } from "@main/generated/envelope";
import { WindowManager } from "@main/services/window-manager";
export const friendPresenceEvent = (payload: FriendPresence) => {
// Broadcast to every window (the friends window has its own renderer/store).
WindowManager.sendToAppWindows("on-friend-presence", {
friendId: payload.friendId,
isOnline: payload.isOnline,
});
};

View file

@ -4,7 +4,8 @@ import { publishNewFriendRequestNotification } from "@main/services/notification
import { WindowManager } from "@main/services/window-manager";
export const friendRequestEvent = async (payload: FriendRequest) => {
WindowManager.mainWindow?.webContents.send("on-sync-friend-requests", {
// Broadcast to every window (the friends window has its own renderer/store).
WindowManager.sendToAppWindows("on-sync-friend-requests", {
friendRequestCount: payload.friendRequestCount,
});

View file

@ -4,6 +4,7 @@ import { Envelope } from "@main/generated/envelope";
import { logger } from "../logger";
import { friendRequestEvent } from "./events/friend-request";
import { friendGameSessionEvent } from "./events/friend-game-session";
import { friendPresenceEvent } from "./events/friend-presence";
import { notificationEvent } from "./events/notification";
export class WSClient {
@ -53,6 +54,10 @@ export class WSClient {
friendGameSessionEvent(envelope.payload.friendGameSession);
}
if (envelope.payload.oneofKind === "friendPresence") {
friendPresenceEvent(envelope.payload.friendPresence);
}
if (envelope.payload.oneofKind === "notification") {
notificationEvent(envelope.payload.notification);
}

View file

@ -15,6 +15,7 @@ import type {
GameAchievement,
Theme,
FriendRequestSync,
FriendPresenceSync,
NotificationSync,
ShortcutLocation,
CreateSteamShortcutOptions,
@ -121,8 +122,6 @@ contextBridge.exposeInMainWorld("electron", {
ipcRenderer.on("on-seeding-status", listener);
return () => ipcRenderer.removeListener("on-seeding-status", listener);
},
checkDebridAvailability: (magnets: string[]) =>
ipcRenderer.invoke("checkDebridAvailability", magnets),
getTorrentFiles: (magnet: string) =>
ipcRenderer.invoke("getTorrentFiles", magnet) as Promise<
{ ok: true; data: TorrentFilesResponse } | { ok: false; error: string }
@ -802,12 +801,17 @@ contextBridge.exposeInMainWorld("electron", {
isPortableVersion: () => ipcRenderer.invoke("isPortableVersion"),
openExternal: (src: string) => ipcRenderer.invoke("openExternal", src),
openCheckout: () => ipcRenderer.invoke("openCheckout"),
getCloudIframeUrl: () => ipcRenderer.invoke("getCloudIframeUrl"),
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options),
showItemInFolder: (path: string) =>
ipcRenderer.invoke("showItemInFolder", path),
getImageDataUrl: (imageUrl: string) =>
ipcRenderer.invoke("getImageDataUrl", imageUrl),
getProcessedFriendImage: (
imageUrl: string | null,
options: { width: number; height: number; preserveAnimation?: boolean }
) => ipcRenderer.invoke("getProcessedFriendImage", imageUrl, options),
hydraApi: {
get: (
url: string,
@ -981,6 +985,8 @@ contextBridge.exposeInMainWorld("electron", {
},
updateFriendRequest: (userId: string, action: FriendRequestAction) =>
ipcRenderer.invoke("updateFriendRequest", userId, action),
syncFriendRequests: (friendRequestCount: number) =>
ipcRenderer.invoke("syncFriendRequests", friendRequestCount),
/* User */
getComparedUnlockedAchievements: (
@ -1155,6 +1161,45 @@ contextBridge.exposeInMainWorld("electron", {
/* Big Picture */
openBigPictureWindow: () => ipcRenderer.invoke("openBigPictureWindow"),
/* Friends */
openFriendsWindow: () => ipcRenderer.invoke("openFriendsWindow"),
minimizeFriendsWindow: () => ipcRenderer.invoke("minimizeFriendsWindow"),
closeFriendsWindow: () => ipcRenderer.invoke("closeFriendsWindow"),
openFriendProfileInMainWindow: (userId: string) =>
ipcRenderer.invoke("openFriendProfileInMainWindow", userId),
openAddFriendModalInMainWindow: () =>
ipcRenderer.invoke("openAddFriendModalInMainWindow"),
onOpenAddFriendModal: (cb: () => void) => {
const listener = () => cb();
ipcRenderer.on("on-open-add-friend-modal", listener);
return () =>
ipcRenderer.removeListener("on-open-add-friend-modal", listener);
},
onFriendsUpdated: (cb: () => void) => {
const listener = () => cb();
ipcRenderer.on("on-friends-updated", listener);
return () => ipcRenderer.removeListener("on-friends-updated", listener);
},
onFriendPresence: (cb: (presence: FriendPresenceSync) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
presence: FriendPresenceSync
) => cb(presence);
ipcRenderer.on("on-friend-presence", listener);
return () => ipcRenderer.removeListener("on-friend-presence", listener);
},
onProfileUpdated: (cb: () => void) => {
const listener = () => cb();
ipcRenderer.on("on-profile-updated", listener);
return () => ipcRenderer.removeListener("on-profile-updated", listener);
},
onNavigate: (cb: (path: string) => void) => {
const listener = (_event: Electron.IpcRendererEvent, path: string) =>
cb(path);
ipcRenderer.on("on-navigate", listener);
return () => ipcRenderer.removeListener("on-navigate", listener);
},
/* Game Launcher Window */
showGameLauncherWindow: () => ipcRenderer.invoke("showGameLauncherWindow"),
closeGameLauncherWindow: () => ipcRenderer.invoke("closeGameLauncherWindow"),

View file

@ -26,7 +26,8 @@ import { useTranslation } from "react-i18next";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useSubscription } from "./hooks/use-subscription";
import { ArchiveDeletionModal } from "./pages/downloads/archive-deletion-error-modal";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { CloudSubscriptionModal } from "./pages/shared-modals/hydra-cloud/cloud-subscription-modal";
import { AddFriendModal } from "./pages/profile/profile-content/add-friend-modal";
import type { UserPreferences } from "@types";
import "./app.scss";
@ -88,6 +89,7 @@ export function App() {
const [showArchiveDeletionModal, setShowArchiveDeletionModal] =
useState(false);
const [archivePaths, setArchivePaths] = useState<string[]>([]);
const [showAddFriendModal, setShowAddFriendModal] = useState(false);
useEffect(() => {
Promise.all([
@ -382,6 +384,22 @@ export function App() {
return () => unsubscribe();
}, [loadAndApplyTheme]);
useEffect(() => {
const unsubscribe = globalThis.electron.onNavigate((path) => {
navigate(path);
});
return () => unsubscribe();
}, [navigate]);
useEffect(() => {
const unsubscribe = globalThis.electron.onOpenAddFriendModal(() => {
setShowAddFriendModal(true);
});
return () => unsubscribe();
}, []);
const playAudio = useCallback(async () => {
const soundUrl = await getAchievementSoundUrl();
const volume = await getAchievementSoundVolume();
@ -426,10 +444,10 @@ export function App() {
duration={toast.duration}
/>
<HydraCloudModal
<CloudSubscriptionModal
visible={isHydraCloudModalVisible}
onClose={hideHydraCloudModal}
feature={hydraCloudFeature}
feature={hydraCloudFeature || undefined}
/>
<ArchiveDeletionModal
@ -438,6 +456,11 @@ export function App() {
onClose={() => setShowArchiveDeletionModal(false)}
/>
<AddFriendModal
visible={showAddFriendModal}
onClose={() => setShowAddFriendModal(false)}
/>
<main>
<Sidebar />

View file

@ -47,7 +47,15 @@ export function AchievementNotificationOverlay() {
}, []);
useEffect(() => {
const unsubscribe = window.electron.onInAppAchievementUnlocked(
const onInAppAchievementUnlocked =
globalThis.electron.onInAppAchievementUnlocked;
// Compatibility guard for remote renderer/preload releases on different commits.
if (typeof onInAppAchievementUnlocked !== "function") {
return () => {};
}
const unsubscribe = onInAppAchievementUnlocked(
(nextPosition, nextAchievements) => {
if (!nextAchievements?.length) return;
if (nextPosition) setPosition(nextPosition);

View file

@ -1,11 +0,0 @@
.debrid-badge {
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
border: 1px solid rgba(12, 241, 202, 0.3);
background: rgba(12, 241, 202, 0.05);
color: #0cf1ca;
padding: 4px 8px;
font-size: 12px;
}

View file

@ -1,18 +0,0 @@
import Meteor from "@renderer/assets/meteor.svg?react";
import "./debrid-badge.scss";
import { useTranslation } from "react-i18next";
export interface DebridBadgeProps {
collapsed?: boolean;
}
export function DebridBadge({ collapsed }: Readonly<DebridBadgeProps>) {
const { t } = useTranslation("hydra_cloud");
return (
<div className="debrid-badge">
<Meteor />
{!collapsed && t("debrid_description")}
</div>
);
}

View file

@ -15,7 +15,6 @@ export * from "./toast/toast";
export * from "./badge/badge";
export * from "./confirmation-modal/confirmation-modal";
export * from "./suspense-wrapper/suspense-wrapper";
export * from "./debrid-badge/debrid-badge";
export * from "./context-menu/context-menu";
export * from "./game-context-menu/game-context-menu";
export * from "./game-context-menu/use-game-actions";

View file

@ -106,6 +106,35 @@
overflow: hidden;
}
&__friends-icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__online-count {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background-color: rgba(255, 255, 255, 0.08);
color: globals.$body-color;
font-size: 11px;
font-weight: 700;
line-height: 1;
&--online {
background-color: globals.$success-color;
color: #ffffff;
}
}
&__game-icon {
width: 20px;
height: 20px;

View file

@ -1,9 +1,9 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { Tooltip } from "react-tooltip";
import type { GameCollection, LibraryGame } from "@types";
import type { GameCollection, LibraryGame, ProfileFriends } from "@types";
import {
Button,
@ -34,6 +34,7 @@ import {
FileDirectoryIcon,
HeartIcon,
PencilIcon,
PeopleIcon,
PlayIcon,
PlusIcon,
TrashIcon,
@ -53,6 +54,7 @@ const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
const SIDEBAR_MAX_WIDTH = 450;
const FAVORITES_COLLECTION_ID = "__favorites__";
const electron = globalThis.electron as Electron;
const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
@ -72,6 +74,7 @@ export function Sidebar() {
}>({ installed: false, version: null, outdated: false });
const [homebrewFolderExists, setHomebrewFolderExists] = useState(false);
const [showDeckyConfirmModal, setShowDeckyConfirmModal] = useState(false);
const [onlineFriendsCount, setOnlineFriendsCount] = useState(0);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@ -223,6 +226,51 @@ export function Sidebar() {
};
}, [dispatch]);
const updateOnlineFriendsCount = useCallback(async () => {
if (!userDetails) {
setOnlineFriendsCount(0);
return;
}
try {
const response = await electron.hydraApi.get<ProfileFriends>(
"/profile/friends",
{ params: { take: 5, skip: 0 } }
);
setOnlineFriendsCount(response.onlineFriends);
} catch {
// ignore transient errors; the next refresh will retry
}
}, [userDetails]);
useEffect(() => {
updateOnlineFriendsCount();
// Authoritative refetches avoid count drift from duplicate/replayed
// presence messages while still avoiding polling on newer preloads.
const unsubscribeFriends = electron.onFriendsUpdated(() => {
updateOnlineFriendsCount();
});
let interval: ReturnType<typeof setInterval> | null = null;
const unsubscribePresence =
typeof electron.onFriendPresence === "function"
? electron.onFriendPresence(() => {
updateOnlineFriendsCount();
})
: () => {
if (interval) clearInterval(interval);
};
if (typeof electron.onFriendPresence !== "function") {
interval = setInterval(updateOnlineFriendsCount, 30_000);
}
return () => {
unsubscribeFriends();
unsubscribePresence();
};
}, [updateOnlineFriendsCount]);
const sidebarRef = useRef<HTMLElement>(null);
const cursorPos = useRef({ x: 0 });
@ -540,6 +588,10 @@ export function Sidebar() {
globalThis.window.electron.openBigPictureWindow();
};
const handleOpenFriendsWindow = () => {
globalThis.window.electron.openFriendsWindow();
};
return (
<aside
ref={sidebarRef}
@ -577,6 +629,30 @@ export function Sidebar() {
</li>
))}
{userDetails && (
<li className="sidebar__menu-item">
<button
type="button"
className="sidebar__menu-item-button"
onClick={handleOpenFriendsWindow}
>
<span className="sidebar__friends-icon">
<PeopleIcon />
</span>
<span>{t("friends")}</span>
<span
className={`sidebar__online-count${
onlineFriendsCount > 0
? " sidebar__online-count--online"
: ""
}`}
>
{onlineFriendsCount}
</span>
</button>
</li>
)}
<li className="sidebar__menu-item">
<button
type="button"

View file

@ -16,6 +16,7 @@ import type {
GameStats,
UserDetails,
FriendRequestSync,
FriendPresenceSync,
NotificationSync,
GameArtifact,
LudusaviBackup,
@ -112,9 +113,6 @@ declare global {
cb: (value: SeedingStatus[]) => void
) => () => Electron.IpcRenderer;
onHardDelete: (cb: () => void) => () => Electron.IpcRenderer;
checkDebridAvailability: (
magnets: string[]
) => Promise<Record<string, boolean>>;
getTorrentFiles: (
magnet: string
) => Promise<
@ -616,6 +614,7 @@ declare global {
/* Misc */
openExternal: (src: string) => Promise<void>;
openCheckout: () => Promise<void>;
getCloudIframeUrl: () => Promise<string>;
getVersion: () => Promise<string>;
isStaging: () => Promise<boolean>;
ping: () => string;
@ -626,6 +625,10 @@ declare global {
) => Promise<Electron.OpenDialogReturnValue>;
showItemInFolder: (path: string) => Promise<void>;
getImageDataUrl: (imageUrl: string) => Promise<string | null>;
getProcessedFriendImage: (
imageUrl: string | null,
options: { width: number; height: number; preserveAnimation?: boolean }
) => Promise<string | null>;
hydraApi: {
get: <T = unknown>(
url: string,
@ -757,6 +760,7 @@ declare global {
userId: string,
action: FriendRequestAction
) => Promise<void>;
syncFriendRequests: (friendRequestCount: number) => Promise<void>;
/* Notifications */
publishNewRepacksNotification: (newRepacksCount: number) => Promise<void>;
@ -776,7 +780,7 @@ declare global {
achievements?: AchievementNotificationInfo[]
) => void
) => () => Electron.IpcRenderer;
onInAppAchievementUnlocked: (
onInAppAchievementUnlocked?: (
cb: (
position: AchievementCustomNotificationPosition,
achievements: AchievementNotificationInfo[]
@ -828,6 +832,20 @@ declare global {
/* Big Picture Window */
openBigPictureWindow: () => Promise<void>;
/* Friends Window */
openFriendsWindow: () => Promise<void>;
minimizeFriendsWindow: () => Promise<void>;
closeFriendsWindow: () => Promise<void>;
openFriendProfileInMainWindow: (userId: string) => Promise<void>;
openAddFriendModalInMainWindow: () => Promise<void>;
onOpenAddFriendModal: (cb: () => void) => () => Electron.IpcRenderer;
onFriendsUpdated: (cb: () => void) => () => Electron.IpcRenderer;
onFriendPresence: (
cb: (presence: FriendPresenceSync) => void
) => () => Electron.IpcRenderer;
onProfileUpdated: (cb: () => void) => () => Electron.IpcRenderer;
onNavigate: (cb: (path: string) => void) => () => Electron.IpcRenderer;
/* Download Options */
onNewDownloadOptions: (
cb: (gamesWithNewOptions: { gameId: string; count: number }[]) => void

View file

@ -82,19 +82,41 @@ export function useUserDetails() {
.get<FriendRequest[]>("/profile/friend-requests")
.then((friendRequests) => {
dispatch(setFriendRequests(friendRequests));
return friendRequests;
})
.catch(() => {});
.catch(() => null);
}, [dispatch]);
// After a local accept/refuse/cancel/send, the API has already mutated the
// request server-side but only the *other* user gets a WS push. Fan the new
// state out to every app window (main, big picture, friends) so their request
// badges and notification lists refresh without a manual reload.
const broadcastFriendRequestSync = useCallback(
(requests: FriendRequest[] | null) => {
if (!requests) return;
const receivedCount = requests.filter(
(request) => request.type === "RECEIVED"
).length;
const { syncFriendRequests } = globalThis.window.electron;
if (typeof syncFriendRequests !== "function") return;
syncFriendRequests(receivedCount).catch(() => {});
},
[]
);
const sendFriendRequest = useCallback(
async (userId: string) => {
return globalThis.window.electron.hydraApi
.post("/profile/friend-requests", {
data: { friendCode: userId },
})
.then(() => fetchFriendRequests());
.then(() => fetchFriendRequests())
.then(broadcastFriendRequestSync);
},
[fetchFriendRequests]
[fetchFriendRequests, broadcastFriendRequestSync]
);
const updateFriendRequestState = useCallback(
@ -102,7 +124,8 @@ export function useUserDetails() {
if (action === "CANCEL") {
return globalThis.window.electron.hydraApi
.delete(`/profile/friend-requests/${userId}`)
.then(() => fetchFriendRequests());
.then(() => fetchFriendRequests())
.then(broadcastFriendRequestSync);
}
return globalThis.window.electron.hydraApi
@ -111,9 +134,10 @@ export function useUserDetails() {
requestState: action,
},
})
.then(() => fetchFriendRequests());
.then(() => fetchFriendRequests())
.then(broadcastFriendRequestSync);
},
[fetchFriendRequests]
[fetchFriendRequests, broadcastFriendRequestSync]
);
const undoFriendship = (userId: string) =>

View file

@ -36,6 +36,7 @@ import Notifications from "./pages/notifications/notifications";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
import { AchievementNotificationOverlay } from "./components/achievements/notification/achievement-notification-overlay";
import GameLauncher from "./pages/game-launcher/game-launcher";
import FriendsWindow from "./pages/friends-window/friends-window";
import BigPictureApp from "../../big-picture/src/app";
import BigPictureCatalogue from "../../big-picture/src/pages/catalogue/catalogue";
import BigPictureComponentLab from "../../big-picture/src/pages/component-lab/component-lab";
@ -95,6 +96,18 @@ if (userPreferences?.language) {
syncDocumentLanguage(i18n.language);
i18n.on("languageChanged", syncDocumentLanguage);
// Every BrowserWindow runs its own renderer with its own i18n instance, so a
// language change must be applied per-window. Subscribe here (the shared entry
// for all routes) so detached windows — friends, game-launcher, etc. — react
// too, not just the routes mounted under <App />.
globalThis.electron.onUserPreferencesUpdated((preferences) => {
if (preferences?.language && preferences.language !== i18n.language) {
i18n.changeLanguage(preferences.language).catch((error) => {
console.error("Failed to change language", error);
});
}
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
@ -119,6 +132,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
element={<AchievementNotification />}
/>
<Route path="/game-launcher" element={<GameLauncher />} />
<Route path="/friends-window" element={<FriendsWindow />} />
<Route path="/big-picture" element={<BigPictureApp />}>
<Route index element={<BigPictureHome />} />

View file

@ -0,0 +1,606 @@
@use "../../scss/globals.scss";
.friends-window {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
background-color: globals.$background-color;
color: globals.$body-color;
// Set explicitly so the window doesn't silently rely on the body rule in
// app.scss (the @font-face itself is loaded globally from main.tsx).
font-family:
Noto Sans,
sans-serif;
font-size: globals.$body-font-size;
overflow: hidden;
&__header {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
// When a profile background is present, cover the whole header (including
// the window-control strip) edge-to-edge and drop the title bar chrome so
// the image reads as one full-bleed banner.
&--has-bg {
&::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(135deg, rgb(0 0 0 / 40%), rgb(0 0 0 / 30%));
pointer-events: none;
}
.friends-window__title-bar {
background-color: transparent;
border-bottom: none;
}
// Lighter scrim makes the image pop but hurts text contrast — restore
// readability with full opacity + a soft shadow (matches profile hero).
.friends-window__profile-status,
.friends-window__profile-name,
.friends-window__profile-game {
opacity: 1;
text-shadow: 0 0 5px rgb(0 0 0 / 60%);
}
}
}
&__header-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
&__title-bar {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: 35px;
min-height: 35px;
// Leave room on the right for the window controls so the title stays
// clear of them.
padding: 0 0 0 calc(globals.$spacing-unit * 2);
background-color: globals.$dark-background-color;
border-bottom: 1px solid globals.$border-color;
-webkit-app-region: drag;
z-index: globals.$title-bar-z-index;
h4 {
margin: 0;
font-size: globals.$small-font-size;
font-weight: 500;
color: globals.$muted-color;
}
}
&__window-controls {
display: flex;
align-items: center;
height: 100%;
margin-left: auto;
// Buttons must stay clickable on the otherwise-draggable title bar.
-webkit-app-region: no-drag;
}
&__window-control {
display: flex;
align-items: center;
justify-content: center;
width: 35px;
height: 35px;
padding: 0;
background: none;
border: none;
color: globals.$muted-color;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
}
&--close:hover {
background-color: globals.$error-color;
color: #ffffff;
}
}
&__content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
// Vertical padding only. The horizontal padding lives on the scrolling
// list (and the search row) so the scrollbar renders outside the right
// padding — same as the main sidebar, which keeps rows off the scrollbar.
// Bottom spacing stays here on the non-scrolling wrapper so it survives
// Blink dropping padding-bottom from a scroll container's overflow region.
padding: calc(globals.$spacing-unit * 2) 0;
gap: calc(globals.$spacing-unit * 2);
}
&__profile {
position: relative;
z-index: 2;
display: flex;
}
&__profile-content {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1.5);
width: 100%;
padding: calc(globals.$spacing-unit * 2);
}
&__profile-avatar {
display: inline-flex;
padding: 0;
background: none;
border: none;
border-radius: 50%;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: globals.$active-opacity;
}
&:disabled {
cursor: default;
opacity: 1;
}
}
&__profile-info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit / 2);
flex: 1;
min-width: 0;
}
&__profile-name-row {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
width: 100%;
min-width: 0;
}
&__profile-game {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
min-width: 0;
color: globals.$body-color;
small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
&__profile-status {
color: globals.$body-color;
opacity: 0.7;
}
&__profile-name {
flex-shrink: 0;
max-width: 100%;
background: none;
border: none;
padding: 0;
font-size: globals.$body-font-size;
font-weight: 700;
color: globals.$muted-color;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.2s ease;
&:hover {
text-decoration: underline;
}
&:disabled {
cursor: default;
text-decoration: none;
}
}
&__friend-code {
display: inline-flex;
align-items: center;
flex-shrink: 0;
min-width: 0;
background: none;
border: none;
padding: 0;
color: globals.$body-color;
font-size: globals.$small-font-size;
cursor: pointer;
transition: color 0.2s ease;
svg {
flex-shrink: 0;
}
&:hover,
&--copied {
color: globals.$muted-color;
.friends-window__friend-code-value {
max-width: 160px;
margin-right: calc(globals.$spacing-unit / 2);
opacity: 1;
color: globals.$muted-color;
}
}
}
&__friend-code-value {
// Hidden until the copy button is hovered (or right after copying), then
// expands inline — mirrors the profile-page hero.
max-width: 0;
margin-right: 0;
opacity: 0;
overflow: hidden;
font-family: monospace;
color: globals.$muted-color;
white-space: nowrap;
text-overflow: ellipsis;
transition:
max-width 0.2s ease,
margin-right 0.2s ease,
opacity 0.2s ease,
color 0.2s ease;
}
&__search-row {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
padding: 0 calc(globals.$spacing-unit * 2);
}
&__add-friend {
display: inline-flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
flex-shrink: 0;
background-color: globals.$muted-color;
border: none;
border-radius: 6px;
color: globals.$dark-background-color;
font-size: globals.$small-font-size;
font-weight: 600;
white-space: nowrap;
padding: 0 calc(globals.$spacing-unit * 1.5);
height: 36px;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: #dadbe1;
}
}
&__search {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
flex: 1;
min-width: 0;
padding: 0 calc(globals.$spacing-unit * 1.5);
height: 36px;
background-color: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 6px;
transition: border-color ease 0.2s;
&:hover {
border-color: rgba(255, 255, 255, 0.5);
}
&:focus-within {
border-color: #dadbe1;
}
}
&__search-icon {
color: globals.$body-color;
flex-shrink: 0;
}
&__search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: globals.$muted-color;
font-size: globals.$body-font-size;
&::placeholder {
color: globals.$body-color;
opacity: 0.6;
}
}
&__search-clear {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
background: none;
border: none;
cursor: pointer;
color: globals.$body-color;
opacity: 0.7;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
&__list-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
flex: 1;
min-height: 0;
overflow-y: auto;
// Symmetric horizontal padding on the scroll element itself, so the
// scrollbar sits outside it and rows clear it evenly on both sides.
padding: 0 calc(globals.$spacing-unit * 2);
}
&__section {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__section-header {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
width: 100%;
// Opaque so list rows scroll cleanly underneath the pinned header.
background-color: globals.$background-color;
border: none;
padding: globals.$spacing-unit 0;
cursor: pointer;
color: globals.$body-color;
transition: color 0.2s ease;
&:hover {
color: globals.$muted-color;
}
svg {
flex-shrink: 0;
}
}
&__section-title {
margin: 0;
font-size: globals.$small-font-size;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: inherit;
}
&__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__friend {
display: flex;
align-items: center;
flex-shrink: 0;
border-radius: 6px;
transition: background-color 0.2s ease;
// Skip layout + paint for rows outside the viewport — near-free
// virtualization for the ~100-row list.
content-visibility: auto;
contain-intrinsic-size: auto 56px;
position: relative;
&:hover {
background-color: globals.$dark-background-color;
}
// Image rows: lazy <img> behind a gradient scrim. The source is a native-
// processed local variant so it stays light to raster.
&--has-bg {
// Scrim pseudo over the image; clips its own corners (no overflow:hidden,
// which caused scroll repaint flicker).
&::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
background: linear-gradient(135deg, rgb(0 0 0 / 40%), rgb(0 0 0 / 30%));
pointer-events: none;
}
// Very slight hover: just fade the row (opacity is GPU-composited, no
// re-raster of the photo).
&:hover {
background-color: transparent;
opacity: 0.9;
}
.friends-window__friend-name,
.friends-window__friend-status {
color: #ffffff;
opacity: 1;
}
}
}
&__friend-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
z-index: 0;
pointer-events: none;
}
&__friend-button {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1.5);
flex: 1;
min-width: 0;
background: none;
border: none;
padding: globals.$spacing-unit;
cursor: pointer;
text-align: left;
position: relative;
z-index: 2;
}
&__avatar-wrapper {
position: relative;
flex-shrink: 0;
}
&__status-orb {
position: absolute;
bottom: 0;
right: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #6d6d6d;
border: 2px solid globals.$background-color;
&--online {
background-color: globals.$success-color;
}
// Larger orb for the 56px profile-hero avatar.
&--profile {
width: 15px;
height: 15px;
bottom: 2px;
right: 2px;
border-width: 3px;
}
}
&__friend-details {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit / 4);
min-width: 0;
}
&__friend-name {
font-size: globals.$body-font-size;
color: globals.$muted-color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__friend-status {
color: globals.$body-color;
opacity: 0.7;
}
&__game-info {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit / 2);
color: globals.$body-color;
small {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
&__game-icon {
border-radius: 2px;
flex-shrink: 0;
}
&__request-actions {
display: flex;
gap: calc(globals.$spacing-unit / 2);
margin-right: globals.$spacing-unit;
flex-shrink: 0;
}
&__request-action {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid globals.$border-color;
background-color: rgba(255, 255, 255, 0.04);
color: globals.$body-color;
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
&--accept:hover {
background-color: globals.$success-color;
border-color: globals.$success-color;
color: #ffffff;
}
&--refuse:hover {
background-color: globals.$error-color;
border-color: globals.$error-color;
color: #ffffff;
}
}
&__empty {
margin: 0;
padding: globals.$spacing-unit;
font-size: globals.$small-font-size;
color: globals.$body-color;
opacity: 0.7;
}
}

View file

@ -0,0 +1,695 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CopyIcon,
DashIcon,
PlusIcon,
SearchIcon,
XIcon,
} from "@primer/octicons-react";
import { Avatar } from "@renderer/components";
import { useAppSelector, useUserDetails } from "@renderer/hooks";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import type { ProfileFriends, UserFriend } from "@types";
import "./friends-window.scss";
const PAGE_SIZE = 100;
const COPIED_FEEDBACK_MS = 1200;
const electron = globalThis.electron as Electron;
type FriendImageSize = { width: number; height: number };
const FRIEND_AVATAR_IMAGE_SIZE = { width: 80, height: 80 };
const PROFILE_AVATAR_IMAGE_SIZE = { width: 112, height: 112 };
const FRIEND_BACKGROUND_IMAGE_SIZE = { width: 480, height: 96 };
const PROFILE_BACKGROUND_IMAGE_SIZE = { width: 420, height: 180 };
const FRIEND_IMAGE_PROCESSING_BATCH_SIZE = 6;
const isRemoteImageUrl = (
imageUrl: string | null | undefined
): imageUrl is string =>
Boolean(imageUrl?.startsWith("http://") || imageUrl?.startsWith("https://"));
const getProcessedImageKey = (imageUrl: string, size: FriendImageSize) =>
`${size.width}x${size.height}:${imageUrl}`;
export default function FriendsWindow() {
const { t } = useTranslation("friends_window");
const {
userDetails,
friendRequests,
fetchUserDetails,
updateUserDetails,
fetchFriendRequests,
updateFriendRequestState,
} = useUserDetails();
const [friends, setFriends] = useState<UserFriend[]>([]);
const [onlineCount, setOnlineCount] = useState(0);
const [search, setSearch] = useState("");
const [searchResults, setSearchResults] = useState<UserFriend[] | null>(null);
const [isCopied, setIsCopied] = useState(false);
const [processedImages, setProcessedImages] = useState<
Record<string, string>
>({});
const fetchFriends = useCallback(async () => {
try {
const response = await electron.hydraApi.get<ProfileFriends>(
"/profile/friends",
{ params: { take: PAGE_SIZE, skip: 0 } }
);
setFriends(response.friends);
setOnlineCount(response.onlineFriends);
} catch {
// ignore transient errors; the next refresh will retry
}
}, []);
const fetchOnlineFriendsCount = useCallback(async () => {
try {
const response = await electron.hydraApi.get<ProfileFriends>(
"/profile/friends",
{ params: { take: 1, skip: 0 } }
);
setOnlineCount(response.onlineFriends);
} catch {
// ignore transient errors; the next refresh will retry
}
}, []);
const refreshUserDetails = useCallback(() => {
// fetchUserDetails() only returns the data; updateUserDetails() writes it
// into this window's store.
return fetchUserDetails().then((details) => {
if (details) updateUserDetails(details);
});
}, [fetchUserDetails, updateUserDetails]);
useEffect(() => {
document.title = t("title");
}, [t]);
useEffect(() => {
// This window has its own Redux store, so hydrate the signed-in user.
refreshUserDetails();
fetchFriends();
fetchFriendRequests();
}, [refreshUserDetails, fetchFriends, fetchFriendRequests]);
useEffect(() => {
const unsubscribeFriends = electron.onFriendsUpdated(() => {
fetchFriends();
});
let interval: ReturnType<typeof setInterval> | null = null;
const unsubscribePresence =
typeof electron.onFriendPresence === "function"
? electron.onFriendPresence(({ friendId, isOnline }) => {
// Patch the visible page, but refetch the total because this page is
// capped and the event can be for an off-page friend.
setFriends((prev) =>
prev.map((friend) =>
friend.id === friendId ? { ...friend, isOnline } : friend
)
);
fetchOnlineFriendsCount();
})
: () => {
if (interval) clearInterval(interval);
};
if (typeof electron.onFriendPresence !== "function") {
interval = setInterval(fetchFriends, 30_000);
}
const unsubscribeRequests = electron.onSyncFriendRequests(() => {
fetchFriendRequests();
});
const unsubscribeProfile = electron.onProfileUpdated(() => {
refreshUserDetails();
});
return () => {
unsubscribeFriends();
unsubscribePresence();
unsubscribeRequests();
unsubscribeProfile();
};
}, [
fetchFriends,
fetchFriendRequests,
fetchOnlineFriendsCount,
refreshUserDetails,
]);
// Debounced friend search
useEffect(() => {
const query = search.trim();
if (!query) {
setSearchResults(null);
return;
}
const timeout = setTimeout(async () => {
try {
const response = await electron.hydraApi.get<{
friends: UserFriend[];
}>("/profile/friends/search", {
params: { query, take: PAGE_SIZE, skip: 0 },
});
setSearchResults(response.friends);
} catch {
setSearchResults([]);
}
}, 300);
return () => clearTimeout(timeout);
}, [search]);
// /profile/friends/search does not return isOnline, so reuse the presence
// info we already have from the full friends list.
const onlineMap = useMemo(() => {
const map = new Map<string, boolean>();
friends.forEach((friend) => map.set(friend.id, Boolean(friend.isOnline)));
return map;
}, [friends]);
const displayedFriends = useMemo(() => {
if (searchResults === null) return friends;
return searchResults.map((friend) => ({
...friend,
isOnline: friend.isOnline ?? onlineMap.get(friend.id) ?? false,
}));
}, [searchResults, friends, onlineMap]);
const onlineFriends = displayedFriends.filter((friend) => friend.isOnline);
const offlineFriends = displayedFriends.filter((friend) => !friend.isOnline);
// null = follow the default (collapsed when the section is empty); a boolean
// means the user explicitly toggled it.
const [onlineOverride, setOnlineOverride] = useState<boolean | null>(null);
const [offlineOverride, setOfflineOverride] = useState<boolean | null>(null);
const [requestsOverride, setRequestsOverride] = useState<boolean | null>(
null
);
const onlineCollapsed = onlineOverride ?? onlineFriends.length === 0;
const offlineCollapsed = offlineOverride ?? offlineFriends.length === 0;
const receivedRequests = friendRequests.filter(
(request) => request.type === "RECEIVED"
);
const profile = userDetails;
useEffect(() => {
if (typeof electron.getProcessedFriendImage !== "function") return;
const pendingImages = new Map<
string,
{ imageUrl: string; size: FriendImageSize }
>();
const addImage = (
imageUrl: string | null | undefined,
size: FriendImageSize
) => {
if (!isRemoteImageUrl(imageUrl)) return;
const key = getProcessedImageKey(imageUrl, size);
if (!processedImages[key]) {
pendingImages.set(key, { imageUrl, size });
}
};
addImage(profile?.profileImageUrl, PROFILE_AVATAR_IMAGE_SIZE);
addImage(profile?.backgroundImageUrl, PROFILE_BACKGROUND_IMAGE_SIZE);
for (const friend of friends) {
addImage(friend.profileImageUrl, FRIEND_AVATAR_IMAGE_SIZE);
addImage(friend.backgroundImageUrl, FRIEND_BACKGROUND_IMAGE_SIZE);
}
for (const friend of searchResults ?? []) {
addImage(friend.profileImageUrl, FRIEND_AVATAR_IMAGE_SIZE);
addImage(friend.backgroundImageUrl, FRIEND_BACKGROUND_IMAGE_SIZE);
}
for (const request of friendRequests) {
addImage(request.profileImageUrl, FRIEND_AVATAR_IMAGE_SIZE);
}
if (!pendingImages.size) return;
let cancelled = false;
const loadImages = async () => {
const entries = [...pendingImages.entries()];
for (
let index = 0;
index < entries.length;
index += FRIEND_IMAGE_PROCESSING_BATCH_SIZE
) {
const batch = entries.slice(
index,
index + FRIEND_IMAGE_PROCESSING_BATCH_SIZE
);
await Promise.all(
batch.map(async ([key, { imageUrl, size }]) => {
const processedImageUrl = await electron
.getProcessedFriendImage(imageUrl, {
width: size.width,
height: size.height,
preserveAnimation: true,
})
.catch(() => imageUrl);
if (cancelled) return;
setProcessedImages((current) => {
if (current[key] === processedImageUrl) return current;
return {
...current,
[key]: processedImageUrl ?? imageUrl,
};
});
})
);
if (cancelled) return;
}
};
loadImages();
return () => {
cancelled = true;
};
}, [friends, friendRequests, profile, processedImages, searchResults]);
const getProcessedImageUrl = (
imageUrl: string | null | undefined,
size: FriendImageSize
) => {
if (!imageUrl) return imageUrl;
return processedImages[getProcessedImageKey(imageUrl, size)] ?? imageUrl;
};
const selfGame = useAppSelector((state) => state.gameRunning.gameRunning);
const requestsCollapsed = requestsOverride ?? receivedRequests.length === 0;
const handleCopyFriendCode = () => {
if (!profile?.id) return;
navigator.clipboard.writeText(profile.id);
setIsCopied(true);
setTimeout(() => setIsCopied(false), COPIED_FEEDBACK_MS);
};
const handleFriendClick = (friendId: string) => {
electron.openFriendProfileInMainWindow(friendId);
};
const handleAddFriend = () => {
// The add-friend modal is too large for this tiny window, so open it in the
// main window instead.
electron.openAddFriendModalInMainWindow();
};
const handleAcceptRequest = (userId: string) => {
updateFriendRequestState(userId, "ACCEPTED")
.then(() => fetchFriends())
.catch(() => {});
};
const handleRefuseRequest = (userId: string) => {
updateFriendRequestState(userId, "REFUSED").catch(() => {});
};
const getGameIcon = (game: { iconUrl: string | null; title: string }) => {
if (game.iconUrl) {
return (
<img
className="friends-window__game-icon"
alt={game.title}
width={16}
height={16}
src={game.iconUrl}
/>
);
}
return <SteamLogo width={16} height={16} />;
};
const renderFriend = (friend: UserFriend) => {
const backgroundImageUrl = getProcessedImageUrl(
friend.backgroundImageUrl,
FRIEND_BACKGROUND_IMAGE_SIZE
);
return (
<li
key={friend.id}
className={`friends-window__friend${
backgroundImageUrl ? " friends-window__friend--has-bg" : ""
}`}
>
{backgroundImageUrl && (
<img
src={backgroundImageUrl}
alt=""
loading="lazy"
decoding="async"
className="friends-window__friend-bg"
/>
)}
<button
type="button"
className="friends-window__friend-button"
onClick={() => handleFriendClick(friend.id)}
title={
friend.currentGame
? t("playing", { game: friend.currentGame.title })
: undefined
}
>
<div className="friends-window__avatar-wrapper">
<Avatar
size={40}
src={getProcessedImageUrl(
friend.profileImageUrl,
FRIEND_AVATAR_IMAGE_SIZE
)}
alt={friend.displayName}
/>
<span
className={`friends-window__status-orb${
friend.isOnline ? " friends-window__status-orb--online" : ""
}`}
/>
</div>
<div className="friends-window__friend-details">
<span className="friends-window__friend-name">
{friend.displayName}
</span>
{friend.currentGame ? (
<div className="friends-window__game-info">
{getGameIcon(friend.currentGame)}
<small>{friend.currentGame.title}</small>
</div>
) : (
<small className="friends-window__friend-status">
{friend.isOnline ? t("online") : t("offline")}
</small>
)}
</div>
</button>
</li>
);
};
const renderSectionHeader = (
title: string,
count: number,
collapsed: boolean,
onToggle: () => void
) => (
<button
type="button"
className="friends-window__section-header"
onClick={onToggle}
aria-expanded={!collapsed}
>
{collapsed ? (
<ChevronRightIcon size={16} />
) : (
<ChevronDownIcon size={16} />
)}
<span className="friends-window__section-title">
{title} ({count})
</span>
</button>
);
const renderCollapsibleSection = (
title: string,
count: number,
list: UserFriend[],
collapsed: boolean,
onToggle: () => void,
emptyText: string
) => (
<section className="friends-window__section">
{renderSectionHeader(title, count, collapsed, onToggle)}
{!collapsed &&
(list.length > 0 ? (
<ul className="friends-window__list">{list.map(renderFriend)}</ul>
) : (
<p className="friends-window__empty">{emptyText}</p>
))}
</section>
);
const profileBackgroundImageUrl = getProcessedImageUrl(
profile?.backgroundImageUrl,
PROFILE_BACKGROUND_IMAGE_SIZE
);
return (
<div className="friends-window">
<div
className={`friends-window__header${
profileBackgroundImageUrl ? " friends-window__header--has-bg" : ""
}`}
>
{profileBackgroundImageUrl && (
<img
src={profileBackgroundImageUrl}
alt=""
className="friends-window__header-bg"
/>
)}
<header className="friends-window__title-bar">
<h4>{t("title")}</h4>
<div className="friends-window__window-controls">
<button
type="button"
className="friends-window__window-control"
onClick={() => electron.minimizeFriendsWindow()}
title={t("minimize")}
aria-label={t("minimize")}
>
<DashIcon size={16} />
</button>
<button
type="button"
className="friends-window__window-control friends-window__window-control--close"
onClick={() => electron.closeFriendsWindow()}
title={t("close")}
aria-label={t("close")}
>
<XIcon size={16} />
</button>
</div>
</header>
<section className="friends-window__profile">
<div className="friends-window__profile-content">
<button
type="button"
className="friends-window__profile-avatar"
onClick={() => profile?.id && handleFriendClick(profile.id)}
disabled={!profile?.id}
title={profile?.displayName}
>
<div className="friends-window__avatar-wrapper">
<Avatar
size={56}
src={getProcessedImageUrl(
profile?.profileImageUrl,
PROFILE_AVATAR_IMAGE_SIZE
)}
alt={profile?.displayName}
/>
<span className="friends-window__status-orb friends-window__status-orb--online friends-window__status-orb--profile" />
</div>
</button>
<div className="friends-window__profile-info">
<div className="friends-window__profile-name-row">
<button
type="button"
className="friends-window__profile-name"
onClick={() => profile?.id && handleFriendClick(profile.id)}
disabled={!profile?.id}
>
{profile?.displayName}
</button>
{profile?.id && (
<button
type="button"
className={`friends-window__friend-code${
isCopied ? " friends-window__friend-code--copied" : ""
}`}
onClick={handleCopyFriendCode}
title={t("copy_friend_code")}
>
<span className="friends-window__friend-code-value">
{isCopied ? t("copied") : profile.id}
</span>
<CopyIcon size={14} />
</button>
)}
</div>
{selfGame ? (
<div className="friends-window__profile-game">
{getGameIcon({
iconUrl: selfGame.iconUrl,
title: selfGame.title,
})}
<small>{selfGame.title}</small>
</div>
) : (
<small className="friends-window__profile-status">
{t("online")}
</small>
)}
</div>
</div>
</section>
</div>
<div className="friends-window__content">
<div className="friends-window__search-row">
<div className="friends-window__search">
<SearchIcon size={16} className="friends-window__search-icon" />
<input
type="text"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("search_placeholder")}
className="friends-window__search-input"
/>
{search && (
<button
type="button"
className="friends-window__search-clear"
onClick={() => setSearch("")}
title={t("clear_search")}
aria-label={t("clear_search")}
>
<XIcon size={16} />
</button>
)}
</div>
<button
type="button"
className="friends-window__add-friend"
onClick={handleAddFriend}
title={t("add_friend")}
>
<PlusIcon size={16} />
{t("add_friend")}
</button>
</div>
<div className="friends-window__list-container">
{receivedRequests.length > 0 && (
<section className="friends-window__section">
{renderSectionHeader(
t("friend_requests"),
receivedRequests.length,
requestsCollapsed,
() => setRequestsOverride(!requestsCollapsed)
)}
{!requestsCollapsed && (
<ul className="friends-window__list">
{receivedRequests.map((request) => (
<li key={request.id} className="friends-window__friend">
<button
type="button"
className="friends-window__friend-button"
onClick={() => handleFriendClick(request.id)}
>
<div className="friends-window__avatar-wrapper">
<Avatar
size={40}
src={getProcessedImageUrl(
request.profileImageUrl,
FRIEND_AVATAR_IMAGE_SIZE
)}
alt={request.displayName}
/>
</div>
<span className="friends-window__friend-name">
{request.displayName}
</span>
</button>
<div className="friends-window__request-actions">
<button
type="button"
className="friends-window__request-action friends-window__request-action--accept"
onClick={() => handleAcceptRequest(request.id)}
title={t("accept")}
>
<CheckIcon size={16} />
</button>
<button
type="button"
className="friends-window__request-action friends-window__request-action--refuse"
onClick={() => handleRefuseRequest(request.id)}
title={t("refuse")}
>
<XIcon size={16} />
</button>
</div>
</li>
))}
</ul>
)}
</section>
)}
{renderCollapsibleSection(
t("online"),
onlineCount,
onlineFriends,
onlineCollapsed,
() => setOnlineOverride(!onlineCollapsed),
searchResults === null ? t("no_friends") : t("no_results")
)}
{offlineFriends.length > 0 &&
renderCollapsibleSection(
t("offline"),
offlineFriends.length,
offlineFriends,
offlineCollapsed,
() => setOfflineOverride(!offlineCollapsed),
t("no_results")
)}
</div>
</div>
</div>
);
}

View file

@ -2,11 +2,12 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { NoteIcon } from "@primer/octicons-react";
import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import { useTranslation } from "react-i18next";
import type { GameReview, Game, GameShop } from "@types";
import { ReviewForm } from "./review-form";
import { ReviewItem } from "./review-item";
import { ReviewThread } from "./review-thread";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
import "./game-reviews.scss";
@ -59,6 +60,9 @@ export function GameReviews({
const [showReviewForm, setShowReviewForm] = useState(false);
const [votingReviews, setVotingReviews] = useState<Set<string>>(new Set());
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
const [openReplyReviewId, setOpenReplyReviewId] = useState<string | null>(
null
);
const previousVotesRef = useRef<
Map<string, { upvotes: number; downvotes: number }>
@ -70,6 +74,7 @@ export function GameReviews({
StarterKit.configure({
link: false,
}),
Underline,
],
content: "",
editorProps: {
@ -506,11 +511,12 @@ export function GameReviews({
}}
>
{reviews.map((review) => (
<ReviewItem
<ReviewThread
key={review.id}
shop={shop}
objectId={objectId}
review={review}
userDetailsId={userDetailsId}
isBlocked={review.isBlocked}
isVisible={visibleBlockedReviews.has(review.id)}
isVoting={votingReviews.has(review.id)}
previousVotes={
@ -519,10 +525,14 @@ export function GameReviews({
downvotes: 0,
}
}
onVote={handleVoteReview}
onDelete={handleDeleteReview}
onVoteReview={handleVoteReview}
onDeleteReview={handleDeleteReview}
onToggleVisibility={toggleBlockedReview}
onAnimationComplete={handleVoteAnimationComplete}
composerOpen={openReplyReviewId === review.id}
onComposerOpenChange={(open) =>
setOpenReplyReviewId(open ? review.id : null)
}
/>
))}
</div>

View file

@ -20,6 +20,7 @@ import {
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import { RemoveGameFromLibraryModal } from "./remove-from-library-modal";
import { ResetAchievementsModal } from "./reset-achievements-modal";
import { ChangeGamePlaytimeModal } from "./change-game-playtime-modal";
@ -126,7 +127,8 @@ export function GameOptionsModal({
isGameDeleting,
cancelDownload,
} = useDownload();
const { userDetails } = useUserDetails();
const { userDetails, hasActiveSubscription } = useUserDetails();
const { showHydraCloudModal } = useSubscription();
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
@ -668,8 +670,27 @@ export function GameOptionsModal({
);
useEffect(() => {
if (visible) setSelectedCategory(initialCategory ?? "general");
}, [initialCategory, visible]);
if (!visible) return;
const category = initialCategory ?? "general";
if (category === "hydra_cloud" && !hasActiveSubscription) {
setSelectedCategory("general");
showHydraCloudModal("backup");
return;
}
setSelectedCategory(category);
}, [hasActiveSubscription, initialCategory, showHydraCloudModal, visible]);
// Non-subscribers don't open the cloud-save panel; clicking the menu item
// presents the Hydra Cloud promo (highlighting cloud saving) instead.
const handleSelectCategory = (category: typeof selectedCategory) => {
if (category === "hydra_cloud" && !hasActiveSubscription) {
showHydraCloudModal("backup");
return;
}
setSelectedCategory(category);
};
const shouldShowCreateStartMenuShortcut =
window.electron.platform === "win32";
@ -757,7 +778,7 @@ export function GameOptionsModal({
<GameOptionsSidebar
categories={categories}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
onSelectCategory={handleSelectCategory}
/>
<div className="game-options-modal__panel">
{selectedCategory === "general" && (

View file

@ -11,7 +11,6 @@ import { Tooltip } from "react-tooltip";
import {
Badge,
Button,
DebridBadge,
Modal,
TextField,
CheckboxField,
@ -22,12 +21,7 @@ import { DownloadSettingsModal } from "./download-settings-modal";
import { gameDetailsContext } from "@renderer/context";
import { Downloader } from "@shared";
import { orderBy } from "lodash-es";
import {
useDate,
useFeature,
useAppDispatch,
useAppSelector,
} from "@renderer/hooks";
import { useDate, useAppDispatch, useAppSelector } from "@renderer/hooks";
import { clearNewDownloadOptions } from "@renderer/features";
import { levelDBService } from "@renderer/services/leveldb.service";
import { getGameKey } from "@renderer/helpers";
@ -63,9 +57,6 @@ export function RepacksModal({
);
const [filterTerm, setFilterTerm] = useState("");
const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>(
{}
);
const [lastCheckTimestamp, setLastCheckTimestamp] = useState<string | null>(
null
);
@ -85,33 +76,6 @@ export function RepacksModal({
(state) => state.userPreferences.value
);
const getHashFromMagnet = (magnet: string) => {
if (!magnet || typeof magnet !== "string") {
return null;
}
const hashRegex = /xt=urn:btih:([a-zA-Z0-9]+)/i;
const match = magnet.match(hashRegex);
return match ? match[1].toLowerCase() : null;
};
const { isFeatureEnabled, Feature } = useFeature();
useEffect(() => {
if (!isFeatureEnabled(Feature.NimbusPreview)) {
return;
}
const magnets = repacks.flatMap((repack) =>
repack.uris.filter((uri) => uri.startsWith("magnet:"))
);
window.electron.checkDebridAvailability(magnets).then((availableHashes) => {
setHashesInDebrid(availableHashes);
});
}, [repacks, isFeatureEnabled, Feature]);
useEffect(() => {
const fetchDownloadSources = async () => {
const sources = (await levelDBService.values(
@ -177,19 +141,8 @@ export function RepacksModal({
}, [visible, game, dispatch]);
const sortedRepacks = useMemo(() => {
return orderBy(
repacks,
[
(repack) => {
const magnet = repack.uris.find((uri) => uri.startsWith("magnet:"));
const hash = magnet ? getHashFromMagnet(magnet) : null;
return hash ? (hashesInDebrid[hash] ?? false) : false;
},
(repack) => repack.uploadDate,
],
["desc", "desc"]
);
}, [repacks, hashesInDebrid]);
return orderBy(repacks, [(repack) => repack.uploadDate], ["desc"]);
}, [repacks]);
const getRepackAvailabilityStatus = (
repack: GameRepack
@ -419,10 +372,6 @@ export function RepacksModal({
{repack.fileSize} - {repack.downloadSourceName} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
</p>
{hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && (
<DebridBadge />
)}
</Button>
);
})

View file

@ -4,11 +4,6 @@
&__review-item {
overflow: hidden;
word-wrap: break-word;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.09);
border-radius: 12px;
padding: calc(globals.$spacing-unit * 1.75);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&__review-header {
@ -80,6 +75,12 @@
align-items: center;
}
&__review-actions-left {
display: flex;
align-items: center;
gap: 12px;
}
&__review-votes {
display: flex;
gap: 12px;
@ -172,7 +173,7 @@
&__blocked-review-hide-link {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
color: #ffc107;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
@ -180,7 +181,7 @@
transition: color 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
color: #ffeb3b;
}
}

View file

@ -3,7 +3,7 @@ import { ThumbsUp, ThumbsDown, Star, Languages } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import type { GameReview } from "@types";
import { getReviewTranslationLanguage, sanitizeHtml } from "@shared";
@ -28,6 +28,7 @@ interface ReviewItemProps {
reviewId: string,
votes: { upvotes: number; downvotes: number }
) => void;
replyAction?: ReactNode;
}
const getRatingText = (score: number, t: (key: string) => string): string => {
@ -58,10 +59,11 @@ export function ReviewItem({
onDelete,
onToggleVisibility,
onAnimationComplete,
replyAction,
}: Readonly<ReviewItemProps>) {
const navigate = useNavigate();
const { t, i18n } = useTranslation("game_details");
const { formatDistance } = useDate();
const { formatDistance, formatDateTime } = useDate();
const { numberFormatter } = useFormat();
const [showOriginal, setShowOriginal] = useState(false);
@ -178,7 +180,10 @@ export function ReviewItem({
</div>
</div>
</div>
<div className="game-details__review-date">
<div
className="game-details__review-date"
title={formatDateTime(new Date(review.createdAt))}
>
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}
@ -221,103 +226,106 @@ export function ReviewItem({
)}
</div>
<div className="game-details__review-actions">
<div className="game-details__review-votes">
<motion.button
className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? "game-details__vote-button--active" : ""}`}
onClick={() => onVote(review.id, "upvote")}
disabled={isVoting}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: isVoting ? "not-allowed" : "pointer",
}}
animate={
review.hasUpvoted
? {
scale: [1, 1.2, 1],
transition: { duration: 0.3 },
}
: {}
}
>
<ThumbsUp size={16} />
<AnimatePresence mode="wait">
<motion.span
key={review.upvotes || 0}
custom={(review.upvotes || 0) > previousVotes.upvotes}
variants={{
enter: (isIncreasing: boolean) => ({
y: isIncreasing ? 10 : -10,
opacity: 0,
}),
center: { y: 0, opacity: 1 },
exit: (isIncreasing: boolean) => ({
y: isIncreasing ? -10 : 10,
opacity: 0,
}),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2 }}
onAnimationComplete={() => {
onAnimationComplete(review.id, {
upvotes: review.upvotes || 0,
downvotes: review.downvotes || 0,
});
}}
>
{formatNumber(review.upvotes || 0)}
</motion.span>
</AnimatePresence>
</motion.button>
<motion.button
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? "game-details__vote-button--active" : ""}`}
onClick={() => onVote(review.id, "downvote")}
disabled={isVoting}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: isVoting ? "not-allowed" : "pointer",
}}
animate={
review.hasDownvoted
? {
scale: [1, 1.2, 1],
transition: { duration: 0.3 },
}
: {}
}
>
<ThumbsDown size={16} />
<AnimatePresence mode="wait">
<motion.span
key={review.downvotes || 0}
custom={(review.downvotes || 0) > previousVotes.downvotes}
variants={{
enter: (isIncreasing: boolean) => ({
y: isIncreasing ? 10 : -10,
opacity: 0,
}),
center: { y: 0, opacity: 1 },
exit: (isIncreasing: boolean) => ({
y: isIncreasing ? -10 : 10,
opacity: 0,
}),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2 }}
onAnimationComplete={() => {
onAnimationComplete(review.id, {
upvotes: review.upvotes || 0,
downvotes: review.downvotes || 0,
});
}}
>
{formatNumber(review.downvotes || 0)}
</motion.span>
</AnimatePresence>
</motion.button>
<div className="game-details__review-actions-left">
<div className="game-details__review-votes">
<motion.button
className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? "game-details__vote-button--active" : ""}`}
onClick={() => onVote(review.id, "upvote")}
disabled={isVoting}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: isVoting ? "not-allowed" : "pointer",
}}
animate={
review.hasUpvoted
? {
scale: [1, 1.2, 1],
transition: { duration: 0.3 },
}
: {}
}
>
<ThumbsUp size={16} />
<AnimatePresence mode="wait">
<motion.span
key={review.upvotes || 0}
custom={(review.upvotes || 0) > previousVotes.upvotes}
variants={{
enter: (isIncreasing: boolean) => ({
y: isIncreasing ? 10 : -10,
opacity: 0,
}),
center: { y: 0, opacity: 1 },
exit: (isIncreasing: boolean) => ({
y: isIncreasing ? -10 : 10,
opacity: 0,
}),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2 }}
onAnimationComplete={() => {
onAnimationComplete(review.id, {
upvotes: review.upvotes || 0,
downvotes: review.downvotes || 0,
});
}}
>
{formatNumber(review.upvotes || 0)}
</motion.span>
</AnimatePresence>
</motion.button>
<motion.button
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? "game-details__vote-button--active" : ""}`}
onClick={() => onVote(review.id, "downvote")}
disabled={isVoting}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: isVoting ? "not-allowed" : "pointer",
}}
animate={
review.hasDownvoted
? {
scale: [1, 1.2, 1],
transition: { duration: 0.3 },
}
: {}
}
>
<ThumbsDown size={16} />
<AnimatePresence mode="wait">
<motion.span
key={review.downvotes || 0}
custom={(review.downvotes || 0) > previousVotes.downvotes}
variants={{
enter: (isIncreasing: boolean) => ({
y: isIncreasing ? 10 : -10,
opacity: 0,
}),
center: { y: 0, opacity: 1 },
exit: (isIncreasing: boolean) => ({
y: isIncreasing ? -10 : 10,
opacity: 0,
}),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2 }}
onAnimationComplete={() => {
onAnimationComplete(review.id, {
upvotes: review.upvotes || 0,
downvotes: review.downvotes || 0,
});
}}
>
{formatNumber(review.downvotes || 0)}
</motion.span>
</AnimatePresence>
</motion.button>
</div>
{replyAction}
</div>
{userDetailsId === review.user.id && (
<button

View file

@ -0,0 +1,171 @@
@use "../../scss/globals.scss";
.game-details {
&__review-thread {
display: flex;
flex-direction: column;
}
&__review-replies {
position: relative;
margin-top: calc(globals.$spacing-unit * 2);
margin-left: calc(globals.$spacing-unit * 2.5);
padding-left: calc(globals.$spacing-unit * 2);
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
&__reply-thread-line {
position: absolute;
top: 0;
bottom: 0;
left: -5px;
width: 2px;
padding: 0 5px;
border: none;
background-color: rgba(255, 255, 255, 0.12);
background-clip: content-box;
box-sizing: content-box;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.32);
}
&:disabled {
cursor: default;
}
}
&__reply-thread {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3.5);
}
&__reply-item {
word-wrap: break-word;
overflow: hidden;
}
&__reply-header {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.75);
margin-bottom: calc(globals.$spacing-unit * 0.75);
button {
display: inline-flex;
align-items: center;
}
}
&__reply-date {
color: rgba(255, 255, 255, 0.5);
font-size: globals.$small-font-size;
white-space: nowrap;
margin-left: auto;
}
&__reply-content {
font-size: globals.$body-font-size;
line-height: 1.5;
}
&__reply-actions {
margin-top: calc(globals.$spacing-unit * 1);
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
.game-details__vote-button {
padding: 4px 8px;
font-size: globals.$small-font-size;
}
}
&__reply-action-link {
display: inline-flex;
align-items: center;
gap: 5px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.78);
font-size: globals.$small-font-size;
font-weight: 500;
cursor: pointer;
padding: 4px 6px;
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.12);
}
}
&__delete-reply-button {
padding: 5px;
margin-left: auto;
}
&__reply-thread-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: calc(globals.$spacing-unit * 1.5);
}
&__reply-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.78);
font-size: globals.$small-font-size;
font-weight: 600;
cursor: pointer;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: #ffffff;
text-decoration: underline;
}
&:disabled {
opacity: 0.6;
cursor: default;
text-decoration: none;
}
&--primary {
&:hover {
color: #ffffff;
}
}
}
&__reply-composer {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1);
.game-details__review-input {
min-height: 72px;
.ProseMirror {
min-height: 48px;
}
}
}
&__reply-composer-actions {
display: flex;
justify-content: flex-end;
gap: calc(globals.$spacing-unit * 1);
}
}

View file

@ -0,0 +1,150 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import { Button } from "@renderer/components";
import "./review-replies.scss";
const MAX_REPLY_CHARS = 1000;
interface ReviewReplyComposerProps {
prefill?: string;
submitting: boolean;
onSubmit: (answerHtml: string) => void;
onCancel: () => void;
}
const escapeHtml = (value: string) =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
export function ReviewReplyComposer({
prefill,
submitting,
onSubmit,
onCancel,
}: Readonly<ReviewReplyComposerProps>) {
const { t } = useTranslation("game_details");
const [charCount, setCharCount] = useState(prefill?.length ?? 0);
const editor = useEditor({
extensions: [
StarterKit.configure({
link: false,
}),
Underline,
],
content: prefill ? `<p>${escapeHtml(prefill)}</p>` : "",
autofocus: "end",
editorProps: {
attributes: {
class: "game-details__review-editor",
"data-placeholder": t("write_reply_placeholder"),
},
handlePaste: (view, event) => {
const htmlContent = event.clipboardData?.getData("text/html") || "";
const plainText = event.clipboardData?.getData("text/plain") || "";
const currentText = view.state.doc.textContent;
const remainingChars = MAX_REPLY_CHARS - currentText.length;
if ((htmlContent || plainText) && remainingChars > 0) {
event.preventDefault();
if (htmlContent) {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = htmlContent;
const textLength = tempDiv.textContent?.length || 0;
if (textLength <= remainingChars) {
return false;
}
}
const truncatedText = plainText.slice(0, remainingChars);
view.dispatch(view.state.tr.insertText(truncatedText));
return true;
}
return false;
},
},
onUpdate: ({ editor }) => {
const text = editor.getText();
setCharCount(text.length);
if (text.length > MAX_REPLY_CHARS) {
const truncatedContent = text.slice(0, MAX_REPLY_CHARS);
editor.commands.setContent(truncatedContent);
setCharCount(MAX_REPLY_CHARS);
}
},
});
const handleSubmit = () => {
const answerHtml = editor?.getHTML() || "";
onSubmit(answerHtml);
};
const isEmpty = !editor?.getText().trim();
return (
<div className="game-details__reply-composer">
<div className="game-details__review-input-container">
<div className="game-details__review-input-header">
<div className="game-details__review-editor-toolbar">
<button
type="button"
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`game-details__editor-button ${editor?.isActive("bold") ? "is-active" : ""}`}
disabled={!editor}
>
<strong>B</strong>
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleItalic().run()}
className={`game-details__editor-button ${editor?.isActive("italic") ? "is-active" : ""}`}
disabled={!editor}
>
<em>I</em>
</button>
<button
type="button"
onClick={() => editor?.chain().focus().toggleUnderline().run()}
className={`game-details__editor-button ${editor?.isActive("underline") ? "is-active" : ""}`}
disabled={!editor}
>
<u>U</u>
</button>
</div>
<div className="game-details__review-char-counter">
<span className={charCount > MAX_REPLY_CHARS ? "over-limit" : ""}>
{charCount}/{MAX_REPLY_CHARS}
</span>
</div>
</div>
<div className="game-details__review-input game-details__reply-input">
<EditorContent editor={editor} />
</div>
</div>
<div className="game-details__reply-composer-actions">
<Button theme="outline" onClick={onCancel} disabled={submitting}>
{t("cancel")}
</Button>
<Button
theme="primary"
onClick={handleSubmit}
disabled={isEmpty || submitting || charCount > MAX_REPLY_CHARS}
>
{submitting ? t("posting_reply") : t("post_reply")}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,198 @@
import { useState } from "react";
import { TrashIcon, ReplyIcon } from "@primer/octicons-react";
import { ThumbsUp, ThumbsDown, Languages } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import type { GameReviewAnswer } from "@types";
import { getReviewTranslationLanguage, sanitizeHtml } from "@shared";
import { useDate } from "@renderer/hooks";
import { formatNumber } from "@renderer/helpers";
import { Avatar } from "@renderer/components";
interface ReviewReplyItemProps {
reply: GameReviewAnswer;
userDetailsId?: string;
isVoting: boolean;
onVote: (answerId: string, voteType: "upvote" | "downvote") => void;
onDelete: (answerId: string) => void;
onReplyTo: (displayName: string) => void;
}
export function ReviewReplyItem({
reply,
userDetailsId,
isVoting,
onVote,
onDelete,
onReplyTo,
}: Readonly<ReviewReplyItemProps>) {
const navigate = useNavigate();
const { t, i18n } = useTranslation("game_details");
const { formatDistance, formatDateTime } = useDate();
const [showOriginal, setShowOriginal] = useState(false);
const isOwnReply = userDetailsId === reply.user.id;
const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || "";
const isDifferentLanguage =
getBaseLanguage(reply.detectedLanguage) !== getBaseLanguage(i18n.language);
const replyTranslationLanguage = getReviewTranslationLanguage(i18n.language);
const needsTranslation =
!isOwnReply &&
isDifferentLanguage &&
reply.translations[replyTranslationLanguage];
const getLanguageName = (languageCode: string | null) => {
if (!languageCode) return "";
try {
const displayNames = new Intl.DisplayNames([i18n.language], {
type: "language",
});
return displayNames.of(languageCode) || languageCode.toUpperCase();
} catch {
return languageCode.toUpperCase();
}
};
const displayContent = needsTranslation
? reply.translations[replyTranslationLanguage]
: reply.answerHtml;
const getVoteButtonCursor = () => {
if (userDetailsId && isVoting) return "not-allowed";
if (userDetailsId) return "pointer";
return "default";
};
const voteButtonCursor = getVoteButtonCursor();
return (
<div className="game-details__reply-item">
<div className="game-details__reply-header">
<button
onClick={() => navigate(`/profile/${reply.user.id}`)}
title={reply.user.displayName}
>
<Avatar
src={reply.user.profileImageUrl}
alt={reply.user.displayName || "User"}
size={28}
/>
</button>
<button
className="game-details__review-display-name game-details__review-display-name--clickable"
onClick={() => reply.user.id && navigate(`/profile/${reply.user.id}`)}
>
{reply.user.displayName || "Anonymous"}
</button>
<span
className="game-details__reply-date"
title={formatDateTime(new Date(reply.createdAt))}
>
{formatDistance(new Date(reply.createdAt), new Date(), {
addSuffix: true,
})}
</span>
</div>
<div
className="game-details__review-content game-details__reply-content"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(displayContent),
}}
/>
{needsTranslation && (
<>
<button
className="game-details__review-translation-toggle"
onClick={() => setShowOriginal(!showOriginal)}
>
<Languages size={13} />
{showOriginal
? t("hide_original")
: t("show_original_translated_from", {
language: getLanguageName(reply.detectedLanguage),
})}
</button>
{showOriginal && (
<div
className="game-details__review-content game-details__reply-content"
style={{
opacity: 0.6,
marginTop: "8px",
}}
dangerouslySetInnerHTML={{
__html: sanitizeHtml(reply.answerHtml),
}}
/>
)}
</>
)}
<div className="game-details__reply-actions">
<div className="game-details__review-votes">
<motion.button
className={`game-details__vote-button game-details__vote-button--upvote ${reply.hasUpvoted ? "game-details__vote-button--active" : ""}`}
onClick={() => onVote(reply.id, "upvote")}
disabled={isVoting || !userDetailsId}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: voteButtonCursor,
}}
animate={
reply.hasUpvoted
? { scale: [1, 1.2, 1], transition: { duration: 0.3 } }
: {}
}
>
<ThumbsUp size={14} />
<span>{formatNumber(reply.upvotes || 0)}</span>
</motion.button>
<motion.button
className={`game-details__vote-button game-details__vote-button--downvote ${reply.hasDownvoted ? "game-details__vote-button--active" : ""}`}
onClick={() => onVote(reply.id, "downvote")}
disabled={isVoting || !userDetailsId}
style={{
opacity: isVoting ? 0.5 : 1,
cursor: voteButtonCursor,
}}
animate={
reply.hasDownvoted
? { scale: [1, 1.2, 1], transition: { duration: 0.3 } }
: {}
}
>
<ThumbsDown size={14} />
<span>{formatNumber(reply.downvotes || 0)}</span>
</motion.button>
</div>
{userDetailsId && (
<button
className="game-details__reply-action-link"
onClick={() => onReplyTo(reply.user.displayName || "")}
title={t("reply")}
>
<ReplyIcon size={14} />
<span>{t("reply")}</span>
</button>
)}
{isOwnReply && (
<button
className="game-details__delete-review-button game-details__delete-reply-button"
onClick={() => onDelete(reply.id)}
title={t("delete_reply")}
>
<TrashIcon size={14} />
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,417 @@
import { useState } from "react";
import { ReplyIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import type { GameReview, GameReviewAnswer, GameShop } from "@types";
import { useToast } from "@renderer/hooks";
import { ReviewItem } from "./review-item";
import { ReviewReplyItem } from "./review-reply-item";
import { ReviewReplyComposer } from "./review-reply-composer";
import "./review-replies.scss";
interface ReviewThreadProps {
shop: GameShop;
objectId: string;
review: GameReview;
userDetailsId?: string;
isVisible: boolean;
isVoting: boolean;
previousVotes: { upvotes: number; downvotes: number };
onVoteReview: (reviewId: string, voteType: "upvote" | "downvote") => void;
onDeleteReview: (reviewId: string) => void;
onToggleVisibility: (reviewId: string) => void;
onAnimationComplete: (
reviewId: string,
votes: { upvotes: number; downvotes: number }
) => void;
composerOpen: boolean;
onComposerOpenChange: (open: boolean) => void;
}
const REPLIES_TAKE = 10;
const PREVIEW_LIMIT = 5;
const VOTE_FEEDBACK_MS = 500;
const electron = globalThis.electron as Electron;
const voteDelta = (next: boolean, previous: boolean) =>
Number(next) - Number(previous);
const getOptimisticReplyVote = (
reply: GameReviewAnswer,
voteType: "upvote" | "downvote"
) => {
let hasUpvoted = reply.hasUpvoted;
let hasDownvoted = reply.hasDownvoted;
if (voteType === "upvote") {
hasUpvoted = !reply.hasUpvoted;
hasDownvoted = hasUpvoted ? false : reply.hasDownvoted;
}
if (voteType === "downvote") {
hasDownvoted = !reply.hasDownvoted;
hasUpvoted = hasDownvoted ? false : reply.hasUpvoted;
}
return {
...reply,
hasUpvoted,
hasDownvoted,
upvotes: Math.max(
0,
(reply.upvotes || 0) + voteDelta(hasUpvoted, reply.hasUpvoted)
),
downvotes: Math.max(
0,
(reply.downvotes || 0) + voteDelta(hasDownvoted, reply.hasDownvoted)
),
};
};
const mergeReplies = (
existing: GameReviewAnswer[],
incoming: GameReviewAnswer[]
) => {
const seen = new Set(existing.map((reply) => reply.id));
return [...existing, ...incoming.filter((reply) => !seen.has(reply.id))];
};
export function ReviewThread({
shop,
objectId,
review,
userDetailsId,
isVisible,
isVoting,
previousVotes,
onVoteReview,
onDeleteReview,
onToggleVisibility,
onAnimationComplete,
composerOpen,
onComposerOpenChange,
}: Readonly<ReviewThreadProps>) {
const { t } = useTranslation("game_details");
const { showSuccessToast, showErrorToast } = useToast();
const [replies, setReplies] = useState<GameReviewAnswer[]>(
review.answers ?? []
);
const [totalCount, setTotalCount] = useState(review.answerCount ?? 0);
const [expanded, setExpanded] = useState(false);
const [hidden, setHidden] = useState(false);
const [loading, setLoading] = useState(false);
const [serverLoaded, setServerLoaded] = useState(0);
const [votingAnswers, setVotingAnswers] = useState<Set<string>>(new Set());
const [prefill, setPrefill] = useState("");
const [submitting, setSubmitting] = useState(false);
const baseUrl = `/games/${shop}/${objectId}/reviews/${review.id}/answers`;
const displayedReplies = hidden
? []
: expanded
? replies
: replies.slice(0, PREVIEW_LIMIT);
const fetchReplies = async (skip: number, replace: boolean) => {
setLoading(true);
try {
const params = new URLSearchParams({
take: REPLIES_TAKE.toString(),
skip: skip.toString(),
});
const response = await electron.hydraApi.get<
{ answers: GameReviewAnswer[]; totalCount: number } | undefined
>(`${baseUrl}?${params.toString()}`, { needsAuth: false });
const fetched = response?.answers ?? [];
setTotalCount(response?.totalCount ?? totalCount);
setReplies((prev) => (replace ? fetched : mergeReplies(prev, fetched)));
setServerLoaded((prev) =>
replace ? fetched.length : prev + fetched.length
);
setExpanded(true);
} catch (error) {
console.error("Failed to load replies:", error);
showErrorToast(t("replies_load_failed"));
} finally {
setLoading(false);
}
};
const handleExpand = () => {
if (loading) return;
setHidden(false);
fetchReplies(0, true);
};
const handleLoadMore = () => {
if (loading) return;
fetchReplies(serverLoaded, false);
};
const handleHide = () => {
setHidden(true);
setExpanded(false);
};
const handleToggleReplies = () => {
if (loading) return;
if (hidden) {
handleExpand();
} else {
handleHide();
}
};
const handleReplyTo = (displayName: string) => {
setPrefill(displayName ? `@${displayName} ` : "");
onComposerOpenChange(true);
};
const handleVoteReply = async (
answerId: string,
voteType: "upvote" | "downvote"
) => {
if (!userDetailsId || votingAnswers.has(answerId)) return;
const replyIndex = replies.findIndex((reply) => reply.id === answerId);
if (replyIndex === -1) return;
setVotingAnswers((prev) => new Set(prev).add(answerId));
const originalReply = replies[replyIndex];
const updatedReply = getOptimisticReplyVote(originalReply, voteType);
setReplies((prev) =>
prev.map((reply) => (reply.id === answerId ? updatedReply : reply))
);
try {
const response = await electron.hydraApi.put<
{ upvotes: number; downvotes: number } | undefined
>(`${baseUrl}/${answerId}/${voteType}`, { data: {} });
if (response) {
setReplies((prev) =>
prev.map((reply) =>
reply.id === answerId
? {
...reply,
upvotes: response.upvotes,
downvotes: response.downvotes,
}
: reply
)
);
}
} catch (error) {
console.error(`Failed to ${voteType} reply:`, error);
setReplies((prev) =>
prev.map((reply) => (reply.id === answerId ? originalReply : reply))
);
showErrorToast(t("vote_failed"));
} finally {
setTimeout(() => {
setVotingAnswers((prev) => {
const next = new Set(prev);
next.delete(answerId);
return next;
});
}, VOTE_FEEDBACK_MS);
}
};
const handleDeleteReply = async (answerId: string) => {
try {
await electron.hydraApi.delete(`${baseUrl}/${answerId}`);
setReplies((prev) => prev.filter((reply) => reply.id !== answerId));
setTotalCount((prev) => Math.max(0, prev - 1));
setServerLoaded((prev) => Math.max(0, prev - 1));
showSuccessToast(t("reply_deleted_successfully"));
} catch (error) {
console.error("Failed to delete reply:", error);
showErrorToast(t("reply_deletion_failed"));
}
};
const handlePostReply = async (answerHtml: string) => {
const tempDiv = document.createElement("div");
tempDiv.innerHTML = answerHtml;
const text = tempDiv.textContent?.trim() || "";
if (!text) {
showErrorToast(t("reply_cannot_be_empty"));
return;
}
if (submitting) return;
setSubmitting(true);
try {
const response = await electron.hydraApi.post<
GameReviewAnswer | undefined
>(`${baseUrl}`, {
data: { answerHtml },
});
if (response) {
setReplies((prev) => mergeReplies(prev, [response]));
setTotalCount((prev) => prev + 1);
setHidden(false);
setExpanded(true);
}
onComposerOpenChange(false);
setPrefill("");
showSuccessToast(t("reply_submitted_successfully"));
} catch (error) {
console.error("Failed to submit reply:", error);
showErrorToast(t("reply_submission_failed"));
} finally {
setSubmitting(false);
}
};
const blockedCollapsed = review.isBlocked && !isVisible;
const hasReplies = replies.length > 0;
const effectiveTotal = Math.max(totalCount, replies.length);
const canViewAll =
!expanded && hasReplies && displayedReplies.length < effectiveTotal;
const canLoadMore = !hidden && expanded && replies.length < totalCount;
const showHide = !hidden && hasReplies;
const showInlineReply =
Boolean(userDetailsId) && !blockedCollapsed && !hasReplies && !composerOpen;
const showThreadActions =
canViewAll ||
canLoadMore ||
showHide ||
(Boolean(userDetailsId) && !composerOpen && hasReplies && !hidden);
const showRepliesSection = !blockedCollapsed && (hasReplies || composerOpen);
return (
<div className="game-details__review-thread">
<ReviewItem
review={review}
userDetailsId={userDetailsId}
isBlocked={review.isBlocked}
isVisible={isVisible}
isVoting={isVoting}
previousVotes={previousVotes}
onVote={onVoteReview}
onDelete={onDeleteReview}
onToggleVisibility={onToggleVisibility}
onAnimationComplete={onAnimationComplete}
replyAction={
showInlineReply ? (
<button
className="game-details__reply-action-link"
onClick={() => handleReplyTo("")}
title={t("reply")}
>
<ReplyIcon size={14} />
<span>{t("reply")}</span>
</button>
) : undefined
}
/>
{showRepliesSection && (
<div className="game-details__review-replies">
{hasReplies && (
<button
type="button"
className="game-details__reply-thread-line"
onClick={handleToggleReplies}
disabled={loading}
aria-label={
hidden
? t("view_all_replies", { count: effectiveTotal })
: t("hide_replies")
}
/>
)}
{hasReplies && (
<div className="game-details__reply-thread">
{displayedReplies.map((reply) => (
<ReviewReplyItem
key={reply.id}
reply={reply}
userDetailsId={userDetailsId}
isVoting={votingAnswers.has(reply.id)}
onVote={handleVoteReply}
onDelete={handleDeleteReply}
onReplyTo={handleReplyTo}
/>
))}
</div>
)}
{showThreadActions && (
<div className="game-details__reply-thread-actions">
{canViewAll && (
<button
className="game-details__reply-toggle"
onClick={handleExpand}
disabled={loading}
>
{loading
? t("loading_replies")
: t("view_all_replies", { count: effectiveTotal })}
</button>
)}
{canLoadMore && (
<button
className="game-details__reply-toggle"
onClick={handleLoadMore}
disabled={loading}
>
{loading ? t("loading_replies") : t("load_more_replies")}
</button>
)}
{showHide && (
<button
className="game-details__reply-toggle"
onClick={handleHide}
>
{t("hide_replies")}
</button>
)}
{userDetailsId && !composerOpen && hasReplies && !hidden && (
<button
className="game-details__reply-toggle game-details__reply-toggle--primary"
onClick={() => handleReplyTo("")}
>
<ReplyIcon size={14} />
<span>{t("reply")}</span>
</button>
)}
</div>
)}
{composerOpen && userDetailsId && (
<ReviewReplyComposer
key={prefill}
prefill={prefill}
submitting={submitting}
onSubmit={handlePostReply}
onCancel={() => {
onComposerOpenChange(false);
setPrefill("");
}}
/>
)}
</div>
)}
</div>
);
}

View file

@ -4,6 +4,7 @@ import {
PersonIcon,
ClockIcon,
StarFillIcon,
CommentDiscussionIcon,
} from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@ -20,6 +21,7 @@ const parseNotificationUrl = (notificationUrl: string): string => {
const badgeName = url.searchParams.get("name");
const gameTitle = url.searchParams.get("title");
const showReviews = url.searchParams.get("reviews");
const reviewId = url.searchParams.get("reviewId");
if (url.pathname === "/profile" && userId) {
return `/profile/${userId}`;
@ -33,6 +35,7 @@ const parseNotificationUrl = (notificationUrl: string): string => {
const params = new URLSearchParams();
if (gameTitle) params.set("title", gameTitle);
if (showReviews) params.set("reviews", showReviews);
if (reviewId) params.set("reviewId", reviewId);
const queryString = params.toString();
return queryString ? `${url.pathname}?${queryString}` : url.pathname;
}
@ -148,6 +151,16 @@ export function NotificationItem({
}),
showActions: false,
};
case "REVIEW_ANSWER":
return {
title: t("review_answer_title", {
gameTitle: notification.variables.gameTitle,
}),
description: t("review_answer_description", {
displayName: notification.variables.answerAuthorDisplayName,
}),
showActions: false,
};
default:
return {
title: t("notification"),
@ -159,15 +172,20 @@ export function NotificationItem({
const content = getNotificationContent();
const isBadge = notification.type === "BADGE_RECEIVED";
const isReview = notification.type === "REVIEW_UPVOTE";
const isReviewUpvote = notification.type === "REVIEW_UPVOTE";
const isReviewAnswer = notification.type === "REVIEW_ANSWER";
const isReview = isReviewUpvote || isReviewAnswer;
const getIcon = () => {
if (notification.pictureUrl) {
return <img src={notification.pictureUrl} alt="" />;
}
if (isReview) {
if (isReviewUpvote) {
return <StarFillIcon size={24} />;
}
if (isReviewAnswer) {
return <CommentDiscussionIcon size={24} />;
}
return <PersonIcon size={24} />;
};

View file

@ -143,6 +143,17 @@ export default function Notifications() {
return () => unsubscribe();
}, []);
// Accepting/refusing a friend request in another window (e.g. the friends
// window) deletes its FRIEND_REQUEST_RECEIVED notification server-side. Refetch
// so the stale entry disappears here instead of lingering until reload.
useEffect(() => {
const unsubscribe = window.electron.onSyncFriendRequests(() => {
if (userDetails) fetchApiNotifications(0, false, filter);
});
return () => unsubscribe();
}, [userDetails, fetchApiNotifications, filter]);
// Cleanup timeouts on unmount
useEffect(() => {
return () => {

View file

@ -8,6 +8,7 @@ import { useToast, useUserDetails } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import { getProfileImageMetadata } from "../profile-image-metadata";
import { ProfileImageCropModal } from "../profile-image-crop-modal/profile-image-crop-modal";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./upload-background-image-button.scss";
export function UploadBackgroundImageButton() {
@ -16,6 +17,7 @@ export function UploadBackgroundImageButton() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuClosing, setIsMenuClosing] = useState(false);
const [showRemoveBannerModal, setShowRemoveBannerModal] = useState(false);
const { showHydraCloudModal } = useSubscription();
const [bannerImageToCrop, setBannerImageToCrop] = useState<string | null>(
null
);
@ -144,7 +146,24 @@ export function UploadBackgroundImageButton() {
};
}, [isMenuOpen]);
if (!isMe || !hasActiveSubscription) return null;
if (!isMe) return null;
// Non-subscribers always see the button, but clicking it presents the Hydra
// Cloud promo (highlighting profile customization) instead of the file picker.
if (!hasActiveSubscription) {
return (
<div className="upload-background-image-button__wrapper">
<Button
theme="outline"
className="upload-background-image-button"
onClick={() => showHydraCloudModal("customization")}
>
<UploadIcon />
{t("upload_banner")}
</Button>
</div>
);
}
const cropModal = (
<ProfileImageCropModal

View file

@ -0,0 +1,105 @@
.cloud-subscription-modal {
&__overlay {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background-color: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(2px);
// Kept mounted (iframe stays loaded + sized) but invisible and
// click-through, so opening is instant with no layout shift.
&--hidden {
opacity: 0;
pointer-events: none;
backdrop-filter: none;
}
&--open {
opacity: 1;
animation: cloud-modal-fade-in 0.2s ease forwards;
}
&--closing {
animation: cloud-modal-fade-out 0.25s ease forwards;
}
}
// As large as fits on screen while keeping the design's aspect ratio, so the
// whole frame is visible with no scroll and the content reads comfortably.
&__frame {
position: relative;
aspect-ratio: 1536 / 836;
width: min(96vw, calc(94vh * 1536 / 836));
max-height: 94vh;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
}
&__iframe {
width: 100%;
height: 100%;
border: none;
display: block;
background-color: #080808;
}
&__fallback {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 32px;
background-color: #080808;
color: #fff;
text-align: center;
}
&__close {
position: absolute;
top: 16px;
right: 16px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background-color: rgba(255, 255, 255, 0.12);
color: #fff;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.24);
}
}
}
@keyframes cloud-modal-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes cloud-modal-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}

View file

@ -0,0 +1,168 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@renderer/components";
import type { HydraCloudFeature } from "@types";
import "./cloud-subscription-modal.scss";
export interface CloudSubscriptionModalProps {
visible: boolean;
onClose: () => void;
// Which card the promo highlights. Forwarded to the iframe.
feature?: HydraCloudFeature;
}
export function CloudSubscriptionModal({
visible,
onClose,
feature,
}: CloudSubscriptionModalProps) {
const { t, i18n } = useTranslation("hydra_cloud");
const lang = i18n.language?.split("-")[0] || "en";
const [isClosing, setIsClosing] = useState(false);
const [cloudIframeUrl, setCloudIframeUrl] = useState("");
const [isIframeUnavailable, setIsIframeUnavailable] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const cloudIframeOrigin = cloudIframeUrl
? new URL(cloudIframeUrl).origin
: "";
useEffect(() => {
let isMounted = true;
const getCloudIframeUrl = window.electron.getCloudIframeUrl;
// Remote renderer/preload can be deployed from different commits.
if (typeof getCloudIframeUrl !== "function") {
setIsIframeUnavailable(true);
return;
}
getCloudIframeUrl()
.then((url) => {
if (isMounted) setCloudIframeUrl(url);
})
.catch(() => {
if (isMounted) setIsIframeUnavailable(true);
});
return () => {
isMounted = false;
};
}, []);
const handleClose = useCallback(() => {
setIsClosing(true);
const start = performance.now();
requestAnimationFrame(function step(time) {
if (time - start <= 250) {
requestAnimationFrame(step);
} else {
setIsClosing(false);
onClose();
}
});
}, [onClose]);
// Subscribe + escape handling only while the modal is open.
useEffect(() => {
if (!visible) return;
const onMessage = (event: MessageEvent) => {
if (
event.origin !== cloudIframeOrigin ||
event.source !== iframeRef.current?.contentWindow
) {
return;
}
if (
event.data?.source === "hydra-cloud" &&
event.data.type === "subscribe"
) {
window.electron.openCheckout();
handleClose();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") handleClose();
};
window.addEventListener("message", onMessage);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("message", onMessage);
window.removeEventListener("keydown", onKeyDown);
};
}, [visible, handleClose, cloudIframeOrigin]);
// Sync the launcher language + selected feature to the (already loaded)
// iframe each time it opens, so it can switch cards without reloading.
useEffect(() => {
if (visible && cloudIframeOrigin) {
iframeRef.current?.contentWindow?.postMessage(
{ source: "hydra", lang, feature },
cloudIframeOrigin
);
}
}, [visible, lang, feature, cloudIframeOrigin]);
// The overlay (and the iframe inside it) stays mounted for the lifetime of the
// profile so the frame is preloaded; only its visibility is toggled. A hidden
// iframe still loads its document, so opening is instant with no layout shift.
const state = isClosing ? "closing" : visible ? "open" : "hidden";
const handleClickOpenCheckout = () => {
window.electron.openCheckout();
handleClose();
};
return createPortal(
<div
className={`cloud-subscription-modal__overlay cloud-subscription-modal__overlay--${state}`}
role="dialog"
aria-hidden={state === "hidden"}
onPointerDown={(event) => {
if (visible && !isClosing && event.target === event.currentTarget) {
handleClose();
}
}}
>
<div className="cloud-subscription-modal__frame">
<button
type="button"
className="cloud-subscription-modal__close"
onClick={handleClose}
aria-label="Close"
tabIndex={state === "hidden" ? -1 : 0}
>
<XIcon size={20} />
</button>
{isIframeUnavailable ? (
<div
className="cloud-subscription-modal__fallback"
data-hydra-cloud-feature={feature}
>
{t("hydra_cloud_feature_found")}
<Button onClick={handleClickOpenCheckout}>{t("learn_more")}</Button>
</div>
) : (
<iframe
ref={iframeRef}
title="Hydra Cloud"
className="cloud-subscription-modal__iframe"
src={
cloudIframeUrl
? `${cloudIframeUrl}?lng=${lang}${
feature ? `&feature=${feature}` : ""
}`
: undefined
}
/>
)}
</div>
</div>,
document.body
);
}

View file

@ -1,10 +0,0 @@
@use "../../../scss/globals.scss";
.hydra-cloud-modal {
&__container {
display: flex;
width: 500px;
flex-direction: column;
gap: calc(globals.$spacing-unit * 2);
}
}

View file

@ -1,33 +0,0 @@
import { Button, Modal } from "@renderer/components";
import { useTranslation } from "react-i18next";
import "./hydra-cloud-modal.scss";
export interface HydraCloudModalProps {
feature: string;
visible: boolean;
onClose: () => void;
}
export const HydraCloudModal = ({
feature,
visible,
onClose,
}: HydraCloudModalProps) => {
const { t } = useTranslation("hydra_cloud");
const handleClickOpenCheckout = () => {
window.electron.openCheckout();
};
return (
<Modal visible={visible} title={t("hydra_cloud")} onClose={onClose}>
<div
className="hydra-cloud-modal__container"
data-hydra-cloud-feature={feature}
>
{t("hydra_cloud_feature_found")}
<Button onClick={handleClickOpenCheckout}>{t("learn_more")}</Button>
</div>
</Modal>
);
};

View file

@ -9,7 +9,8 @@ export * from "./download-contract";
export type HydraCloudFeature =
| "achievements"
| "backup"
| "achievements-points";
| "achievements-points"
| "customization";
export interface DiskUsage {
free: number;
@ -151,11 +152,13 @@ export interface UserFriend {
id: string;
displayName: string;
profileImageUrl: string | null;
backgroundImageUrl?: string | null;
currentGame:
| (ShopAssets & {
sessionDurationInSeconds: number;
})
| null;
isOnline?: boolean;
}
export interface UserFriends {
@ -163,6 +166,12 @@ export interface UserFriends {
friends: UserFriend[];
}
export interface ProfileFriends {
totalFriends: number;
onlineFriends: number;
friends: UserFriend[];
}
export interface UserBlocks {
totalBlocks: number;
blocks: UserFriend[];
@ -172,6 +181,11 @@ export interface FriendRequestSync {
friendRequestCount: number;
}
export interface FriendPresenceSync {
friendId: string;
isOnline: boolean;
}
export interface NotificationSync {
notificationCount: number;
}
@ -268,6 +282,27 @@ export interface GameStats {
reviewCount: number;
}
export interface GameReviewAnswer {
id: string;
answerHtml: string;
createdAt: string;
updatedAt: string;
upvotes: number;
downvotes: number;
isBlocked: boolean;
hasUpvoted: boolean;
hasDownvoted: boolean;
user: {
id: string;
displayName: string;
profileImageUrl: string | null;
};
translations: {
[key: string]: string;
};
detectedLanguage: string | null;
}
export interface GameReview {
id: string;
reviewHtml: string;
@ -276,6 +311,8 @@ export interface GameReview {
updatedAt: string;
upvotes: number;
downvotes: number;
answerCount: number;
answers: GameReviewAnswer[];
isBlocked: boolean;
hasUpvoted: boolean;
hasDownvoted: boolean;
@ -350,7 +387,8 @@ export type NotificationType =
| "FRIEND_REQUEST_RECEIVED"
| "FRIEND_REQUEST_ACCEPTED"
| "BADGE_RECEIVED"
| "REVIEW_UPVOTE";
| "REVIEW_UPVOTE"
| "REVIEW_ANSWER";
export type LocalNotificationType =
| "EXTRACTION_COMPLETE"

File diff suppressed because one or more lines are too long