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:
parent
0e89f082ad
commit
e40173119f
96 changed files with 4826 additions and 402 deletions
107
.github/workflows/build.yml
vendored
107
.github/workflows/build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
1
native/hydra-native/Cargo.lock
generated
1
native/hydra-native/Cargo.lock
generated
|
|
@ -278,6 +278,7 @@ dependencies = [
|
|||
"napi-build",
|
||||
"napi-derive",
|
||||
"sysinfo",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
2
proto
|
|
@ -1 +1 @@
|
|||
Subproject commit 6f11c99c572420a282ba5149b6866e39b8a4569c
|
||||
Subproject commit 97a0cbd3601c18fc6e60f90eab0cf15c07eaaf99
|
||||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "Скопировано!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {}),
|
||||
|
|
|
|||
10
src/main/events/friends/friends-window-controls.ts
Normal file
10
src/main/events/friends/friends-window-controls.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { WindowManager } from "@main/services";
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
ipcMain.handle("minimizeFriendsWindow", () => {
|
||||
WindowManager.minimizeFriendsWindow();
|
||||
});
|
||||
|
||||
ipcMain.handle("closeFriendsWindow", () => {
|
||||
WindowManager.closeFriendsWindow();
|
||||
});
|
||||
162
src/main/events/friends/get-processed-friend-image.ts
Normal file
162
src/main/events/friends/get-processed-friend-image.ts
Normal 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);
|
||||
6
src/main/events/friends/index.ts
Normal file
6
src/main/events/friends/index.ts
Normal 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";
|
||||
6
src/main/events/friends/open-add-friend-modal.ts
Normal file
6
src/main/events/friends/open-add-friend-modal.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { WindowManager } from "@main/services";
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
ipcMain.handle("openAddFriendModalInMainWindow", () => {
|
||||
WindowManager.openAddFriendModalInMainWindow();
|
||||
});
|
||||
6
src/main/events/friends/open-friend-profile.ts
Normal file
6
src/main/events/friends/open-friend-profile.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { WindowManager } from "@main/services";
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
ipcMain.handle("openFriendProfileInMainWindow", (_event, userId: string) => {
|
||||
WindowManager.focusMainWindowAndNavigate(`/profile/${userId}`);
|
||||
});
|
||||
6
src/main/events/friends/open-friends-window.ts
Normal file
6
src/main/events/friends/open-friends-window.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { WindowManager } from "@main/services";
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
ipcMain.handle("openFriendsWindow", () => {
|
||||
WindowManager.openFriendsWindow();
|
||||
});
|
||||
13
src/main/events/friends/sync-friend-requests.ts
Normal file
13
src/main/events/friends/sync-friend-requests.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
10
src/main/services/ws/events/friend-presence.ts
Normal file
10
src/main/services/ws/events/friend-presence.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
26
src/renderer/src/declaration.d.ts
vendored
26
src/renderer/src/declaration.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
606
src/renderer/src/pages/friends-window/friends-window.scss
Normal file
606
src/renderer/src/pages/friends-window/friends-window.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
695
src/renderer/src/pages/friends-window/friends-window.tsx
Normal file
695
src/renderer/src/pages/friends-window/friends-window.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
171
src/renderer/src/pages/game-details/review-replies.scss
Normal file
171
src/renderer/src/pages/game-details/review-replies.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
150
src/renderer/src/pages/game-details/review-reply-composer.tsx
Normal file
150
src/renderer/src/pages/game-details/review-reply-composer.tsx
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
198
src/renderer/src/pages/game-details/review-reply-item.tsx
Normal file
198
src/renderer/src/pages/game-details/review-reply-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
417
src/renderer/src/pages/game-details/review-thread.tsx
Normal file
417
src/renderer/src/pages/game-details/review-thread.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
Loading…
Reference in a new issue