@@ -47,6 +47,8 @@ pub use message::{Message, Attachment, Reaction};
4747mod profile;
4848pub use profile:: { Profile , Status } ;
4949
50+ mod profile_sync;
51+
5052mod chat;
5153pub use chat:: { Chat , ChatType , ChatMetadata } ;
5254
@@ -3378,6 +3380,113 @@ async fn get_invited_users(npub: String) -> Result<u32, String> {
33783380
33793381 Ok ( unique_acceptors. len ( ) as u32 )
33803382}
3383+
3384+ // Guy Fawkes Day 2025 constants
3385+ // TESTING: Using today (Nov 4th, 2025) for testing (will change to Nov 5th for production)
3386+ const FAWKES_DAY_START : u64 = 1762214400 ; // 2025-11-04 00:00:00 UTC (TESTING - actual Nov 4, 2025)
3387+ const FAWKES_DAY_END : u64 = 1762300800 ; // 2025-11-05 00:00:00 UTC (TESTING - actual Nov 5, 2025)
3388+ // PRODUCTION VALUES (uncomment for release):
3389+ // const FAWKES_DAY_START: u64 = 1762300800; // 2025-11-05 00:00:00 UTC
3390+ // const FAWKES_DAY_END: u64 = 1762387200; // 2025-11-06 00:00:00 UTC
3391+
3392+ /// Helper function to check if a user has a valid Guy Fawkes Day badge
3393+ async fn has_fawkes_badge_internal ( user_pubkey : PublicKey ) -> Result < bool , String > {
3394+ let client = NOSTR_CLIENT . get ( ) . ok_or ( "Nostr client not initialized" ) ?;
3395+
3396+ // Fetch the user's badge claim event
3397+ let filter = Filter :: new ( )
3398+ . author ( user_pubkey)
3399+ . kind ( Kind :: ApplicationSpecificData )
3400+ . custom_tag ( SingleLetterTag :: lowercase ( Alphabet :: D ) , "fawkes_2025" )
3401+ . limit ( 10 ) ;
3402+
3403+ let events = client
3404+ . fetch_events_from ( vec ! [ TRUSTED_RELAY ] , filter, std:: time:: Duration :: from_secs ( 10 ) )
3405+ . await
3406+ . map_err ( |e| e. to_string ( ) ) ?;
3407+
3408+ // Check if they have a valid badge claim
3409+ for event in events {
3410+ if event. content == "fawkes_badge_claimed" {
3411+ let timestamp = event. created_at . as_u64 ( ) ;
3412+ // Verify the timestamp is within the valid window
3413+ if timestamp >= FAWKES_DAY_START && timestamp < FAWKES_DAY_END {
3414+ return Ok ( true ) ;
3415+ }
3416+ }
3417+ }
3418+
3419+ Ok ( false )
3420+ }
3421+
3422+ /// Claim the Guy Fawkes Day badge (5th November 2025)
3423+ /// This should be called on login to check if it's Guy Fawkes Day and claim the badge
3424+ #[ tauri:: command]
3425+ async fn claim_fawkes_badge ( ) -> Result < bool , String > {
3426+ let client = NOSTR_CLIENT . get ( ) . ok_or ( "Nostr client not initialized" ) ?;
3427+
3428+ // Get current timestamp
3429+ let now = std:: time:: SystemTime :: now ( )
3430+ . duration_since ( std:: time:: UNIX_EPOCH )
3431+ . map_err ( |e| e. to_string ( ) ) ?
3432+ . as_secs ( ) ;
3433+
3434+ println ! ( "[FAWKES] Current timestamp: {}" , now) ;
3435+ println ! ( "[FAWKES] Valid window: {} - {}" , FAWKES_DAY_START , FAWKES_DAY_END ) ;
3436+
3437+ // Check if we're in the valid time window
3438+ if now < FAWKES_DAY_START || now >= FAWKES_DAY_END {
3439+ println ! ( "[FAWKES] Outside valid time window, not claiming badge" ) ;
3440+ return Ok ( false ) ; // Not Guy Fawkes Day
3441+ }
3442+
3443+ println ! ( "[FAWKES] Inside valid time window, proceeding with claim..." ) ;
3444+
3445+ // Get my public key
3446+ let signer = client. signer ( ) . await . map_err ( |e| e. to_string ( ) ) ?;
3447+ let my_public_key = signer. get_public_key ( ) . await . map_err ( |e| e. to_string ( ) ) ?;
3448+
3449+ // Check if we already have the badge using the helper function
3450+ if has_fawkes_badge_internal ( my_public_key) . await ? {
3451+ return Ok ( true ) ; // Already claimed
3452+ }
3453+
3454+ // Create and publish the badge claim event
3455+ // TESTING: Add 5-minute expiration for testing (remove for production)
3456+ let expiry_time = Timestamp :: from_secs (
3457+ std:: time:: SystemTime :: now ( )
3458+ . duration_since ( std:: time:: UNIX_EPOCH )
3459+ . unwrap ( )
3460+ . as_secs ( ) + 300 // 5 minutes for testing
3461+ ) ;
3462+
3463+ let event_builder = EventBuilder :: new ( Kind :: ApplicationSpecificData , "fawkes_badge_claimed" )
3464+ . tag ( Tag :: custom ( TagKind :: d ( ) , vec ! [ "fawkes_2025" ] ) )
3465+ . tag ( Tag :: expiration ( expiry_time) ) ; // TESTING: Remove this line for production
3466+
3467+ // Build and sign the event
3468+ let event = client. sign_event_builder ( event_builder) . await . map_err ( |e| e. to_string ( ) ) ?;
3469+
3470+ // Verify the timestamp is within the valid window (double-check after signing)
3471+ if event. created_at . as_u64 ( ) < FAWKES_DAY_START || event. created_at . as_u64 ( ) >= FAWKES_DAY_END {
3472+ return Err ( "Event timestamp is outside the valid Guy Fawkes Day window" . to_string ( ) ) ;
3473+ }
3474+
3475+ // Send to trusted relay
3476+ client. send_event_to ( [ TRUSTED_RELAY ] , & event) . await . map_err ( |e| e. to_string ( ) ) ?;
3477+
3478+ Ok ( true )
3479+ }
3480+
3481+ /// Check if a user has the Guy Fawkes Day badge
3482+ #[ tauri:: command]
3483+ async fn check_fawkes_badge ( npub : String ) -> Result < bool , String > {
3484+ // Convert npub to PublicKey
3485+ let user_pubkey = PublicKey :: from_bech32 ( & npub) . map_err ( |e| e. to_string ( ) ) ?;
3486+
3487+ // Use the helper function
3488+ has_fawkes_badge_internal ( user_pubkey) . await
3489+ }
33813490// MLS Tauri Commands
33823491
33833492
@@ -3758,18 +3867,24 @@ async fn invite_member_to_group(
37583867 . ok_or_else ( || format ! ( "No device keypackages found for {}" , member_npub) ) ?;
37593868
37603869 // Run non-Send MLS engine work on a blocking thread
3870+ let group_id_clone = group_id. clone ( ) ;
37613871 tokio:: task:: spawn_blocking ( move || {
37623872 let handle = TAURI_APP . get ( ) . ok_or ( "App handle not initialized" ) ?. clone ( ) ;
37633873 let rt = tokio:: runtime:: Handle :: current ( ) ;
37643874 rt. block_on ( async move {
37653875 let mls = MlsService :: new_persistent ( & handle) . map_err ( |e| e. to_string ( ) ) ?;
3766- mls. add_member_device ( & group_id , & member_npub, & device_id)
3876+ mls. add_member_device ( & group_id_clone , & member_npub, & device_id)
37673877 . await
37683878 . map_err ( |e| e. to_string ( ) )
37693879 } )
37703880 } )
37713881 . await
3772- . map_err ( |e| format ! ( "Task join error: {}" , e) ) ?
3882+ . map_err ( |e| format ! ( "Task join error: {}" , e) ) ??;
3883+
3884+ // Sync participants array after adding member
3885+ sync_mls_group_participants ( group_id) . await ?;
3886+
3887+ Ok ( ( ) )
37733888}
37743889
37753890/// Remove a member device from an MLS group
@@ -3780,18 +3895,24 @@ async fn remove_mls_member_device(
37803895 device_id : String ,
37813896) -> Result < ( ) , String > {
37823897 // Run non-Send MLS engine work on a blocking thread; drive async via current runtime
3898+ let group_id_clone = group_id. clone ( ) ;
37833899 tokio:: task:: spawn_blocking ( move || {
37843900 let handle = TAURI_APP . get ( ) . ok_or ( "App handle not initialized" ) ?. clone ( ) ;
37853901 let rt = tokio:: runtime:: Handle :: current ( ) ;
37863902 rt. block_on ( async move {
37873903 let mls = MlsService :: new_persistent ( & handle) . map_err ( |e| e. to_string ( ) ) ?;
3788- mls. remove_member_device ( & group_id , & member_npub, & device_id)
3904+ mls. remove_member_device ( & group_id_clone , & member_npub, & device_id)
37893905 . await
37903906 . map_err ( |e| e. to_string ( ) )
37913907 } )
37923908 } )
37933909 . await
3794- . map_err ( |e| format ! ( "Task join error: {}" , e) ) ?
3910+ . map_err ( |e| format ! ( "Task join error: {}" , e) ) ??;
3911+
3912+ // Sync participants array after removing member
3913+ sync_mls_group_participants ( group_id) . await ?;
3914+
3915+ Ok ( ( ) )
37953916}
37963917
37973918/// Sync MLS groups with the network
@@ -3849,6 +3970,11 @@ async fn sync_mls_groups_now(
38493970 eprintln ! ( "[MLS] sync_group_since_cursor failed for {}: {}" , gid, e) ;
38503971 }
38513972 }
3973+
3974+ // Sync participants array to ensure it matches actual group members
3975+ if let Err ( e) = sync_mls_group_participants ( gid. clone ( ) ) . await {
3976+ eprintln ! ( "[MLS] Failed to sync participants for group {}: {}" , gid, e) ;
3977+ }
38523978 }
38533979
38543980 Ok ( ( total_processed, total_new) )
@@ -4104,6 +4230,11 @@ async fn accept_mls_welcome(welcome_event_id_hex: String) -> Result<bool, String
41044230 } ) ) ;
41054231 }
41064232
4233+ // Sync the participants array with actual group members from the engine
4234+ if let Err ( e) = sync_mls_group_participants ( nostr_group_id. clone ( ) ) . await {
4235+ eprintln ! ( "[MLS] Failed to sync participants after welcome accept: {}" , e) ;
4236+ }
4237+
41074238 // Immediately prefetch recent MLS messages for this group so the chat list shows previews
41084239 // and ordering without requiring the user to open the chat. This loads a recent slice
41094240 // (48h window by default in sync_group_since_cursor) rather than full history.
@@ -4330,6 +4461,45 @@ struct GroupMembers {
43304461 admins : Vec < String > , // admin npubs
43314462}
43324463
4464+ /// Sync the participants array for an MLS group chat with the actual members from the engine
4465+ /// This ensures chat.participants is always up-to-date
4466+ async fn sync_mls_group_participants ( group_id : String ) -> Result < ( ) , String > {
4467+ // Get actual members from the engine
4468+ let group_members = get_mls_group_members ( group_id. clone ( ) ) . await ?;
4469+
4470+ // Update the chat's participants array
4471+ let mut state = STATE . lock ( ) . await ;
4472+ if let Some ( chat) = state. get_chat_mut ( & group_id) {
4473+ let old_count = chat. participants . len ( ) ;
4474+ chat. participants = group_members. members . clone ( ) ;
4475+ let new_count = chat. participants . len ( ) ;
4476+
4477+ if old_count != new_count {
4478+ eprintln ! (
4479+ "[MLS] Synced participants for group {}: {} -> {} members" ,
4480+ & group_id[ ..8 ] ,
4481+ old_count,
4482+ new_count
4483+ ) ;
4484+ }
4485+
4486+ // Save updated chat to disk
4487+ let chat_clone = chat. clone ( ) ;
4488+ drop ( state) ;
4489+
4490+ if let Some ( handle) = TAURI_APP . get ( ) {
4491+ if let Err ( e) = db_migration:: save_chat ( handle. clone ( ) , & chat_clone) . await {
4492+ eprintln ! ( "[MLS] Failed to save chat after syncing participants: {}" , e) ;
4493+ }
4494+ }
4495+ } else {
4496+ drop ( state) ;
4497+ eprintln ! ( "[MLS] Chat not found when syncing participants: {}" , group_id) ;
4498+ }
4499+
4500+ Ok ( ( ) )
4501+ }
4502+
43334503/// Get members (npubs) of an MLS group from the persistent engine (on-demand)
43344504#[ tauri:: command]
43354505async fn get_mls_group_members ( group_id : String ) -> Result < GroupMembers , String > {
@@ -4493,6 +4663,38 @@ async fn refresh_keypackages_for_contact(
44934663
44944664/// Remove orphaned MLS groups from metadata that are not in engine state
44954665
4666+ #[ tauri:: command]
4667+ async fn queue_profile_sync ( npub : String , priority : String , force_refresh : bool ) -> Result < ( ) , String > {
4668+ let sync_priority = match priority. as_str ( ) {
4669+ "critical" => profile_sync:: SyncPriority :: Critical ,
4670+ "high" => profile_sync:: SyncPriority :: High ,
4671+ "medium" => profile_sync:: SyncPriority :: Medium ,
4672+ "low" => profile_sync:: SyncPriority :: Low ,
4673+ _ => return Err ( format ! ( "Invalid priority: {}" , priority) ) ,
4674+ } ;
4675+
4676+ profile_sync:: queue_profile_sync ( npub, sync_priority, force_refresh) . await ;
4677+ Ok ( ( ) )
4678+ }
4679+
4680+ #[ tauri:: command]
4681+ async fn queue_chat_profiles_sync ( chat_id : String , is_opening : bool ) -> Result < ( ) , String > {
4682+ profile_sync:: queue_chat_profiles ( chat_id, is_opening) . await ;
4683+ Ok ( ( ) )
4684+ }
4685+
4686+ #[ tauri:: command]
4687+ async fn refresh_profile_now ( npub : String ) -> Result < ( ) , String > {
4688+ profile_sync:: refresh_profile_now ( npub) . await ;
4689+ Ok ( ( ) )
4690+ }
4691+
4692+ #[ tauri:: command]
4693+ async fn sync_all_profiles ( ) -> Result < ( ) , String > {
4694+ profile_sync:: sync_all_profiles ( ) . await ;
4695+ Ok ( ( ) )
4696+ }
4697+
44964698#[ cfg_attr( mobile, tauri:: mobile_entry_point) ]
44974699pub fn run ( ) {
44984700 #[ cfg( target_os = "linux" ) ]
@@ -4547,6 +4749,12 @@ pub fn run() {
45474749
45484750 // Set as our accessible static app handle
45494751 TAURI_APP . set ( handle) . unwrap ( ) ;
4752+
4753+ // Start the profile sync background processor
4754+ tauri:: async_runtime:: spawn ( async {
4755+ profile_sync:: start_profile_sync_processor ( ) . await ;
4756+ } ) ;
4757+
45504758 Ok ( ( ) )
45514759 } )
45524760 . invoke_handler ( tauri:: generate_handler![
@@ -4612,6 +4820,8 @@ pub fn run() {
46124820 get_or_create_invite_code,
46134821 accept_invite_code,
46144822 get_invited_users,
4823+ claim_fawkes_badge,
4824+ check_fawkes_badge,
46154825 db:: get_invite_code,
46164826 db:: set_invite_code,
46174827 export_keys,
@@ -4638,6 +4848,11 @@ pub fn run() {
46384848 leave_mls_group,
46394849 list_group_cursors,
46404850 refresh_keypackages_for_contact,
4851+ // Profile sync commands
4852+ queue_profile_sync,
4853+ queue_chat_profiles_sync,
4854+ refresh_profile_now,
4855+ sync_all_profiles,
46414856 #[ cfg( all( not( target_os = "android" ) , feature = "whisper" ) ) ]
46424857 whisper:: delete_whisper_model,
46434858 #[ cfg( all( not( target_os = "android" ) , feature = "whisper" ) ) ]
0 commit comments