collab working in worker

Orual 3c83bb11 b95932ff

+658 -775
+12 -12
crates/weaver-app/public/editor_worker.js
··· 232 233 let WASM_VECTOR_LEN = 0; 234 235 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1) { 236 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1); 237 } 238 239 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 240 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 241 } 242 243 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 244 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 245 } 246 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 248 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 249 } 250 251 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2) { ··· 258 259 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2) { 260 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2); 261 - } 262 - 263 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 264 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 265 } 266 267 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { ··· 1071 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_); 1072 return ret; 1073 }; 1074 - imports.wbg.__wbindgen_cast_70089f6cfcc0d4b7 = function(arg0, arg1) { 1075 - // Cast intrinsic for `Closure(Closure { dtor_idx: 391, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 392, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 1076 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); 1077 return ret; 1078 };
··· 232 233 let WASM_VECTOR_LEN = 0; 234 235 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 236 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 237 } 238 239 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1) { 240 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______3_(arg0, arg1); 241 } 242 243 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 244 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 245 } 246 247 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1) { 248 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______(arg0, arg1); 249 + } 250 + 251 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { 252 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2); 253 } 254 255 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_(arg0, arg1, arg2) { ··· 262 263 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2) { 264 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_CloseEvent__CloseEvent_____(arg0, arg1, arg2); 265 } 266 267 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue__wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2, arg3) { ··· 1071 const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent______1_); 1072 return ret; 1073 }; 1074 + imports.wbg.__wbindgen_cast_82f5386d361eee3f = function(arg0, arg1) { 1075 + // Cast intrinsic for `Closure(Closure { dtor_idx: 441, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 442, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 1076 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); 1077 return ret; 1078 };
+15 -15
crates/weaver-app/public/embed_worker.js
··· 232 233 let WASM_VECTOR_LEN = 0; 234 235 - function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 236 - wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 237 - } 238 - 239 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 240 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 241 } 242 243 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { ··· 809 const ret = getStringFromWasm0(arg0, arg1); 810 return ret; 811 }; 812 - imports.wbg.__wbindgen_cast_28fcab414066dd47 = function(arg0, arg1) { 813 - // Cast intrinsic for `Closure(Closure { dtor_idx: 2276, function: Function { arguments: [Externref], shim_idx: 2277, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 814 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 815 return ret; 816 }; 817 - imports.wbg.__wbindgen_cast_80e60da953ba3dd9 = function(arg0, arg1) { 818 - // Cast intrinsic for `Closure(Closure { dtor_idx: 310, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 311, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 819 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); 820 return ret; 821 }; 822 - imports.wbg.__wbindgen_cast_a731521d4dc80277 = function(arg0, arg1) { 823 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1194, function: Function { arguments: [], shim_idx: 1195, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 824 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 825 return ret; 826 }; 827 - imports.wbg.__wbindgen_cast_e0273882c29884ec = function(arg0, arg1) { 828 - // Cast intrinsic for `Closure(Closure { dtor_idx: 1454, function: Function { arguments: [], shim_idx: 1455, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 829 - const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 830 return ret; 831 }; 832 imports.wbg.__wbindgen_init_externref_table = function() {
··· 232 233 let WASM_VECTOR_LEN = 0; 234 235 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2) { 236 wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____(arg0, arg1, arg2); 237 + } 238 + 239 + function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1) { 240 + wasm.wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_(arg0, arg1); 241 } 242 243 function wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____(arg0, arg1, arg2) { ··· 809 const ret = getStringFromWasm0(arg0, arg1); 810 return ret; 811 }; 812 + imports.wbg.__wbindgen_cast_b44cc294b43af732 = function(arg0, arg1) { 813 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1192, function: Function { arguments: [], shim_idx: 1193, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 814 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke______); 815 return ret; 816 }; 817 + imports.wbg.__wbindgen_cast_c8f3d1634b3b2a31 = function(arg0, arg1) { 818 + // Cast intrinsic for `Closure(Closure { dtor_idx: 335, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 336, ret: Unit, inner_ret: Some(Unit) }, mutable: false }) -> Externref`. 819 const ret = makeClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__Fn__web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___web_sys_1984c39bba2ffe3a___features__gen_MessageEvent__MessageEvent_____); 820 return ret; 821 }; 822 + imports.wbg.__wbindgen_cast_e60e2a067b8a256d = function(arg0, arg1) { 823 + // Cast intrinsic for `Closure(Closure { dtor_idx: 1452, function: Function { arguments: [], shim_idx: 1453, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 824 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut_____Output________1_, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke_______1_); 825 return ret; 826 }; 827 + imports.wbg.__wbindgen_cast_e76354026ca30a8f = function(arg0, arg1) { 828 + // Cast intrinsic for `Closure(Closure { dtor_idx: 2274, function: Function { arguments: [Externref], shim_idx: 2275, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. 829 + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen_1add006a0ed82fd3___closure__destroy___dyn_core_b125d98f3949a913___ops__function__FnMut__wasm_bindgen_1add006a0ed82fd3___JsValue____Output_______, wasm_bindgen_1add006a0ed82fd3___convert__closures_____invoke___wasm_bindgen_1add006a0ed82fd3___JsValue_____); 830 return ret; 831 }; 832 imports.wbg.__wbindgen_init_externref_table = function() {
+4 -3
crates/weaver-app/src/collab_context.rs
··· 4 //! the CollabCoordinator component for display in the editor debug panel. 5 6 use dioxus::prelude::*; 7 8 /// Debug state for the collab session, displayed in editor debug panel. 9 #[derive(Clone, Default)] 10 pub struct CollabDebugState { 11 /// Our node ID 12 - pub node_id: Option<String>, 13 /// Our relay URL 14 - pub relay_url: Option<String>, 15 /// URI of our published session record 16 pub session_record_uri: Option<String>, 17 /// Number of discovered peers ··· 21 /// Whether we've joined the gossip swarm 22 pub is_joined: bool, 23 /// Last error message 24 - pub last_error: Option<String>, 25 } 26 27 /// Hook to get the collab debug state signal.
··· 4 //! the CollabCoordinator component for display in the editor debug panel. 5 6 use dioxus::prelude::*; 7 + use jacquard::smol_str::SmolStr; 8 9 /// Debug state for the collab session, displayed in editor debug panel. 10 #[derive(Clone, Default)] 11 pub struct CollabDebugState { 12 /// Our node ID 13 + pub node_id: Option<SmolStr>, 14 /// Our relay URL 15 + pub relay_url: Option<SmolStr>, 16 /// URI of our published session record 17 pub session_record_uri: Option<String>, 18 /// Number of discovered peers ··· 22 /// Whether we've joined the gossip swarm 23 pub is_joined: bool, 24 /// Last error message 25 + pub last_error: Option<SmolStr>, 26 } 27 28 /// Hook to get the collab debug state signal.
-6
crates/weaver-app/src/components/editor/actions.rs
··· 727 .map(|a| a.with_range(range)) 728 } 729 730 - /// Look up an action for the given key and modifiers, with the current range applied. 731 - #[allow(dead_code)] 732 - pub fn lookup_key(&self, key: Key, modifiers: Modifiers, range: Range) -> Option<EditorAction> { 733 - self.lookup(KeyCombo::with_modifiers(key, modifiers), range) 734 - } 735 - 736 /// Add or replace a keybinding. 737 #[allow(dead_code)] 738 pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) {
··· 727 .map(|a| a.with_range(range)) 728 } 729 730 /// Add or replace a keybinding. 731 #[allow(dead_code)] 732 pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) {
+19 -17
crates/weaver-app/src/components/editor/collab.rs
··· 16 use dioxus::prelude::*; 17 18 #[cfg(target_arch = "wasm32")] 19 use jacquard::types::string::AtUri; 20 21 use weaver_common::transport::PresenceSnapshot; ··· 53 Initializing, 54 /// Creating session record on PDS 55 CreatingSession { 56 - node_id: String, 57 - relay_url: Option<String>, 58 }, 59 /// Active collab session 60 Active { session_uri: AtUri<'static> }, 61 /// Error state 62 - Error(String), 63 } 64 65 /// Coordinator component that bridges worker and PDS. ··· 147 148 // Initialize worker with current document snapshot 149 let snapshot = doc.export_snapshot(); 150 - let draft_key = resource_uri.clone(); // Use resource URI as the key 151 spawn(async move { 152 if let Some(ref mut sink) = *worker_sink.write() { 153 if let Err(e) = sink ··· 227 let uri = match AtUri::new(&resource_uri) { 228 Ok(u) => u.into_static(), 229 Err(e) => { 230 - let err = format!("Invalid resource URI: {e}"); 231 debug_state 232 .with_mut(|ds| ds.last_error = Some(err.clone())); 233 state.set(CoordinatorState::Error(err)); ··· 239 let strong_ref = match fetcher.confirm_record_ref(&uri).await { 240 Ok(r) => r, 241 Err(e) => { 242 - let err = format!("Failed to get resource ref: {e}"); 243 debug_state 244 .with_mut(|ds| ds.last_error = Some(err.clone())); 245 state.set(CoordinatorState::Error(err)); ··· 322 }); 323 } 324 Err(e) => { 325 - let err = format!("Failed to create session: {e}"); 326 debug_state 327 .with_mut(|ds| ds.last_error = Some(err.clone())); 328 state.set(CoordinatorState::Error(err)); ··· 367 let fetcher = fetcher.clone(); 368 369 // Get our profile info and send BroadcastJoin 370 - let (our_did, our_display_name) = match fetcher.current_did().await { 371 Some(did) => { 372 - let display_name = match fetcher.fetch_profile(&did.clone().into()).await { 373 Ok(profile) => { 374 match &profile.inner { 375 ProfileDataViewInner::ProfileView(p) => { 376 - p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) 377 } 378 ProfileDataViewInner::ProfileViewDetailed(p) => { 379 - p.display_name.as_ref().map(|s| s.to_string()).unwrap_or_else(|| did.to_string()) 380 } 381 ProfileDataViewInner::TangledProfileView(p) => { 382 - p.handle.to_string() 383 } 384 - _ => did.to_string(), 385 } 386 } 387 - Err(_) => did.to_string(), 388 }; 389 - (did.to_string(), display_name) 390 } 391 None => { 392 tracing::warn!("CollabCoordinator: no current DID for Join message"); 393 - ("unknown".to_string(), "Anonymous".to_string()) 394 } 395 }; 396 ··· 471 Ok(peers) => { 472 debug_state.with_mut(|ds| ds.discovered_peers = peers.len()); 473 if !peers.is_empty() { 474 - let peer_ids: Vec<String> = 475 peers.into_iter().map(|p| p.node_id).collect(); 476 477 if let Some(ref mut s) = *worker_sink.write() {
··· 16 use dioxus::prelude::*; 17 18 #[cfg(target_arch = "wasm32")] 19 + use jacquard::smol_str::{format_smolstr, SmolStr}; 20 + #[cfg(target_arch = "wasm32")] 21 use jacquard::types::string::AtUri; 22 23 use weaver_common::transport::PresenceSnapshot; ··· 55 Initializing, 56 /// Creating session record on PDS 57 CreatingSession { 58 + node_id: SmolStr, 59 + relay_url: Option<SmolStr>, 60 }, 61 /// Active collab session 62 Active { session_uri: AtUri<'static> }, 63 /// Error state 64 + Error(SmolStr), 65 } 66 67 /// Coordinator component that bridges worker and PDS. ··· 149 150 // Initialize worker with current document snapshot 151 let snapshot = doc.export_snapshot(); 152 + let draft_key: SmolStr = resource_uri.clone().into(); // Use resource URI as the key 153 spawn(async move { 154 if let Some(ref mut sink) = *worker_sink.write() { 155 if let Err(e) = sink ··· 229 let uri = match AtUri::new(&resource_uri) { 230 Ok(u) => u.into_static(), 231 Err(e) => { 232 + let err = format_smolstr!("Invalid resource URI: {e}"); 233 debug_state 234 .with_mut(|ds| ds.last_error = Some(err.clone())); 235 state.set(CoordinatorState::Error(err)); ··· 241 let strong_ref = match fetcher.confirm_record_ref(&uri).await { 242 Ok(r) => r, 243 Err(e) => { 244 + let err = format_smolstr!("Failed to get resource ref: {e}"); 245 debug_state 246 .with_mut(|ds| ds.last_error = Some(err.clone())); 247 state.set(CoordinatorState::Error(err)); ··· 324 }); 325 } 326 Err(e) => { 327 + let err = format_smolstr!("Failed to create session: {e}"); 328 debug_state 329 .with_mut(|ds| ds.last_error = Some(err.clone())); 330 state.set(CoordinatorState::Error(err)); ··· 369 let fetcher = fetcher.clone(); 370 371 // Get our profile info and send BroadcastJoin 372 + let (our_did, our_display_name): (SmolStr, SmolStr) = match fetcher.current_did().await { 373 Some(did) => { 374 + let display_name: SmolStr = match fetcher.fetch_profile(&did.clone().into()).await { 375 Ok(profile) => { 376 match &profile.inner { 377 ProfileDataViewInner::ProfileView(p) => { 378 + p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) 379 } 380 ProfileDataViewInner::ProfileViewDetailed(p) => { 381 + p.display_name.as_ref().map(|s| s.as_ref().into()).unwrap_or_else(|| did.as_ref().into()) 382 } 383 ProfileDataViewInner::TangledProfileView(p) => { 384 + p.handle.as_ref().into() 385 } 386 + _ => did.as_ref().into(), 387 } 388 } 389 + Err(_) => did.as_ref().into(), 390 }; 391 + (did.as_ref().into(), display_name) 392 } 393 None => { 394 tracing::warn!("CollabCoordinator: no current DID for Join message"); 395 + ("unknown".into(), "Anonymous".into()) 396 } 397 }; 398 ··· 473 Ok(peers) => { 474 debug_state.with_mut(|ds| ds.discovered_peers = peers.len()); 475 if !peers.is_empty() { 476 + let peer_ids: Vec<SmolStr> = 477 peers.into_iter().map(|p| p.node_id).collect(); 478 479 if let Some(ref mut s) = *worker_sink.write() {
+4 -4
crates/weaver-app/src/components/editor/component.rs
··· 4 use jacquard::IntoStatic; 5 use jacquard::cowstr::ToCowStr; 6 use jacquard::identity::resolver::IdentityResolver; 7 - use jacquard::smol_str::SmolStr; 8 use jacquard::types::aturi::AtUri; 9 use jacquard::types::blob::BlobRef; 10 use jacquard::types::ident::AtIdentifier; ··· 790 let _ = sink 791 .send(WorkerInput::Init { 792 snapshot, 793 - draft_key, 794 }) 795 .await; 796 } ··· 846 }; 847 848 let cursor_offset = doc.cursor.read().offset; 849 - let editing_uri = doc.entry_ref().map(|r| r.uri.to_string()); 850 - let editing_cid = doc.entry_ref().map(|r| r.cid.to_string()); 851 852 let sink_clone = worker_sink.clone(); 853
··· 4 use jacquard::IntoStatic; 5 use jacquard::cowstr::ToCowStr; 6 use jacquard::identity::resolver::IdentityResolver; 7 + use jacquard::smol_str::{SmolStr, ToSmolStr}; 8 use jacquard::types::aturi::AtUri; 9 use jacquard::types::blob::BlobRef; 10 use jacquard::types::ident::AtIdentifier; ··· 790 let _ = sink 791 .send(WorkerInput::Init { 792 snapshot, 793 + draft_key: draft_key.into(), 794 }) 795 .await; 796 } ··· 846 }; 847 848 let cursor_offset = doc.cursor.read().offset; 849 + let editing_uri = doc.entry_ref().map(|r| r.uri.to_smolstr()); 850 + let editing_cid = doc.entry_ref().map(|r| r.cid.to_smolstr()); 851 852 let sink_clone = worker_sink.clone(); 853
+12 -7
crates/weaver-app/src/components/editor/storage.rs
··· 20 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 21 use gloo_storage::{LocalStorage, Storage}; 22 use jacquard::IntoStatic; 23 use jacquard::types::string::{AtUri, Cid}; 24 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 25 use loro::cursor::Cursor; ··· 49 50 /// Entry title (for debugging/display in drafts list) 51 #[serde(default)] 52 - pub title: String, 53 54 /// Base64-encoded CRDT snapshot (contains ALL fields including embeds) 55 #[serde(default, skip_serializing_if = "Option::is_none")] ··· 65 66 /// AT-URI if editing an existing entry (None for new entries) 67 #[serde(default, skip_serializing_if = "Option::is_none")] 68 - pub editing_uri: Option<String>, 69 70 /// CID of the entry if editing an existing entry 71 #[serde(default, skip_serializing_if = "Option::is_none")] 72 - pub editing_cid: Option<String>, 73 } 74 75 /// Build the full storage key from a draft key. ··· 97 98 let snapshot = EditorSnapshot { 99 content: doc.content(), 100 - title: doc.title(), 101 snapshot: snapshot_b64, 102 cursor: doc.loro_cursor().cloned(), 103 cursor_offset: doc.cursor.read().offset, 104 - editing_uri: doc.entry_ref().map(|r| r.uri.to_string()), 105 - editing_cid: doc.entry_ref().map(|r| r.cid.to_string()), 106 }; 107 108 let write_start = crate::perf::now(); ··· 235 // Try to load just the metadata 236 if let Ok(snapshot) = LocalStorage::get::<EditorSnapshot>(&key) { 237 let draft_key = key.strip_prefix(DRAFT_KEY_PREFIX).unwrap_or(&key); 238 - drafts.push((draft_key.to_string(), snapshot.title, snapshot.editing_uri)); 239 } 240 } 241 }
··· 20 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 21 use gloo_storage::{LocalStorage, Storage}; 22 use jacquard::IntoStatic; 23 + use jacquard::smol_str::{SmolStr, ToSmolStr}; 24 use jacquard::types::string::{AtUri, Cid}; 25 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 26 use loro::cursor::Cursor; ··· 50 51 /// Entry title (for debugging/display in drafts list) 52 #[serde(default)] 53 + pub title: SmolStr, 54 55 /// Base64-encoded CRDT snapshot (contains ALL fields including embeds) 56 #[serde(default, skip_serializing_if = "Option::is_none")] ··· 66 67 /// AT-URI if editing an existing entry (None for new entries) 68 #[serde(default, skip_serializing_if = "Option::is_none")] 69 + pub editing_uri: Option<SmolStr>, 70 71 /// CID of the entry if editing an existing entry 72 #[serde(default, skip_serializing_if = "Option::is_none")] 73 + pub editing_cid: Option<SmolStr>, 74 } 75 76 /// Build the full storage key from a draft key. ··· 98 99 let snapshot = EditorSnapshot { 100 content: doc.content(), 101 + title: doc.title().into(), 102 snapshot: snapshot_b64, 103 cursor: doc.loro_cursor().cloned(), 104 cursor_offset: doc.cursor.read().offset, 105 + editing_uri: doc.entry_ref().map(|r| r.uri.to_smolstr()), 106 + editing_cid: doc.entry_ref().map(|r| r.cid.to_smolstr()), 107 }; 108 109 let write_start = crate::perf::now(); ··· 236 // Try to load just the metadata 237 if let Ok(snapshot) = LocalStorage::get::<EditorSnapshot>(&key) { 238 let draft_key = key.strip_prefix(DRAFT_KEY_PREFIX).unwrap_or(&key); 239 + drafts.push(( 240 + draft_key.to_string(), 241 + snapshot.title.to_string(), 242 + snapshot.editing_uri.map(|s| s.to_string()), 243 + )); 244 } 245 } 246 }
+6 -51
crates/weaver-app/src/components/editor/sync.rs
··· 239 } 240 } 241 242 - /// Extract (authority, rkey) from a canonical draft key (synthetic AT-URI). 243 - /// 244 - /// Parses `at://{authority}/sh.weaver.edit.draft/{rkey}` and returns the components. 245 - /// Authority can be a DID or handle. 246 - #[allow(dead_code)] 247 - pub fn parse_draft_key( 248 - draft_key: &str, 249 - ) -> Option<(jacquard::types::ident::AtIdentifier<'static>, String)> { 250 - let uri = AtUri::new(draft_key).ok()?; 251 - let authority = uri.authority().clone().into_static(); 252 - let rkey = uri.rkey()?.0.as_str().to_string(); 253 - Some((authority, rkey)) 254 - } 255 - 256 /// Result of a sync operation. 257 #[derive(Clone, Debug)] 258 pub enum SyncResult { ··· 268 }, 269 /// No changes to sync 270 NoChanges, 271 - } 272 - 273 - /// Find the edit root for an entry using constellation backlinks. 274 - /// 275 - /// Queries constellation for `sh.weaver.edit.root` records that reference 276 - /// the given entry URI via the `.doc.value.entry.uri` path. 277 - #[allow(dead_code)] 278 - pub async fn find_edit_root_for_entry( 279 - fetcher: &Fetcher, 280 - entry_uri: &AtUri<'_>, 281 - ) -> Result<Option<RecordId<'static>>, WeaverError> { 282 - let constellation_url = Url::parse(CONSTELLATION_URL) 283 - .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid constellation URL: {}", e)))?; 284 - 285 - let query = GetBacklinksQuery { 286 - subject: Uri::At(entry_uri.clone().into_static()), 287 - source: format_smolstr!("{}:doc.value.entry.uri", ROOT_NSID).into(), 288 - cursor: None, 289 - did: vec![], 290 - limit: 1, 291 - }; 292 - 293 - let response = fetcher 294 - .client 295 - .xrpc(constellation_url) 296 - .send(&query) 297 - .await 298 - .map_err(|e| WeaverError::InvalidNotebook(format!("Constellation query failed: {}", e)))?; 299 - 300 - let output = response.into_output().map_err(|e| { 301 - WeaverError::InvalidNotebook(format!("Failed to parse constellation response: {}", e)) 302 - })?; 303 - 304 - Ok(output.records.into_iter().next().map(|r| r.into_static())) 305 } 306 307 /// Find ALL edit.root records across collaborators for an entry. ··· 551 } 552 553 /// Find all diffs for a root record using constellation backlinks. 554 - #[allow(dead_code)] 555 pub async fn find_diffs_for_root( 556 fetcher: &Fetcher, 557 root_uri: &AtUri<'_>, ··· 916 fetcher: &Fetcher, 917 entry_uri: &AtUri<'_>, 918 ) -> Result<Option<PdsEditState>, WeaverError> { 919 - // Find the edit root for this entry 920 - let root_id = match find_edit_root_for_entry(fetcher, entry_uri).await? { 921 Some(id) => id, 922 None => return Ok(None), 923 };
··· 239 } 240 } 241 242 /// Result of a sync operation. 243 #[derive(Clone, Debug)] 244 pub enum SyncResult { ··· 254 }, 255 /// No changes to sync 256 NoChanges, 257 } 258 259 /// Find ALL edit.root records across collaborators for an entry. ··· 503 } 504 505 /// Find all diffs for a root record using constellation backlinks. 506 pub async fn find_diffs_for_root( 507 fetcher: &Fetcher, 508 root_uri: &AtUri<'_>, ··· 867 fetcher: &Fetcher, 868 entry_uri: &AtUri<'_>, 869 ) -> Result<Option<PdsEditState>, WeaverError> { 870 + // Find the edit root for this entry (take first if multiple exist) 871 + let root_id = match find_all_edit_roots_for_entry(fetcher, entry_uri) 872 + .await? 873 + .into_iter() 874 + .next() 875 + { 876 Some(id) => id, 877 None => return Ok(None), 878 };
+408 -294
crates/weaver-app/src/components/editor/worker.rs
··· 18 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 19 use jacquard::smol_str::format_smolstr; 20 21 /// Input messages to the editor worker. 22 #[derive(Serialize, Deserialize, Debug, Clone)] 23 pub enum WorkerInput { ··· 26 /// Full Loro snapshot bytes 27 snapshot: Vec<u8>, 28 /// Draft key for storage 29 - draft_key: String, 30 }, 31 /// Apply incremental Loro updates to the shadow document. 32 ApplyUpdates { ··· 38 /// Current cursor position (for snapshot metadata) 39 cursor_offset: usize, 40 /// Editing URI if editing existing entry 41 - editing_uri: Option<String>, 42 /// Editing CID if editing existing entry 43 - editing_cid: Option<String>, 44 }, 45 /// Start collab session (worker will spawn CollabNode) 46 StartCollab { 47 /// blake3 hash of resource URI (32 bytes) 48 topic: [u8; 32], 49 /// Bootstrap peer node IDs (z-base32 strings) 50 - bootstrap_peers: Vec<String>, 51 }, 52 /// Loro updates from local edits (forward to gossip) 53 BroadcastUpdate { ··· 57 /// New peers discovered by main thread 58 AddPeers { 59 /// Node ID strings 60 - peers: Vec<String>, 61 }, 62 /// Announce ourselves to peers (sent after AddPeers) 63 BroadcastJoin { 64 /// Our DID 65 - did: String, 66 /// Our display name 67 - display_name: String, 68 }, 69 /// Local cursor position changed 70 BroadcastCursor { ··· 85 /// Snapshot export completed. 86 Snapshot { 87 /// Draft key for storage 88 - draft_key: String, 89 /// Base64-encoded Loro snapshot 90 b64_snapshot: String, 91 /// Human-readable content (for debugging) 92 content: String, 93 /// Entry title 94 - title: String, 95 /// Cursor offset 96 cursor_offset: usize, 97 /// Editing URI 98 - editing_uri: Option<String>, 99 /// Editing CID 100 - editing_cid: Option<String>, 101 /// Export timing in ms 102 export_ms: f64, 103 /// Encode timing in ms 104 encode_ms: f64, 105 }, 106 /// Error occurred. 107 - Error { message: String }, 108 /// Collab node ready, here's info for session record 109 CollabReady { 110 /// Node ID (z-base32 string) 111 - node_id: String, 112 /// Relay URL for browser connectivity 113 - relay_url: Option<String>, 114 }, 115 /// Collab session joined successfully 116 CollabJoined, ··· 132 use super::*; 133 use futures_util::sink::SinkExt; 134 use futures_util::stream::StreamExt; 135 - use gloo_worker::reactor::{reactor, ReactorScope}; 136 use weaver_common::transport::CollaboratorInfo; 137 138 #[cfg(feature = "collab-worker")] 139 use std::sync::Arc; 140 #[cfg(feature = "collab-worker")] 141 use weaver_common::transport::{ ··· 155 #[reactor] 156 pub async fn EditorReactor(mut scope: ReactorScope<WorkerInput, WorkerOutput>) { 157 let mut doc: Option<loro::LoroDoc> = None; 158 - let mut draft_key = String::new(); 159 160 // Collab state (only used when collab-worker feature enabled) 161 #[cfg(feature = "collab-worker")] ··· 196 } 197 } 198 CollabEvent::PresenceChanged(snapshot) => { 199 - if let Err(e) = scope.send(WorkerOutput::PresenceUpdate(snapshot)).await { 200 - tracing::error!("Failed to send PresenceUpdate to coordinator: {e}"); 201 } 202 } 203 CollabEvent::PeerConnected => { ··· 218 // Fall through to message handling below 219 tracing::debug!(?msg, "Worker: received message"); 220 match msg { 221 - WorkerInput::Init { 222 - snapshot, 223 - draft_key: key, 224 - } => { 225 - let new_doc = loro::LoroDoc::new(); 226 - if !snapshot.is_empty() { 227 - if let Err(e) = new_doc.import(&snapshot) { 228 - if let Err(send_err) = scope 229 - .send(WorkerOutput::Error { 230 - message: format_smolstr!("Failed to import snapshot: {e}").to_string(), 231 - }) 232 - .await 233 - { 234 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 235 } 236 - continue; 237 } 238 - } 239 - doc = Some(new_doc); 240 - draft_key = key; 241 - if let Err(e) = scope.send(WorkerOutput::Ready).await { 242 - tracing::error!("Failed to send Ready to coordinator: {e}"); 243 - } 244 - } 245 246 - WorkerInput::ApplyUpdates { updates } => { 247 - if let Some(ref doc) = doc { 248 - if let Err(e) = doc.import(&updates) { 249 - tracing::warn!("Worker failed to import updates: {e}"); 250 } 251 - } 252 - } 253 254 - WorkerInput::ExportSnapshot { 255 - cursor_offset, 256 - editing_uri, 257 - editing_cid, 258 - } => { 259 - let Some(ref doc) = doc else { 260 - if let Err(e) = scope 261 - .send(WorkerOutput::Error { 262 - message: "No document initialized".into(), 263 - }) 264 - .await 265 - { 266 - tracing::error!("Failed to send Error to coordinator: {e}"); 267 - } 268 - continue; 269 - }; 270 271 - let export_start = crate::perf::now(); 272 - let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 273 - Ok(bytes) => bytes, 274 - Err(e) => { 275 - if let Err(send_err) = scope 276 - .send(WorkerOutput::Error { 277 - message: format_smolstr!("Export failed: {e}").to_string(), 278 }) 279 .await 280 { 281 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 282 } 283 - continue; 284 } 285 - }; 286 - let export_ms = crate::perf::now() - export_start; 287 288 - let encode_start = crate::perf::now(); 289 - let b64_snapshot = BASE64.encode(&snapshot_bytes); 290 - let encode_ms = crate::perf::now() - encode_start; 291 - 292 - let content = doc.get_text("content").to_string(); 293 - let title = doc.get_text("title").to_string(); 294 295 - if let Err(e) = scope 296 - .send(WorkerOutput::Snapshot { 297 - draft_key: draft_key.clone(), 298 - b64_snapshot, 299 - content, 300 - title, 301 - cursor_offset, 302 - editing_uri, 303 - editing_cid, 304 - export_ms, 305 - encode_ms, 306 - }) 307 - .await 308 - { 309 - tracing::error!("Failed to send Snapshot to coordinator: {e}"); 310 - } 311 - } 312 313 - // ============================================================ 314 - // Collab handlers - full impl when collab-worker feature enabled 315 - // ============================================================ 316 - #[cfg(feature = "collab-worker")] 317 - WorkerInput::StartCollab { 318 - topic, 319 - bootstrap_peers, 320 - } => { 321 - // Spawn CollabNode 322 - let node = match CollabNode::spawn(None).await { 323 - Ok(n) => n, 324 - Err(e) => { 325 - if let Err(send_err) = scope 326 - .send(WorkerOutput::Error { 327 - message: format_smolstr!("Failed to spawn CollabNode: {e}").to_string(), 328 }) 329 .await 330 { 331 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 332 } 333 - continue; 334 - } 335 - }; 336 337 - // Wait for relay connection 338 - let relay_url = node.wait_for_relay().await; 339 - let node_id = node.node_id_string(); 340 341 - // Send ready so main thread can create session record 342 - if let Err(e) = scope 343 - .send(WorkerOutput::CollabReady { 344 - node_id, 345 - relay_url: Some(relay_url), 346 - }) 347 - .await 348 - { 349 - tracing::error!("Failed to send CollabReady to coordinator: {e}"); 350 - } 351 352 - collab_node = Some(node.clone()); 353 354 - // Parse bootstrap peers 355 - let peers: Vec<_> = bootstrap_peers 356 - .iter() 357 - .filter_map(|s| parse_node_id(s).ok()) 358 - .collect(); 359 360 - // Join gossip session 361 - let topic_id = TopicId::from_bytes(topic); 362 - match CollabSession::join(node, topic_id, peers).await { 363 - Ok((session, mut events)) => { 364 - let session = Arc::new(session); 365 - collab_session = Some(session.clone()); 366 - if let Err(e) = scope.send(WorkerOutput::CollabJoined).await { 367 - tracing::error!("Failed to send CollabJoined to coordinator: {e}"); 368 - } 369 370 - // NOTE: Don't broadcast Join here - wait for BroadcastJoin message 371 - // after peers have been added via AddPeers 372 - 373 - // Create channel for events from spawned task 374 - let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); 375 - collab_event_rx = Some(event_rx); 376 377 - // Spawn event handler task that sends via channel 378 - wasm_bindgen_futures::spawn_local(async move { 379 - let mut presence = PresenceTracker::new(); 380 - 381 - while let Some(Ok(event)) = events.next().await { 382 - match event { 383 - SessionEvent::Message { from, message } => { 384 - match message { 385 - CollabMessage::LoroUpdate { data, .. } => { 386 - if event_tx.send(CollabEvent::RemoteUpdates { data }).is_err() { 387 - tracing::warn!("Collab event channel closed"); 388 - return; 389 - } 390 - } 391 - CollabMessage::Join { did, display_name } => { 392 - tracing::info!(%from, %did, %display_name, "Received Join message"); 393 - presence.add_collaborator(from, did, display_name); 394 - if event_tx.send(CollabEvent::PresenceChanged( 395 - presence_to_snapshot(&presence), 396 - )).is_err() { 397 - tracing::warn!("Collab event channel closed"); 398 - return; 399 } 400 } 401 - CollabMessage::Leave { .. } => { 402 - presence.remove_collaborator(&from); 403 - if event_tx.send(CollabEvent::PresenceChanged( 404 - presence_to_snapshot(&presence), 405 - )).is_err() { 406 - tracing::warn!("Collab event channel closed"); 407 return; 408 } 409 } 410 - CollabMessage::Cursor { 411 - position, 412 - selection, 413 - .. 414 - } => { 415 - // Note: cursor updates require the collaborator to exist 416 - // (added via Join message) 417 - let exists = presence.contains(&from); 418 - tracing::debug!(%from, position, ?selection, exists, "Received Cursor message"); 419 - presence.update_cursor(&from, position, selection); 420 - if event_tx.send(CollabEvent::PresenceChanged( 421 - presence_to_snapshot(&presence), 422 - )).is_err() { 423 - tracing::warn!("Collab event channel closed"); 424 return; 425 } 426 } 427 - _ => {} 428 - } 429 - } 430 - SessionEvent::PeerJoined(peer) => { 431 - tracing::info!(%peer, "PeerJoined - notifying coordinator"); 432 - // Notify coordinator so it can send BroadcastJoin 433 - // Don't add to presence yet - wait for their Join message 434 - if event_tx.send(CollabEvent::PeerConnected).is_err() { 435 - tracing::warn!("Collab event channel closed"); 436 - return; 437 - } 438 - } 439 - SessionEvent::PeerLeft(peer) => { 440 - presence.remove_collaborator(&peer); 441 - if event_tx.send(CollabEvent::PresenceChanged( 442 - presence_to_snapshot(&presence), 443 - )).is_err() { 444 - tracing::warn!("Collab event channel closed"); 445 - return; 446 } 447 } 448 - SessionEvent::Joined => {} 449 } 450 } 451 - }); 452 - } 453 - Err(e) => { 454 - if let Err(send_err) = scope 455 - .send(WorkerOutput::Error { 456 - message: format_smolstr!("Failed to join session: {e}").to_string(), 457 - }) 458 - .await 459 - { 460 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 461 } 462 } 463 - } 464 - } 465 466 - #[cfg(feature = "collab-worker")] 467 - WorkerInput::BroadcastUpdate { data } => { 468 - if let Some(ref session) = collab_session { 469 - let msg = CollabMessage::LoroUpdate { 470 - data, 471 - version: vec![], 472 - }; 473 - if let Err(e) = session.broadcast(&msg).await { 474 - tracing::warn!("Broadcast failed: {e}"); 475 } 476 - } 477 - } 478 479 - #[cfg(feature = "collab-worker")] 480 - WorkerInput::BroadcastCursor { position, selection } => { 481 - if let Some(ref session) = collab_session { 482 - tracing::debug!(position, ?selection, "Worker: broadcasting cursor"); 483 - let msg = CollabMessage::Cursor { 484 position, 485 selection, 486 - color: OUR_COLOR, 487 - }; 488 - if let Err(e) = session.broadcast(&msg).await { 489 - tracing::warn!("Cursor broadcast failed: {e}"); 490 } 491 - } else { 492 - tracing::debug!(position, ?selection, "Worker: BroadcastCursor but no session"); 493 - } 494 - } 495 496 - #[cfg(feature = "collab-worker")] 497 - WorkerInput::AddPeers { peers } => { 498 - tracing::info!(count = peers.len(), "Worker: received AddPeers"); 499 - if let Some(ref session) = collab_session { 500 - let peer_ids: Vec<_> = peers 501 .iter() 502 .filter_map(|s| { 503 match parse_node_id(s) { ··· 509 } 510 }) 511 .collect(); 512 - tracing::info!(parsed_count = peer_ids.len(), "Worker: joining peers"); 513 - if let Err(e) = session.join_peers(peer_ids).await { 514 - tracing::warn!("Failed to add peers: {e}"); 515 } 516 - } else { 517 - tracing::warn!("Worker: AddPeers but no collab_session"); 518 - } 519 - } 520 521 - #[cfg(feature = "collab-worker")] 522 - WorkerInput::BroadcastJoin { did, display_name } => { 523 - if let Some(ref session) = collab_session { 524 - let join_msg = CollabMessage::Join { did, display_name }; 525 - if let Err(e) = session.broadcast(&join_msg).await { 526 - tracing::warn!("Failed to broadcast Join: {e}"); 527 } 528 - } 529 - } 530 531 - #[cfg(feature = "collab-worker")] 532 - WorkerInput::StopCollab => { 533 - collab_session = None; 534 - collab_node = None; 535 - collab_event_rx = None; 536 - if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { 537 - tracing::error!("Failed to send CollabStopped to coordinator: {e}"); 538 - } 539 - } 540 - 541 } // end match msg 542 } // end RaceResult::CoordinatorMsg(Some(msg)) 543 } // end match race_result ··· 548 let Some(msg) = scope.next().await else { break }; 549 tracing::debug!(?msg, "Worker: received message"); 550 match msg { 551 - WorkerInput::Init { snapshot, draft_key: key } => { 552 let new_doc = loro::LoroDoc::new(); 553 if !snapshot.is_empty() { 554 if let Err(e) = new_doc.import(&snapshot) { 555 if let Err(send_err) = scope 556 .send(WorkerOutput::Error { 557 - message: format_smolstr!("Failed to import snapshot: {e}").to_string(), 558 }) 559 .await 560 { 561 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 562 } 563 continue; 564 } ··· 576 } 577 } 578 } 579 - WorkerInput::ExportSnapshot { cursor_offset, editing_uri, editing_cid } => { 580 let Some(ref doc) = doc else { 581 - if let Err(e) = scope.send(WorkerOutput::Error { message: "No document initialized".into() }).await { 582 tracing::error!("Failed to send Error to coordinator: {e}"); 583 } 584 continue; ··· 587 let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 588 Ok(bytes) => bytes, 589 Err(e) => { 590 - if let Err(send_err) = scope.send(WorkerOutput::Error { message: format_smolstr!("Export failed: {e}").to_string() }).await { 591 - tracing::error!("Failed to send Error to coordinator: {send_err}"); 592 } 593 continue; 594 } ··· 598 let b64_snapshot = BASE64.encode(&snapshot_bytes); 599 let encode_ms = crate::perf::now() - encode_start; 600 let content = doc.get_text("content").to_string(); 601 - let title = doc.get_text("title").to_string(); 602 - if let Err(e) = scope.send(WorkerOutput::Snapshot { 603 - draft_key: draft_key.clone(), b64_snapshot, content, title, 604 - cursor_offset, editing_uri, editing_cid, export_ms, encode_ms, 605 - }).await { 606 tracing::error!("Failed to send Snapshot to coordinator: {e}"); 607 } 608 } 609 // Collab stubs for non-collab-worker build 610 WorkerInput::StartCollab { .. } => { 611 - if let Err(e) = scope.send(WorkerOutput::Error { message: "Collab not enabled".into() }).await { 612 tracing::error!("Failed to send Error to coordinator: {e}"); 613 } 614 } ··· 632 let collaborators = tracker 633 .collaborators() 634 .map(|c| CollaboratorInfo { 635 - node_id: c.node_id.to_string(), 636 did: c.did.clone(), 637 display_name: c.display_name.clone(), 638 color: c.color, ··· 689 use super::*; 690 use crate::cache_impl; 691 use gloo_worker::{HandlerId, Worker, WorkerScope}; 692 use jacquard::client::UnauthenticatedSession; 693 use jacquard::identity::JacquardResolver; 694 use jacquard::prelude::*; 695 use jacquard::types::string::AtUri; 696 - use jacquard::IntoStatic; 697 use std::time::Duration; 698 699 /// Embed worker with persistent cache. ··· 731 let at_uri = match AtUri::new_owned(uri_str.clone()) { 732 Ok(u) => u, 733 Err(e) => { 734 - errors.insert(uri_str, format_smolstr!("Invalid AT URI: {e}").to_string()); 735 continue; 736 } 737 }; ··· 773 results.insert(uri_str, html); 774 } 775 Err(e) => { 776 - errors.insert(uri_str, format_smolstr!("{:?}", e).to_string()); 777 } 778 } 779 }
··· 18 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 19 use jacquard::smol_str::format_smolstr; 20 21 + use jacquard::smol_str::SmolStr; 22 + 23 /// Input messages to the editor worker. 24 #[derive(Serialize, Deserialize, Debug, Clone)] 25 pub enum WorkerInput { ··· 28 /// Full Loro snapshot bytes 29 snapshot: Vec<u8>, 30 /// Draft key for storage 31 + draft_key: SmolStr, 32 }, 33 /// Apply incremental Loro updates to the shadow document. 34 ApplyUpdates { ··· 40 /// Current cursor position (for snapshot metadata) 41 cursor_offset: usize, 42 /// Editing URI if editing existing entry 43 + editing_uri: Option<SmolStr>, 44 /// Editing CID if editing existing entry 45 + editing_cid: Option<SmolStr>, 46 }, 47 /// Start collab session (worker will spawn CollabNode) 48 StartCollab { 49 /// blake3 hash of resource URI (32 bytes) 50 topic: [u8; 32], 51 /// Bootstrap peer node IDs (z-base32 strings) 52 + bootstrap_peers: Vec<SmolStr>, 53 }, 54 /// Loro updates from local edits (forward to gossip) 55 BroadcastUpdate { ··· 59 /// New peers discovered by main thread 60 AddPeers { 61 /// Node ID strings 62 + peers: Vec<SmolStr>, 63 }, 64 /// Announce ourselves to peers (sent after AddPeers) 65 BroadcastJoin { 66 /// Our DID 67 + did: SmolStr, 68 /// Our display name 69 + display_name: SmolStr, 70 }, 71 /// Local cursor position changed 72 BroadcastCursor { ··· 87 /// Snapshot export completed. 88 Snapshot { 89 /// Draft key for storage 90 + draft_key: SmolStr, 91 /// Base64-encoded Loro snapshot 92 b64_snapshot: String, 93 /// Human-readable content (for debugging) 94 content: String, 95 /// Entry title 96 + title: SmolStr, 97 /// Cursor offset 98 cursor_offset: usize, 99 /// Editing URI 100 + editing_uri: Option<SmolStr>, 101 /// Editing CID 102 + editing_cid: Option<SmolStr>, 103 /// Export timing in ms 104 export_ms: f64, 105 /// Encode timing in ms 106 encode_ms: f64, 107 }, 108 /// Error occurred. 109 + Error { message: SmolStr }, 110 /// Collab node ready, here's info for session record 111 CollabReady { 112 /// Node ID (z-base32 string) 113 + node_id: SmolStr, 114 /// Relay URL for browser connectivity 115 + relay_url: Option<SmolStr>, 116 }, 117 /// Collab session joined successfully 118 CollabJoined, ··· 134 use super::*; 135 use futures_util::sink::SinkExt; 136 use futures_util::stream::StreamExt; 137 + use gloo_worker::reactor::{ReactorScope, reactor}; 138 use weaver_common::transport::CollaboratorInfo; 139 140 #[cfg(feature = "collab-worker")] 141 + use jacquard::smol_str::ToSmolStr; 142 + #[cfg(feature = "collab-worker")] 143 use std::sync::Arc; 144 #[cfg(feature = "collab-worker")] 145 use weaver_common::transport::{ ··· 159 #[reactor] 160 pub async fn EditorReactor(mut scope: ReactorScope<WorkerInput, WorkerOutput>) { 161 let mut doc: Option<loro::LoroDoc> = None; 162 + let mut draft_key = SmolStr::default(); 163 164 // Collab state (only used when collab-worker feature enabled) 165 #[cfg(feature = "collab-worker")] ··· 200 } 201 } 202 CollabEvent::PresenceChanged(snapshot) => { 203 + if let Err(e) = scope.send(WorkerOutput::PresenceUpdate(snapshot)).await 204 + { 205 + tracing::error!( 206 + "Failed to send PresenceUpdate to coordinator: {e}" 207 + ); 208 } 209 } 210 CollabEvent::PeerConnected => { ··· 225 // Fall through to message handling below 226 tracing::debug!(?msg, "Worker: received message"); 227 match msg { 228 + WorkerInput::Init { 229 + snapshot, 230 + draft_key: key, 231 + } => { 232 + let new_doc = loro::LoroDoc::new(); 233 + if !snapshot.is_empty() { 234 + if let Err(e) = new_doc.import(&snapshot) { 235 + if let Err(send_err) = scope 236 + .send(WorkerOutput::Error { 237 + message: format_smolstr!( 238 + "Failed to import snapshot: {e}" 239 + ), 240 + }) 241 + .await 242 + { 243 + tracing::error!( 244 + "Failed to send Error to coordinator: {send_err}" 245 + ); 246 + } 247 + continue; 248 + } 249 + } 250 + doc = Some(new_doc); 251 + draft_key = key; 252 + if let Err(e) = scope.send(WorkerOutput::Ready).await { 253 + tracing::error!("Failed to send Ready to coordinator: {e}"); 254 } 255 } 256 257 + WorkerInput::ApplyUpdates { updates } => { 258 + if let Some(ref doc) = doc { 259 + if let Err(e) = doc.import(&updates) { 260 + tracing::warn!("Worker failed to import updates: {e}"); 261 + } 262 + } 263 } 264 265 + WorkerInput::ExportSnapshot { 266 + cursor_offset, 267 + editing_uri, 268 + editing_cid, 269 + } => { 270 + let Some(ref doc) = doc else { 271 + if let Err(e) = scope 272 + .send(WorkerOutput::Error { 273 + message: "No document initialized".into(), 274 + }) 275 + .await 276 + { 277 + tracing::error!("Failed to send Error to coordinator: {e}"); 278 + } 279 + continue; 280 + }; 281 282 + let export_start = crate::perf::now(); 283 + let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 284 + Ok(bytes) => bytes, 285 + Err(e) => { 286 + if let Err(send_err) = scope 287 + .send(WorkerOutput::Error { 288 + message: format_smolstr!("Export failed: {e}"), 289 + }) 290 + .await 291 + { 292 + tracing::error!( 293 + "Failed to send Error to coordinator: {send_err}" 294 + ); 295 + } 296 + continue; 297 + } 298 + }; 299 + let export_ms = crate::perf::now() - export_start; 300 + 301 + let encode_start = crate::perf::now(); 302 + let b64_snapshot = BASE64.encode(&snapshot_bytes); 303 + let encode_ms = crate::perf::now() - encode_start; 304 + 305 + let content = doc.get_text("content").to_string(); 306 + let title: SmolStr = doc.get_text("title").to_string().into(); 307 + 308 + if let Err(e) = scope 309 + .send(WorkerOutput::Snapshot { 310 + draft_key: draft_key.clone(), 311 + b64_snapshot, 312 + content, 313 + title, 314 + cursor_offset, 315 + editing_uri, 316 + editing_cid, 317 + export_ms, 318 + encode_ms, 319 }) 320 .await 321 { 322 + tracing::error!("Failed to send Snapshot to coordinator: {e}"); 323 } 324 } 325 326 + // ============================================================ 327 + // Collab handlers - full impl when collab-worker feature enabled 328 + // ============================================================ 329 + #[cfg(feature = "collab-worker")] 330 + WorkerInput::StartCollab { 331 + topic, 332 + bootstrap_peers, 333 + } => { 334 + // Spawn CollabNode 335 + let node = match CollabNode::spawn(None).await { 336 + Ok(n) => n, 337 + Err(e) => { 338 + if let Err(send_err) = scope 339 + .send(WorkerOutput::Error { 340 + message: format_smolstr!( 341 + "Failed to spawn CollabNode: {e}" 342 + ), 343 + }) 344 + .await 345 + { 346 + tracing::error!( 347 + "Failed to send Error to coordinator: {send_err}" 348 + ); 349 + } 350 + continue; 351 + } 352 + }; 353 354 + // Wait for relay connection 355 + let relay_url = node.wait_for_relay().await; 356 + let node_id = node.node_id_string(); 357 358 + // Send ready so main thread can create session record 359 + if let Err(e) = scope 360 + .send(WorkerOutput::CollabReady { 361 + node_id, 362 + relay_url: Some(relay_url), 363 }) 364 .await 365 { 366 + tracing::error!("Failed to send CollabReady to coordinator: {e}"); 367 } 368 369 + collab_node = Some(node.clone()); 370 371 + // Parse bootstrap peers 372 + let peers: Vec<_> = bootstrap_peers 373 + .iter() 374 + .filter_map(|s| parse_node_id(s).ok()) 375 + .collect(); 376 377 + // Join gossip session 378 + let topic_id = TopicId::from_bytes(topic); 379 + match CollabSession::join(node, topic_id, peers).await { 380 + Ok((session, mut events)) => { 381 + let session = Arc::new(session); 382 + collab_session = Some(session.clone()); 383 + if let Err(e) = scope.send(WorkerOutput::CollabJoined).await { 384 + tracing::error!( 385 + "Failed to send CollabJoined to coordinator: {e}" 386 + ); 387 + } 388 389 + // NOTE: Don't broadcast Join here - wait for BroadcastJoin message 390 + // after peers have been added via AddPeers 391 392 + // Create channel for events from spawned task 393 + let (event_tx, event_rx) = 394 + tokio::sync::mpsc::unbounded_channel(); 395 + collab_event_rx = Some(event_rx); 396 397 + // Spawn event handler task that sends via channel 398 + wasm_bindgen_futures::spawn_local(async move { 399 + let mut presence = PresenceTracker::new(); 400 401 + while let Some(Ok(event)) = events.next().await { 402 + match event { 403 + SessionEvent::Message { from, message } => { 404 + match message { 405 + CollabMessage::LoroUpdate { 406 + data, .. 407 + } => { 408 + if event_tx 409 + .send(CollabEvent::RemoteUpdates { 410 + data, 411 + }) 412 + .is_err() 413 + { 414 + tracing::warn!( 415 + "Collab event channel closed" 416 + ); 417 + return; 418 + } 419 + } 420 + CollabMessage::Join { 421 + did, 422 + display_name, 423 + } => { 424 + tracing::info!(%from, %did, %display_name, "Received Join message"); 425 + presence.add_collaborator( 426 + from, 427 + did, 428 + display_name, 429 + ); 430 + if event_tx 431 + .send(CollabEvent::PresenceChanged( 432 + presence_to_snapshot(&presence), 433 + )) 434 + .is_err() 435 + { 436 + tracing::warn!( 437 + "Collab event channel closed" 438 + ); 439 + return; 440 + } 441 + } 442 + CollabMessage::Leave { .. } => { 443 + presence.remove_collaborator(&from); 444 + if event_tx 445 + .send(CollabEvent::PresenceChanged( 446 + presence_to_snapshot(&presence), 447 + )) 448 + .is_err() 449 + { 450 + tracing::warn!( 451 + "Collab event channel closed" 452 + ); 453 + return; 454 + } 455 + } 456 + CollabMessage::Cursor { 457 + position, 458 + selection, 459 + .. 460 + } => { 461 + // Note: cursor updates require the collaborator to exist 462 + // (added via Join message) 463 + let exists = presence.contains(&from); 464 + tracing::debug!(%from, position, ?selection, exists, "Received Cursor message"); 465 + presence.update_cursor( 466 + &from, position, selection, 467 + ); 468 + if event_tx 469 + .send(CollabEvent::PresenceChanged( 470 + presence_to_snapshot(&presence), 471 + )) 472 + .is_err() 473 + { 474 + tracing::warn!( 475 + "Collab event channel closed" 476 + ); 477 + return; 478 + } 479 + } 480 + _ => {} 481 } 482 } 483 + SessionEvent::PeerJoined(peer) => { 484 + tracing::info!(%peer, "PeerJoined - notifying coordinator"); 485 + // Notify coordinator so it can send BroadcastJoin 486 + // Don't add to presence yet - wait for their Join message 487 + if event_tx 488 + .send(CollabEvent::PeerConnected) 489 + .is_err() 490 + { 491 + tracing::warn!( 492 + "Collab event channel closed" 493 + ); 494 return; 495 } 496 } 497 + SessionEvent::PeerLeft(peer) => { 498 + presence.remove_collaborator(&peer); 499 + if event_tx 500 + .send(CollabEvent::PresenceChanged( 501 + presence_to_snapshot(&presence), 502 + )) 503 + .is_err() 504 + { 505 + tracing::warn!( 506 + "Collab event channel closed" 507 + ); 508 return; 509 } 510 } 511 + SessionEvent::Joined => {} 512 } 513 } 514 + }); 515 + } 516 + Err(e) => { 517 + if let Err(send_err) = scope 518 + .send(WorkerOutput::Error { 519 + message: format_smolstr!("Failed to join session: {e}"), 520 + }) 521 + .await 522 + { 523 + tracing::error!( 524 + "Failed to send Error to coordinator: {send_err}" 525 + ); 526 } 527 } 528 } 529 } 530 531 + #[cfg(feature = "collab-worker")] 532 + WorkerInput::BroadcastUpdate { data } => { 533 + if let Some(ref session) = collab_session { 534 + let msg = CollabMessage::LoroUpdate { 535 + data, 536 + version: vec![], 537 + }; 538 + if let Err(e) = session.broadcast(&msg).await { 539 + tracing::warn!("Broadcast failed: {e}"); 540 + } 541 + } 542 } 543 544 + #[cfg(feature = "collab-worker")] 545 + WorkerInput::BroadcastCursor { 546 position, 547 selection, 548 + } => { 549 + if let Some(ref session) = collab_session { 550 + tracing::debug!( 551 + position, 552 + ?selection, 553 + "Worker: broadcasting cursor" 554 + ); 555 + let msg = CollabMessage::Cursor { 556 + position, 557 + selection, 558 + color: OUR_COLOR, 559 + }; 560 + if let Err(e) = session.broadcast(&msg).await { 561 + tracing::warn!("Cursor broadcast failed: {e}"); 562 + } 563 + } else { 564 + tracing::debug!( 565 + position, 566 + ?selection, 567 + "Worker: BroadcastCursor but no session" 568 + ); 569 + } 570 } 571 572 + #[cfg(feature = "collab-worker")] 573 + WorkerInput::AddPeers { peers } => { 574 + tracing::info!(count = peers.len(), "Worker: received AddPeers"); 575 + if let Some(ref session) = collab_session { 576 + let peer_ids: Vec<_> = peers 577 .iter() 578 .filter_map(|s| { 579 match parse_node_id(s) { ··· 585 } 586 }) 587 .collect(); 588 + tracing::info!( 589 + parsed_count = peer_ids.len(), 590 + "Worker: joining peers" 591 + ); 592 + if let Err(e) = session.join_peers(peer_ids).await { 593 + tracing::warn!("Failed to add peers: {e}"); 594 + } 595 + } else { 596 + tracing::warn!("Worker: AddPeers but no collab_session"); 597 + } 598 } 599 600 + #[cfg(feature = "collab-worker")] 601 + WorkerInput::BroadcastJoin { did, display_name } => { 602 + if let Some(ref session) = collab_session { 603 + let join_msg = CollabMessage::Join { did, display_name }; 604 + if let Err(e) = session.broadcast(&join_msg).await { 605 + tracing::warn!("Failed to broadcast Join: {e}"); 606 + } 607 + } 608 } 609 610 + #[cfg(feature = "collab-worker")] 611 + WorkerInput::StopCollab => { 612 + collab_session = None; 613 + collab_node = None; 614 + collab_event_rx = None; 615 + if let Err(e) = scope.send(WorkerOutput::CollabStopped).await { 616 + tracing::error!("Failed to send CollabStopped to coordinator: {e}"); 617 + } 618 + } 619 } // end match msg 620 } // end RaceResult::CoordinatorMsg(Some(msg)) 621 } // end match race_result ··· 626 let Some(msg) = scope.next().await else { break }; 627 tracing::debug!(?msg, "Worker: received message"); 628 match msg { 629 + WorkerInput::Init { 630 + snapshot, 631 + draft_key: key, 632 + } => { 633 let new_doc = loro::LoroDoc::new(); 634 if !snapshot.is_empty() { 635 if let Err(e) = new_doc.import(&snapshot) { 636 if let Err(send_err) = scope 637 .send(WorkerOutput::Error { 638 + message: format_smolstr!("Failed to import snapshot: {e}"), 639 }) 640 .await 641 { 642 + tracing::error!( 643 + "Failed to send Error to coordinator: {send_err}" 644 + ); 645 } 646 continue; 647 } ··· 659 } 660 } 661 } 662 + WorkerInput::ExportSnapshot { 663 + cursor_offset, 664 + editing_uri, 665 + editing_cid, 666 + } => { 667 let Some(ref doc) = doc else { 668 + if let Err(e) = scope 669 + .send(WorkerOutput::Error { 670 + message: "No document initialized".into(), 671 + }) 672 + .await 673 + { 674 tracing::error!("Failed to send Error to coordinator: {e}"); 675 } 676 continue; ··· 679 let snapshot_bytes = match doc.export(loro::ExportMode::Snapshot) { 680 Ok(bytes) => bytes, 681 Err(e) => { 682 + if let Err(send_err) = scope 683 + .send(WorkerOutput::Error { 684 + message: format_smolstr!("Export failed: {e}"), 685 + }) 686 + .await 687 + { 688 + tracing::error!( 689 + "Failed to send Error to coordinator: {send_err}" 690 + ); 691 } 692 continue; 693 } ··· 697 let b64_snapshot = BASE64.encode(&snapshot_bytes); 698 let encode_ms = crate::perf::now() - encode_start; 699 let content = doc.get_text("content").to_string(); 700 + let title: SmolStr = doc.get_text("title").to_string().into(); 701 + if let Err(e) = scope 702 + .send(WorkerOutput::Snapshot { 703 + draft_key: draft_key.clone(), 704 + b64_snapshot, 705 + content, 706 + title, 707 + cursor_offset, 708 + editing_uri, 709 + editing_cid, 710 + export_ms, 711 + encode_ms, 712 + }) 713 + .await 714 + { 715 tracing::error!("Failed to send Snapshot to coordinator: {e}"); 716 } 717 } 718 // Collab stubs for non-collab-worker build 719 WorkerInput::StartCollab { .. } => { 720 + if let Err(e) = scope 721 + .send(WorkerOutput::Error { 722 + message: "Collab not enabled".into(), 723 + }) 724 + .await 725 + { 726 tracing::error!("Failed to send Error to coordinator: {e}"); 727 } 728 } ··· 746 let collaborators = tracker 747 .collaborators() 748 .map(|c| CollaboratorInfo { 749 + node_id: c.node_id.to_smolstr(), 750 did: c.did.clone(), 751 display_name: c.display_name.clone(), 752 color: c.color, ··· 803 use super::*; 804 use crate::cache_impl; 805 use gloo_worker::{HandlerId, Worker, WorkerScope}; 806 + use jacquard::IntoStatic; 807 use jacquard::client::UnauthenticatedSession; 808 use jacquard::identity::JacquardResolver; 809 use jacquard::prelude::*; 810 use jacquard::types::string::AtUri; 811 use std::time::Duration; 812 813 /// Embed worker with persistent cache. ··· 845 let at_uri = match AtUri::new_owned(uri_str.clone()) { 846 Ok(u) => u, 847 Err(e) => { 848 + errors.insert(uri_str, format!("Invalid AT URI: {e}")); 849 continue; 850 } 851 }; ··· 887 results.insert(uri_str, html); 888 } 889 Err(e) => { 890 + errors.insert(uri_str, format!("{:?}", e)); 891 } 892 } 893 }
-91
crates/weaver-app/src/components/entry.rs
··· 23 use std::sync::Arc; 24 use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, entry}; 25 26 - // #[component] 27 - // pub fn EntryPage( 28 - // ident: ReadSignal<AtIdentifier<'static>>, 29 - // book_title: ReadSignal<SmolStr>, 30 - // title: ReadSignal<SmolStr>, 31 - // ) -> Element { 32 - // rsx! { 33 - // {std::iter::once(rsx! {Entry {ident, book_title, title}})} 34 - // } 35 - // } 36 - 37 #[component] 38 pub fn EntryPage( 39 ident: ReadSignal<AtIdentifier<'static>>, ··· 81 // Use read() instead of read_unchecked() for proper reactive tracking 82 match &*entry.read() { 83 Some((book_entry_view, entry_record)) => { 84 - if let Some(embeds) = &entry_record.embeds { 85 - if let Some(_images) = &embeds.images { 86 - // Register blob mappings with service worker (client-side only) 87 - // #[cfg(all( 88 - // target_family = "wasm", 89 - // target_os = "unknown", 90 - // not(feature = "fullstack-server") 91 - // ))] 92 - // { 93 - // let fetcher = fetcher.clone(); 94 - // let images = _images.clone().into_static(); 95 - // spawn(async move { 96 - // let images = images.clone(); 97 - // let fetcher = fetcher.clone(); 98 - // let _ = crate::service_worker::register_entry_blobs( 99 - // &ident(), 100 - // book_title().as_str(), 101 - // &_images, 102 - // &fetcher, 103 - // ) 104 - // .await; 105 - // }); 106 - // } 107 - } 108 - } 109 rsx! { EntryPageView { 110 book_entry_view: book_entry_view.clone(), 111 entry_record: entry_record.clone(), ··· 149 let truncated: String = cleaned.chars().take(max_len - 3).collect(); 150 format!("{}...", truncated) 151 } 152 - } 153 - 154 - /// Truncate markdown content for preview (preserves markdown syntax) 155 - /// Takes first few paragraphs up to max_chars, truncating at paragraph boundary 156 - fn truncate_markdown_preview(content: &str, max_chars: usize, max_paragraphs: usize) -> String { 157 - let mut result = String::new(); 158 - let mut char_count = 0; 159 - let mut para_count = 0; 160 - let mut in_code_block = false; 161 - 162 - for line in content.lines() { 163 - // Track code blocks to avoid breaking them 164 - if line.trim().starts_with("```") { 165 - in_code_block = !in_code_block; 166 - // Skip code blocks in preview entirely 167 - if in_code_block { 168 - continue; 169 - } 170 - } 171 - 172 - if in_code_block { 173 - continue; 174 - } 175 - 176 - // Skip headings, images in preview 177 - let trimmed = line.trim(); 178 - if trimmed.starts_with('#') || trimmed.starts_with('!') { 179 - continue; 180 - } 181 - 182 - // Empty line = paragraph boundary 183 - if trimmed.is_empty() { 184 - if !result.is_empty() && !result.ends_with("\n\n") { 185 - para_count += 1; 186 - if para_count >= max_paragraphs || char_count >= max_chars { 187 - break; 188 - } 189 - result.push_str("\n\n"); 190 - } 191 - continue; 192 - } 193 - 194 - // Check if adding this line would exceed limit 195 - if char_count + line.len() > max_chars && !result.is_empty() { 196 - break; 197 - } 198 - 199 - if !result.is_empty() && !result.ends_with('\n') { 200 - result.push('\n'); 201 - } 202 - result.push_str(line); 203 - char_count += line.len(); 204 - } 205 - 206 - result.trim().to_string() 207 } 208 209 /// OpenGraph and Twitter Card meta tags for entries
··· 23 use std::sync::Arc; 24 use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, entry}; 25 26 #[component] 27 pub fn EntryPage( 28 ident: ReadSignal<AtIdentifier<'static>>, ··· 70 // Use read() instead of read_unchecked() for proper reactive tracking 71 match &*entry.read() { 72 Some((book_entry_view, entry_record)) => { 73 rsx! { EntryPageView { 74 book_entry_view: book_entry_view.clone(), 75 entry_record: entry_record.clone(), ··· 113 let truncated: String = cleaned.chars().take(max_len - 3).collect(); 114 format!("{}...", truncated) 115 } 116 } 117 118 /// OpenGraph and Twitter Card meta tags for entries
+117 -211
crates/weaver-app/src/components/identity.rs
··· 107 }); 108 109 // Extract pinned URIs from profile (only Weaver ProfileView has pinned) 110 let pinned_uris = use_memo(move || { 111 use jacquard::IntoStatic; 112 - use jacquard::types::aturi::AtUri; 113 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 114 115 let Some(prof) = profile.read().as_ref().cloned() else { 116 - return Vec::<AtUri<'static>>::new(); 117 }; 118 119 match &prof.inner { 120 - ProfileDataViewInner::ProfileView(p) => p 121 - .pinned 122 - .as_ref() 123 - .map(|pins| pins.iter().map(|r| r.uri.clone().into_static()).collect()) 124 - .unwrap_or_default(), 125 - _ => Vec::new(), 126 } 127 }); 128 ··· 149 }); 150 151 // Helper to check if a URI is pinned 152 - fn is_pinned(uri: &str, pinned: &[jacquard::types::aturi::AtUri<'static>]) -> bool { 153 - pinned.iter().any(|p| p.as_ref() == uri) 154 } 155 156 // Build pinned items (matching notebooks/entries against pinned URIs) ··· 158 let nbs = notebooks.read(); 159 let standalone = standalone_entries.read(); 160 let ents = all_entries.read(); 161 - let pinned = pinned_uris.read(); 162 163 let mut items: Vec<ProfileTimelineItem> = Vec::new(); 164 ··· 166 if let Some(nbs) = nbs.as_ref() { 167 if let Some(all_ents) = ents.as_ref() { 168 for (notebook, entry_refs) in nbs { 169 - if is_pinned(notebook.uri.as_ref(), &pinned) { 170 let sort_date = entry_refs 171 .iter() 172 .filter_map(|r| { ··· 190 191 // Check standalone entries 192 for (view, entry) in standalone.iter() { 193 - if is_pinned(view.uri.as_ref(), &pinned) { 194 items.push(ProfileTimelineItem::StandaloneEntry { 195 entry_view: view.clone(), 196 entry: entry.clone(), ··· 204 ProfileTimelineItem::Notebook { notebook, .. } => notebook.uri.as_ref(), 205 ProfileTimelineItem::StandaloneEntry { entry_view, .. } => entry_view.uri.as_ref(), 206 }; 207 - pinned 208 .iter() 209 - .position(|p| p.as_ref() == uri) 210 .unwrap_or(usize::MAX) 211 }); 212 ··· 218 let nbs = notebooks.read(); 219 let standalone = standalone_entries.read(); 220 let ents = all_entries.read(); 221 - let pinned = pinned_uris.read(); 222 223 let mut items: Vec<ProfileTimelineItem> = Vec::new(); 224 ··· 226 if let Some(nbs) = nbs.as_ref() { 227 if let Some(all_ents) = ents.as_ref() { 228 for (notebook, entry_refs) in nbs { 229 - if !is_pinned(notebook.uri.as_ref(), &pinned) { 230 let sort_date = entry_refs 231 .iter() 232 .filter_map(|r| { ··· 250 251 // Add standalone entries (excluding pinned) 252 for (view, entry) in standalone.iter() { 253 - if !is_pinned(view.uri.as_ref(), &pinned) { 254 items.push(ProfileTimelineItem::StandaloneEntry { 255 entry_view: view.clone(), 256 entry: entry.clone(), ··· 443 } 444 445 #[component] 446 pub fn NotebookCard( 447 notebook: NotebookView<'static>, 448 entry_refs: Vec<StrongRef<'static>>, ··· 567 if entry_list.len() <= 5 { 568 // Show all entries if 5 or fewer 569 rsx! { 570 - for entry_view in entry_list.iter() { 571 - { 572 - let entry_title = entry_view.entry.title.as_ref() 573 - .map(|t| t.as_ref()) 574 - .unwrap_or("Untitled"); 575 - 576 - // Get path from view, fallback to title 577 - let entry_path = entry_view.entry.path 578 - .as_ref() 579 - .map(|p| p.as_ref().to_string()) 580 - .unwrap_or_else(|| entry_title.to_string()); 581 - 582 - // Parse entry for created_at and preview 583 - let parsed_entry = from_data::<Entry>(&entry_view.entry.record).ok(); 584 - 585 - let preview_html = parsed_entry.as_ref().map(|entry| { 586 - let parser = markdown_weaver::Parser::new(&entry.content); 587 - let mut html_buf = String::new(); 588 - markdown_weaver::html::push_html(&mut html_buf, parser); 589 - html_buf 590 - }); 591 - 592 - let created_at = parsed_entry.as_ref() 593 - .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 594 - 595 - let entry_uri = entry_view.entry.uri.clone().into_static(); 596 - 597 - rsx! { 598 - div { class: "notebook-entry-preview", 599 - div { class: "entry-preview-header", 600 - Link { 601 - to: Route::EntryPage { 602 - ident: ident.clone(), 603 - book_title: book_title.clone(), 604 - title: entry_path.clone().into() 605 - }, 606 - class: "entry-preview-title-link", 607 - div { class: "entry-preview-title", "{entry_title}" } 608 - } 609 - if let Some(ref date) = created_at { 610 - div { class: "entry-preview-date", "{date}" } 611 - } 612 - // EntryActions handles visibility via permissions 613 - crate::components::EntryActions { 614 - entry_uri, 615 - entry_cid: entry_view.entry.cid.clone().into_static(), 616 - entry_title: entry_title.to_string(), 617 - in_notebook: true, 618 - notebook_title: Some(book_title.clone()), 619 - permissions: entry_view.entry.permissions.clone() 620 - } 621 - } 622 - if let Some(ref html) = preview_html { 623 - Link { 624 - to: Route::EntryPage { 625 - ident: ident.clone(), 626 - book_title: book_title.clone(), 627 - title: entry_path.clone().into() 628 - }, 629 - class: "entry-preview-content-link", 630 - div { class: "entry-preview-content", dangerous_inner_html: "{html}" } 631 - } 632 - } 633 - } 634 - } 635 } 636 } 637 } ··· 639 // Show first, interstitial, and last 640 rsx! { 641 if let Some(first_entry) = entry_list.first() { 642 - { 643 - let entry_title = first_entry.entry.title.as_ref() 644 - .map(|t| t.as_ref()) 645 - .unwrap_or("Untitled"); 646 - 647 - // Get path from view, fallback to title 648 - let entry_path = first_entry.entry.path 649 - .as_ref() 650 - .map(|p| p.as_ref().to_string()) 651 - .unwrap_or_else(|| entry_title.to_string()); 652 - 653 - // Parse entry for created_at and preview 654 - let parsed_entry = from_data::<Entry>(&first_entry.entry.record).ok(); 655 - 656 - let preview_html = parsed_entry.as_ref().map(|entry| { 657 - let parser = markdown_weaver::Parser::new(&entry.content); 658 - let mut html_buf = String::new(); 659 - markdown_weaver::html::push_html(&mut html_buf, parser); 660 - html_buf 661 - }); 662 - 663 - let created_at = parsed_entry.as_ref() 664 - .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 665 - 666 - let entry_uri = first_entry.entry.uri.clone().into_static(); 667 - 668 - rsx! { 669 - div { class: "notebook-entry-preview notebook-entry-preview-first", 670 - div { class: "entry-preview-header", 671 - Link { 672 - to: Route::EntryPage { 673 - ident: ident.clone(), 674 - book_title: book_title.clone(), 675 - title: entry_path.clone().into() 676 - }, 677 - class: "entry-preview-title-link", 678 - div { class: "entry-preview-title", "{entry_title}" } 679 - } 680 - if let Some(ref date) = created_at { 681 - div { class: "entry-preview-date", "{date}" } 682 - } 683 - // EntryActions handles visibility via permissions 684 - crate::components::EntryActions { 685 - entry_uri, 686 - entry_cid: first_entry.entry.cid.clone().into_static(), 687 - entry_title: entry_title.to_string(), 688 - in_notebook: true, 689 - notebook_title: Some(book_title.clone()), 690 - permissions: first_entry.entry.permissions.clone() 691 - } 692 - } 693 - if let Some(ref html) = preview_html { 694 - Link { 695 - to: Route::EntryPage { 696 - ident: ident.clone(), 697 - book_title: book_title.clone(), 698 - title: entry_path.clone().into() 699 - }, 700 - class: "entry-preview-content-link", 701 - div { class: "entry-preview-content", dangerous_inner_html: "{html}" } 702 - } 703 - } 704 - } 705 - } 706 } 707 } 708 ··· 719 } 720 721 if let Some(last_entry) = entry_list.last() { 722 - { 723 - let entry_title = last_entry.entry.title.as_ref() 724 - .map(|t| t.as_ref()) 725 - .unwrap_or("Untitled"); 726 - 727 - // Get path from view, fallback to title 728 - let entry_path = last_entry.entry.path 729 - .as_ref() 730 - .map(|p| p.as_ref().to_string()) 731 - .unwrap_or_else(|| entry_title.to_string()); 732 - 733 - // Parse entry for created_at and preview 734 - let parsed_entry = from_data::<Entry>(&last_entry.entry.record).ok(); 735 - 736 - let preview_html = parsed_entry.as_ref().map(|entry| { 737 - let parser = markdown_weaver::Parser::new(&entry.content); 738 - let mut html_buf = String::new(); 739 - markdown_weaver::html::push_html(&mut html_buf, parser); 740 - html_buf 741 - }); 742 - 743 - let created_at = parsed_entry.as_ref() 744 - .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 745 - 746 - let entry_uri = last_entry.entry.uri.clone().into_static(); 747 - 748 - rsx! { 749 - div { class: "notebook-entry-preview notebook-entry-preview-last", 750 - div { class: "entry-preview-header", 751 - Link { 752 - to: Route::EntryPage { 753 - ident: ident.clone(), 754 - book_title: book_title.clone(), 755 - title: entry_path.clone().into() 756 - }, 757 - class: "entry-preview-title-link", 758 - div { class: "entry-preview-title", "{entry_title}" } 759 - } 760 - if let Some(ref date) = created_at { 761 - div { class: "entry-preview-date", "{date}" } 762 - } 763 - // EntryActions handles visibility via permissions 764 - crate::components::EntryActions { 765 - entry_uri, 766 - entry_cid: last_entry.entry.cid.clone().into_static(), 767 - entry_title: entry_title.to_string(), 768 - in_notebook: true, 769 - notebook_title: Some(book_title.clone()), 770 - permissions: last_entry.entry.permissions.clone() 771 - } 772 - } 773 - if let Some(ref html) = preview_html { 774 - Link { 775 - to: Route::EntryPage { 776 - ident: ident.clone(), 777 - book_title: book_title.clone(), 778 - title: entry_path.clone().into() 779 - }, 780 - class: "entry-preview-content-link", 781 - div { class: "entry-preview-content", dangerous_inner_html: "{html}" } 782 - } 783 - } 784 - } 785 - } 786 } 787 } 788 }
··· 107 }); 108 109 // Extract pinned URIs from profile (only Weaver ProfileView has pinned) 110 + // Returns (Vec for ordering, HashSet for O(1) lookups) 111 let pinned_uris = use_memo(move || { 112 use jacquard::IntoStatic; 113 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 114 115 let Some(prof) = profile.read().as_ref().cloned() else { 116 + return (Vec::<String>::new(), HashSet::<String>::new()); 117 }; 118 119 match &prof.inner { 120 + ProfileDataViewInner::ProfileView(p) => { 121 + let uris: Vec<String> = p 122 + .pinned 123 + .as_ref() 124 + .map(|pins| pins.iter().map(|r| r.uri.as_ref().to_string()).collect()) 125 + .unwrap_or_default(); 126 + let set: HashSet<String> = uris.iter().cloned().collect(); 127 + (uris, set) 128 + } 129 + _ => (Vec::new(), HashSet::new()), 130 } 131 }); 132 ··· 153 }); 154 155 // Helper to check if a URI is pinned 156 + fn is_pinned(uri: &str, pinned_set: &HashSet<String>) -> bool { 157 + pinned_set.contains(uri) 158 } 159 160 // Build pinned items (matching notebooks/entries against pinned URIs) ··· 162 let nbs = notebooks.read(); 163 let standalone = standalone_entries.read(); 164 let ents = all_entries.read(); 165 + let (pinned_vec, pinned_set) = &*pinned_uris.read(); 166 167 let mut items: Vec<ProfileTimelineItem> = Vec::new(); 168 ··· 170 if let Some(nbs) = nbs.as_ref() { 171 if let Some(all_ents) = ents.as_ref() { 172 for (notebook, entry_refs) in nbs { 173 + if is_pinned(notebook.uri.as_ref(), pinned_set) { 174 let sort_date = entry_refs 175 .iter() 176 .filter_map(|r| { ··· 194 195 // Check standalone entries 196 for (view, entry) in standalone.iter() { 197 + if is_pinned(view.uri.as_ref(), pinned_set) { 198 items.push(ProfileTimelineItem::StandaloneEntry { 199 entry_view: view.clone(), 200 entry: entry.clone(), ··· 208 ProfileTimelineItem::Notebook { notebook, .. } => notebook.uri.as_ref(), 209 ProfileTimelineItem::StandaloneEntry { entry_view, .. } => entry_view.uri.as_ref(), 210 }; 211 + pinned_vec 212 .iter() 213 + .position(|p| p == uri) 214 .unwrap_or(usize::MAX) 215 }); 216 ··· 222 let nbs = notebooks.read(); 223 let standalone = standalone_entries.read(); 224 let ents = all_entries.read(); 225 + let (_pinned_vec, pinned_set) = &*pinned_uris.read(); 226 227 let mut items: Vec<ProfileTimelineItem> = Vec::new(); 228 ··· 230 if let Some(nbs) = nbs.as_ref() { 231 if let Some(all_ents) = ents.as_ref() { 232 for (notebook, entry_refs) in nbs { 233 + if !is_pinned(notebook.uri.as_ref(), pinned_set) { 234 let sort_date = entry_refs 235 .iter() 236 .filter_map(|r| { ··· 254 255 // Add standalone entries (excluding pinned) 256 for (view, entry) in standalone.iter() { 257 + if !is_pinned(view.uri.as_ref(), pinned_set) { 258 items.push(ProfileTimelineItem::StandaloneEntry { 259 entry_view: view.clone(), 260 entry: entry.clone(), ··· 447 } 448 449 #[component] 450 + fn NotebookEntryPreview( 451 + book_entry_view: weaver_api::sh_weaver::notebook::BookEntryView<'static>, 452 + ident: AtIdentifier<'static>, 453 + book_title: SmolStr, 454 + #[props(default)] extra_class: Option<&'static str>, 455 + ) -> Element { 456 + use jacquard::{IntoStatic, from_data}; 457 + use weaver_api::sh_weaver::notebook::entry::Entry; 458 + 459 + let entry_view = &book_entry_view.entry; 460 + 461 + let entry_title = entry_view.title.as_ref() 462 + .map(|t| t.as_ref()) 463 + .unwrap_or("Untitled"); 464 + 465 + let entry_path = entry_view.path 466 + .as_ref() 467 + .map(|p| p.as_ref().to_string()) 468 + .unwrap_or_else(|| entry_title.to_string()); 469 + 470 + let parsed_entry = from_data::<Entry>(&entry_view.record).ok(); 471 + 472 + let preview_html = parsed_entry.as_ref().map(|entry| { 473 + let parser = markdown_weaver::Parser::new(&entry.content); 474 + let mut html_buf = String::new(); 475 + markdown_weaver::html::push_html(&mut html_buf, parser); 476 + html_buf 477 + }); 478 + 479 + let created_at = parsed_entry.as_ref() 480 + .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 481 + 482 + let entry_uri = entry_view.uri.clone().into_static(); 483 + 484 + let class_name = if let Some(extra) = extra_class { 485 + format!("notebook-entry-preview {}", extra) 486 + } else { 487 + "notebook-entry-preview".to_string() 488 + }; 489 + 490 + rsx! { 491 + div { class: "{class_name}", 492 + div { class: "entry-preview-header", 493 + Link { 494 + to: Route::EntryPage { 495 + ident: ident.clone(), 496 + book_title: book_title.clone(), 497 + title: entry_path.clone().into() 498 + }, 499 + class: "entry-preview-title-link", 500 + div { class: "entry-preview-title", "{entry_title}" } 501 + } 502 + if let Some(ref date) = created_at { 503 + div { class: "entry-preview-date", "{date}" } 504 + } 505 + crate::components::EntryActions { 506 + entry_uri, 507 + entry_cid: entry_view.cid.clone().into_static(), 508 + entry_title: entry_title.to_string(), 509 + in_notebook: true, 510 + notebook_title: Some(book_title.clone()), 511 + permissions: entry_view.permissions.clone() 512 + } 513 + } 514 + if let Some(ref html) = preview_html { 515 + Link { 516 + to: Route::EntryPage { 517 + ident: ident.clone(), 518 + book_title: book_title.clone(), 519 + title: entry_path.clone().into() 520 + }, 521 + class: "entry-preview-content-link", 522 + div { class: "entry-preview-content", dangerous_inner_html: "{html}" } 523 + } 524 + } 525 + } 526 + } 527 + } 528 + 529 + #[component] 530 pub fn NotebookCard( 531 notebook: NotebookView<'static>, 532 entry_refs: Vec<StrongRef<'static>>, ··· 651 if entry_list.len() <= 5 { 652 // Show all entries if 5 or fewer 653 rsx! { 654 + for entry_view in entry_list.iter() { 655 + NotebookEntryPreview { 656 + book_entry_view: entry_view.clone(), 657 + ident: ident.clone(), 658 + book_title: book_title.clone(), 659 } 660 } 661 } ··· 663 // Show first, interstitial, and last 664 rsx! { 665 if let Some(first_entry) = entry_list.first() { 666 + NotebookEntryPreview { 667 + book_entry_view: first_entry.clone(), 668 + ident: ident.clone(), 669 + book_title: book_title.clone(), 670 + extra_class: "notebook-entry-preview-first", 671 } 672 } 673 ··· 684 } 685 686 if let Some(last_entry) = entry_list.last() { 687 + NotebookEntryPreview { 688 + book_entry_view: last_entry.clone(), 689 + ident: ident.clone(), 690 + book_title: book_title.clone(), 691 + extra_class: "notebook-entry-preview-last", 692 } 693 } 694 }
-12
crates/weaver-app/src/data.rs
··· 906 .await 907 .ok(); 908 } 909 - #[cfg(all(target_family = "wasm", target_os = "unknown",))] 910 - { 911 - tracing::info!("Registering standalone entry blobs"); 912 - let _ = 913 - crate::service_worker::register_standalone_entry_blobs( 914 - &ident, 915 - at_uri.rkey().unwrap().0.as_str(), 916 - images, 917 - &fetcher, 918 - ) 919 - .await; 920 - } 921 } 922 } 923 }
··· 906 .await 907 .ok(); 908 } 909 } 910 } 911 }
+27 -28
crates/weaver-app/src/og/mod.rs
··· 5 6 use crate::cache_impl::{Cache, new_cache}; 7 use askama::Template; 8 - use jacquard::smol_str::SmolStr; 9 - use jacquard::smol_str::format_smolstr; 10 use std::sync::OnceLock; 11 use std::time::Duration; 12 ··· 40 #[derive(Debug)] 41 pub enum OgError { 42 NotFound, 43 - FetchError(String), 44 - RenderError(String), 45 - TemplateError(String), 46 } 47 48 impl std::fmt::Display for OgError { ··· 77 pub struct TextOnlyTemplate { 78 pub title_lines: Vec<String>, 79 pub content_lines: Vec<String>, 80 - pub notebook_title: String, 81 - pub author_handle: String, 82 } 83 84 /// Hero image template (full-bleed image with overlay) ··· 87 pub struct HeroImageTemplate { 88 pub hero_image_data: String, 89 pub title_lines: Vec<String>, 90 - pub notebook_title: String, 91 - pub author_handle: String, 92 } 93 94 /// Notebook index template ··· 96 #[template(path = "og_notebook.svg", escape = "none")] 97 pub struct NotebookTemplate { 98 pub title_lines: Vec<String>, 99 - pub author_handle: String, 100 pub entry_count: usize, 101 pub entry_titles: Vec<String>, 102 } ··· 107 pub struct ProfileTemplate { 108 pub avatar_data: Option<String>, 109 pub display_name_lines: Vec<String>, 110 - pub handle: String, 111 pub bio_lines: Vec<String>, 112 pub notebook_count: usize, 113 } ··· 119 pub banner_image_data: String, 120 pub avatar_data: Option<String>, 121 pub display_name_lines: Vec<String>, 122 - pub handle: String, 123 pub bio_lines: Vec<String>, 124 pub notebook_count: usize, 125 } ··· 166 }; 167 168 let tree = usvg::Tree::from_str(svg, &options) 169 - .map_err(|e| OgError::RenderError(format!("Failed to parse SVG: {}", e)))?; 170 171 let mut pixmap = tiny_skia::Pixmap::new(OG_WIDTH, OG_HEIGHT) 172 - .ok_or_else(|| OgError::RenderError("Failed to create pixmap".to_string()))?; 173 174 resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); 175 176 pixmap 177 .encode_png() 178 - .map_err(|e| OgError::RenderError(format!("Failed to encode PNG: {}", e))) 179 } 180 181 /// Generate a text-only OG image ··· 191 let template = TextOnlyTemplate { 192 title_lines, 193 content_lines, 194 - notebook_title: notebook_title.to_string(), 195 - author_handle: author_handle.to_string(), 196 }; 197 198 let svg = template 199 .render() 200 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 201 202 render_svg_to_png(&svg) 203 } ··· 214 let template = HeroImageTemplate { 215 hero_image_data: hero_image_data.to_string(), 216 title_lines, 217 - notebook_title: notebook_title.to_string(), 218 - author_handle: author_handle.to_string(), 219 }; 220 221 let svg = template 222 .render() 223 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 224 225 render_svg_to_png(&svg) 226 } ··· 258 259 let template = NotebookTemplate { 260 title_lines, 261 - author_handle: author_handle.to_string(), 262 entry_count, 263 entry_titles, 264 }; 265 266 let svg = template 267 .render() 268 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 269 270 render_svg_to_png(&svg) 271 } ··· 284 let template = ProfileTemplate { 285 avatar_data, 286 display_name_lines, 287 - handle: handle.to_string(), 288 bio_lines, 289 notebook_count, 290 }; 291 292 let svg = template 293 .render() 294 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 295 296 render_svg_to_png(&svg) 297 } ··· 312 banner_image_data, 313 avatar_data, 314 display_name_lines, 315 - handle: handle.to_string(), 316 bio_lines, 317 notebook_count, 318 }; 319 320 let svg = template 321 .render() 322 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 323 324 render_svg_to_png(&svg) 325 } ··· 330 331 let svg = template 332 .render() 333 - .map_err(|e| OgError::TemplateError(e.to_string()))?; 334 335 render_svg_to_png(&svg) 336 }
··· 5 6 use crate::cache_impl::{Cache, new_cache}; 7 use askama::Template; 8 + use jacquard::smol_str::{SmolStr, ToSmolStr, format_smolstr}; 9 use std::sync::OnceLock; 10 use std::time::Duration; 11 ··· 39 #[derive(Debug)] 40 pub enum OgError { 41 NotFound, 42 + FetchError(SmolStr), 43 + RenderError(SmolStr), 44 + TemplateError(SmolStr), 45 } 46 47 impl std::fmt::Display for OgError { ··· 76 pub struct TextOnlyTemplate { 77 pub title_lines: Vec<String>, 78 pub content_lines: Vec<String>, 79 + pub notebook_title: SmolStr, 80 + pub author_handle: SmolStr, 81 } 82 83 /// Hero image template (full-bleed image with overlay) ··· 86 pub struct HeroImageTemplate { 87 pub hero_image_data: String, 88 pub title_lines: Vec<String>, 89 + pub notebook_title: SmolStr, 90 + pub author_handle: SmolStr, 91 } 92 93 /// Notebook index template ··· 95 #[template(path = "og_notebook.svg", escape = "none")] 96 pub struct NotebookTemplate { 97 pub title_lines: Vec<String>, 98 + pub author_handle: SmolStr, 99 pub entry_count: usize, 100 pub entry_titles: Vec<String>, 101 } ··· 106 pub struct ProfileTemplate { 107 pub avatar_data: Option<String>, 108 pub display_name_lines: Vec<String>, 109 + pub handle: SmolStr, 110 pub bio_lines: Vec<String>, 111 pub notebook_count: usize, 112 } ··· 118 pub banner_image_data: String, 119 pub avatar_data: Option<String>, 120 pub display_name_lines: Vec<String>, 121 + pub handle: SmolStr, 122 pub bio_lines: Vec<String>, 123 pub notebook_count: usize, 124 } ··· 165 }; 166 167 let tree = usvg::Tree::from_str(svg, &options) 168 + .map_err(|e| OgError::RenderError(format_smolstr!("Failed to parse SVG: {}", e)))?; 169 170 let mut pixmap = tiny_skia::Pixmap::new(OG_WIDTH, OG_HEIGHT) 171 + .ok_or_else(|| OgError::RenderError("Failed to create pixmap".to_smolstr()))?; 172 173 resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); 174 175 pixmap 176 .encode_png() 177 + .map_err(|e| OgError::RenderError(format_smolstr!("Failed to encode PNG: {}", e))) 178 } 179 180 /// Generate a text-only OG image ··· 190 let template = TextOnlyTemplate { 191 title_lines, 192 content_lines, 193 + notebook_title: notebook_title.to_smolstr(), 194 + author_handle: author_handle.to_smolstr(), 195 }; 196 197 let svg = template 198 .render() 199 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 200 201 render_svg_to_png(&svg) 202 } ··· 213 let template = HeroImageTemplate { 214 hero_image_data: hero_image_data.to_string(), 215 title_lines, 216 + notebook_title: notebook_title.to_smolstr(), 217 + author_handle: author_handle.to_smolstr(), 218 }; 219 220 let svg = template 221 .render() 222 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 223 224 render_svg_to_png(&svg) 225 } ··· 257 258 let template = NotebookTemplate { 259 title_lines, 260 + author_handle: author_handle.to_smolstr(), 261 entry_count, 262 entry_titles, 263 }; 264 265 let svg = template 266 .render() 267 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 268 269 render_svg_to_png(&svg) 270 } ··· 283 let template = ProfileTemplate { 284 avatar_data, 285 display_name_lines, 286 + handle: handle.to_smolstr(), 287 bio_lines, 288 notebook_count, 289 }; 290 291 let svg = template 292 .render() 293 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 294 295 render_svg_to_png(&svg) 296 } ··· 311 banner_image_data, 312 avatar_data, 313 display_name_lines, 314 + handle: handle.to_smolstr(), 315 bio_lines, 316 notebook_count, 317 }; 318 319 let svg = template 320 .render() 321 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 322 323 render_svg_to_png(&svg) 324 } ··· 329 330 let svg = template 331 .render() 332 + .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 333 334 render_svg_to_png(&svg) 335 }
+5 -4
crates/weaver-common/src/agent.rs
··· 16 use jacquard::types::string::{AtUri, Did, RecordKey, Rkey}; 17 use jacquard::types::tid::Tid; 18 use jacquard::types::uri::Uri; 19 use jacquard::url::Url; 20 use jacquard::{CowStr, IntoStatic}; 21 use mime_sniffer::MimeTypeSniffer; ··· 2180 2181 peers.push(SessionPeer { 2182 did: record_id.did.into_static(), 2183 - node_id: session_record.value.node_id.to_string(), 2184 relay_url: session_record 2185 .value 2186 .relay_url 2187 - .map(|u| u.as_str().to_string()), 2188 created_at: session_record.value.created_at, 2189 expires_at: session_record.value.expires_at, 2190 }); ··· 2301 /// The peer's DID. 2302 pub did: Did<'a>, 2303 /// The peer's iroh NodeId (z-base32 encoded). 2304 - pub node_id: String, 2305 /// Optional relay URL for browser clients. 2306 - pub relay_url: Option<String>, 2307 /// When the session was created. 2308 pub created_at: jacquard::types::string::Datetime, 2309 /// When the session expires (if set).
··· 16 use jacquard::types::string::{AtUri, Did, RecordKey, Rkey}; 17 use jacquard::types::tid::Tid; 18 use jacquard::types::uri::Uri; 19 + use jacquard::smol_str::SmolStr; 20 use jacquard::url::Url; 21 use jacquard::{CowStr, IntoStatic}; 22 use mime_sniffer::MimeTypeSniffer; ··· 2181 2182 peers.push(SessionPeer { 2183 did: record_id.did.into_static(), 2184 + node_id: session_record.value.node_id.as_ref().into(), 2185 relay_url: session_record 2186 .value 2187 .relay_url 2188 + .map(|u| u.as_ref().into()), 2189 created_at: session_record.value.created_at, 2190 expires_at: session_record.value.expires_at, 2191 }); ··· 2302 /// The peer's DID. 2303 pub did: Did<'a>, 2304 /// The peer's iroh NodeId (z-base32 encoded). 2305 + pub node_id: SmolStr, 2306 /// Optional relay URL for browser clients. 2307 + pub relay_url: Option<SmolStr>, 2308 /// When the session was created. 2309 pub created_at: jacquard::types::string::Datetime, 2310 /// When the session expires (if set).
+6 -5
crates/weaver-common/src/transport/messages.rs
··· 1 //! Wire protocol for collaborative editing messages. 2 3 use serde::{Deserialize, Serialize}; 4 5 /// Messages exchanged between collaborators over gossip. ··· 26 /// Collaborator joined the session 27 Join { 28 /// DID of the joining user 29 - did: String, 30 /// Display name for presence UI 31 - display_name: String, 32 }, 33 34 /// Collaborator left the session 35 Leave { 36 /// DID of the leaving user 37 - did: String, 38 }, 39 40 /// Request sync from peers (late joiner) ··· 183 #[test] 184 fn test_roundtrip_join() { 185 let msg = CollabMessage::Join { 186 - did: "did:plc:abc123".to_string(), 187 - display_name: "Alice".to_string(), 188 }; 189 let bytes = msg.to_bytes().unwrap(); 190 let decoded = CollabMessage::from_bytes(&bytes).unwrap();
··· 1 //! Wire protocol for collaborative editing messages. 2 3 + use jacquard::smol_str::SmolStr; 4 use serde::{Deserialize, Serialize}; 5 6 /// Messages exchanged between collaborators over gossip. ··· 27 /// Collaborator joined the session 28 Join { 29 /// DID of the joining user 30 + did: SmolStr, 31 /// Display name for presence UI 32 + display_name: SmolStr, 33 }, 34 35 /// Collaborator left the session 36 Leave { 37 /// DID of the leaving user 38 + did: SmolStr, 39 }, 40 41 /// Request sync from peers (late joiner) ··· 184 #[test] 185 fn test_roundtrip_join() { 186 let msg = CollabMessage::Join { 187 + did: "did:plc:abc123".into(), 188 + display_name: "Alice".into(), 189 }; 190 let bytes = msg.to_bytes().unwrap(); 191 let decoded = CollabMessage::from_bytes(&bytes).unwrap();
+6 -5
crates/weaver-common/src/transport/node.rs
··· 6 use iroh::EndpointId; 7 use iroh::SecretKey; 8 use iroh_gossip::net::{GOSSIP_ALPN, Gossip}; 9 use miette::Diagnostic; 10 use std::sync::Arc; 11 ··· 80 } 81 82 /// Get the node ID as a z-base32 string for storage in AT Protocol records. 83 - pub fn node_id_string(&self) -> String { 84 - self.endpoint.id().to_string() 85 } 86 87 /// Get a reference to the gossip handler for joining topics. ··· 103 /// 104 /// This should be published in session records so other peers can connect 105 /// via relay (essential for browser-to-browser connections). 106 - pub fn relay_url(&self) -> Option<String> { 107 self.endpoint 108 .addr() 109 .relay_urls() 110 .next() 111 - .map(|url| url.to_string()) 112 } 113 114 /// Get the full node address including relay info. ··· 131 /// 132 /// Waits indefinitely for relay - browser clients require relay URLs 133 /// for peer discovery. Returns the relay URL once connected. 134 - pub async fn wait_for_relay(&self) -> String { 135 self.endpoint.online().await; 136 // After online(), relay_url should always be Some for browser clients 137 self.relay_url()
··· 6 use iroh::EndpointId; 7 use iroh::SecretKey; 8 use iroh_gossip::net::{GOSSIP_ALPN, Gossip}; 9 + use jacquard::smol_str::{SmolStr, ToSmolStr}; 10 use miette::Diagnostic; 11 use std::sync::Arc; 12 ··· 81 } 82 83 /// Get the node ID as a z-base32 string for storage in AT Protocol records. 84 + pub fn node_id_string(&self) -> SmolStr { 85 + self.endpoint.id().to_smolstr() 86 } 87 88 /// Get a reference to the gossip handler for joining topics. ··· 104 /// 105 /// This should be published in session records so other peers can connect 106 /// via relay (essential for browser-to-browser connections). 107 + pub fn relay_url(&self) -> Option<SmolStr> { 108 self.endpoint 109 .addr() 110 .relay_urls() 111 .next() 112 + .map(|url| url.to_smolstr()) 113 } 114 115 /// Get the full node address including relay info. ··· 132 /// 133 /// Waits indefinitely for relay - browser clients require relay URLs 134 /// for peer discovery. Returns the relay URL once connected. 135 + pub async fn wait_for_relay(&self) -> SmolStr { 136 self.endpoint.online().await; 137 // After online(), relay_url should always be Some for browser clients 138 self.relay_url()
+11 -5
crates/weaver-common/src/transport/presence.rs
··· 7 use std::collections::HashMap; 8 9 use iroh::EndpointId; 10 use web_time::Instant; 11 12 /// A remote collaborator's cursor state. ··· 26 #[derive(Debug, Clone)] 27 pub struct Collaborator { 28 /// The collaborator's DID. 29 - pub did: String, 30 /// Display name for UI. 31 - pub display_name: String, 32 /// Assigned colour (RGBA). 33 pub color: u32, 34 /// Current cursor state. ··· 65 } 66 67 /// Add a collaborator when they join. 68 - pub fn add_collaborator(&mut self, node_id: EndpointId, did: String, display_name: String) { 69 let color = self.assign_color(); 70 self.collaborators.insert( 71 node_id, 72 Collaborator { 73 - did, 74 - display_name, 75 color, 76 cursor: None, 77 node_id,
··· 7 use std::collections::HashMap; 8 9 use iroh::EndpointId; 10 + use jacquard::smol_str::SmolStr; 11 use web_time::Instant; 12 13 /// A remote collaborator's cursor state. ··· 27 #[derive(Debug, Clone)] 28 pub struct Collaborator { 29 /// The collaborator's DID. 30 + pub did: SmolStr, 31 /// Display name for UI. 32 + pub display_name: SmolStr, 33 /// Assigned colour (RGBA). 34 pub color: u32, 35 /// Current cursor state. ··· 66 } 67 68 /// Add a collaborator when they join. 69 + pub fn add_collaborator( 70 + &mut self, 71 + node_id: EndpointId, 72 + did: impl Into<SmolStr>, 73 + display_name: impl Into<SmolStr>, 74 + ) { 75 let color = self.assign_color(); 76 self.collaborators.insert( 77 node_id, 78 Collaborator { 79 + did: did.into(), 80 + display_name: display_name.into(), 81 color, 82 cursor: None, 83 node_id,
+6 -5
crates/weaver-common/src/transport/presence_types.rs
··· 1 //! Presence types for main thread rendering. 2 //! 3 - //! These types use String node IDs instead of EndpointId, 4 //! allowing them to be used without the iroh feature. 5 6 use serde::{Deserialize, Serialize}; 7 8 /// A remote collaborator's cursor for rendering. 9 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 10 pub struct RemoteCursorInfo { 11 /// Node ID as string (z-base32 encoded) 12 - pub node_id: String, 13 /// Character offset in the document 14 pub position: usize, 15 /// Selection range (anchor, head) if any ··· 22 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 23 pub struct CollaboratorInfo { 24 /// Node ID as string 25 - pub node_id: String, 26 /// The collaborator's DID 27 - pub did: String, 28 /// Display name for UI 29 - pub display_name: String, 30 /// Assigned colour (RGBA) 31 pub color: u32, 32 /// Current cursor position (if known)
··· 1 //! Presence types for main thread rendering. 2 //! 3 + //! These types use SmolStr node IDs instead of EndpointId, 4 //! allowing them to be used without the iroh feature. 5 6 + use jacquard::smol_str::SmolStr; 7 use serde::{Deserialize, Serialize}; 8 9 /// A remote collaborator's cursor for rendering. 10 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 11 pub struct RemoteCursorInfo { 12 /// Node ID as string (z-base32 encoded) 13 + pub node_id: SmolStr, 14 /// Character offset in the document 15 pub position: usize, 16 /// Selection range (anchor, head) if any ··· 23 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 24 pub struct CollaboratorInfo { 25 /// Node ID as string 26 + pub node_id: SmolStr, 27 /// The collaborator's DID 28 + pub did: SmolStr, 29 /// Display name for UI 30 + pub display_name: SmolStr, 31 /// Assigned colour (RGBA) 32 pub color: u32, 33 /// Current cursor position (if known)