messages, wavings and images

This commit is contained in:
2026-08-26 00:59:31 +08:00
parent 5d7d294a80
commit 74a5268420
22 changed files with 1779 additions and 89 deletions
+172
View File
@@ -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()));
}
}