BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: drafts with CRUD operations and migration

+738 -8
+7 -7
docs/tasks/14-drafts.md
··· 8 8 9 9 ### Backend - `src-tauri/src/drafts.rs` + `src-tauri/src/commands/drafts.rs` 10 10 11 - - [ ] SQLite migration: `drafts` table (`id TEXT PRIMARY KEY, account_did TEXT NOT NULL, text TEXT NOT NULL, reply_parent_uri TEXT, reply_parent_cid TEXT, reply_root_uri TEXT, reply_root_cid TEXT, quote_uri TEXT, quote_cid TEXT, title TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL`) 12 - - [ ] `Draft` and `DraftInput` structs mirroring the schema 13 - - [ ] `list_drafts(account_did: String)` — return all drafts for the account, ordered by `updated_at` desc 14 - - [ ] `get_draft(id: String)` — single draft by ID 15 - - [ ] `save_draft(input: DraftInput)` — upsert: if `id` is present and exists, update; otherwise insert with new UUID 16 - - [ ] `delete_draft(id: String)` — hard delete 17 - - [ ] `submit_draft(id: String)` — load draft, call `create_post`, delete draft on success, return `CreateRecordResult` 11 + - [x] SQLite migration: `drafts` table (`id TEXT PRIMARY KEY, account_did TEXT NOT NULL, text TEXT NOT NULL, reply_parent_uri TEXT, reply_parent_cid TEXT, reply_root_uri TEXT, reply_root_cid TEXT, quote_uri TEXT, quote_cid TEXT, title TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL`) 12 + - [x] `Draft` and `DraftInput` structs mirroring the schema 13 + - [x] `list_drafts(account_did: String)` — return all drafts for the account, ordered by `updated_at` desc 14 + - [x] `get_draft(id: String)` — single draft by ID 15 + - [x] `save_draft(input: DraftInput)` — upsert: if `id` is present and exists, update; otherwise insert with new UUID 16 + - [x] `delete_draft(id: String)` — hard delete 17 + - [x] `submit_draft(id: String)` — load draft, call `create_post`, delete draft on success, return `CreateRecordResult` 18 18 19 19 ### Frontend - Drafts List Panel 20 20
+32
src-tauri/src/commands/drafts.rs
··· 1 + #![allow(clippy::needless_pass_by_value)] 2 + 3 + use crate::drafts::{self, Draft, DraftInput}; 4 + use crate::error::AppError; 5 + use crate::feed::CreateRecordResult; 6 + use crate::state::AppState; 7 + use tauri::State; 8 + 9 + #[tauri::command] 10 + pub fn list_drafts(account_did: String, state: State<'_, AppState>) -> Result<Vec<Draft>, AppError> { 11 + drafts::list_drafts(&account_did, &state) 12 + } 13 + 14 + #[tauri::command] 15 + pub fn get_draft(id: String, state: State<'_, AppState>) -> Result<Draft, AppError> { 16 + drafts::get_draft(&id, &state) 17 + } 18 + 19 + #[tauri::command] 20 + pub fn save_draft(input: DraftInput, state: State<'_, AppState>) -> Result<Draft, AppError> { 21 + drafts::save_draft(&input, &state) 22 + } 23 + 24 + #[tauri::command] 25 + pub fn delete_draft(id: String, state: State<'_, AppState>) -> Result<(), AppError> { 26 + drafts::delete_draft(&id, &state) 27 + } 28 + 29 + #[tauri::command] 30 + pub async fn submit_draft(id: String, state: State<'_, AppState>) -> Result<CreateRecordResult, AppError> { 31 + drafts::submit_draft(id, &state).await 32 + }
+1
src-tauri/src/commands/mod.rs
··· 10 10 11 11 pub mod columns; 12 12 pub mod diagnostics; 13 + pub mod drafts; 13 14 pub mod explorer; 14 15 pub mod search; 15 16 pub mod settings;
+1
src-tauri/src/db.rs
··· 58 58 "embeddings_opt_in", 59 59 include_str!("migrations/010_embeddings_opt_in.sql"), 60 60 ), 61 + Migration::new(11, "drafts", include_str!("migrations/011_drafts.sql")), 61 62 ]; 62 63 63 64 pub fn initialize_database(app: &AppHandle) -> Result<DbPool, AppError> {
+674
src-tauri/src/drafts.rs
··· 1 + use super::error::{AppError, Result}; 2 + use super::feed::{self, CreateRecordResult, EmbedInput, ReplyRefInput, StrongRefInput}; 3 + use super::state::AppState; 4 + use rusqlite::{params, Connection, OptionalExtension}; 5 + use serde::{Deserialize, Serialize}; 6 + use tauri_plugin_log::log; 7 + use uuid::Uuid; 8 + 9 + #[derive(Debug, Clone, Serialize)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct Draft { 12 + pub id: String, 13 + pub account_did: String, 14 + pub text: String, 15 + pub reply_parent_uri: Option<String>, 16 + pub reply_parent_cid: Option<String>, 17 + pub reply_root_uri: Option<String>, 18 + pub reply_root_cid: Option<String>, 19 + pub quote_uri: Option<String>, 20 + pub quote_cid: Option<String>, 21 + pub title: Option<String>, 22 + pub created_at: String, 23 + pub updated_at: String, 24 + } 25 + 26 + #[derive(Debug, Deserialize)] 27 + #[serde(rename_all = "camelCase")] 28 + pub struct DraftInput { 29 + pub id: Option<String>, 30 + pub text: String, 31 + pub reply_parent_uri: Option<String>, 32 + pub reply_parent_cid: Option<String>, 33 + pub reply_root_uri: Option<String>, 34 + pub reply_root_cid: Option<String>, 35 + pub quote_uri: Option<String>, 36 + pub quote_cid: Option<String>, 37 + pub title: Option<String>, 38 + } 39 + 40 + fn row_to_draft(row: &rusqlite::Row<'_>) -> rusqlite::Result<Draft> { 41 + Ok(Draft { 42 + id: row.get(0)?, 43 + account_did: row.get(1)?, 44 + text: row.get(2)?, 45 + reply_parent_uri: row.get(3)?, 46 + reply_parent_cid: row.get(4)?, 47 + reply_root_uri: row.get(5)?, 48 + reply_root_cid: row.get(6)?, 49 + quote_uri: row.get(7)?, 50 + quote_cid: row.get(8)?, 51 + title: row.get(9)?, 52 + created_at: row.get(10)?, 53 + updated_at: row.get(11)?, 54 + }) 55 + } 56 + 57 + fn db_list_drafts(conn: &Connection, account_did: &str) -> Result<Vec<Draft>> { 58 + let mut stmt = conn.prepare( 59 + "SELECT id, account_did, text, reply_parent_uri, reply_parent_cid, 60 + reply_root_uri, reply_root_cid, quote_uri, quote_cid, 61 + title, created_at, updated_at 62 + FROM drafts 63 + WHERE account_did = ?1 64 + ORDER BY updated_at DESC", 65 + )?; 66 + 67 + let rows = stmt.query_map(params![account_did], row_to_draft)?; 68 + 69 + let mut drafts = Vec::new(); 70 + for row in rows { 71 + drafts.push(row?); 72 + } 73 + Ok(drafts) 74 + } 75 + 76 + fn db_get_draft(conn: &Connection, id: &str) -> Result<Draft> { 77 + conn.query_row( 78 + "SELECT id, account_did, text, reply_parent_uri, reply_parent_cid, 79 + reply_root_uri, reply_root_cid, quote_uri, quote_cid, 80 + title, created_at, updated_at 81 + FROM drafts 82 + WHERE id = ?1", 83 + params![id], 84 + row_to_draft, 85 + ) 86 + .optional()? 87 + .ok_or_else(|| AppError::validation(format!("draft {id} not found"))) 88 + } 89 + 90 + fn db_save_draft(conn: &Connection, account_did: &str, input: &DraftInput) -> Result<Draft> { 91 + let id = match &input.id { 92 + Some(existing_id) => { 93 + let exists: bool = conn 94 + .query_row( 95 + "SELECT COUNT(*) FROM drafts WHERE id = ?1 AND account_did = ?2", 96 + params![existing_id, account_did], 97 + |row| row.get::<_, i64>(0), 98 + ) 99 + .map(|count| count > 0) 100 + .unwrap_or(false); 101 + 102 + if exists { 103 + conn.execute( 104 + "UPDATE drafts 105 + SET text = ?1, reply_parent_uri = ?2, reply_parent_cid = ?3, 106 + reply_root_uri = ?4, reply_root_cid = ?5, 107 + quote_uri = ?6, quote_cid = ?7, title = ?8, 108 + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') 109 + WHERE id = ?9 AND account_did = ?10", 110 + params![ 111 + input.text, 112 + input.reply_parent_uri, 113 + input.reply_parent_cid, 114 + input.reply_root_uri, 115 + input.reply_root_cid, 116 + input.quote_uri, 117 + input.quote_cid, 118 + input.title, 119 + existing_id, 120 + account_did, 121 + ], 122 + )?; 123 + existing_id.clone() 124 + } else { 125 + db_insert_draft(conn, account_did, input)? 126 + } 127 + } 128 + None => db_insert_draft(conn, account_did, input)?, 129 + }; 130 + 131 + db_get_draft(conn, &id) 132 + } 133 + 134 + fn db_insert_draft(conn: &Connection, account_did: &str, input: &DraftInput) -> Result<String> { 135 + let id = Uuid::new_v4().to_string(); 136 + conn.execute( 137 + "INSERT INTO drafts (id, account_did, text, reply_parent_uri, reply_parent_cid, 138 + reply_root_uri, reply_root_cid, quote_uri, quote_cid, 139 + title, created_at, updated_at) 140 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 141 + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 142 + strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", 143 + params![ 144 + id, 145 + account_did, 146 + input.text, 147 + input.reply_parent_uri, 148 + input.reply_parent_cid, 149 + input.reply_root_uri, 150 + input.reply_root_cid, 151 + input.quote_uri, 152 + input.quote_cid, 153 + input.title, 154 + ], 155 + )?; 156 + Ok(id) 157 + } 158 + 159 + fn db_delete_draft(conn: &Connection, id: &str) -> Result<()> { 160 + let affected = conn.execute("DELETE FROM drafts WHERE id = ?1", params![id])?; 161 + if affected == 0 { 162 + log::warn!("delete_draft: no draft found with id {id}"); 163 + } 164 + Ok(()) 165 + } 166 + 167 + pub fn list_drafts(account_did: &str, state: &AppState) -> Result<Vec<Draft>> { 168 + let conn = state.auth_store.lock_connection()?; 169 + db_list_drafts(&conn, account_did) 170 + } 171 + 172 + pub fn get_draft(id: &str, state: &AppState) -> Result<Draft> { 173 + let conn = state.auth_store.lock_connection()?; 174 + db_get_draft(&conn, id) 175 + } 176 + 177 + pub fn save_draft(input: &DraftInput, state: &AppState) -> Result<Draft> { 178 + let account_did = state 179 + .active_session 180 + .read() 181 + .map_err(|error| AppError::state_poisoned(format!("active_session poisoned: {error}")))? 182 + .as_ref() 183 + .ok_or_else(|| AppError::validation("no active account"))? 184 + .did 185 + .clone(); 186 + 187 + let conn = state.auth_store.lock_connection()?; 188 + db_save_draft(&conn, &account_did, input) 189 + } 190 + 191 + pub fn delete_draft(id: &str, state: &AppState) -> Result<()> { 192 + let conn = state.auth_store.lock_connection()?; 193 + db_delete_draft(&conn, id) 194 + } 195 + 196 + pub async fn submit_draft(id: String, state: &AppState) -> Result<CreateRecordResult> { 197 + let account_did = state 198 + .active_session 199 + .read() 200 + .map_err(|error| AppError::state_poisoned(format!("active_session poisoned: {error}")))? 201 + .as_ref() 202 + .ok_or_else(|| AppError::validation("no active account"))? 203 + .did 204 + .clone(); 205 + 206 + let draft = { 207 + let conn = state.auth_store.lock_connection()?; 208 + let draft = db_get_draft(&conn, &id)?; 209 + if draft.account_did != account_did { 210 + return Err(AppError::validation("draft does not belong to the active account")); 211 + } 212 + draft 213 + }; 214 + 215 + let reply_to = build_reply_ref(&draft)?; 216 + let embed = build_embed(&draft)?; 217 + 218 + let result = feed::create_post(draft.text, reply_to, embed, state).await?; 219 + 220 + { 221 + let conn = state.auth_store.lock_connection()?; 222 + if let Err(error) = db_delete_draft(&conn, &id) { 223 + log::error!("submit_draft: failed to delete draft {id} after successful post: {error}"); 224 + } 225 + } 226 + 227 + Ok(result) 228 + } 229 + 230 + fn build_reply_ref(draft: &Draft) -> Result<Option<ReplyRefInput>> { 231 + match ( 232 + &draft.reply_parent_uri, 233 + &draft.reply_parent_cid, 234 + &draft.reply_root_uri, 235 + &draft.reply_root_cid, 236 + ) { 237 + (Some(parent_uri), Some(parent_cid), Some(root_uri), Some(root_cid)) => Ok(Some(ReplyRefInput { 238 + parent: StrongRefInput { uri: parent_uri.clone(), cid: parent_cid.clone() }, 239 + root: StrongRefInput { uri: root_uri.clone(), cid: root_cid.clone() }, 240 + })), 241 + (None, None, None, None) => Ok(None), 242 + _ => Err(AppError::validation( 243 + "draft has incomplete reply reference — all four reply fields must be set together", 244 + )), 245 + } 246 + } 247 + 248 + fn build_embed(draft: &Draft) -> Result<Option<EmbedInput>> { 249 + match (&draft.quote_uri, &draft.quote_cid) { 250 + (Some(uri), Some(cid)) => Ok(Some(EmbedInput::Record { 251 + record: StrongRefInput { uri: uri.clone(), cid: cid.clone() }, 252 + })), 253 + (None, None) => Ok(None), 254 + _ => Err(AppError::validation( 255 + "draft has incomplete quote reference — both quote_uri and quote_cid must be set together", 256 + )), 257 + } 258 + } 259 + 260 + #[cfg(test)] 261 + mod tests { 262 + use super::*; 263 + use rusqlite::Connection; 264 + 265 + fn draft_db() -> Connection { 266 + let conn = Connection::open_in_memory().expect("in-memory db should open"); 267 + conn.execute_batch(include_str!("migrations/011_drafts.sql")) 268 + .expect("drafts migration should apply"); 269 + conn 270 + } 271 + 272 + fn insert_draft(conn: &Connection, account_did: &str, text: &str) -> Draft { 273 + let input = DraftInput { 274 + id: None, 275 + text: text.to_string(), 276 + reply_parent_uri: None, 277 + reply_parent_cid: None, 278 + reply_root_uri: None, 279 + reply_root_cid: None, 280 + quote_uri: None, 281 + quote_cid: None, 282 + title: None, 283 + }; 284 + db_save_draft(conn, account_did, &input).expect("insert should succeed") 285 + } 286 + 287 + #[test] 288 + fn migration_creates_drafts_table() { 289 + let conn = draft_db(); 290 + let count: i64 = conn 291 + .query_row("SELECT COUNT(*) FROM drafts", [], |row| row.get(0)) 292 + .expect("should query empty drafts table"); 293 + assert_eq!(count, 0); 294 + } 295 + 296 + #[test] 297 + fn save_draft_inserts_with_generated_uuid_when_no_id() { 298 + let conn = draft_db(); 299 + let input = DraftInput { 300 + id: None, 301 + text: "hello world".to_string(), 302 + reply_parent_uri: None, 303 + reply_parent_cid: None, 304 + reply_root_uri: None, 305 + reply_root_cid: None, 306 + quote_uri: None, 307 + quote_cid: None, 308 + title: Some("my draft".to_string()), 309 + }; 310 + 311 + let draft = db_save_draft(&conn, "did:plc:alice", &input).expect("save should succeed"); 312 + 313 + assert!(!draft.id.is_empty()); 314 + assert_eq!(draft.account_did, "did:plc:alice"); 315 + assert_eq!(draft.text, "hello world"); 316 + assert_eq!(draft.title, Some("my draft".to_string())); 317 + assert!(draft.reply_parent_uri.is_none()); 318 + assert!(draft.quote_uri.is_none()); 319 + assert!(!draft.created_at.is_empty()); 320 + assert!(!draft.updated_at.is_empty()); 321 + } 322 + 323 + #[test] 324 + fn save_draft_updates_existing_when_id_matches() { 325 + let conn = draft_db(); 326 + let original = insert_draft(&conn, "did:plc:alice", "original text"); 327 + 328 + let input = DraftInput { 329 + id: Some(original.id.clone()), 330 + text: "updated text".to_string(), 331 + reply_parent_uri: None, 332 + reply_parent_cid: None, 333 + reply_root_uri: None, 334 + reply_root_cid: None, 335 + quote_uri: None, 336 + quote_cid: None, 337 + title: Some("updated title".to_string()), 338 + }; 339 + 340 + let updated = db_save_draft(&conn, "did:plc:alice", &input).expect("update should succeed"); 341 + 342 + assert_eq!(updated.id, original.id, "id should remain the same after update"); 343 + assert_eq!(updated.text, "updated text"); 344 + assert_eq!(updated.title, Some("updated title".to_string())); 345 + assert_eq!( 346 + updated.created_at, original.created_at, 347 + "created_at should not change on update" 348 + ); 349 + 350 + let count: i64 = conn 351 + .query_row("SELECT COUNT(*) FROM drafts", [], |row| row.get(0)) 352 + .expect("count should succeed"); 353 + assert_eq!(count, 1, "update should not create a new row"); 354 + } 355 + 356 + #[test] 357 + fn save_draft_inserts_new_when_id_not_found_in_db() { 358 + let conn = draft_db(); 359 + 360 + let input = DraftInput { 361 + id: Some("non-existent-id".to_string()), 362 + text: "orphan draft".to_string(), 363 + reply_parent_uri: None, 364 + reply_parent_cid: None, 365 + reply_root_uri: None, 366 + reply_root_cid: None, 367 + quote_uri: None, 368 + quote_cid: None, 369 + title: None, 370 + }; 371 + 372 + let draft = db_save_draft(&conn, "did:plc:alice", &input).expect("save should succeed"); 373 + 374 + assert_ne!( 375 + draft.id, "non-existent-id", 376 + "a new UUID should be generated when the provided id does not exist" 377 + ); 378 + assert_eq!(draft.text, "orphan draft"); 379 + } 380 + 381 + #[test] 382 + fn save_draft_cannot_update_another_accounts_draft() { 383 + let conn = draft_db(); 384 + let alice_draft = insert_draft(&conn, "did:plc:alice", "alice's post"); 385 + 386 + let input = DraftInput { 387 + id: Some(alice_draft.id.clone()), 388 + text: "bob's takeover".to_string(), 389 + reply_parent_uri: None, 390 + reply_parent_cid: None, 391 + reply_root_uri: None, 392 + reply_root_cid: None, 393 + quote_uri: None, 394 + quote_cid: None, 395 + title: None, 396 + }; 397 + 398 + // Bob submits with alice's draft id — should insert a new draft, not update alice's 399 + let saved = db_save_draft(&conn, "did:plc:bob", &input).expect("save should succeed"); 400 + assert_ne!(saved.id, alice_draft.id, "cross-account update must not occur"); 401 + 402 + let alice_unchanged = db_get_draft(&conn, &alice_draft.id).expect("alice's draft should still exist"); 403 + assert_eq!( 404 + alice_unchanged.text, "alice's post", 405 + "alice's draft must remain unchanged" 406 + ); 407 + } 408 + 409 + #[test] 410 + fn list_drafts_returns_only_active_account_drafts() { 411 + let conn = draft_db(); 412 + insert_draft(&conn, "did:plc:alice", "alice draft 1"); 413 + insert_draft(&conn, "did:plc:alice", "alice draft 2"); 414 + insert_draft(&conn, "did:plc:bob", "bob draft"); 415 + 416 + let alice_drafts = db_list_drafts(&conn, "did:plc:alice").expect("list should succeed"); 417 + assert_eq!(alice_drafts.len(), 2); 418 + assert!(alice_drafts.iter().all(|d| d.account_did == "did:plc:alice")); 419 + 420 + let bob_drafts = db_list_drafts(&conn, "did:plc:bob").expect("list should succeed"); 421 + assert_eq!(bob_drafts.len(), 1); 422 + } 423 + 424 + #[test] 425 + fn list_drafts_returns_empty_for_unknown_account() { 426 + let conn = draft_db(); 427 + let drafts = db_list_drafts(&conn, "did:plc:ghost").expect("list should succeed"); 428 + assert!(drafts.is_empty()); 429 + } 430 + 431 + #[test] 432 + fn list_drafts_ordered_by_updated_at_desc() { 433 + let conn = draft_db(); 434 + 435 + // Insert with explicit timestamps to ensure ordering 436 + conn.execute( 437 + "INSERT INTO drafts (id, account_did, text, created_at, updated_at) 438 + VALUES ('draft-old', 'did:plc:alice', 'old', '2024-01-01T00:00:00.000Z', '2024-01-01T00:00:00.000Z')", 439 + [], 440 + ) 441 + .expect("insert old draft"); 442 + 443 + conn.execute( 444 + "INSERT INTO drafts (id, account_did, text, created_at, updated_at) 445 + VALUES ('draft-new', 'did:plc:alice', 'new', '2024-01-02T00:00:00.000Z', '2024-01-02T00:00:00.000Z')", 446 + [], 447 + ) 448 + .expect("insert new draft"); 449 + 450 + let drafts = db_list_drafts(&conn, "did:plc:alice").expect("list should succeed"); 451 + assert_eq!(drafts.len(), 2); 452 + assert_eq!(drafts[0].id, "draft-new", "most recently updated draft should be first"); 453 + assert_eq!(drafts[1].id, "draft-old"); 454 + } 455 + 456 + #[test] 457 + fn get_draft_returns_correct_draft() { 458 + let conn = draft_db(); 459 + let created = insert_draft(&conn, "did:plc:alice", "get me"); 460 + 461 + let fetched = db_get_draft(&conn, &created.id).expect("get should succeed"); 462 + assert_eq!(fetched.id, created.id); 463 + assert_eq!(fetched.text, "get me"); 464 + } 465 + 466 + #[test] 467 + fn get_draft_errors_for_missing_id() { 468 + let conn = draft_db(); 469 + let result = db_get_draft(&conn, "does-not-exist"); 470 + assert!(result.is_err(), "get_draft should return an error for missing id"); 471 + } 472 + 473 + #[test] 474 + fn delete_draft_removes_draft() { 475 + let conn = draft_db(); 476 + let draft = insert_draft(&conn, "did:plc:alice", "to be deleted"); 477 + 478 + db_delete_draft(&conn, &draft.id).expect("delete should succeed"); 479 + 480 + let result = db_get_draft(&conn, &draft.id); 481 + assert!(result.is_err(), "draft should be gone after delete"); 482 + } 483 + 484 + #[test] 485 + fn delete_draft_is_idempotent_for_missing_id() { 486 + let conn = draft_db(); 487 + // Deleting a non-existent draft should not error 488 + db_delete_draft(&conn, "ghost-id").expect("delete of missing draft should not error"); 489 + } 490 + 491 + #[test] 492 + fn save_draft_preserves_reply_fields() { 493 + let conn = draft_db(); 494 + let input = DraftInput { 495 + id: None, 496 + text: "a reply".to_string(), 497 + reply_parent_uri: Some("at://did:plc:parent/app.bsky.feed.post/abc".to_string()), 498 + reply_parent_cid: Some("bafyparent".to_string()), 499 + reply_root_uri: Some("at://did:plc:root/app.bsky.feed.post/xyz".to_string()), 500 + reply_root_cid: Some("bafyroot".to_string()), 501 + quote_uri: None, 502 + quote_cid: None, 503 + title: None, 504 + }; 505 + 506 + let draft = db_save_draft(&conn, "did:plc:alice", &input).expect("save should succeed"); 507 + assert_eq!( 508 + draft.reply_parent_uri.as_deref(), 509 + Some("at://did:plc:parent/app.bsky.feed.post/abc") 510 + ); 511 + assert_eq!(draft.reply_parent_cid.as_deref(), Some("bafyparent")); 512 + assert_eq!( 513 + draft.reply_root_uri.as_deref(), 514 + Some("at://did:plc:root/app.bsky.feed.post/xyz") 515 + ); 516 + assert_eq!(draft.reply_root_cid.as_deref(), Some("bafyroot")); 517 + } 518 + 519 + #[test] 520 + fn save_draft_preserves_quote_fields() { 521 + let conn = draft_db(); 522 + let input = DraftInput { 523 + id: None, 524 + text: "quoting".to_string(), 525 + reply_parent_uri: None, 526 + reply_parent_cid: None, 527 + reply_root_uri: None, 528 + reply_root_cid: None, 529 + quote_uri: Some("at://did:plc:quoted/app.bsky.feed.post/qrs".to_string()), 530 + quote_cid: Some("bafyquote".to_string()), 531 + title: None, 532 + }; 533 + 534 + let draft = db_save_draft(&conn, "did:plc:alice", &input).expect("save should succeed"); 535 + assert_eq!( 536 + draft.quote_uri.as_deref(), 537 + Some("at://did:plc:quoted/app.bsky.feed.post/qrs") 538 + ); 539 + assert_eq!(draft.quote_cid.as_deref(), Some("bafyquote")); 540 + } 541 + 542 + #[test] 543 + fn build_reply_ref_returns_none_when_no_reply_fields() { 544 + let draft = Draft { 545 + id: "id".to_string(), 546 + account_did: "did:plc:alice".to_string(), 547 + text: "plain post".to_string(), 548 + reply_parent_uri: None, 549 + reply_parent_cid: None, 550 + reply_root_uri: None, 551 + reply_root_cid: None, 552 + quote_uri: None, 553 + quote_cid: None, 554 + title: None, 555 + created_at: "2024-01-01T00:00:00.000Z".to_string(), 556 + updated_at: "2024-01-01T00:00:00.000Z".to_string(), 557 + }; 558 + 559 + let result = build_reply_ref(&draft).expect("build_reply_ref should succeed"); 560 + assert!(result.is_none()); 561 + } 562 + 563 + #[test] 564 + fn build_reply_ref_returns_some_when_all_reply_fields_present() { 565 + let draft = Draft { 566 + id: "id".to_string(), 567 + account_did: "did:plc:alice".to_string(), 568 + text: "a reply".to_string(), 569 + reply_parent_uri: Some("at://did:plc:p/app.bsky.feed.post/1".to_string()), 570 + reply_parent_cid: Some("bafy1".to_string()), 571 + reply_root_uri: Some("at://did:plc:r/app.bsky.feed.post/2".to_string()), 572 + reply_root_cid: Some("bafy2".to_string()), 573 + quote_uri: None, 574 + quote_cid: None, 575 + title: None, 576 + created_at: "2024-01-01T00:00:00.000Z".to_string(), 577 + updated_at: "2024-01-01T00:00:00.000Z".to_string(), 578 + }; 579 + 580 + let result = build_reply_ref(&draft).expect("build_reply_ref should succeed"); 581 + assert!(result.is_some()); 582 + let reply = result.unwrap(); 583 + assert_eq!(reply.parent.uri, "at://did:plc:p/app.bsky.feed.post/1"); 584 + assert_eq!(reply.root.cid, "bafy2"); 585 + } 586 + 587 + #[test] 588 + fn build_reply_ref_errors_on_partial_reply_fields() { 589 + let draft = Draft { 590 + id: "id".to_string(), 591 + account_did: "did:plc:alice".to_string(), 592 + text: "broken reply".to_string(), 593 + reply_parent_uri: Some("at://did:plc:p/app.bsky.feed.post/1".to_string()), 594 + reply_parent_cid: None, // missing 595 + reply_root_uri: None, 596 + reply_root_cid: None, 597 + quote_uri: None, 598 + quote_cid: None, 599 + title: None, 600 + created_at: "2024-01-01T00:00:00.000Z".to_string(), 601 + updated_at: "2024-01-01T00:00:00.000Z".to_string(), 602 + }; 603 + 604 + assert!(build_reply_ref(&draft).is_err(), "partial reply fields should error"); 605 + } 606 + 607 + #[test] 608 + fn build_embed_returns_none_when_no_quote_fields() { 609 + let draft = Draft { 610 + id: "id".to_string(), 611 + account_did: "did:plc:alice".to_string(), 612 + text: "plain".to_string(), 613 + reply_parent_uri: None, 614 + reply_parent_cid: None, 615 + reply_root_uri: None, 616 + reply_root_cid: None, 617 + quote_uri: None, 618 + quote_cid: None, 619 + title: None, 620 + created_at: "2024-01-01T00:00:00.000Z".to_string(), 621 + updated_at: "2024-01-01T00:00:00.000Z".to_string(), 622 + }; 623 + 624 + let result = build_embed(&draft).expect("build_embed should succeed"); 625 + assert!(result.is_none()); 626 + } 627 + 628 + #[test] 629 + fn build_embed_returns_record_embed_when_quote_fields_present() { 630 + let draft = Draft { 631 + id: "id".to_string(), 632 + account_did: "did:plc:alice".to_string(), 633 + text: "quoting".to_string(), 634 + reply_parent_uri: None, 635 + reply_parent_cid: None, 636 + reply_root_uri: None, 637 + reply_root_cid: None, 638 + quote_uri: Some("at://did:plc:q/app.bsky.feed.post/abc".to_string()), 639 + quote_cid: Some("bafyq".to_string()), 640 + title: None, 641 + created_at: "2024-01-01T00:00:00.000Z".to_string(), 642 + updated_at: "2024-01-01T00:00:00.000Z".to_string(), 643 + }; 644 + 645 + let result = build_embed(&draft).expect("build_embed should succeed"); 646 + assert!(result.is_some()); 647 + match result.unwrap() { 648 + EmbedInput::Record { record } => { 649 + assert_eq!(record.uri, "at://did:plc:q/app.bsky.feed.post/abc"); 650 + assert_eq!(record.cid, "bafyq"); 651 + } 652 + } 653 + } 654 + 655 + #[test] 656 + fn build_embed_errors_on_partial_quote_fields() { 657 + let draft = Draft { 658 + id: "id".to_string(), 659 + account_did: "did:plc:alice".to_string(), 660 + text: "broken quote".to_string(), 661 + reply_parent_uri: None, 662 + reply_parent_cid: None, 663 + reply_root_uri: None, 664 + reply_root_cid: None, 665 + quote_uri: Some("at://did:plc:q/app.bsky.feed.post/abc".to_string()), 666 + quote_cid: None, // missing 667 + title: None, 668 + created_at: "2024-01-01T00:00:00.000Z".to_string(), 669 + updated_at: "2024-01-01T00:00:00.000Z".to_string(), 670 + }; 671 + 672 + assert!(build_embed(&draft).is_err(), "partial quote fields should error"); 673 + } 674 + }
+7 -1
src-tauri/src/lib.rs
··· 6 6 mod conversations; 7 7 mod db; 8 8 mod diagnostics; 9 + mod drafts; 9 10 mod error; 10 11 mod explorer; 11 12 mod feed; ··· 154 155 cmd::get_convo_for_members, 155 156 cmd::get_messages, 156 157 cmd::send_message, 157 - cmd::update_read 158 + cmd::update_read, 159 + cmd::drafts::list_drafts, 160 + cmd::drafts::get_draft, 161 + cmd::drafts::save_draft, 162 + cmd::drafts::delete_draft, 163 + cmd::drafts::submit_draft 158 164 ]) 159 165 .run(tauri::generate_context!()) 160 166 .expect("error while running tauri application");
+16
src-tauri/src/migrations/011_drafts.sql
··· 1 + CREATE TABLE drafts ( 2 + id TEXT PRIMARY KEY, 3 + account_did TEXT NOT NULL, 4 + text TEXT NOT NULL, 5 + reply_parent_uri TEXT, 6 + reply_parent_cid TEXT, 7 + reply_root_uri TEXT, 8 + reply_root_cid TEXT, 9 + quote_uri TEXT, 10 + quote_cid TEXT, 11 + title TEXT, 12 + created_at TEXT NOT NULL, 13 + updated_at TEXT NOT NULL 14 + ); 15 + 16 + CREATE INDEX idx_drafts_account_updated ON drafts (account_did, updated_at DESC);