messages, wavings and images
This commit is contained in:
Generated
+235
-1
@@ -514,6 +514,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -562,6 +564,12 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -1554,6 +1562,16 @@ dependencies = [
|
||||
"r-efi 6.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.18.4"
|
||||
@@ -2044,9 +2062,24 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2179,6 +2212,16 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.99"
|
||||
@@ -2311,6 +2354,16 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libwebp-sys"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -3137,6 +3190,12 @@ version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -3356,6 +3415,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4448,6 +4531,48 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@@ -5425,6 +5550,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webp"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1"
|
||||
dependencies = [
|
||||
"libwebp-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
@@ -5479,6 +5613,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
@@ -5752,6 +5892,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
@@ -5800,13 +5949,30 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
@@ -5843,6 +6009,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5861,6 +6033,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5879,12 +6057,24 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5903,6 +6093,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
@@ -5921,6 +6117,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
@@ -5939,6 +6141,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
@@ -5957,6 +6165,12 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
@@ -6071,11 +6285,13 @@ dependencies = [
|
||||
"sqlx",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-specta",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"uuid",
|
||||
"webp",
|
||||
"windows 0.58.0",
|
||||
"wyd-common",
|
||||
]
|
||||
@@ -6085,6 +6301,8 @@ name = "wyd-common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6095,6 +6313,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"futures-util",
|
||||
"image",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"uuid",
|
||||
@@ -6292,6 +6511,21 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.12.0"
|
||||
|
||||
@@ -6,3 +6,7 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
specta = { version = "2.0.0-rc.21", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
+133
-4
@@ -1,6 +1,11 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
pub const VERSION: u8 = 1;
|
||||
pub const MAX_INTERACTION_PAYLOAD_BYTES: usize = 160 * 1024;
|
||||
pub const MAX_IMAGE_B64_SIZE: usize = 150 * 1024;
|
||||
pub const MAX_IMAGE_DIMENSION: u32 = 480;
|
||||
pub const MAX_TEXT_CHARS: usize = 500;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -30,16 +35,90 @@ pub enum ClientMessage {
|
||||
payload: String,
|
||||
signature: String,
|
||||
},
|
||||
Interaction {
|
||||
interaction_id: String,
|
||||
recipient_id: String,
|
||||
payload: String,
|
||||
signature: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InteractionDeliveryStatus {
|
||||
Delivered,
|
||||
Unavailable,
|
||||
Busy,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InteractionContent {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
Wave,
|
||||
Image {
|
||||
#[serde(rename = "mediaType")]
|
||||
#[specta(rename = "mediaType")]
|
||||
media_type: String,
|
||||
data: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl InteractionContent {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
match self {
|
||||
Self::Text { text } if text.trim().is_empty() => {
|
||||
Err("Message cannot be empty".to_owned())
|
||||
}
|
||||
Self::Text { text } if text.chars().count() > MAX_TEXT_CHARS => {
|
||||
Err(format!("Message exceeds {MAX_TEXT_CHARS} characters"))
|
||||
}
|
||||
Self::Image { media_type, .. }
|
||||
if !matches!(media_type.as_str(), "image/webp" | "image/jpeg") =>
|
||||
{
|
||||
Err("Image must be WebP or JPEG".to_owned())
|
||||
}
|
||||
Self::Image { data, .. } if data.is_empty() || data.len() > MAX_IMAGE_B64_SIZE => {
|
||||
Err(format!(
|
||||
"Compressed image must be at most {} KiB",
|
||||
MAX_IMAGE_B64_SIZE / 1024
|
||||
))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ServerMessage {
|
||||
Challenge { version: u8, challenge: String },
|
||||
Challenge {
|
||||
version: u8,
|
||||
challenge: String,
|
||||
},
|
||||
Registered,
|
||||
FriendProfileUpdated { profile: Profile },
|
||||
FriendProfiles { profiles: Vec<Profile> },
|
||||
FriendLiveData { friend_id: String, payload: String },
|
||||
FriendProfileUpdated {
|
||||
profile: Profile,
|
||||
},
|
||||
FriendProfiles {
|
||||
profiles: Vec<Profile>,
|
||||
},
|
||||
FriendLiveData {
|
||||
friend_id: String,
|
||||
payload: String,
|
||||
},
|
||||
FriendInteraction {
|
||||
interaction_id: String,
|
||||
friend_id: String,
|
||||
payload: String,
|
||||
},
|
||||
InteractionDelivery {
|
||||
interaction_id: String,
|
||||
status: InteractionDeliveryStatus,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn register_bytes(challenge: &str, profile: &Profile, friends: &[String]) -> Vec<u8> {
|
||||
@@ -56,6 +135,19 @@ pub fn message_bytes(payload: &str) -> Vec<u8> {
|
||||
format!("wyd-message-v{VERSION}\n{payload}").into_bytes()
|
||||
}
|
||||
|
||||
pub fn interaction_bytes(interaction_id: &str, recipient_id: &str, payload: &str) -> Vec<u8> {
|
||||
let mut bytes = format!("wyd-interaction-v{VERSION}\0").into_bytes();
|
||||
for value in [
|
||||
interaction_id.as_bytes(),
|
||||
recipient_id.as_bytes(),
|
||||
payload.as_bytes(),
|
||||
] {
|
||||
bytes.extend_from_slice(&(value.len() as u64).to_be_bytes());
|
||||
bytes.extend_from_slice(value);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn profile_bytes(profile: &Profile) -> Vec<u8> {
|
||||
format!(
|
||||
"wyd-profile-v{VERSION}\n{}\n{}",
|
||||
@@ -67,3 +159,40 @@ pub fn profile_bytes(profile: &Profile) -> Vec<u8> {
|
||||
pub fn friends_bytes(friends: &[String]) -> Vec<u8> {
|
||||
format!("wyd-friends-v{VERSION}\n{}", friends.join("\n")).into_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{InteractionContent, MAX_TEXT_CHARS, interaction_bytes};
|
||||
|
||||
#[test]
|
||||
fn interaction_signature_bytes_are_length_delimited() {
|
||||
assert_ne!(
|
||||
interaction_bytes("one", "two", "three\nfour"),
|
||||
interaction_bytes("one\ntwo", "three", "four"),
|
||||
);
|
||||
assert_ne!(
|
||||
interaction_bytes("id", "recipient-a", "payload"),
|
||||
interaction_bytes("id", "recipient-b", "payload"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_content_enforces_shared_wire_invariants() {
|
||||
assert!(InteractionContent::Wave.validate().is_ok());
|
||||
assert!(
|
||||
InteractionContent::Text {
|
||||
text: " ".to_owned()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
InteractionContent::Text {
|
||||
text: "x".repeat(MAX_TEXT_CHARS + 1)
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_str::<InteractionContent>(r#"{"type":"unknown"}"#).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "s
|
||||
base64 = "0.22.1"
|
||||
ed25519-dalek = "3.0.0"
|
||||
futures-util = "0.3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "webp"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
wyd-common = { path = "../src-common" }
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use axum::extract::ws::Message;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
use wyd_common::{
|
||||
InteractionContent, InteractionDeliveryStatus, MAX_IMAGE_DIMENSION,
|
||||
MAX_INTERACTION_PAYLOAD_BYTES, ServerMessage, interaction_bytes,
|
||||
};
|
||||
|
||||
use super::{Clients, verify};
|
||||
|
||||
pub(super) async fn relay(
|
||||
clients: &Clients,
|
||||
public_key: &str,
|
||||
connection_id: Uuid,
|
||||
interaction_id: &str,
|
||||
recipient_id: &str,
|
||||
payload: String,
|
||||
signature: &str,
|
||||
) -> Option<InteractionDeliveryStatus> {
|
||||
let (source_key, recipient) = {
|
||||
let clients = clients.lock().await;
|
||||
let source = clients
|
||||
.get(public_key)
|
||||
.filter(|client| client.connection_id == connection_id)?;
|
||||
let recipient = clients
|
||||
.get(recipient_id)
|
||||
.filter(|recipient| {
|
||||
source.friends.iter().any(|friend| friend == recipient_id)
|
||||
&& recipient.friends.iter().any(|friend| friend == public_key)
|
||||
})
|
||||
.map(|recipient| recipient.sender.clone());
|
||||
(source.key, recipient)
|
||||
};
|
||||
|
||||
if !verify(
|
||||
&source_key,
|
||||
&interaction_bytes(interaction_id, recipient_id, &payload),
|
||||
signature,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
if payload.len() > MAX_INTERACTION_PAYLOAD_BYTES
|
||||
|| recipient_id == public_key
|
||||
|| !payload_is_valid(&payload)
|
||||
{
|
||||
return Some(InteractionDeliveryStatus::Rejected);
|
||||
}
|
||||
let Some(recipient) = recipient else {
|
||||
return Some(InteractionDeliveryStatus::Unavailable);
|
||||
};
|
||||
|
||||
let message = Message::Text(
|
||||
serde_json::to_string(&ServerMessage::FriendInteraction {
|
||||
interaction_id: interaction_id.to_owned(),
|
||||
friend_id: public_key.to_owned(),
|
||||
payload,
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
);
|
||||
Some(match recipient.try_send(message) {
|
||||
Ok(()) => InteractionDeliveryStatus::Delivered,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => InteractionDeliveryStatus::Busy,
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => InteractionDeliveryStatus::Unavailable,
|
||||
})
|
||||
}
|
||||
|
||||
fn payload_is_valid(payload: &str) -> bool {
|
||||
let Ok(content) = serde_json::from_str::<InteractionContent>(payload) else {
|
||||
return false;
|
||||
};
|
||||
if content.validate().is_err() {
|
||||
return false;
|
||||
}
|
||||
let InteractionContent::Image { media_type, data } = content else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let Ok(bytes) = STANDARD.decode(data) else {
|
||||
return false;
|
||||
};
|
||||
let expected_format = match media_type.as_str() {
|
||||
"image/webp" => image::ImageFormat::WebP,
|
||||
"image/jpeg" => image::ImageFormat::Jpeg,
|
||||
_ => return false,
|
||||
};
|
||||
if image::guess_format(&bytes).ok() != Some(expected_format) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Ok((width, height)) =
|
||||
image::ImageReader::with_format(Cursor::new(bytes), expected_format).into_dimensions()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
width > 0 && height > 0 && width <= MAX_IMAGE_DIMENSION && height <= MAX_IMAGE_DIMENSION
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use image::{Rgb, RgbImage};
|
||||
use wyd_common::{MAX_TEXT_CHARS, Profile};
|
||||
|
||||
use super::*;
|
||||
use crate::network::Client;
|
||||
|
||||
fn jpeg_payload(width: u32, height: u32) -> String {
|
||||
let image = RgbImage::from_pixel(width, height, Rgb([30, 90, 150]));
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 50)
|
||||
.encode_image(&image)
|
||||
.unwrap();
|
||||
serde_json::json!({
|
||||
"type": "image",
|
||||
"mediaType": "image/jpeg",
|
||||
"data": STANDARD.encode(bytes),
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payload_validation_enforces_the_shared_schema_and_image_details() {
|
||||
assert!(payload_is_valid(r#"{"type":"wave"}"#));
|
||||
assert!(!payload_is_valid(r#"{"type":"unknown"}"#));
|
||||
assert!(!payload_is_valid(r#"{"type":"text","text":" "}"#));
|
||||
assert!(!payload_is_valid(
|
||||
&serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "x".repeat(MAX_TEXT_CHARS + 1),
|
||||
})
|
||||
.to_string()
|
||||
));
|
||||
assert!(payload_is_valid(&jpeg_payload(
|
||||
MAX_IMAGE_DIMENSION,
|
||||
MAX_IMAGE_DIMENSION
|
||||
)));
|
||||
assert!(!payload_is_valid(&jpeg_payload(MAX_IMAGE_DIMENSION + 1, 1)));
|
||||
assert!(!payload_is_valid(
|
||||
r#"{"type":"image","mediaType":"image/jpeg","data":"not-base64"}"#
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_is_targeted_signed_and_reports_delivery_state() {
|
||||
let signing_key = SigningKey::from_bytes(&[8; 32]);
|
||||
let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes());
|
||||
let connection_id = Uuid::new_v4();
|
||||
let (source_sender, _source_receiver) = mpsc::channel(1);
|
||||
let (target_sender, mut target_receiver) = mpsc::channel(1);
|
||||
let (one_sided_sender, mut one_sided_receiver) = mpsc::channel(1);
|
||||
let clients = Clients::default();
|
||||
let mut registry = clients.lock().await;
|
||||
registry.insert(
|
||||
public_key.clone(),
|
||||
Client {
|
||||
connection_id,
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: public_key.clone(),
|
||||
display_name: "Source".to_owned(),
|
||||
},
|
||||
friends: vec!["target".to_owned(), "one-sided".to_owned()],
|
||||
sender: source_sender,
|
||||
},
|
||||
);
|
||||
registry.insert(
|
||||
"target".to_owned(),
|
||||
Client {
|
||||
connection_id: Uuid::new_v4(),
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: "target".to_owned(),
|
||||
display_name: "Target".to_owned(),
|
||||
},
|
||||
friends: vec![public_key.clone()],
|
||||
sender: target_sender.clone(),
|
||||
},
|
||||
);
|
||||
registry.insert(
|
||||
"one-sided".to_owned(),
|
||||
Client {
|
||||
connection_id: Uuid::new_v4(),
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: "one-sided".to_owned(),
|
||||
display_name: "One-sided".to_owned(),
|
||||
},
|
||||
friends: Vec::new(),
|
||||
sender: one_sided_sender,
|
||||
},
|
||||
);
|
||||
drop(registry);
|
||||
|
||||
let interaction_id = "interaction-1";
|
||||
let payload = r#"{"type":"wave"}"#.to_owned();
|
||||
let signature = URL_SAFE_NO_PAD.encode(
|
||||
signing_key
|
||||
.sign(&interaction_bytes(interaction_id, "target", &payload))
|
||||
.to_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
relay(
|
||||
&clients,
|
||||
&public_key,
|
||||
connection_id,
|
||||
interaction_id,
|
||||
"target",
|
||||
payload.clone(),
|
||||
&signature,
|
||||
)
|
||||
.await,
|
||||
Some(InteractionDeliveryStatus::Delivered)
|
||||
);
|
||||
assert!(target_receiver.recv().await.is_some());
|
||||
assert!(one_sided_receiver.try_recv().is_err());
|
||||
|
||||
assert!(
|
||||
target_sender
|
||||
.try_send(Message::Ping(Vec::new().into()))
|
||||
.is_ok()
|
||||
);
|
||||
let busy_id = "interaction-2";
|
||||
let busy_signature = URL_SAFE_NO_PAD.encode(
|
||||
signing_key
|
||||
.sign(&interaction_bytes(busy_id, "target", &payload))
|
||||
.to_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
relay(
|
||||
&clients,
|
||||
&public_key,
|
||||
connection_id,
|
||||
busy_id,
|
||||
"target",
|
||||
payload.clone(),
|
||||
&busy_signature,
|
||||
)
|
||||
.await,
|
||||
Some(InteractionDeliveryStatus::Busy)
|
||||
);
|
||||
|
||||
let unavailable_signature = URL_SAFE_NO_PAD.encode(
|
||||
signing_key
|
||||
.sign(&interaction_bytes(interaction_id, "one-sided", &payload))
|
||||
.to_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
relay(
|
||||
&clients,
|
||||
&public_key,
|
||||
connection_id,
|
||||
interaction_id,
|
||||
"one-sided",
|
||||
payload.clone(),
|
||||
&unavailable_signature,
|
||||
)
|
||||
.await,
|
||||
Some(InteractionDeliveryStatus::Unavailable)
|
||||
);
|
||||
assert_eq!(
|
||||
relay(
|
||||
&clients,
|
||||
&public_key,
|
||||
Uuid::new_v4(),
|
||||
interaction_id,
|
||||
"target",
|
||||
payload.clone(),
|
||||
&signature,
|
||||
)
|
||||
.await,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
relay(
|
||||
&clients,
|
||||
&public_key,
|
||||
connection_id,
|
||||
interaction_id,
|
||||
"target",
|
||||
"tampered".to_owned(),
|
||||
&signature,
|
||||
)
|
||||
.await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relay_rejects_invalid_payloads_before_delivery() {
|
||||
let signing_key = SigningKey::from_bytes(&[9; 32]);
|
||||
let public_key = URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes());
|
||||
let connection_id = Uuid::new_v4();
|
||||
let (source_sender, _source_receiver) = mpsc::channel(1);
|
||||
let (target_sender, mut target_receiver) = mpsc::channel(1);
|
||||
let clients = Clients::default();
|
||||
let mut registry = clients.lock().await;
|
||||
registry.insert(
|
||||
public_key.clone(),
|
||||
Client {
|
||||
connection_id,
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: public_key.clone(),
|
||||
display_name: "Source".to_owned(),
|
||||
},
|
||||
friends: vec!["target".to_owned()],
|
||||
sender: source_sender,
|
||||
},
|
||||
);
|
||||
registry.insert(
|
||||
"target".to_owned(),
|
||||
Client {
|
||||
connection_id: Uuid::new_v4(),
|
||||
key: signing_key.verifying_key(),
|
||||
profile: Profile {
|
||||
id: "target".to_owned(),
|
||||
display_name: "Target".to_owned(),
|
||||
},
|
||||
friends: vec![public_key.clone()],
|
||||
sender: target_sender,
|
||||
},
|
||||
);
|
||||
drop(registry);
|
||||
|
||||
for (interaction_id, payload) in [
|
||||
("unknown", r#"{"type":"unknown"}"#.to_owned()),
|
||||
(
|
||||
"oversized",
|
||||
serde_json::json!({
|
||||
"type": "wave",
|
||||
"padding": "x".repeat(MAX_INTERACTION_PAYLOAD_BYTES),
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
(
|
||||
"long-text",
|
||||
serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "x".repeat(MAX_TEXT_CHARS + 1),
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
("oversized-image", jpeg_payload(MAX_IMAGE_DIMENSION + 1, 1)),
|
||||
] {
|
||||
let signature = URL_SAFE_NO_PAD.encode(
|
||||
signing_key
|
||||
.sign(&interaction_bytes(interaction_id, "target", &payload))
|
||||
.to_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
relay(
|
||||
&clients,
|
||||
&public_key,
|
||||
connection_id,
|
||||
interaction_id,
|
||||
"target",
|
||||
payload,
|
||||
&signature,
|
||||
)
|
||||
.await,
|
||||
Some(InteractionDeliveryStatus::Rejected)
|
||||
);
|
||||
}
|
||||
assert!(target_receiver.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ use wyd_common::{
|
||||
register_bytes,
|
||||
};
|
||||
|
||||
mod interactions;
|
||||
|
||||
type Clients = Arc<Mutex<HashMap<String, Client>>>;
|
||||
|
||||
struct Client {
|
||||
@@ -25,9 +27,7 @@ struct Client {
|
||||
key: VerifyingKey,
|
||||
#[allow(dead_code)] // Used when presence and profile lookup are exposed.
|
||||
profile: Profile,
|
||||
#[allow(dead_code)] // Used when friend-authorized message routing is added.
|
||||
friends: Vec<String>,
|
||||
#[allow(dead_code)] // Used when server-side message routing is added.
|
||||
sender: mpsc::Sender<Message>,
|
||||
}
|
||||
|
||||
@@ -112,6 +112,31 @@ async fn connected(mut socket: WebSocket, clients: Clients) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Interaction {
|
||||
interaction_id,
|
||||
recipient_id,
|
||||
payload,
|
||||
signature,
|
||||
}) => {
|
||||
let Some(status) = interactions::relay(
|
||||
&clients,
|
||||
&public_key,
|
||||
connection_id,
|
||||
&interaction_id,
|
||||
&recipient_id,
|
||||
payload,
|
||||
&signature,
|
||||
).await else {
|
||||
break;
|
||||
};
|
||||
let response = ServerMessage::InteractionDelivery {
|
||||
interaction_id,
|
||||
status,
|
||||
};
|
||||
if writer.send(Message::Text(serde_json::to_string(&response).unwrap().into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::ProfileUpdated { profile, signature }) => {
|
||||
if !update_profile(&clients, &public_key, connection_id, profile, &signature).await {
|
||||
break;
|
||||
|
||||
@@ -19,10 +19,12 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["macos-private-api", "unstable"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22.1"
|
||||
image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png", "webp"] }
|
||||
lazy_static = "1.5.0"
|
||||
ed25519-dalek = "3.0.0"
|
||||
getrandom = "0.4.3"
|
||||
@@ -35,13 +37,13 @@ tauri-specta = { version = "2.0.0-rc.21", features = ["derive", "typescript"] }
|
||||
specta = "2.0.0-rc.21"
|
||||
specta-typescript = { version = "0.0.9" }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
webp = { version = "0.3.1", default-features = false }
|
||||
wyd-common = { path = "../src-common" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Globalization",
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, ImageReader, Limits, Rgb, RgbImage};
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use wyd_common::{InteractionContent, MAX_IMAGE_B64_SIZE, MAX_IMAGE_DIMENSION};
|
||||
|
||||
use crate::network::Network;
|
||||
|
||||
const MAX_SOURCE_BYTES: usize = 25 * 1024 * 1024;
|
||||
const MAX_SOURCE_DIMENSION: u32 = 16_384;
|
||||
const MAX_DECODE_ALLOC: u64 = 128 * 1024 * 1024;
|
||||
const SCALES: [f32; 3] = [1.0, 0.8, 0.65];
|
||||
const JPEG_QUALITIES: [u8; 4] = [50, 38, 26, 18];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EncodedImage {
|
||||
media_type: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn pick_and_send_image(
|
||||
recipient_id: String,
|
||||
app: AppHandle,
|
||||
network: State<'_, Network>,
|
||||
) -> Result<bool, String> {
|
||||
let Some(file) = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("Images", &["png", "jpg", "jpeg", "gif", "webp"])
|
||||
.blocking_pick_file()
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let path = file
|
||||
.as_path()
|
||||
.ok_or_else(|| "The selected image is not a local file".to_owned())?
|
||||
.to_owned();
|
||||
let encoded = tauri::async_runtime::spawn_blocking(move || compress_image_file(&path))
|
||||
.await
|
||||
.map_err(|error| format!("Image compression task failed: {error}"))??;
|
||||
|
||||
send_encoded_image(&network, recipient_id, encoded).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn send_image_bytes(
|
||||
recipient_id: String,
|
||||
bytes: Vec<u8>,
|
||||
network: State<'_, Network>,
|
||||
) -> Result<(), String> {
|
||||
let encoded = tauri::async_runtime::spawn_blocking(move || compress_image_bytes(&bytes))
|
||||
.await
|
||||
.map_err(|error| format!("Image compression task failed: {error}"))??;
|
||||
send_encoded_image(&network, recipient_id, encoded).await
|
||||
}
|
||||
|
||||
async fn send_encoded_image(
|
||||
network: &Network,
|
||||
recipient_id: String,
|
||||
encoded: EncodedImage,
|
||||
) -> Result<(), String> {
|
||||
network
|
||||
.send_interaction(
|
||||
recipient_id,
|
||||
InteractionContent::Image {
|
||||
media_type: encoded.media_type,
|
||||
data: encoded.data,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn compress_image_file(path: &std::path::Path) -> Result<EncodedImage, String> {
|
||||
let metadata =
|
||||
std::fs::metadata(path).map_err(|_| "The selected image could not be read".to_owned())?;
|
||||
if metadata.len() > MAX_SOURCE_BYTES as u64 {
|
||||
return Err(format!(
|
||||
"The selected image must be at most {} MiB",
|
||||
MAX_SOURCE_BYTES / 1024 / 1024
|
||||
));
|
||||
}
|
||||
let bytes =
|
||||
std::fs::read(path).map_err(|_| "The selected image could not be read".to_owned())?;
|
||||
compress_image_bytes(&bytes)
|
||||
}
|
||||
|
||||
fn compress_image_bytes(bytes: &[u8]) -> Result<EncodedImage, String> {
|
||||
if bytes.is_empty() {
|
||||
return Err("Choose an image file".to_owned());
|
||||
}
|
||||
if bytes.len() > MAX_SOURCE_BYTES {
|
||||
return Err(format!(
|
||||
"The selected image must be at most {} MiB",
|
||||
MAX_SOURCE_BYTES / 1024 / 1024
|
||||
));
|
||||
}
|
||||
|
||||
let mut reader = ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.map_err(|_| "The selected image could not be decoded".to_owned())?;
|
||||
let mut limits = Limits::default();
|
||||
limits.max_image_width = Some(MAX_SOURCE_DIMENSION);
|
||||
limits.max_image_height = Some(MAX_SOURCE_DIMENSION);
|
||||
limits.max_alloc = Some(MAX_DECODE_ALLOC);
|
||||
reader.limits(limits);
|
||||
let source = reader
|
||||
.decode()
|
||||
.map_err(|_| "The selected image could not be decoded".to_owned())?;
|
||||
let source = composite_onto_white(source);
|
||||
let (width, height) = scaled_dimensions(source.width(), source.height());
|
||||
|
||||
for scale in SCALES {
|
||||
let width = ((width as f32 * scale).round() as u32).max(1);
|
||||
let height = ((height as f32 * scale).round() as u32).max(1);
|
||||
let resized = image::imageops::resize(&source, width, height, FilterType::Triangle);
|
||||
|
||||
for quality in JPEG_QUALITIES {
|
||||
let encoded =
|
||||
webp::Encoder::from_rgb(resized.as_raw(), width, height).encode(quality as f32);
|
||||
let data = STANDARD.encode(&*encoded);
|
||||
if data.len() <= MAX_IMAGE_B64_SIZE {
|
||||
return Ok(EncodedImage {
|
||||
media_type: "image/webp".to_owned(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for quality in JPEG_QUALITIES {
|
||||
let mut encoded = Vec::new();
|
||||
JpegEncoder::new_with_quality(&mut encoded, quality)
|
||||
.encode_image(&resized)
|
||||
.map_err(|_| "Image compression failed".to_owned())?;
|
||||
let data = STANDARD.encode(encoded);
|
||||
if data.len() <= MAX_IMAGE_B64_SIZE {
|
||||
return Ok(EncodedImage {
|
||||
media_type: "image/jpeg".to_owned(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Image is still too detailed after compression".to_owned())
|
||||
}
|
||||
|
||||
fn scaled_dimensions(width: u32, height: u32) -> (u32, u32) {
|
||||
let largest = width.max(height);
|
||||
if largest <= MAX_IMAGE_DIMENSION {
|
||||
return (width, height);
|
||||
}
|
||||
|
||||
let scale = MAX_IMAGE_DIMENSION as f64 / largest as f64;
|
||||
(
|
||||
((width as f64 * scale).round() as u32).max(1),
|
||||
((height as f64 * scale).round() as u32).max(1),
|
||||
)
|
||||
}
|
||||
|
||||
fn composite_onto_white(image: DynamicImage) -> RgbImage {
|
||||
let rgba = image.into_rgba8();
|
||||
RgbImage::from_fn(rgba.width(), rgba.height(), |x, y| {
|
||||
let pixel = rgba.get_pixel(x, y).0;
|
||||
let alpha = pixel[3] as u16;
|
||||
let blend = |channel: u8| ((channel as u16 * alpha + 255 * (255 - alpha)) / 255) as u8;
|
||||
Rgb([blend(pixel[0]), blend(pixel[1]), blend(pixel[2])])
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::{ImageFormat, Rgba, RgbaImage};
|
||||
|
||||
fn png_bytes(width: u32, height: u32) -> Vec<u8> {
|
||||
let image = RgbaImage::from_pixel(width, height, Rgba([20, 80, 160, 128]));
|
||||
let mut bytes = Cursor::new(Vec::new());
|
||||
DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut bytes, ImageFormat::Png)
|
||||
.unwrap();
|
||||
bytes.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compresses_and_limits_image_payload() {
|
||||
let compressed = compress_image_bytes(&png_bytes(900, 600)).unwrap();
|
||||
|
||||
assert_eq!(compressed.media_type, "image/webp");
|
||||
assert!(!compressed.data.is_empty());
|
||||
assert!(compressed.data.len() <= MAX_IMAGE_B64_SIZE);
|
||||
assert!(STANDARD.decode(compressed.data).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_and_invalid_input() {
|
||||
assert_eq!(
|
||||
compress_image_bytes(&[]).unwrap_err(),
|
||||
"Choose an image file"
|
||||
);
|
||||
assert_eq!(
|
||||
compress_image_bytes(b"not an image").unwrap_err(),
|
||||
"The selected image could not be decoded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_aspect_ratio_within_dimension_limit() {
|
||||
assert_eq!(scaled_dimensions(960, 480), (480, 240));
|
||||
assert_eq!(scaled_dimensions(120, 80), (120, 80));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri_specta::Event;
|
||||
use wyd_common::InteractionContent;
|
||||
|
||||
use crate::network::Network;
|
||||
|
||||
const SEEN_INTERACTION_LIMIT: usize = 256;
|
||||
|
||||
fn validate_content(content: &InteractionContent) -> Result<(), String> {
|
||||
content.validate()?;
|
||||
if let InteractionContent::Image { data, .. } = content {
|
||||
STANDARD
|
||||
.decode(data)
|
||||
.map_err(|_| "Image contains invalid Base64 data".to_owned())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Type, Event)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FriendInteractionReceived {
|
||||
pub interaction_id: String,
|
||||
pub friend_id: String,
|
||||
pub content: InteractionContent,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SeenInteractions {
|
||||
ids: HashSet<String>,
|
||||
order: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl SeenInteractions {
|
||||
fn insert(&mut self, id: String) -> bool {
|
||||
if !self.ids.insert(id.clone()) {
|
||||
return false;
|
||||
}
|
||||
self.order.push_back(id);
|
||||
if self.order.len() > SEEN_INTERACTION_LIMIT
|
||||
&& let Some(oldest) = self.order.pop_front()
|
||||
{
|
||||
self.ids.remove(&oldest);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct InteractionState(Mutex<SeenInteractions>);
|
||||
|
||||
pub(crate) fn receive(
|
||||
handle: &AppHandle,
|
||||
interaction_id: String,
|
||||
friend_id: String,
|
||||
payload: &str,
|
||||
) {
|
||||
let Ok(content) = serde_json::from_str::<InteractionContent>(payload) else {
|
||||
eprintln!("failed to decode friend interaction");
|
||||
return;
|
||||
};
|
||||
if let Err(error) = validate_content(&content) {
|
||||
eprintln!("rejected friend interaction: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
let dedupe_id = format!("{friend_id}\0{interaction_id}");
|
||||
let state = handle.state::<InteractionState>();
|
||||
let Ok(mut seen) = state.0.lock() else {
|
||||
eprintln!("failed to lock received interaction state");
|
||||
return;
|
||||
};
|
||||
if !seen.insert(dedupe_id) {
|
||||
return;
|
||||
}
|
||||
drop(seen);
|
||||
|
||||
if let Err(error) = (FriendInteractionReceived {
|
||||
interaction_id,
|
||||
friend_id,
|
||||
content,
|
||||
})
|
||||
.emit(handle)
|
||||
{
|
||||
eprintln!("failed to emit friend interaction: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn send_interaction(
|
||||
recipient_id: String,
|
||||
content: InteractionContent,
|
||||
network: State<'_, Network>,
|
||||
) -> Result<(), String> {
|
||||
validate_content(&content)?;
|
||||
network.send_interaction(recipient_id, content).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wyd_common::MAX_TEXT_CHARS;
|
||||
|
||||
#[test]
|
||||
fn validates_interaction_content_limits() {
|
||||
assert!(validate_content(&InteractionContent::Wave).is_ok());
|
||||
assert!(
|
||||
InteractionContent::Text {
|
||||
text: "hello".to_owned()
|
||||
}
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
InteractionContent::Text {
|
||||
text: " ".to_owned()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
InteractionContent::Text {
|
||||
text: "x".repeat(MAX_TEXT_CHARS + 1)
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
InteractionContent::Image {
|
||||
media_type: "image/png".to_owned(),
|
||||
data: STANDARD.encode([1, 2, 3]),
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_media_type_uses_the_typescript_wire_name() {
|
||||
let content = InteractionContent::Image {
|
||||
media_type: "image/webp".to_owned(),
|
||||
data: STANDARD.encode([1, 2, 3]),
|
||||
};
|
||||
let value = serde_json::to_value(&content).unwrap();
|
||||
|
||||
assert_eq!(value["mediaType"], "image/webp");
|
||||
assert!(value.get("media_type").is_none());
|
||||
assert!(matches!(
|
||||
serde_json::from_value(value).unwrap(),
|
||||
InteractionContent::Image { media_type, .. } if media_type == "image/webp"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn received_interaction_ids_are_bounded_and_deduplicated() {
|
||||
let mut seen = SeenInteractions::default();
|
||||
assert!(seen.insert("first".to_owned()));
|
||||
assert!(!seen.insert("first".to_owned()));
|
||||
for index in 0..SEEN_INTERACTION_LIMIT {
|
||||
assert!(seen.insert(format!("id-{index}")));
|
||||
}
|
||||
assert_eq!(seen.ids.len(), SEEN_INTERACTION_LIMIT);
|
||||
assert!(seen.insert("first".to_owned()));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
mod cursor;
|
||||
mod db;
|
||||
mod friends;
|
||||
mod images;
|
||||
mod interactions;
|
||||
mod keypair;
|
||||
mod live_data;
|
||||
mod network;
|
||||
@@ -16,6 +18,7 @@ async fn launch_app(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>>
|
||||
let handle = app.handle();
|
||||
db::init(handle).await?;
|
||||
keypair::init(handle).await?;
|
||||
app.manage(interactions::InteractionState::default());
|
||||
network::init(handle).await?;
|
||||
app.manage(ufa::ForegroundAppState::default());
|
||||
app.manage(cursor::CursorState::default());
|
||||
@@ -38,6 +41,7 @@ pub fn run() {
|
||||
.expect("Failed to export TypeScript bindings");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(specta_builder.invoke_handler())
|
||||
.setup(move |app| {
|
||||
@@ -67,6 +71,9 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
profile::update_profile,
|
||||
keypair::get_public_key,
|
||||
network::list_statuses,
|
||||
images::pick_and_send_image,
|
||||
images::send_image_bytes,
|
||||
interactions::send_interaction,
|
||||
ui::scene::update_scene_hitboxes,
|
||||
])
|
||||
.events(tauri_specta::collect_events![
|
||||
@@ -77,6 +84,7 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
cursor::CursorPositionChanged,
|
||||
ufa::ForegroundAppChanged,
|
||||
ufa::FriendForegroundAppChanged,
|
||||
interactions::FriendInteractionReceived,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,22 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::stream::FuturesUnordered;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri_specta::Event;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio::sync::{mpsc, oneshot, watch};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use wyd_common::{
|
||||
ClientMessage, Profile, ServerMessage, friends_bytes, message_bytes, profile_bytes,
|
||||
register_bytes,
|
||||
ClientMessage, InteractionContent, InteractionDeliveryStatus, Profile, ServerMessage,
|
||||
friends_bytes, interaction_bytes, message_bytes, profile_bytes, register_bytes,
|
||||
};
|
||||
|
||||
use crate::db::AppDatabase;
|
||||
use crate::friends::{self, FriendsChanged};
|
||||
use crate::interactions;
|
||||
use crate::keypair::AppKeypair;
|
||||
use crate::live_data::LiveData;
|
||||
use crate::remotes::{self, Remote, RemotesChanged};
|
||||
@@ -49,10 +51,18 @@ pub struct NetworkStatusChanged {
|
||||
struct Connection {
|
||||
remote: Remote,
|
||||
sender: mpsc::Sender<String>,
|
||||
interaction_sender: mpsc::Sender<InteractionRequest>,
|
||||
task: tauri::async_runtime::JoinHandle<()>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
struct InteractionRequest {
|
||||
interaction_id: String,
|
||||
recipient_id: String,
|
||||
payload: String,
|
||||
response: oneshot::Sender<InteractionDeliveryStatus>,
|
||||
}
|
||||
|
||||
pub struct Network {
|
||||
connections: Mutex<HashMap<String, Connection>>,
|
||||
statuses: Statuses,
|
||||
@@ -86,6 +96,66 @@ impl Network {
|
||||
self.profile.send_replace(profile);
|
||||
}
|
||||
|
||||
pub async fn send_interaction(
|
||||
&self,
|
||||
recipient_id: String,
|
||||
content: InteractionContent,
|
||||
) -> Result<(), String> {
|
||||
if recipient_id == self.keypair.public_key() {
|
||||
return Err("Interactions can only be sent to a friend".to_owned());
|
||||
}
|
||||
let payload = serde_json::to_string(&content).map_err(|error| error.to_string())?;
|
||||
let interaction_id = uuid::Uuid::new_v4().to_string();
|
||||
let senders: Vec<_> = self
|
||||
.connections
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.values()
|
||||
.map(|connection| connection.interaction_sender.clone())
|
||||
.collect();
|
||||
if senders.is_empty() {
|
||||
return Err("No relay connections are configured".to_owned());
|
||||
}
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for sender in senders {
|
||||
let (response, receiver) = oneshot::channel();
|
||||
let request = InteractionRequest {
|
||||
interaction_id: interaction_id.clone(),
|
||||
recipient_id: recipient_id.clone(),
|
||||
payload: payload.clone(),
|
||||
response,
|
||||
};
|
||||
if sender.try_send(request).is_ok() {
|
||||
responses.push(receiver);
|
||||
}
|
||||
}
|
||||
if responses.is_empty() {
|
||||
return Err("Relay connections are busy or disconnected".to_owned());
|
||||
}
|
||||
|
||||
let mut pending: FuturesUnordered<_> = responses
|
||||
.into_iter()
|
||||
.map(|response| tokio::time::timeout(Duration::from_secs(5), response))
|
||||
.collect();
|
||||
let mut statuses = Vec::new();
|
||||
while let Some(result) = pending.next().await {
|
||||
if let Ok(Ok(status)) = result {
|
||||
if status == InteractionDeliveryStatus::Delivered {
|
||||
return Ok(());
|
||||
}
|
||||
statuses.push(status);
|
||||
}
|
||||
}
|
||||
if statuses.contains(&InteractionDeliveryStatus::Busy) {
|
||||
return Err("Friend is busy; try again".to_owned());
|
||||
}
|
||||
if statuses.contains(&InteractionDeliveryStatus::Rejected) {
|
||||
return Err("Relay rejected the interaction".to_owned());
|
||||
}
|
||||
Err("Friend is no longer available".to_owned())
|
||||
}
|
||||
|
||||
fn sync_remotes(&self, handle: &AppHandle, remotes: Vec<Remote>) -> Result<(), String> {
|
||||
let desired: HashMap<_, _> = remotes
|
||||
.into_iter()
|
||||
@@ -113,6 +183,7 @@ impl Network {
|
||||
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
let (sender, receiver) = mpsc::channel(32);
|
||||
let (interaction_sender, interaction_receiver) = mpsc::channel(16);
|
||||
set_initial(
|
||||
&self.statuses,
|
||||
&remote,
|
||||
@@ -128,12 +199,14 @@ impl Network {
|
||||
self.friends.subscribe(),
|
||||
self.keypair.clone(),
|
||||
receiver,
|
||||
interaction_receiver,
|
||||
));
|
||||
connections.insert(
|
||||
remote.id.clone(),
|
||||
Connection {
|
||||
remote,
|
||||
sender,
|
||||
interaction_sender,
|
||||
task,
|
||||
generation,
|
||||
},
|
||||
@@ -195,6 +268,7 @@ async fn run(
|
||||
mut friends: watch::Receiver<Vec<String>>,
|
||||
keypair: AppKeypair,
|
||||
mut outgoing: mpsc::Receiver<String>,
|
||||
mut active_outgoing: mpsc::Receiver<InteractionRequest>,
|
||||
) {
|
||||
loop {
|
||||
changed(
|
||||
@@ -213,6 +287,7 @@ async fn run(
|
||||
&mut friends,
|
||||
&keypair,
|
||||
&mut outgoing,
|
||||
&mut active_outgoing,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -238,6 +313,7 @@ async fn connect(
|
||||
friends: &mut watch::Receiver<Vec<String>>,
|
||||
keypair: &AppKeypair,
|
||||
outgoing: &mut mpsc::Receiver<String>,
|
||||
active_outgoing: &mut mpsc::Receiver<InteractionRequest>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (socket, _) = tokio_tungstenite::connect_async(url(remote)).await?;
|
||||
let (mut writer, mut reader) = socket.split();
|
||||
@@ -272,6 +348,11 @@ async fn connect(
|
||||
return Err("server rejected registration".into());
|
||||
}
|
||||
while outgoing.try_recv().is_ok() {}
|
||||
while let Ok(request) = active_outgoing.try_recv() {
|
||||
let _ = request
|
||||
.response
|
||||
.send(InteractionDeliveryStatus::Unavailable);
|
||||
}
|
||||
send(&mut writer, &ClientMessage::SyncFriendProfiles).await?;
|
||||
changed(
|
||||
handle,
|
||||
@@ -281,6 +362,7 @@ async fn connect(
|
||||
ConnectionState::Connected,
|
||||
);
|
||||
|
||||
let mut pending_interactions = HashMap::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
payload = outgoing.recv() => {
|
||||
@@ -290,6 +372,21 @@ async fn connect(
|
||||
payload,
|
||||
}).await?;
|
||||
}
|
||||
request = active_outgoing.recv() => {
|
||||
let request = request.ok_or("interaction sender closed")?;
|
||||
let signature = keypair.sign(&interaction_bytes(
|
||||
&request.interaction_id,
|
||||
&request.recipient_id,
|
||||
&request.payload,
|
||||
));
|
||||
send(&mut writer, &ClientMessage::Interaction {
|
||||
interaction_id: request.interaction_id.clone(),
|
||||
recipient_id: request.recipient_id,
|
||||
payload: request.payload,
|
||||
signature,
|
||||
}).await?;
|
||||
pending_interactions.insert(request.interaction_id, request.response);
|
||||
}
|
||||
changed = profiles.changed() => {
|
||||
changed.map_err(|_| "profile sender closed")?;
|
||||
let current = profiles.borrow_and_update().clone();
|
||||
@@ -337,6 +434,18 @@ async fn connect(
|
||||
}
|
||||
}
|
||||
}
|
||||
ServerMessage::FriendInteraction {
|
||||
interaction_id,
|
||||
friend_id,
|
||||
payload,
|
||||
} => {
|
||||
interactions::receive(handle, interaction_id, friend_id, &payload);
|
||||
}
|
||||
ServerMessage::InteractionDelivery { interaction_id, status } => {
|
||||
if let Some(response) = pending_interactions.remove(&interaction_id) {
|
||||
let _ = response.send(status);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Message::Ping(data) => writer.send(Message::Pong(data)).await?,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::types::AppMeta;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use lazy_static::lazy_static;
|
||||
use objc2::runtime::{AnyClass, AnyObject, Sel};
|
||||
use objc2::{class, msg_send, sel};
|
||||
|
||||
@@ -4,16 +4,16 @@ use std::iter;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use std::path::Path;
|
||||
use std::ptr;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::Globalization::GetUserDefaultLangID;
|
||||
use windows::Win32::System::ProcessStatus::GetModuleFileNameExW;
|
||||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||||
use windows::Win32::UI::Accessibility::{SetWinEventHook, UnhookWinEvent, HWINEVENTHOOK};
|
||||
use windows::Win32::UI::Accessibility::{HWINEVENTHOOK, SetWinEventHook, UnhookWinEvent};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
DispatchMessageW, GetForegroundWindow, GetMessageW, GetWindowTextW, GetWindowThreadProcessId,
|
||||
EVENT_SYSTEM_FOREGROUND, MSG, WINEVENT_OUTOFCONTEXT,
|
||||
DispatchMessageW, EVENT_SYSTEM_FOREGROUND, GetForegroundWindow, GetMessageW, GetWindowTextW,
|
||||
GetWindowThreadProcessId, MSG, WINEVENT_OUTOFCONTEXT,
|
||||
};
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
pub fn listen_for_active_app_changes<F>(callback: F)
|
||||
where
|
||||
@@ -286,13 +286,13 @@ impl Drop for IconHandle {
|
||||
}
|
||||
|
||||
fn get_active_app_icon_b64(exe_path: &str) -> Option<String> {
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use windows::Win32::Graphics::Gdi::{
|
||||
CreateCompatibleBitmap, CreateCompatibleDC, DeleteDC, DeleteObject, GetDIBits, GetObjectW,
|
||||
SelectObject, BITMAP, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS,
|
||||
BI_RGB, BITMAP, BITMAPINFO, BITMAPINFOHEADER, CreateCompatibleBitmap, CreateCompatibleDC,
|
||||
DIB_RGB_COLORS, DeleteDC, DeleteObject, GetDIBits, GetObjectW, SelectObject,
|
||||
};
|
||||
use windows::Win32::UI::Shell::{SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON};
|
||||
use windows::Win32::UI::Shell::{SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON, SHGetFileInfoW};
|
||||
use windows::Win32::UI::WindowsAndMessaging::{GetIconInfo, ICONINFO};
|
||||
|
||||
// Check cache first
|
||||
|
||||
@@ -44,6 +44,15 @@ async getPublicKey() : Promise<string> {
|
||||
async listStatuses() : Promise<ConnectionStatus[]> {
|
||||
return await TAURI_INVOKE("list_statuses");
|
||||
},
|
||||
async pickAndSendImage(recipientId: string) : Promise<boolean> {
|
||||
return await TAURI_INVOKE("pick_and_send_image", { recipientId });
|
||||
},
|
||||
async sendImageBytes(recipientId: string, bytes: number[]) : Promise<null> {
|
||||
return await TAURI_INVOKE("send_image_bytes", { recipientId, bytes });
|
||||
},
|
||||
async sendInteraction(recipientId: string, content: InteractionContent) : Promise<null> {
|
||||
return await TAURI_INVOKE("send_interaction", { recipientId, content });
|
||||
},
|
||||
async updateSceneHitboxes(hitboxes: SceneHitbox[]) : Promise<null> {
|
||||
return await TAURI_INVOKE("update_scene_hitboxes", { hitboxes });
|
||||
}
|
||||
@@ -56,6 +65,7 @@ export const events = __makeEvents__<{
|
||||
cursorPositionChanged: CursorPositionChanged,
|
||||
foregroundAppChanged: ForegroundAppChanged,
|
||||
friendForegroundAppChanged: FriendForegroundAppChanged,
|
||||
friendInteractionReceived: FriendInteractionReceived,
|
||||
friendsChanged: FriendsChanged,
|
||||
networkStatusChanged: NetworkStatusChanged,
|
||||
profileChanged: ProfileChanged,
|
||||
@@ -64,6 +74,7 @@ remotesChanged: RemotesChanged
|
||||
cursorPositionChanged: "cursor-position-changed",
|
||||
foregroundAppChanged: "foreground-app-changed",
|
||||
friendForegroundAppChanged: "friend-foreground-app-changed",
|
||||
friendInteractionReceived: "friend-interaction-received",
|
||||
friendsChanged: "friends-changed",
|
||||
networkStatusChanged: "network-status-changed",
|
||||
profileChanged: "profile-changed",
|
||||
@@ -95,7 +106,9 @@ raw: CursorPosition;
|
||||
mapped: CursorPosition }
|
||||
export type ForegroundAppChanged = { meta: AppMeta }
|
||||
export type FriendForegroundAppChanged = { friendId: string; meta: AppMeta }
|
||||
export type FriendInteractionReceived = { interactionId: string; friendId: string; content: InteractionContent }
|
||||
export type FriendsChanged = { friends: User[] }
|
||||
export type InteractionContent = { type: "text"; text: string } | { type: "wave" } | { type: "image"; mediaType: string; data: string }
|
||||
export type NetworkStatusChanged = { statuses: ConnectionStatus[] }
|
||||
export type ProfileChanged = { profile: User }
|
||||
export type Remote = { id: string; address: string; name: string | null; port: number | null }
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
triggerClass?: string;
|
||||
panelClass?: string;
|
||||
panelStyle?: string;
|
||||
closeOnOutsidePointer?: boolean;
|
||||
closeOnWindowBlur?: boolean;
|
||||
closeOnFocusOut?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -29,6 +32,9 @@
|
||||
triggerClass = "",
|
||||
panelClass = "",
|
||||
panelStyle,
|
||||
closeOnOutsidePointer: dismissOnOutsidePointer = true,
|
||||
closeOnWindowBlur: dismissOnWindowBlur = true,
|
||||
closeOnFocusOut: dismissOnFocusOut = true,
|
||||
}: Props = $props();
|
||||
|
||||
let root = $state<HTMLDivElement>();
|
||||
@@ -57,6 +63,7 @@
|
||||
function closeOnOutsidePointer(event: PointerEvent) {
|
||||
if (
|
||||
open &&
|
||||
dismissOnOutsidePointer &&
|
||||
root &&
|
||||
event.target instanceof Node &&
|
||||
!root.contains(event.target)
|
||||
@@ -68,6 +75,7 @@
|
||||
function closeOnFocusOut(event: FocusEvent) {
|
||||
if (
|
||||
open &&
|
||||
dismissOnFocusOut &&
|
||||
root &&
|
||||
(!(event.relatedTarget instanceof Node) ||
|
||||
!root.contains(event.relatedTarget))
|
||||
@@ -77,7 +85,7 @@
|
||||
}
|
||||
|
||||
function closeOnWindowBlur() {
|
||||
if (open) setOpen(false);
|
||||
if (open && dismissOnWindowBlur) setOpen(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initConnectionStatusesListener } from "./connection-status";
|
||||
import { initFriendsListener } from "./friends";
|
||||
import { initInteractionListener } from "./interactions";
|
||||
import { initLiveMetadataListeners } from "./live-metadata";
|
||||
import { initProfileListener } from "./profile";
|
||||
import { initRemotesListener } from "./remotes";
|
||||
@@ -16,6 +17,7 @@ export function initAppListeners(): Unlisten {
|
||||
initProfileListener(),
|
||||
initConnectionStatusesListener(),
|
||||
initLiveMetadataListeners(),
|
||||
initInteractionListener(),
|
||||
])
|
||||
.then((results) => {
|
||||
const listeners = results.flatMap((result) =>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { events, type FriendInteractionReceived } from "$lib/bindings";
|
||||
|
||||
export const incomingInteraction = writable<FriendInteractionReceived | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export async function initInteractionListener() {
|
||||
return events.friendInteractionReceived.listen((event) => {
|
||||
incomingInteraction.set(event.payload);
|
||||
});
|
||||
}
|
||||
@@ -1,22 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Popover from "$lib/components/popover.svelte";
|
||||
import { friends } from "$lib/listeners/friends";
|
||||
import { incomingInteraction } from "$lib/listeners/interactions";
|
||||
import { liveMetadata } from "$lib/listeners/live-metadata";
|
||||
import SceneUserPopoverContent from "./scene-user-popover-content.svelte";
|
||||
import { profile } from "$lib/listeners/profile";
|
||||
import SceneImageViewer from "./popovers/image-viewer.svelte";
|
||||
import SceneInteractionBubble from "./popovers/interaction-bubble.svelte";
|
||||
import SceneUserPopoverContent from "./popovers/user-interaction.svelte";
|
||||
import { startHitboxSync } from "./hitboxes";
|
||||
|
||||
let selectedUserId: string | null = null;
|
||||
let selectedUserX: number | null = null;
|
||||
let selectedUserId = $state<string | null>(null);
|
||||
let selectedUserX = $state<number | null>(null);
|
||||
let lockedPopoverUserId = $state<string | null>(null);
|
||||
let viewedImage = $state<{ source: string; senderName: string } | null>(null);
|
||||
|
||||
onMount(startHitboxSync);
|
||||
|
||||
$effect(() => {
|
||||
const current = $incomingInteraction;
|
||||
if (!current) return;
|
||||
const duration = current.content.type === "wave" ? 4_000 : 10_000;
|
||||
const timer = window.setTimeout(
|
||||
() => dismissInteraction(current.interactionId),
|
||||
duration,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
});
|
||||
|
||||
function displayName(userId: string) {
|
||||
return $profile?.id === userId
|
||||
? $profile.displayName
|
||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
||||
"Unknown user");
|
||||
}
|
||||
|
||||
function dismissInteraction(interactionId: string) {
|
||||
incomingInteraction.update((current) =>
|
||||
current?.interactionId === interactionId ? null : current,
|
||||
);
|
||||
}
|
||||
|
||||
function setPopoverOpen(
|
||||
userId: string,
|
||||
cursorX: number,
|
||||
open: boolean,
|
||||
trigger: HTMLButtonElement,
|
||||
) {
|
||||
if (open && lockedPopoverUserId && lockedPopoverUserId !== userId) return;
|
||||
selectedUserId = open ? userId : null;
|
||||
if (!open && lockedPopoverUserId === userId) lockedPopoverUserId = null;
|
||||
selectedUserX = open
|
||||
? window.innerWidth > 0
|
||||
? trigger.getBoundingClientRect().left / window.innerWidth
|
||||
@@ -30,6 +63,8 @@
|
||||
{#each Object.entries($liveMetadata.cursorPositions) as [userId, cursor], index (userId)}
|
||||
{@const foregroundApp = $liveMetadata.foregroundApps.get(userId)}
|
||||
{@const popoverId = `scene-user-${index}`}
|
||||
{@const interaction =
|
||||
$incomingInteraction?.friendId === userId ? $incomingInteraction : null}
|
||||
{#if cursor}
|
||||
{@const renderedX =
|
||||
selectedUserId === userId ? selectedUserX : cursor.mapped.x}
|
||||
@@ -37,6 +72,18 @@
|
||||
class="absolute bottom-0 left-0 flex flex-col items-center gap-1 transition-transform duration-1000 ease-linear"
|
||||
style:transform={`translateX(${(renderedX ?? cursor.mapped.x) * 100}vw)`}
|
||||
>
|
||||
{#if interaction}
|
||||
<SceneInteractionBubble
|
||||
{interaction}
|
||||
senderName={displayName(userId)}
|
||||
onDismiss={() => dismissInteraction(interaction.interactionId)}
|
||||
onOpenImage={(source) => {
|
||||
viewedImage = { source, senderName: displayName(userId) };
|
||||
dismissInteraction(interaction.interactionId);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if foregroundApp?.ico}
|
||||
<img
|
||||
src={`data:image/png;base64,${foregroundApp.ico}`}
|
||||
@@ -55,6 +102,9 @@
|
||||
triggerClass="scene-hitbox"
|
||||
panelClass="scene-hitbox bottom-[calc(100%+0.75rem)] w-68"
|
||||
panelStyle={`left: clamp(calc(0.5rem - ${(renderedX ?? cursor.mapped.x) * 100}vw), -8rem, calc(100vw - ${(renderedX ?? cursor.mapped.x) * 100}vw - 17.5rem))`}
|
||||
closeOnOutsidePointer={lockedPopoverUserId !== userId}
|
||||
closeOnWindowBlur={lockedPopoverUserId !== userId}
|
||||
closeOnFocusOut={lockedPopoverUserId !== userId}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<img
|
||||
@@ -68,8 +118,19 @@
|
||||
titleId={`${popoverId}-title`}
|
||||
{userId}
|
||||
isLocal={userId === $liveMetadata.localId}
|
||||
{cursor}
|
||||
{foregroundApp}
|
||||
onModeChange={(active) =>
|
||||
(lockedPopoverUserId = active ? userId : null)}
|
||||
onDismiss={() => {
|
||||
selectedUserId = null;
|
||||
selectedUserX = null;
|
||||
lockedPopoverUserId = null;
|
||||
}}
|
||||
onSent={() => {
|
||||
selectedUserId = null;
|
||||
selectedUserX = null;
|
||||
lockedPopoverUserId = null;
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -77,3 +138,11 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if viewedImage}
|
||||
<SceneImageViewer
|
||||
source={viewedImage.source}
|
||||
senderName={viewedImage.senderName}
|
||||
onClose={() => (viewedImage = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fade, scale } from "svelte/transition";
|
||||
|
||||
type Props = {
|
||||
source: string;
|
||||
senderName: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { source, senderName, onClose }: Props = $props();
|
||||
let closeButton: HTMLButtonElement;
|
||||
|
||||
onMount(() => closeButton.focus({ preventScroll: true }));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(event) => event.key === "Escape" && onClose()} />
|
||||
|
||||
<div
|
||||
class="scene-hitbox fixed inset-0 z-50 grid place-items-center bg-black/55 p-4 backdrop-blur-sm"
|
||||
role="presentation"
|
||||
onclick={(event) => event.currentTarget === event.target && onClose()}
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
<div
|
||||
class="relative grid max-h-full max-w-full place-items-center border border-white/20 bg-black/70 p-2 shadow-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Image from ${senderName}`}
|
||||
transition:scale={{ start: 0.96, duration: 180 }}
|
||||
>
|
||||
<img
|
||||
src={source}
|
||||
alt={`Image sent by ${senderName}`}
|
||||
class="max-h-[calc(100vh-3rem)] max-w-[calc(100vw-3rem)] object-contain"
|
||||
/>
|
||||
<div
|
||||
class="absolute right-1 top-1 flex items-center gap-2 bg-black/70 px-1 py-1 text-white"
|
||||
>
|
||||
<span class="max-w-48 truncate pl-1 text-[0.65rem]">{senderName}</span>
|
||||
<button
|
||||
bind:this={closeButton}
|
||||
type="button"
|
||||
class="grid size-6 place-items-center text-lg leading-none hover:bg-white/15 focus-visible:outline focus-visible:outline-white"
|
||||
aria-label="Close image"
|
||||
onclick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { fly } from "svelte/transition";
|
||||
import type { FriendInteractionReceived } from "$lib/bindings";
|
||||
|
||||
type Props = {
|
||||
interaction: FriendInteractionReceived;
|
||||
senderName: string;
|
||||
onDismiss: () => void;
|
||||
onOpenImage: (source: string) => void;
|
||||
};
|
||||
|
||||
let { interaction, senderName, onDismiss, onOpenImage }: Props = $props();
|
||||
let imageSource = $derived(
|
||||
interaction.content.type === "image"
|
||||
? `data:${interaction.content.mediaType};base64,${interaction.content.data}`
|
||||
: "",
|
||||
);
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="scene-hitbox absolute bottom-[calc(100%+0.65rem)] left-1/2 z-30 w-36 -translate-x-1/2 overflow-hidden border border-base-300 bg-base-100/95 text-base-content shadow-lg backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
transition:fly={{ y: 8, duration: 180 }}
|
||||
>
|
||||
<header class="flex items-center gap-1 border-b border-base-200 px-2 py-1">
|
||||
<span class="min-w-0 flex-1 truncate text-[0.65rem] font-medium">
|
||||
{senderName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-5 place-items-center text-xs text-base-content/50 transition-colors hover:text-base-content"
|
||||
aria-label="Dismiss interaction"
|
||||
onclick={onDismiss}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if interaction.content.type === "wave"}
|
||||
<div class="flex items-center justify-center gap-2 px-3 py-2 text-xs">
|
||||
<span
|
||||
class="origin-bottom-right animate-[wiggle_650ms_ease-in-out_2] text-xl"
|
||||
>👋</span
|
||||
>
|
||||
<span>waved</span>
|
||||
</div>
|
||||
{:else if interaction.content.type === "text"}
|
||||
<p
|
||||
class="max-h-24 overflow-auto whitespace-pre-wrap wrap-break-words px-2 py-2 text-xs leading-relaxed"
|
||||
>
|
||||
{interaction.content.text}
|
||||
</p>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full cursor-zoom-in bg-base-200"
|
||||
aria-label={`Open image from ${senderName}`}
|
||||
onclick={() => onOpenImage(imageSource)}
|
||||
>
|
||||
<img
|
||||
src={imageSource}
|
||||
alt={`Preview sent by ${senderName}`}
|
||||
class="h-24 w-full object-cover"
|
||||
/>
|
||||
<span class="block px-2 py-1 text-[0.6rem] text-base-content/60">
|
||||
Click to enlarge
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
@keyframes wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
30% {
|
||||
transform: rotate(22deg);
|
||||
}
|
||||
65% {
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
commands,
|
||||
type AppMeta,
|
||||
type InteractionContent,
|
||||
} from "$lib/bindings";
|
||||
import { friends } from "$lib/listeners/friends";
|
||||
import { profile } from "$lib/listeners/profile";
|
||||
|
||||
type Props = {
|
||||
titleId: string;
|
||||
userId: string;
|
||||
isLocal: boolean;
|
||||
foregroundApp?: AppMeta;
|
||||
onModeChange: (active: boolean) => void;
|
||||
onDismiss: () => void;
|
||||
onSent: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
titleId,
|
||||
userId,
|
||||
isLocal,
|
||||
foregroundApp,
|
||||
onModeChange,
|
||||
onDismiss,
|
||||
onSent,
|
||||
}: Props = $props();
|
||||
let mode = $state<"message" | "image" | null>(null);
|
||||
let message = $state("");
|
||||
let error = $state("");
|
||||
let sending = $state(false);
|
||||
let username = $derived(
|
||||
$profile?.id === userId
|
||||
? $profile.displayName
|
||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
||||
"Unknown user"),
|
||||
);
|
||||
|
||||
function selectMode(nextMode: "message" | "image") {
|
||||
mode = mode === nextMode ? null : nextMode;
|
||||
onModeChange(mode !== null);
|
||||
error = "";
|
||||
}
|
||||
|
||||
async function sendContent(content: InteractionContent) {
|
||||
sending = true;
|
||||
error = "";
|
||||
try {
|
||||
await commands.sendInteraction(userId, content);
|
||||
message = "";
|
||||
mode = null;
|
||||
onSent();
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = message.trim();
|
||||
if (text) await sendContent({ type: "text", text });
|
||||
}
|
||||
|
||||
async function sendImage() {
|
||||
await runImageSend(() => commands.pickAndSendImage(userId));
|
||||
}
|
||||
|
||||
async function pasteImage(event: ClipboardEvent) {
|
||||
if (mode !== "image" || sending) return;
|
||||
const file = Array.from(event.clipboardData?.items ?? [])
|
||||
.find((item) => item.type.startsWith("image/"))
|
||||
?.getAsFile();
|
||||
if (!file) return;
|
||||
|
||||
event.preventDefault();
|
||||
await runImageSend(async () => {
|
||||
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
await commands.sendImageBytes(userId, bytes);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function runImageSend(send: () => Promise<boolean>) {
|
||||
if (sending) return;
|
||||
sending = true;
|
||||
error = "";
|
||||
try {
|
||||
const sent = await send();
|
||||
if (sent) {
|
||||
mode = null;
|
||||
onSent();
|
||||
}
|
||||
} catch (cause) {
|
||||
error = String(cause);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="w-68 overflow-hidden text-base-content" onpaste={pasteImage}>
|
||||
<header
|
||||
class="ml-2 flex w-max items-center gap-1 border border-b-0 border-base-300 bg-base-200 px-2 py-0.5"
|
||||
>
|
||||
<p id={titleId} class="max-w-52 truncate text-xs">{username}</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="relative flex flex-col gap-3 border border-base-200 bg-base-100 p-2 text-sm shadow-lg"
|
||||
>
|
||||
{#if mode !== null}
|
||||
<button
|
||||
class="absolute right-1 top-1 grid size-6 place-items-center text-lg leading-none btn btn-square btn-ghost"
|
||||
aria-label="Close interaction popover"
|
||||
onclick={onDismiss}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h3 class="text-xs text-base-content/50">Currently enjoying</h3>
|
||||
<div class="mt-1 flex min-w-0 items-center gap-2">
|
||||
{#if foregroundApp?.ico}
|
||||
<img
|
||||
src={`data:image/png;base64,${foregroundApp.ico}`}
|
||||
alt=""
|
||||
class="size-6 shrink-0 object-contain"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm">
|
||||
{foregroundApp?.local ??
|
||||
foregroundApp?.unlocal ??
|
||||
"Waiting for data"}
|
||||
</p>
|
||||
{#if foregroundApp?.local && foregroundApp.unlocal}
|
||||
<p class="truncate text-xs text-base-content/50">
|
||||
{foregroundApp.unlocal}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if !isLocal}
|
||||
<section class="border-t border-base-200 pt-2 w-full">
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class:!bg-primary={mode === "message"}
|
||||
class:!text-primary-content={mode === "message"}
|
||||
class="btn btn-ghost btn-xs"
|
||||
aria-pressed={mode === "message"}
|
||||
onclick={() => selectMode("message")}
|
||||
>
|
||||
Message
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
disabled={sending}
|
||||
onclick={() => sendContent({ type: "wave" })}
|
||||
>
|
||||
Wave
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class:!bg-primary={mode === "image"}
|
||||
class:!text-primary-content={mode === "image"}
|
||||
class="btn btn-ghost btn-xs"
|
||||
aria-pressed={mode === "image"}
|
||||
onclick={() => selectMode("image")}
|
||||
>
|
||||
Image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mode === "message"}
|
||||
<form
|
||||
class="mt-2 grid gap-1 border-l-2 border-primary bg-base-200/55 p-2"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void sendMessage();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
bind:value={message}
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
class="textarea textarea-xs w-full resize-none bg-base-100"
|
||||
placeholder={`Write to ${username}`}
|
||||
aria-label={`Message ${username}`}></textarea>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-[0.6rem] text-base-content/45">
|
||||
{message.length}/500
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-xs"
|
||||
disabled={sending || !message.trim()}
|
||||
>
|
||||
{sending ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if mode === "image"}
|
||||
<div
|
||||
class="mt-2 grid min-h-24 place-items-center border border-primary/60 bg-primary/5 p-2 text-center"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs">Choose or paste an image</p>
|
||||
<p class="mt-0.5 text-[0.6rem] text-base-content/45">
|
||||
Compressed to 480 px · 150 KiB
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-xs mt-2"
|
||||
disabled={sending}
|
||||
onclick={sendImage}
|
||||
>
|
||||
{sending ? "Compressing…" : "Choose image"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="mt-2 text-xs text-error" role="alert">{error}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { AppMeta, CursorPositions } from "$lib/bindings";
|
||||
import { friends } from "$lib/listeners/friends";
|
||||
import { profile } from "$lib/listeners/profile";
|
||||
|
||||
type Props = {
|
||||
titleId: string;
|
||||
userId: string;
|
||||
isLocal: boolean;
|
||||
cursor: CursorPositions;
|
||||
foregroundApp?: AppMeta;
|
||||
};
|
||||
|
||||
let { titleId, userId, isLocal, cursor, foregroundApp }: Props = $props();
|
||||
let username = $derived(
|
||||
$profile?.id === userId
|
||||
? $profile.displayName
|
||||
: ($friends.find((friend) => friend.id === userId)?.displayName ??
|
||||
"Unknown user"),
|
||||
);
|
||||
|
||||
function compactId(id: string) {
|
||||
return id.length > 18 ? `${id.slice(0, 9)}…${id.slice(-7)}` : id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="w-68 overflow-hidden text-base-content">
|
||||
<header
|
||||
class="flex w-max items-center ml-2 gap-1 px-2 py-0.5 bg-base-200 border border-base-300 border-b-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p id={titleId} class="truncate text-xs">{username}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="grid gap-3 p-2 text-sm bg-base-100 border border-base-200 shadow-lg"
|
||||
>
|
||||
<section>
|
||||
<h3 class="text-xs text-base-content/50">Currently enjoying</h3>
|
||||
<div class="mt-1 flex min-w-0 items-center gap-2">
|
||||
{#if foregroundApp?.ico}
|
||||
<img
|
||||
src={`data:image/png;base64,${foregroundApp.ico}`}
|
||||
alt=""
|
||||
class="size-6 shrink-0 object-contain"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm">
|
||||
{foregroundApp?.local ??
|
||||
foregroundApp?.unlocal ??
|
||||
"Waiting for data"}
|
||||
</p>
|
||||
{#if foregroundApp?.local && foregroundApp.unlocal}
|
||||
<p class="truncate text-xs text-base-content/50">
|
||||
{foregroundApp.unlocal}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
Reference in New Issue
Block a user