···4//! the CollabCoordinator component for display in the editor debug panel.
56use dioxus::prelude::*;
078/// Debug state for the collab session, displayed in editor debug panel.
9#[derive(Clone, Default)]
10pub 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}
2627/// Hook to get the collab debug state signal.
···4//! the CollabCoordinator component for display in the editor debug panel.
56use dioxus::prelude::*;
7+use jacquard::smol_str::SmolStr;
89/// Debug state for the collab session, displayed in editor debug panel.
10#[derive(Clone, Default)]
11pub 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}
2728/// Hook to get the collab debug state signal.
···20#[cfg(all(target_family = "wasm", target_os = "unknown"))]
21use gloo_storage::{LocalStorage, Storage};
22use jacquard::IntoStatic;
023use jacquard::types::string::{AtUri, Cid};
24use weaver_api::com_atproto::repo::strong_ref::StrongRef;
25use loro::cursor::Cursor;
···4950 /// Entry title (for debugging/display in drafts list)
51 #[serde(default)]
52- pub title: String,
5354 /// Base64-encoded CRDT snapshot (contains ALL fields including embeds)
55 #[serde(default, skip_serializing_if = "Option::is_none")]
···6566 /// 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>,
6970 /// 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}
7475/// Build the full storage key from a draft key.
···9798 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 };
107108 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));
0000239 }
240 }
241 }
···20#[cfg(all(target_family = "wasm", target_os = "unknown"))]
21use gloo_storage::{LocalStorage, Storage};
22use jacquard::IntoStatic;
23+use jacquard::smol_str::{SmolStr, ToSmolStr};
24use jacquard::types::string::{AtUri, Cid};
25use weaver_api::com_atproto::repo::strong_ref::StrongRef;
26use loro::cursor::Cursor;
···5051 /// Entry title (for debugging/display in drafts list)
52 #[serde(default)]
53+ pub title: SmolStr,
5455 /// Base64-encoded CRDT snapshot (contains ALL fields including embeds)
56 #[serde(default, skip_serializing_if = "Option::is_none")]
···6667 /// 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>,
7071 /// 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}
7576/// Build the full storage key from a draft key.
···9899 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 };
108109 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}
241242-/// 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)]
258pub 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}
306307/// Find ALL edit.root records across collaborators for an entry.
···551}
552553/// Find all diffs for a root record using constellation backlinks.
554-#[allow(dead_code)]
555pub 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? {
0000921 Some(id) => id,
922 None => return Ok(None),
923 };
···239 }
240}
24100000000000000242/// Result of a sync operation.
243#[derive(Clone, Debug)]
244pub enum SyncResult {
···254 },
255 /// No changes to sync
256 NoChanges,
0000000000000000000000000000000000257}
258259/// Find ALL edit.root records across collaborators for an entry.
···503}
504505/// Find all diffs for a root record using constellation backlinks.
0506pub 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"))]
19use jacquard::smol_str::format_smolstr;
200021/// Input messages to the editor worker.
22#[derive(Serialize, Deserialize, Debug, Clone)]
23pub 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;
137138 #[cfg(feature = "collab-worker")]
00139 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();
159160 // 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}");
000201 }
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}");
000000000000235 }
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- }
245246- 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}");
00250 }
251- }
252- }
253254- 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- };
270271- 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(),
000000000000000000000000000000278 })
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;
287288- 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();
000000000000000000000294295- 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- }
312313- // ============================================================
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- };
336337- // Wait for relay connection
338- let relay_url = node.wait_for_relay().await;
339- let node_id = node.node_id_string();
340341- // 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- }
351352- collab_node = Some(node.clone());
0000000000353354- // Parse bootstrap peers
355- let peers: Vec<_> = bootstrap_peers
356- .iter()
357- .filter_map(|s| parse_node_id(s).ok())
358- .collect();
359360- // 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- }
369370- // 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);
376377- // 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;
0000000000000000000000000000000000000000000000000000000000399 }
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");
00000407 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 => {}
00000000000449 }
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- }
465466- #[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}");
00475 }
476- }
477- }
478479- #[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}");
000000000000000000490 }
491- } else {
492- tracing::debug!(position, ?selection, "Worker: BroadcastCursor but no session");
493- }
494- }
495496- #[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}");
0000000515 }
516- } else {
517- tracing::warn!("Worker: AddPeers but no collab_session");
518- }
519- }
520521- #[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}");
00527 }
528- }
529- }
530531- #[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 } => {
000552 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}");
00562 }
563 continue;
564 }
···576 }
577 }
578 }
579- WorkerInput::ExportSnapshot { cursor_offset, editing_uri, editing_cid } => {
0000580 let Some(ref doc) = doc else {
581- if let Err(e) = scope.send(WorkerOutput::Error { message: "No document initialized".into() }).await {
00000582 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}");
0000000592 }
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 {
0000000000606 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 {
00000612 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};
0692 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;
698699 /// 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"))]
19use jacquard::smol_str::format_smolstr;
2021+use jacquard::smol_str::SmolStr;
22+23/// Input messages to the editor worker.
24#[derive(Serialize, Deserialize, Debug, Clone)]
25pub 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;
139140 #[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();
163164 // 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 }
0255 }
0000000256257+ 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 }
00264265+ 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+ };
281282+ 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 }
0324 }
00325326+ // ============================================================
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+ };
353354+ // Wait for relay connection
355+ let relay_url = node.wait_for_relay().await;
356+ let node_id = node.node_id_string();
00000000000000357358+ // 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),
0000000000363 })
364 .await
365 {
366+ tracing::error!("Failed to send CollabReady to coordinator: {e}");
367 }
000368369+ collab_node = Some(node.clone());
00370371+ // Parse bootstrap peers
372+ let peers: Vec<_> = bootstrap_peers
373+ .iter()
374+ .filter_map(|s| parse_node_id(s).ok())
375+ .collect();
00000376377+ // 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+ }
388389+ // NOTE: Don't broadcast Join here - wait for BroadcastJoin message
390+ // after peers have been added via AddPeers
000391392+ // 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);
00000396397+ // Spawn event handler task that sends via channel
398+ wasm_bindgen_futures::spawn_local(async move {
399+ let mut presence = PresenceTracker::new();
000400401+ 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+ );
000508 return;
509 }
510 }
511+ SessionEvent::Joined => {}
000000000000000000512 }
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 }
0000000000528 }
529 }
00530531+ #[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 }
00543544+ #[cfg(feature = "collab-worker")]
545+ WorkerInput::BroadcastCursor {
000546 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 }
0000571572+ #[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 }
0000599600+ #[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 }
00609610+ #[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+ }
0619 } // 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;
0811 use std::time::Duration;
812813 /// 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
···23use std::sync::Arc;
24use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, entry};
2526-// #[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]
38pub 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}
208209/// OpenGraph and Twitter Card meta tags for entries
···23use std::sync::Arc;
24use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, entry};
250000000000026#[component]
27pub 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)) => {
000000000000000000000000073 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 }
0000000000000000000000000000000000000000000000000000000116}
117118/// OpenGraph and Twitter Card meta tags for entries
+117-211
crates/weaver-app/src/components/identity.rs
···107 });
108109 // Extract pinned URIs from profile (only Weaver ProfileView has pinned)
0110 let pinned_uris = use_memo(move || {
111 use jacquard::IntoStatic;
112- use jacquard::types::aturi::AtUri;
113 use weaver_api::sh_weaver::actor::ProfileDataViewInner;
114115 let Some(prof) = profile.read().as_ref().cloned() else {
116- return Vec::<AtUri<'static>>::new();
117 };
118119 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(),
0000126 }
127 });
128···149 });
150151 // 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 }
155156 // 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();
162163 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| {
···190191 // 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();
222223 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| {
···250251 // 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}
444445#[component]
00000000000000000000000000000000000000000000000000000000000000000000000000000000446pub 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 }
720721 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 });
108109 // 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;
0113 use weaver_api::sh_weaver::actor::ProfileDataViewInner;
114115 let Some(prof) = profile.read().as_ref().cloned() else {
116+ return (Vec::<String>::new(), HashSet::<String>::new());
117 };
118119 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 });
154155 // 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 }
159160 // 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();
166167 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| {
···194195 // 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();
226227 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| {
···254255 // 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}
448449#[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]
530pub 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(),
000000000000000000000000000000000000000000000000000000000000659 }
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",
00000000000000000000000000000000000000000000000000000000000671 }
672 }
673···684 }
685686 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",
00000000000000000000000000000000000000000000000000000000000692 }
693 }
694 }
···1//! Wire protocol for collaborative editing messages.
203use serde::{Deserialize, Serialize};
45/// 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 },
3334 /// Collaborator left the session
35 Leave {
36 /// DID of the leaving user
37- did: String,
38 },
3940 /// 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.
23+use jacquard::smol_str::SmolStr;
4use serde::{Deserialize, Serialize};
56/// 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 },
3435 /// Collaborator left the session
36 Leave {
37 /// DID of the leaving user
38+ did: SmolStr,
39 },
4041 /// 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
···6use iroh::EndpointId;
7use iroh::SecretKey;
8use iroh_gossip::net::{GOSSIP_ALPN, Gossip};
09use miette::Diagnostic;
10use std::sync::Arc;
11···80 }
8182 /// 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 }
8687 /// 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 }
113114 /// 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()
···6use iroh::EndpointId;
7use iroh::SecretKey;
8use iroh_gossip::net::{GOSSIP_ALPN, Gossip};
9+use jacquard::smol_str::{SmolStr, ToSmolStr};
10use miette::Diagnostic;
11use std::sync::Arc;
12···81 }
8283 /// 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 }
8788 /// 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 }
114115 /// 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
···7use std::collections::HashMap;
89use iroh::EndpointId;
010use web_time::Instant;
1112/// A remote collaborator's cursor state.
···26#[derive(Debug, Clone)]
27pub 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 }
6667 /// Add a collaborator when they join.
68- pub fn add_collaborator(&mut self, node_id: EndpointId, did: String, display_name: String) {
0000069 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,
···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.
506use serde::{Deserialize, Serialize};
78/// A remote collaborator's cursor for rendering.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub 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)]
23pub 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.
56+use jacquard::smol_str::SmolStr;
7use serde::{Deserialize, Serialize};
89/// A remote collaborator's cursor for rendering.
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub 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)]
24pub 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)