friends system (UI WIP)

This commit is contained in:
2025-12-14 01:34:17 +08:00
parent b34c55746a
commit b10b206b48
10 changed files with 279 additions and 19 deletions

View File

@@ -7,6 +7,7 @@
"core:default", "core:default",
"opener:default", "opener:default",
"core:event:allow-listen", "core:event:allow-listen",
"core:event:allow-unlisten" "core:event:allow-unlisten",
"core:window:allow-close"
] ]
} }

View File

@@ -1,10 +1,11 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ts_rs::TS; use ts_rs::TS;
use crate::remotes::user::UserProfile; use crate::remotes::{friends::FriendshipResponseDto, user::UserProfile};
#[derive(Default, Serialize, Deserialize, Clone, Debug, TS)] #[derive(Default, Serialize, Deserialize, Clone, Debug, TS)]
#[ts(export)] #[ts(export)]
pub struct AppData { pub struct AppData {
pub user: Option<UserProfile>, pub user: Option<UserProfile>,
pub friends: Option<Vec<FriendshipResponseDto>>,
} }

View File

@@ -0,0 +1,140 @@
use reqwest::{Client, Error};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{lock_r, services::auth::with_auth, state::FDOLL};
#[derive(Default, Serialize, Deserialize, Clone, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct UserBasicDto {
pub id: String,
pub name: String,
pub username: String,
pub picture: String,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct FriendshipResponseDto {
pub id: String,
pub friend: UserBasicDto,
pub created_at: String,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct SendFriendRequestDto {
pub receiver_id: String,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct FriendRequestResponseDto {
pub id: String,
pub sender: UserBasicDto,
pub receiver: UserBasicDto,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
pub struct FriendRemote {
pub base_url: String,
pub client: Client,
}
impl FriendRemote {
pub fn new() -> Self {
let guard = lock_r!(FDOLL);
Self {
base_url: guard
.app_config
.api_base_url
.as_ref()
.expect("App configuration error")
.clone(),
client: guard
.clients
.as_ref()
.expect("App configuration error")
.http_client
.clone(),
}
}
pub async fn get_friends(&self) -> Result<Vec<FriendshipResponseDto>, Error> {
let url = format!("{}/friends", self.base_url);
let resp = with_auth(self.client.get(url)).await.send().await?;
let friends = resp.json().await?;
Ok(friends)
}
pub async fn search_users(&self, username: Option<&str>) -> Result<Vec<UserBasicDto>, Error> {
let mut url = format!("{}/friends/search", self.base_url);
if let Some(u) = username {
url.push_str(&format!("?username={}", u));
}
let resp = with_auth(self.client.get(&url)).await.send().await?;
let users = resp.json().await?;
Ok(users)
}
pub async fn send_friend_request(
&self,
request: SendFriendRequestDto,
) -> Result<FriendRequestResponseDto, Error> {
let url = format!("{}/friends/requests", self.base_url);
let resp = with_auth(self.client.post(url))
.await
.json(&request)
.send()
.await?;
let req_resp = resp.json().await?;
Ok(req_resp)
}
pub async fn get_received_requests(&self) -> Result<Vec<FriendRequestResponseDto>, Error> {
let url = format!("{}/friends/requests/received", self.base_url);
let resp = with_auth(self.client.get(url)).await.send().await?;
let requests = resp.json().await?;
Ok(requests)
}
pub async fn get_sent_requests(&self) -> Result<Vec<FriendRequestResponseDto>, Error> {
let url = format!("{}/friends/requests/sent", self.base_url);
let resp = with_auth(self.client.get(url)).await.send().await?;
let requests = resp.json().await?;
Ok(requests)
}
pub async fn accept_friend_request(
&self,
request_id: &str,
) -> Result<FriendRequestResponseDto, Error> {
let url = format!("{}/friends/requests/{}/accept", self.base_url, request_id);
let resp = with_auth(self.client.post(url)).await.send().await?;
let req_resp = resp.json().await?;
Ok(req_resp)
}
pub async fn deny_friend_request(
&self,
request_id: &str,
) -> Result<FriendRequestResponseDto, Error> {
let url = format!("{}/friends/requests/{}/deny", self.base_url, request_id);
let resp = with_auth(self.client.post(url)).await.send().await?;
let req_resp = resp.json().await?;
Ok(req_resp)
}
pub async fn unfriend(&self, friend_id: &str) -> Result<(), Error> {
let url = format!("{}/friends/{}", self.base_url, friend_id);
let resp = with_auth(self.client.delete(url)).await.send().await?;
resp.error_for_status()?;
Ok(())
}
}

View File

@@ -1 +1,2 @@
pub mod friends;
pub mod user; pub mod user;

View File

@@ -9,11 +9,22 @@ use crate::{lock_r, services::auth::with_auth, state::FDOLL};
#[ts(export)] #[ts(export)]
pub struct UserProfile { pub struct UserProfile {
pub id: String, pub id: String,
pub keycloak_sub: String,
pub name: String, pub name: String,
pub email: String, pub email: String,
pub username: String, pub username: Option<String>,
pub picture: Option<String>,
pub roles: Vec<String>,
pub created_at: String, pub created_at: String,
pub last_login_at: String, pub updated_at: String,
pub last_login_at: Option<String>,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct UpdateUserDto {
// Empty as per API schema
} }
pub struct UserRemote { pub struct UserRemote {
@@ -47,5 +58,25 @@ impl UserRemote {
Ok(user) Ok(user)
} }
// TODO: Add other endpoints as methods pub async fn update_user(
&self,
user_id: Option<&str>,
update: UpdateUserDto,
) -> Result<UserProfile, Error> {
let url = format!("{}/users/{}", self.base_url, user_id.unwrap_or("me"));
let resp = with_auth(self.client.put(url))
.await
.json(&update)
.send()
.await?;
let user = resp.json().await?;
Ok(user)
}
pub async fn delete_user(&self, user_id: Option<&str>) -> Result<(), Error> {
let url = format!("{}/users/{}", self.base_url, user_id.unwrap_or("me"));
let resp = with_auth(self.client.delete(url)).await.send().await?;
resp.error_for_status()?;
Ok(())
}
} }

View File

@@ -1,12 +1,11 @@
use rust_socketio::{ClientBuilder, Payload, RawClient}; use rust_socketio::{ClientBuilder, Payload, RawClient};
use serde_json::json; use serde_json::json;
use tauri::async_runtime; use tauri::{async_runtime, Emitter};
use tracing::{error, info}; use tracing::{error, info};
use crate::{ use crate::{
lock_r, lock_w, get_app_handle, lock_r, lock_w, models::app_config::AppConfig,
services::cursor::CursorPosition, services::cursor::CursorPosition, state::FDOLL,
{models::app_config::AppConfig, state::FDOLL},
}; };
#[allow(non_camel_case_types)] // pretend to be a const like in js #[allow(non_camel_case_types)] // pretend to be a const like in js
@@ -14,13 +13,54 @@ pub struct WS_EVENT;
impl WS_EVENT { impl WS_EVENT {
pub const CURSOR_REPORT_POSITION: &str = "cursor-report-position"; pub const CURSOR_REPORT_POSITION: &str = "cursor-report-position";
pub const FRIEND_REQUEST_RECEIVED: &str = "friend-request-received";
pub const FRIEND_REQUEST_ACCEPTED: &str = "friend-request-accepted";
pub const FRIEND_REQUEST_DENIED: &str = "friend-request-denied";
pub const UNFRIENDED: &str = "unfriended";
} }
// Define a callback for handling incoming messages (e.g., 'pong') fn on_friend_request_received(payload: Payload, _socket: RawClient) {
fn on_pong(payload: Payload, _socket: RawClient) {
match payload { match payload {
Payload::Text(str) => println!("Received pong: {:?}", str), Payload::Text(str) => {
Payload::Binary(bin) => println!("Received pong (binary): {:?}", bin), println!("Received friend request: {:?}", str);
get_app_handle()
.emit(WS_EVENT::FRIEND_REQUEST_RECEIVED, str)
.unwrap();
}
_ => todo!(),
}
}
fn on_friend_request_accepted(payload: Payload, _socket: RawClient) {
match payload {
Payload::Text(str) => {
println!("Received friend request accepted: {:?}", str);
get_app_handle()
.emit(WS_EVENT::FRIEND_REQUEST_ACCEPTED, str)
.unwrap();
}
_ => todo!(),
}
}
fn on_friend_request_denied(payload: Payload, _socket: RawClient) {
match payload {
Payload::Text(str) => {
println!("Received friend request denied: {:?}", str);
get_app_handle()
.emit(WS_EVENT::FRIEND_REQUEST_DENIED, str)
.unwrap();
}
_ => todo!(),
}
}
fn on_unfriended(payload: Payload, _socket: RawClient) {
match payload {
Payload::Text(str) => {
println!("Received unfriended: {:?}", str);
get_app_handle().emit(WS_EVENT::UNFRIENDED, str).unwrap();
}
_ => todo!(), _ => todo!(),
} }
} }
@@ -82,7 +122,16 @@ pub async fn build_ws_client(app_config: &AppConfig) -> rust_socketio::client::C
let client = async_runtime::spawn_blocking(move || { let client = async_runtime::spawn_blocking(move || {
ClientBuilder::new(api_base_url) ClientBuilder::new(api_base_url)
.namespace("/") .namespace("/")
.on("pong", on_pong) .on(
WS_EVENT::FRIEND_REQUEST_RECEIVED,
on_friend_request_received,
)
.on(
WS_EVENT::FRIEND_REQUEST_ACCEPTED,
on_friend_request_accepted,
)
.on(WS_EVENT::FRIEND_REQUEST_DENIED, on_friend_request_denied)
.on(WS_EVENT::UNFRIENDED, on_unfriended)
.auth(json!({ "token": token })) .auth(json!({ "token": token }))
.connect() .connect()
}) })

View File

@@ -5,7 +5,7 @@ use crate::{
app_config::{AppConfig, AuthConfig}, app_config::{AppConfig, AuthConfig},
app_data::AppData, app_data::AppData,
}, },
remotes::user::UserRemote, remotes::{friends::FriendRemote, user::UserRemote},
services::auth::{load_auth_pass, AuthPass}, services::auth::{load_auth_pass, AuthPass},
}; };
use serde_json::json; use serde_json::json;
@@ -99,13 +99,20 @@ pub fn init_fdoll_state() {
/// Populate user data in app state from the server. /// Populate user data in app state from the server.
pub async fn init_app_data() { pub async fn init_app_data() {
let user_remote = UserRemote::new(); let user_remote = UserRemote::new();
let friend_remote = FriendRemote::new();
let user = user_remote let user = user_remote
.get_user(None) .get_user(None)
.await .await
.expect("TODO: handle user profile fetch failure"); .expect("TODO: handle user profile fetch failure");
let friends = friend_remote
.get_friends()
.await
.expect("TODO: handle friends fetch failure");
{ {
let mut guard = lock_w!(FDOLL); let mut guard = lock_w!(FDOLL);
guard.app_data.user = Some(user); guard.app_data.user = Some(user);
guard.app_data.friends = Some(friends);
get_app_handle() get_app_handle()
.emit("app-data-refreshed", json!(guard.app_data)) .emit("app-data-refreshed", json!(guard.app_data))
.expect("TODO: handle event emit fail"); .expect("TODO: handle event emit fail");

View File

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-power-icon lucide-power"
><path d="M12 2v10" /><path d="M18.4 6.6a9 9 0 1 1-12.77.04" /></svg
>

After

Width:  |  Height:  |  Size: 320 B

View File

@@ -2,14 +2,15 @@
import Friends from "./tabs/friends.svelte"; import Friends from "./tabs/friends.svelte";
import Preferences from "./tabs/preferences.svelte"; import Preferences from "./tabs/preferences.svelte";
import YourDolls from "./tabs/your-dolls.svelte"; import YourDolls from "./tabs/your-dolls.svelte";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
</script> </script>
<div <div
class="p-2 h-full absolute inset-0 bg-base-100 border-base-200/50 border border-t-0 rounded-b-xl" class="p-2 h-full absolute inset-0 bg-base-100 border-base-200/50 border border-t-0 rounded-b-xl"
> >
<div class="flex flex-col gap-2 h-full"> <div class="flex flex-col gap-2 h-full">
<div class="size-full"> <div class="size-full flex flex-col gap-2">
<div class="tabs tabs-lift h-full"> <div class="tabs tabs-lift h-full flex-1">
<input <input
type="radio" type="radio"
name="app_menu_tabs" name="app_menu_tabs"
@@ -41,6 +42,17 @@
<Preferences /> <Preferences />
</div> </div>
</div> </div>
<div class="w-full flex flex-row justify-between">
<div></div>
<div class="flex flex-row gap-2">
<button
class="btn btn-sm btn-outline border-neutral-500/50"
onclick={async () => {
await getCurrentWebviewWindow().close();
}}><p class="px-4">Ok</p></button
>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
<script> <script>
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { appData } from "../../../events/app-data"; import { appData } from "../../../events/app-data";
import Power from "../../../assets/icons/power.svelte";
</script> </script>
<div class="size-full flex flex-col justify-between"> <div class="size-full flex flex-col justify-between">
@@ -11,11 +12,15 @@
<div></div> <div></div>
<div> <div>
<button <button
class="btn btn-error btn-sm btn-soft" class="btn btn-error btn-square btn-soft"
onclick={async () => { onclick={async () => {
await invoke("quit_app"); await invoke("quit_app");
}}>Quit Friendolls</button }}
> >
<div class="scale-50">
<Power />
</div>
</button>
</div> </div>
</div> </div>
</div> </div>