618 lines
22 KiB
Rust
618 lines
22 KiB
Rust
//! Friends, product side (F4 §7.5 → the app). A friendship is two
|
|
//! reciprocal signed assertions on one relationship id and request hash;
|
|
//! everything else — one assertion, a declined request, a revocation — is
|
|
//! shown as exactly what it is. Proofs stay on this device; nothing is
|
|
//! published.
|
|
//!
|
|
//! Two carries, chosen per relationship and remembered: the ENCRYPTED
|
|
//! INBOX (inbox.rs — sealed to the recipient's box key, offered to their
|
|
//! contract) whenever the other persona has published one, else the
|
|
//! Soulseek private-message channel the link proof uses. The server can
|
|
//! see that one account wrote to another on the PM carry; it cannot forge
|
|
//! who wrote, which is the one thing that carry needs from it.
|
|
//!
|
|
//! PM wire: `SSFRIEND1?<b64 request>` asks, `SSFRIEND1!<b64 assertion>`
|
|
//! accepts or revokes, `SSFRIEND1-<b58 relationship id>` declines. An
|
|
//! incoming PM REQUEST is bound to its sender by the link handshake (the
|
|
//! request names a key; the sender proves it). An inbox request needs no
|
|
//! such step: the envelope header is signed by the same key the request
|
|
//! names. That link ask waits on a PM, so PM friend messages are handled
|
|
//! on their own task — awaiting inline would block the loop that delivers
|
|
//! the answer.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use persona_state::friend as fr;
|
|
use persona_state::slsk_link::normalize_username;
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{Emitter, Manager};
|
|
|
|
use crate::inbox::{self, Cargo};
|
|
use crate::persona;
|
|
use crate::slsk_link;
|
|
use crate::state::AppState;
|
|
|
|
const REQ_PREFIX: &str = "SSFRIEND1?";
|
|
const ASSERT_PREFIX: &str = "SSFRIEND1!";
|
|
const DECLINE_PREFIX: &str = "SSFRIEND1-";
|
|
const REQUEST_TTL_SECS: u64 = 7 * 24 * 3600;
|
|
pub const EVENT: &str = "community:friends";
|
|
|
|
fn carry_pm() -> String {
|
|
"pm".into()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct Relationship {
|
|
/// Their Soulseek account as the server spelled it — may be empty for a
|
|
/// relationship that only ever travelled the inbox.
|
|
username: String,
|
|
username_normalized: String,
|
|
their_key: String,
|
|
my_key: String,
|
|
request: fr::FriendRequest,
|
|
request_from_me: bool,
|
|
#[serde(default)]
|
|
assertions: Vec<fr::FriendAssertion>,
|
|
#[serde(default)]
|
|
declined_by_me: bool,
|
|
#[serde(default)]
|
|
declined_by_them: bool,
|
|
/// "inbox" | "pm" — how the last message travelled; replies follow it.
|
|
#[serde(default = "carry_pm")]
|
|
carry: String,
|
|
created_at: u64,
|
|
updated_at: u64,
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize)]
|
|
struct Store {
|
|
#[serde(default)]
|
|
relationships: BTreeMap<String, Relationship>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct RelationshipView {
|
|
pub id: String,
|
|
pub username: String,
|
|
pub username_normalized: String,
|
|
pub persona_key: String,
|
|
pub direction: &'static str,
|
|
/// friends | pending-out | pending-in | accepted-waiting | declined |
|
|
/// revoked | expired | reacceptance-required
|
|
pub state: String,
|
|
pub carry: String,
|
|
pub since: u64,
|
|
pub updated_at: u64,
|
|
pub expires_at: u64,
|
|
}
|
|
|
|
fn now_secs() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
fn nonce16() -> [u8; 16] {
|
|
use rand::RngCore;
|
|
let mut n = [0u8; 16];
|
|
rand::thread_rng().fill_bytes(&mut n);
|
|
n
|
|
}
|
|
fn b58(bytes: &[u8]) -> String {
|
|
bs58::encode(bytes).into_string()
|
|
}
|
|
fn key_of(b58s: &str) -> Result<[u8; 32], String> {
|
|
inbox::key_of(b58s)
|
|
}
|
|
|
|
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
Ok(app
|
|
.path()
|
|
.app_data_dir()
|
|
.map_err(|e| e.to_string())?
|
|
.join("freenet")
|
|
.join("friends.json"))
|
|
}
|
|
fn load(app: &tauri::AppHandle) -> Store {
|
|
path(app)
|
|
.ok()
|
|
.and_then(|p| std::fs::read(p).ok())
|
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
fn save(app: &tauri::AppHandle, store: &Store) -> Result<(), String> {
|
|
let p = path(app)?;
|
|
if let Some(dir) = p.parent() {
|
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
|
}
|
|
std::fs::write(&p, serde_json::to_vec_pretty(store).map_err(|e| e.to_string())?)
|
|
.map_err(|e| e.to_string())?;
|
|
let _ = app.emit(EVENT, ());
|
|
Ok(())
|
|
}
|
|
|
|
fn view_of(id: &str, r: &Relationship, now: u64) -> RelationshipView {
|
|
let (my, their) = (key_of(&r.my_key), key_of(&r.their_key));
|
|
let status = match (my, their) {
|
|
(Ok(m), Ok(t)) => fr::friendship_status(
|
|
&m,
|
|
&t,
|
|
&r.request.relationship_id,
|
|
&fr::request_hash(&r.request),
|
|
&r.assertions,
|
|
&[],
|
|
),
|
|
_ => fr::FriendStatus::NotRelated,
|
|
};
|
|
let mine_accepted = r
|
|
.assertions
|
|
.iter()
|
|
.any(|a| b58(&a.signer) == r.my_key && a.accepted);
|
|
let state = match status {
|
|
fr::FriendStatus::Friends => "friends",
|
|
fr::FriendStatus::Revoked => "revoked",
|
|
fr::FriendStatus::ReacceptanceRequired => "reacceptance-required",
|
|
fr::FriendStatus::PendingOneSided | fr::FriendStatus::NotRelated => {
|
|
if r.declined_by_me || r.declined_by_them {
|
|
"declined"
|
|
} else if now > r.request.expires_at && status == fr::FriendStatus::NotRelated {
|
|
"expired"
|
|
} else if mine_accepted {
|
|
"accepted-waiting"
|
|
} else if r.request_from_me {
|
|
"pending-out"
|
|
} else {
|
|
"pending-in"
|
|
}
|
|
}
|
|
};
|
|
let since = r
|
|
.assertions
|
|
.iter()
|
|
.filter(|a| a.accepted)
|
|
.map(|a| a.seq)
|
|
.max()
|
|
.unwrap_or(r.created_at);
|
|
RelationshipView {
|
|
id: id.to_string(),
|
|
username: r.username.clone(),
|
|
username_normalized: r.username_normalized.clone(),
|
|
persona_key: r.their_key.clone(),
|
|
direction: if r.request_from_me { "out" } else { "in" },
|
|
state: state.to_string(),
|
|
carry: r.carry.clone(),
|
|
since,
|
|
updated_at: r.updated_at,
|
|
expires_at: r.request.expires_at,
|
|
}
|
|
}
|
|
|
|
async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
let info = persona::current(&adapter)
|
|
.await?
|
|
.ok_or("create a SoulStream identity first")?;
|
|
key_of(&info.public_key)
|
|
}
|
|
async fn send_pm(app: &tauri::AppHandle, to: &str, text: &str) -> Result<(), String> {
|
|
if to.is_empty() {
|
|
return Err("no Soulseek account is known for them".into());
|
|
}
|
|
let st = app.state::<AppState>();
|
|
let guard = st.client.lock().await;
|
|
guard
|
|
.as_ref()
|
|
.ok_or("not connected to Soulseek")?
|
|
.message_user(to, text)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
fn encode<T: Serialize>(prefix: &str, v: &T) -> Result<String, String> {
|
|
use base64::Engine;
|
|
let bytes = bincode::serialize(v).map_err(|e| e.to_string())?;
|
|
Ok(format!("{prefix}{}", base64::engine::general_purpose::STANDARD.encode(bytes)))
|
|
}
|
|
fn decode<T: for<'de> Deserialize<'de>>(b64: &str) -> Option<T> {
|
|
use base64::Engine;
|
|
let bytes = base64::engine::general_purpose::STANDARD.decode(b64.trim()).ok()?;
|
|
bincode::deserialize(&bytes).ok()
|
|
}
|
|
|
|
/// Deliver by the relationship's carry; an inbox that went unreachable
|
|
/// falls back to the PM carry when an account is known.
|
|
async fn deliver(app: &tauri::AppHandle, rel: &mut Relationship, cargo: Cargo, pm_text: String) -> Result<(), String> {
|
|
if rel.carry == "inbox" {
|
|
match key_of(&rel.their_key) {
|
|
Ok(k) => match inbox::send(app, &k, &cargo).await {
|
|
Ok(()) => return Ok(()),
|
|
Err(e) => log::info!("friends: inbox carry failed ({e}); trying the Soulseek carry"),
|
|
},
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
send_pm(app, &rel.username, &pm_text).await?;
|
|
rel.carry = "pm".into();
|
|
Ok(())
|
|
}
|
|
|
|
async fn sign_assertion(app: &tauri::AppHandle, mut a: fr::FriendAssertion) -> Result<fr::FriendAssertion, String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
a.sig = persona::sign(&adapter, fr::FRIEND_DOMAIN, fr::assertion_sign_payload(&a)).await?;
|
|
Ok(a)
|
|
}
|
|
|
|
fn assertion_for(rel: &Relationship, me: [u8; 32], their: [u8; 32], accepted: bool, now: u64) -> fr::FriendAssertion {
|
|
fr::FriendAssertion {
|
|
relationship_id: rel.request.relationship_id,
|
|
signer: me,
|
|
peer: their,
|
|
request_hash: fr::request_hash(&rel.request),
|
|
accepted,
|
|
seq: now,
|
|
sig: vec![],
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- outgoing
|
|
|
|
async fn start_request(
|
|
app: &tauri::AppHandle,
|
|
their: [u8; 32],
|
|
username: String,
|
|
) -> Result<RelationshipView, String> {
|
|
let me = my_key(app).await?;
|
|
if their == me {
|
|
return Err("that is your own identity".into());
|
|
}
|
|
let now = now_secs();
|
|
let mut req = fr::FriendRequest {
|
|
relationship_id: nonce16(),
|
|
sender: me,
|
|
recipient: their,
|
|
nonce: nonce16(),
|
|
issued_at: now,
|
|
expires_at: now + REQUEST_TTL_SECS,
|
|
sig: vec![],
|
|
};
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
req.sig = persona::sign(&adapter, fr::FRIEND_DOMAIN, fr::request_sign_payload(&req)).await?;
|
|
let id = b58(&req.relationship_id);
|
|
let mut rel = Relationship {
|
|
username_normalized: normalize_username(&username),
|
|
username,
|
|
their_key: b58(&their),
|
|
my_key: b58(&me),
|
|
request: req.clone(),
|
|
request_from_me: true,
|
|
assertions: vec![],
|
|
declined_by_me: false,
|
|
declined_by_them: false,
|
|
carry: if inbox::reachable(app, &their).await { "inbox".into() } else { "pm".into() },
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
deliver(app, &mut rel, Cargo::FriendRequest(req.clone()), encode(REQ_PREFIX, &req)?).await?;
|
|
let mut store = load(app);
|
|
store.relationships.insert(id.clone(), rel);
|
|
save(app, &store)?;
|
|
let rel = &store.relationships[&id];
|
|
log::info!("friends: request sent to {} via {}", if rel.username.is_empty() { &rel.their_key } else { &rel.username }, rel.carry);
|
|
Ok(view_of(&id, rel, now))
|
|
}
|
|
|
|
/// Ask a Soulseek account to be friends. Needs their verified link — the
|
|
/// request is addressed to a KEY.
|
|
pub async fn request(app: &tauri::AppHandle, username: &str) -> Result<RelationshipView, String> {
|
|
let link = slsk_link::known_link(app, username)
|
|
.ok_or("verify their SoulStream ID first (the drawer's Ask button)")?;
|
|
start_request(app, key_of(&link.persona_key)?, link.username.clone()).await
|
|
}
|
|
|
|
/// Ask a SoulStream ID directly — only through their inbox.
|
|
pub async fn request_by_key(app: &tauri::AppHandle, persona_key: &str) -> Result<RelationshipView, String> {
|
|
let their = key_of(persona_key)?;
|
|
if !inbox::reachable(app, &their).await {
|
|
return Err("they have not published an inbox — reachable only through a Soulseek account".into());
|
|
}
|
|
let username = slsk_link::username_for_key(app, persona_key).unwrap_or_default();
|
|
start_request(app, their, username).await
|
|
}
|
|
|
|
pub async fn accept(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
|
|
let me = my_key(app).await?;
|
|
let mut store = load(app);
|
|
let rel = store.relationships.get_mut(id).ok_or("no such request")?;
|
|
if rel.request_from_me {
|
|
return Err("that request is yours to wait on, not to accept".into());
|
|
}
|
|
let their = key_of(&rel.their_key)?;
|
|
let now = now_secs();
|
|
let a = sign_assertion(app, assertion_for(rel, me, their, true, now)).await?;
|
|
deliver(app, rel, Cargo::FriendAssertion(a.clone()), encode(ASSERT_PREFIX, &a)?).await?;
|
|
rel.assertions.push(a);
|
|
rel.declined_by_me = false;
|
|
rel.updated_at = now;
|
|
let v = view_of(id, rel, now);
|
|
save(app, &store)?;
|
|
Ok(v)
|
|
}
|
|
|
|
pub async fn decline(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
|
|
let mut store = load(app);
|
|
let rel = store.relationships.get_mut(id).ok_or("no such request")?;
|
|
let now = now_secs();
|
|
rel.declined_by_me = true;
|
|
rel.updated_at = now;
|
|
let _ = deliver(app, rel, Cargo::FriendDecline(rel.request.relationship_id), format!("{DECLINE_PREFIX}{id}")).await;
|
|
let v = view_of(id, rel, now);
|
|
save(app, &store)?;
|
|
Ok(v)
|
|
}
|
|
|
|
/// Unfriend: a signed revocation goes to them; the record stays as
|
|
/// "revoked" — the design's honesty about what a removal can and cannot do.
|
|
pub async fn remove(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
|
|
let me = my_key(app).await?;
|
|
let mut store = load(app);
|
|
let rel = store.relationships.get_mut(id).ok_or("no such relationship")?;
|
|
let their = key_of(&rel.their_key)?;
|
|
let now = now_secs();
|
|
let a = sign_assertion(app, assertion_for(rel, me, their, false, now)).await?;
|
|
let _ = deliver(app, rel, Cargo::FriendAssertion(a.clone()), encode(ASSERT_PREFIX, &a)?).await;
|
|
rel.assertions.push(a);
|
|
rel.updated_at = now;
|
|
let v = view_of(id, rel, now);
|
|
save(app, &store)?;
|
|
Ok(v)
|
|
}
|
|
|
|
pub fn forget(app: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
|
let mut store = load(app);
|
|
store.relationships.remove(id);
|
|
save(app, &store)
|
|
}
|
|
|
|
pub fn list(app: &tauri::AppHandle) -> Vec<RelationshipView> {
|
|
let now = now_secs();
|
|
let store = load(app);
|
|
let mut v: Vec<_> = store
|
|
.relationships
|
|
.iter()
|
|
.map(|(id, r)| view_of(id, r, now))
|
|
.collect();
|
|
v.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
|
v
|
|
}
|
|
|
|
// ---------------------------------------------------------------- incoming
|
|
|
|
/// Who a message came from, as far as its carry can vouch.
|
|
enum From {
|
|
/// A Soulseek account the server attributed.
|
|
Pm(String),
|
|
/// A persona key that signed the envelope header.
|
|
Inbox([u8; 32]),
|
|
}
|
|
|
|
async fn store_request(app: &tauri::AppHandle, from: From, req: fr::FriendRequest) {
|
|
if !fr::verify_request(&req) {
|
|
log::warn!("friends: a request does not verify");
|
|
return;
|
|
}
|
|
let Ok(me) = my_key(app).await else {
|
|
log::debug!("friends: a request arrived, but no persona is here to receive it");
|
|
return;
|
|
};
|
|
if req.recipient != me {
|
|
return;
|
|
}
|
|
let now = now_secs();
|
|
if now > req.expires_at {
|
|
return;
|
|
}
|
|
// Bind the request's key to whoever sent it.
|
|
let (username, carry) = match from {
|
|
From::Inbox(sender) => {
|
|
if sender != req.sender {
|
|
log::warn!("friends: an inbox request names a key other than its envelope's signer");
|
|
return;
|
|
}
|
|
(slsk_link::username_for_key(app, &b58(&sender)).unwrap_or_default(), "inbox")
|
|
}
|
|
From::Pm(from) => {
|
|
let bound = match slsk_link::known_link(app, &from) {
|
|
Some(l) if l.persona_key == b58(&req.sender) => Some(l),
|
|
_ => match slsk_link::ask(app, &from).await {
|
|
Ok(l) if l.persona_key == b58(&req.sender) => {
|
|
slsk_link::remember(app, l.clone());
|
|
Some(l)
|
|
}
|
|
Ok(_) => {
|
|
log::warn!("friends: {from} proved a different key than the request names");
|
|
None
|
|
}
|
|
Err(e) => {
|
|
log::warn!("friends: could not bind {from} to the request's key: {e}");
|
|
None
|
|
}
|
|
},
|
|
};
|
|
let Some(link) = bound else { return };
|
|
(link.username, "pm")
|
|
}
|
|
};
|
|
let id = b58(&req.relationship_id);
|
|
let mut store = load(app);
|
|
if store.relationships.contains_key(&id) {
|
|
return;
|
|
}
|
|
let who = if username.is_empty() { b58(&req.sender) } else { username.clone() };
|
|
store.relationships.insert(
|
|
id,
|
|
Relationship {
|
|
username_normalized: normalize_username(&username),
|
|
username,
|
|
their_key: b58(&req.sender),
|
|
my_key: b58(&me),
|
|
request: req,
|
|
request_from_me: false,
|
|
assertions: vec![],
|
|
declined_by_me: false,
|
|
declined_by_them: false,
|
|
carry: carry.into(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
},
|
|
);
|
|
if let Err(e) = save(app, &store) {
|
|
log::warn!("friends: could not store the request from {who}: {e}");
|
|
} else {
|
|
log::info!("friends: request from {who} verified and waiting (via {carry})");
|
|
}
|
|
}
|
|
|
|
async fn apply_assertion(app: &tauri::AppHandle, from: From, a: fr::FriendAssertion) {
|
|
if !fr::verify_assertion(&a) {
|
|
log::warn!("friends: an assertion does not verify");
|
|
return;
|
|
}
|
|
let id = b58(&a.relationship_id);
|
|
let mut store = load(app);
|
|
let Some(rel) = store.relationships.get_mut(&id) else {
|
|
log::debug!("friends: assertion for an unknown relationship");
|
|
return;
|
|
};
|
|
let sender_ok = match &from {
|
|
From::Pm(u) => normalize_username(u) == rel.username_normalized,
|
|
From::Inbox(k) => b58(k) == rel.their_key,
|
|
};
|
|
if !sender_ok
|
|
|| b58(&a.signer) != rel.their_key
|
|
|| b58(&a.peer) != rel.my_key
|
|
|| a.request_hash != fr::request_hash(&rel.request)
|
|
{
|
|
log::warn!("friends: an assertion does not belong to its relationship");
|
|
return;
|
|
}
|
|
if rel
|
|
.assertions
|
|
.iter()
|
|
.any(|x| x.signer == a.signer && x.seq == a.seq && x.accepted == a.accepted)
|
|
{
|
|
return;
|
|
}
|
|
let now = now_secs();
|
|
rel.assertions.push(a.clone());
|
|
rel.updated_at = now;
|
|
if let From::Inbox(_) = from {
|
|
rel.carry = "inbox".into();
|
|
}
|
|
// Their acceptance of OUR request completes with our own assertion.
|
|
let mine_exists = rel.assertions.iter().any(|x| b58(&x.signer) == rel.my_key);
|
|
if a.accepted && rel.request_from_me && !mine_exists {
|
|
if let (Ok(me), Ok(their)) = (key_of(&rel.my_key), key_of(&rel.their_key)) {
|
|
match sign_assertion(app, assertion_for(rel, me, their, true, now)).await {
|
|
Ok(mine) => {
|
|
if let Ok(text) = encode(ASSERT_PREFIX, &mine) {
|
|
let _ = deliver(app, rel, Cargo::FriendAssertion(mine.clone()), text).await;
|
|
}
|
|
rel.assertions.push(mine);
|
|
}
|
|
Err(e) => log::warn!("friends: could not sign our assertion: {e}"),
|
|
}
|
|
}
|
|
}
|
|
let state = view_of(&id, rel, now).state;
|
|
let who = if rel.username.is_empty() { rel.their_key.clone() } else { rel.username.clone() };
|
|
if let Err(e) = save(app, &store) {
|
|
log::warn!("friends: could not store an assertion: {e}");
|
|
} else {
|
|
log::info!("friends: {who} → {state}");
|
|
}
|
|
}
|
|
|
|
fn apply_decline(app: &tauri::AppHandle, from: From, id: &str) {
|
|
let mut store = load(app);
|
|
let Some(rel) = store.relationships.get_mut(id) else { return };
|
|
let sender_ok = match &from {
|
|
From::Pm(u) => normalize_username(u) == rel.username_normalized,
|
|
From::Inbox(k) => b58(k) == rel.their_key,
|
|
};
|
|
if !sender_ok || !rel.request_from_me {
|
|
return;
|
|
}
|
|
rel.declined_by_them = true;
|
|
rel.updated_at = now_secs();
|
|
let _ = save(app, &store);
|
|
}
|
|
|
|
/// Sealed cargo from inbox.rs: the envelope's signer is the sender.
|
|
pub(crate) async fn on_inbox_request(app: &tauri::AppHandle, sender: [u8; 32], req: fr::FriendRequest) {
|
|
store_request(app, From::Inbox(sender), req).await;
|
|
}
|
|
pub(crate) async fn on_inbox_assertion(app: &tauri::AppHandle, sender: [u8; 32], a: fr::FriendAssertion) {
|
|
apply_assertion(app, From::Inbox(sender), a).await;
|
|
}
|
|
pub(crate) fn on_inbox_decline(app: &tauri::AppHandle, sender: [u8; 32], id: [u8; 16]) {
|
|
apply_decline(app, From::Inbox(sender), &b58(&id));
|
|
}
|
|
|
|
/// The event pump hands every private message here after the link proof.
|
|
/// True means it was ours and must not surface in the Inbox. Requests and
|
|
/// assertions are handled on their own task (see the module note).
|
|
pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool {
|
|
let msg = message.trim();
|
|
if let Some(rest) = msg.strip_prefix(REQ_PREFIX) {
|
|
if let Some(req) = decode::<fr::FriendRequest>(rest) {
|
|
let (app, from) = (app.clone(), from.to_string());
|
|
tauri::async_runtime::spawn(async move { store_request(&app, From::Pm(from), req).await });
|
|
}
|
|
return true;
|
|
}
|
|
if let Some(rest) = msg.strip_prefix(ASSERT_PREFIX) {
|
|
if let Some(a) = decode::<fr::FriendAssertion>(rest) {
|
|
let (app, from) = (app.clone(), from.to_string());
|
|
tauri::async_runtime::spawn(async move { apply_assertion(&app, From::Pm(from), a).await });
|
|
}
|
|
return true;
|
|
}
|
|
if let Some(rest) = msg.strip_prefix(DECLINE_PREFIX) {
|
|
apply_decline(app, From::Pm(from.to_string()), rest.trim());
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
// ---------------------------------------------------------------- commands
|
|
|
|
#[tauri::command]
|
|
pub fn friends_list(app: tauri::AppHandle) -> Vec<RelationshipView> {
|
|
list(&app)
|
|
}
|
|
#[tauri::command]
|
|
pub async fn friend_request(app: tauri::AppHandle, username: String) -> Result<RelationshipView, String> {
|
|
request(&app, &username).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn friend_request_by_key(app: tauri::AppHandle, persona_key: String) -> Result<RelationshipView, String> {
|
|
request_by_key(&app, &persona_key).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn friend_accept(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
|
|
accept(&app, &id).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn friend_decline(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
|
|
decline(&app, &id).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn friend_remove(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
|
|
remove(&app, &id).await
|
|
}
|
|
#[tauri::command]
|
|
pub fn friend_forget(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
|
forget(&app, &id)
|
|
}
|