Skip to content

Commit e47bd92

Browse files
committed
add: Beta Invite system + UI polish
1 parent 11bb358 commit e47bd92

File tree

16 files changed

+1228
-48
lines changed

16 files changed

+1228
-48
lines changed

src-tauri/src/db.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub struct VectorDB {
1616
pub theme: Option<String>,
1717
pub pkey: Option<String>,
1818
pub seed: Option<String>,
19+
pub invite_code: Option<String>,
1920
}
2021

2122
const DB_PATH: &str = "vector.json";
@@ -252,11 +253,17 @@ pub fn get_db<R: Runtime>(handle: AppHandle<R>) -> Result<VectorDB, String> {
252253
_ => None,
253254
};
254255

256+
let invite_code = match store.get("invite_code") {
257+
Some(value) if value.is_string() => Some(value.as_str().unwrap().to_string()),
258+
_ => None,
259+
};
260+
255261
Ok(VectorDB {
256262
db_version,
257263
theme,
258264
pkey,
259265
seed,
266+
invite_code,
260267
})
261268
}
262269

@@ -381,6 +388,22 @@ pub async fn get_seed<R: Runtime>(handle: AppHandle<R>) -> Result<Option<String>
381388
}
382389
}
383390

391+
#[command]
392+
pub fn set_invite_code<R: Runtime>(handle: AppHandle<R>, code: String) -> Result<(), String> {
393+
let store = get_store(&handle);
394+
store.set("invite_code".to_string(), serde_json::json!(code));
395+
Ok(())
396+
}
397+
398+
#[command]
399+
pub fn get_invite_code<R: Runtime>(handle: AppHandle<R>) -> Result<Option<String>, String> {
400+
let store = get_store(&handle);
401+
match store.get("invite_code") {
402+
Some(value) if value.is_string() => Ok(Some(value.as_str().unwrap().to_string())),
403+
_ => Ok(None),
404+
}
405+
}
406+
384407
#[command]
385408
pub fn remove_setting<R: Runtime>(handle: AppHandle<R>, key: String) -> Result<bool, String> {
386409
let store = get_store(&handle);

src-tauri/src/lib.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use once_cell::sync::OnceCell;
55
use tokio::sync::Mutex;
66
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindowBuilder, WebviewUrl};
77
use tauri_plugin_notification::NotificationExt;
8+
use rand::{thread_rng, Rng};
9+
use rand::distributions::Alphanumeric;
810

911
mod crypto;
1012

@@ -65,6 +67,14 @@ static TAURI_APP: OnceCell<AppHandle> = OnceCell::new();
6567
// TODO: REMOVE AFTER SEVERAL UPDATES - This static is only needed for the one-time migration from nonce-based to hash-based storage
6668
static PENDING_MIGRATION: OnceCell<std::collections::HashMap<String, (String, String)>> = OnceCell::new();
6769

70+
#[derive(Clone)]
71+
struct PendingInviteAcceptance {
72+
invite_code: String,
73+
inviter_pubkey: PublicKey,
74+
}
75+
76+
static PENDING_INVITE: OnceCell<PendingInviteAcceptance> = OnceCell::new();
77+
6878

6979

7080

@@ -887,6 +897,8 @@ async fn migrate_nonce_files_to_hash<R: Runtime>(handle: &AppHandle<R>) -> Resul
887897
/// Pre-fetch the configs from our preferred NIP-96 servers to speed up uploads
888898
#[tauri::command]
889899
async fn warmup_nip96_servers() -> bool {
900+
use nostr_sdk::nips::nip96::get_server_config;
901+
890902
// Public Fileserver
891903
if PUBLIC_NIP96_CONFIG.get().is_none() {
892904
let _ = match get_server_config(Url::parse(TRUSTED_PUBLIC_NIP96).unwrap(), None).await {
@@ -2014,6 +2026,37 @@ async fn encrypt(input: String, password: Option<String>) -> String {
20142026
_ => ()
20152027
}
20162028

2029+
// Check if we have a pending invite acceptance to broadcast
2030+
if let Some(pending_invite) = PENDING_INVITE.get() {
2031+
// Get the Nostr client
2032+
if let Some(client) = NOSTR_CLIENT.get() {
2033+
// Clone the data we need before the async block
2034+
let invite_code = pending_invite.invite_code.clone();
2035+
let inviter_pubkey = pending_invite.inviter_pubkey.clone();
2036+
2037+
// Spawn the broadcast in a separate task to avoid blocking
2038+
tokio::spawn(async move {
2039+
// Create and publish the acceptance event
2040+
let event_builder = EventBuilder::new(Kind::ApplicationSpecificData, "vector_invite_accepted")
2041+
.tag(Tag::custom(TagKind::Custom("l".into()), vec!["vector"]))
2042+
.tag(Tag::custom(TagKind::Custom("d".into()), vec![invite_code.as_str()]))
2043+
.tag(Tag::public_key(inviter_pubkey));
2044+
2045+
// Build the event
2046+
match client.sign_event_builder(event_builder).await {
2047+
Ok(event) => {
2048+
// Send only to trusted relay
2049+
match client.send_event_to([TRUSTED_RELAY], &event).await {
2050+
Ok(_) => println!("Successfully broadcast invite acceptance to trusted relay"),
2051+
Err(e) => eprintln!("Failed to broadcast invite acceptance: {}", e),
2052+
}
2053+
}
2054+
Err(e) => eprintln!("Failed to sign invite acceptance event: {}", e),
2055+
}
2056+
});
2057+
}
2058+
}
2059+
20172060
res
20182061
}
20192062

@@ -2214,6 +2257,199 @@ async fn download_whisper_model<R: Runtime>(_handle: AppHandle<R>, _model_name:
22142257
Err("Whisper model download is not supported on this platform".to_string())
22152258
}
22162259

2260+
/// Generate a random alphanumeric invite code
2261+
fn generate_invite_code() -> String {
2262+
thread_rng()
2263+
.sample_iter(&Alphanumeric)
2264+
.take(8)
2265+
.map(char::from)
2266+
.collect::<String>()
2267+
.to_uppercase()
2268+
}
2269+
2270+
/// Generate or retrieve existing invite code for the current user
2271+
#[tauri::command]
2272+
async fn get_or_create_invite_code() -> Result<String, String> {
2273+
let handle = TAURI_APP.get().ok_or("App handle not initialized")?;
2274+
2275+
// Check if we already have a stored invite code
2276+
if let Ok(Some(existing_code)) = db::get_invite_code(handle.clone()) {
2277+
return Ok(existing_code);
2278+
}
2279+
2280+
// No local code found, check the network
2281+
let client = NOSTR_CLIENT.get().ok_or("Nostr client not initialized")?;
2282+
2283+
// Get our public key
2284+
let signer = client.signer().await.map_err(|e| e.to_string())?;
2285+
let my_public_key = signer.get_public_key().await.map_err(|e| e.to_string())?;
2286+
2287+
// Check if we've already published an invite on the network
2288+
let filter = Filter::new()
2289+
.author(my_public_key)
2290+
.kind(Kind::ApplicationSpecificData)
2291+
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), "vector")
2292+
.limit(100);
2293+
2294+
let events = client
2295+
.fetch_events(filter, std::time::Duration::from_secs(10))
2296+
.await
2297+
.map_err(|e| e.to_string())?;
2298+
2299+
// Look for existing invite events
2300+
for event in events {
2301+
if event.content == "vector_invite" {
2302+
// Extract the r tag (invite code)
2303+
if let Some(r_tag) = event.tags.find(TagKind::Custom(Cow::Borrowed("r"))) {
2304+
if let Some(code) = r_tag.content() {
2305+
// Store it locally
2306+
db::set_invite_code(handle.clone(), code.to_string())
2307+
.map_err(|e| e.to_string())?;
2308+
return Ok(code.to_string());
2309+
}
2310+
}
2311+
}
2312+
}
2313+
2314+
// No existing invite found anywhere, generate a new one
2315+
let new_code = generate_invite_code();
2316+
2317+
// Create and publish the invite event
2318+
let event_builder = EventBuilder::new(Kind::ApplicationSpecificData, "vector_invite")
2319+
.tag(Tag::custom(TagKind::d(), vec!["vector"]))
2320+
.tag(Tag::custom(TagKind::Custom("r".into()), vec![new_code.as_str()]));
2321+
2322+
// Build the event
2323+
let event = client.sign_event_builder(event_builder).await.map_err(|e| e.to_string())?;
2324+
2325+
// Send only to trusted relay
2326+
client.send_event_to([TRUSTED_RELAY], &event).await.map_err(|e| e.to_string())?;
2327+
2328+
// Store locally
2329+
db::set_invite_code(handle.clone(), new_code.clone())
2330+
.map_err(|e| e.to_string())?;
2331+
2332+
Ok(new_code)
2333+
}
2334+
2335+
/// Accept an invite code from another user (deferred until after encryption setup)
2336+
#[tauri::command]
2337+
async fn accept_invite_code(invite_code: String) -> Result<String, String> {
2338+
let client = NOSTR_CLIENT.get().ok_or("Nostr client not initialized")?;
2339+
2340+
// Validate invite code format (8 alphanumeric characters)
2341+
if invite_code.len() != 8 || !invite_code.chars().all(|c| c.is_alphanumeric()) {
2342+
return Err("Invalid invite code format".to_string());
2343+
}
2344+
2345+
// Search for the invite event
2346+
let filter = Filter::new()
2347+
.kind(Kind::ApplicationSpecificData)
2348+
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), "vector")
2349+
.custom_tag(SingleLetterTag::lowercase(Alphabet::R), &invite_code)
2350+
.limit(1);
2351+
2352+
let events = client
2353+
.fetch_events(filter, std::time::Duration::from_secs(10))
2354+
.await
2355+
.map_err(|e| e.to_string())?;
2356+
2357+
// Find the invite event
2358+
let invite_event = events
2359+
.into_iter()
2360+
.find(|e| e.content == "vector_invite")
2361+
.ok_or("Invite code not found")?;
2362+
2363+
// Get the inviter's public key
2364+
let inviter_pubkey = invite_event.pubkey;
2365+
let inviter_npub = inviter_pubkey.to_bech32().map_err(|e| e.to_string())?;
2366+
2367+
// Get our public key
2368+
let signer = client.signer().await.map_err(|e| e.to_string())?;
2369+
let my_public_key = signer.get_public_key().await.map_err(|e| e.to_string())?;
2370+
2371+
// Check if we're trying to accept our own invite
2372+
if inviter_pubkey == my_public_key {
2373+
return Err("Cannot accept your own invite code".to_string());
2374+
}
2375+
2376+
// Store the pending invite acceptance (will be broadcast after encryption setup)
2377+
let pending_invite = PendingInviteAcceptance {
2378+
invite_code: invite_code.clone(),
2379+
inviter_pubkey: inviter_pubkey.clone(),
2380+
};
2381+
2382+
// Try to set the pending invite, ignore if already set
2383+
let _ = PENDING_INVITE.set(pending_invite);
2384+
2385+
// Return the inviter's npub so the frontend can initiate a chat
2386+
Ok(inviter_npub)
2387+
}
2388+
2389+
/// Get the count of unique users who accepted invites from a given npub
2390+
#[tauri::command]
2391+
async fn get_invited_users(npub: String) -> Result<u32, String> {
2392+
let client = NOSTR_CLIENT.get().ok_or("Nostr client not initialized")?;
2393+
2394+
// Convert npub to PublicKey
2395+
let inviter_pubkey = PublicKey::from_bech32(&npub).map_err(|e| e.to_string())?;
2396+
2397+
// First, get the inviter's invite code from the trusted relay
2398+
let filter = Filter::new()
2399+
.author(inviter_pubkey)
2400+
.kind(Kind::ApplicationSpecificData)
2401+
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), "vector")
2402+
.limit(100);
2403+
2404+
let events = client
2405+
.fetch_events_from(vec![TRUSTED_RELAY], filter, std::time::Duration::from_secs(10))
2406+
.await
2407+
.map_err(|e| e.to_string())?;
2408+
2409+
// Find the invite event and extract the invite code
2410+
let invite_code = events
2411+
.iter()
2412+
.find(|e| e.content == "vector_invite")
2413+
.and_then(|e| e.tags.find(TagKind::Custom(Cow::Borrowed("r"))))
2414+
.and_then(|tag| tag.content())
2415+
.ok_or("No invite code found for this user")?;
2416+
2417+
// Now fetch all acceptance events for this invite code from the trusted relay
2418+
let acceptance_filter = Filter::new()
2419+
.kind(Kind::ApplicationSpecificData)
2420+
.custom_tag(SingleLetterTag::lowercase(Alphabet::D), invite_code)
2421+
.limit(1000); // Allow fetching many acceptances
2422+
2423+
let acceptance_events = client
2424+
.fetch_events_from(vec![TRUSTED_RELAY], acceptance_filter, std::time::Duration::from_secs(10))
2425+
.await
2426+
.map_err(|e| e.to_string())?;
2427+
2428+
// Filter for acceptance events that reference our inviter and collect unique acceptors
2429+
let mut unique_acceptors = std::collections::HashSet::new();
2430+
2431+
for event in acceptance_events {
2432+
if event.content == "vector_invite_accepted" {
2433+
// Check if this acceptance references our inviter
2434+
let references_inviter = event.tags
2435+
.iter()
2436+
.any(|tag| {
2437+
if let Some(TagStandard::PublicKey { public_key, .. }) = tag.as_standardized() {
2438+
*public_key == inviter_pubkey
2439+
} else {
2440+
false
2441+
}
2442+
});
2443+
2444+
if references_inviter {
2445+
unique_acceptors.insert(event.pubkey);
2446+
}
2447+
}
2448+
}
2449+
2450+
Ok(unique_acceptors.len() as u32)
2451+
}
2452+
22172453
#[cfg_attr(mobile, tauri::mobile_entry_point)]
22182454
pub fn run() {
22192455
#[cfg(target_os = "linux")]
@@ -2383,6 +2619,11 @@ pub fn run() {
23832619
get_platform_features,
23842620
transcribe,
23852621
download_whisper_model,
2622+
get_or_create_invite_code,
2623+
accept_invite_code,
2624+
get_invited_users,
2625+
db::get_invite_code,
2626+
db::set_invite_code,
23862627
#[cfg(all(not(target_os = "android"), feature = "whisper"))]
23872628
whisper::delete_whisper_model,
23882629
#[cfg(all(not(target_os = "android"), feature = "whisper"))]

src-tauri/src/upload.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ where
209209
// Track stalled uploads
210210
let mut last_bytes_sent = 0u64;
211211
let mut stall_counter = 0;
212-
const STALL_THRESHOLD: u32 = 50; // 5 seconds (50 * 100ms) without progress
212+
const STALL_THRESHOLD: u32 = 200; // 20 seconds (200 * 100ms) without progress
213213

214214
// Use tokio::select to concurrently wait for the response and report progress
215215
let response = loop {

src/icons/aggro-glitch.gif

7.64 KB
Loading

src/icons/gift.svg

Lines changed: 3 additions & 0 deletions
Loading

src/icons/vector-check.svg

Lines changed: 50 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)