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: draft autosave and restore

+1161 -91
+82 -24
src-tauri/src/drafts.rs
··· 87 87 .ok_or_else(|| AppError::validation(format!("draft {id} not found"))) 88 88 } 89 89 90 + fn db_get_draft_for_account(conn: &Connection, id: &str, account_did: &str) -> Result<Draft> { 91 + let draft = db_get_draft(conn, id)?; 92 + if draft.account_did != account_did { 93 + return Err(AppError::validation("draft does not belong to the active account")); 94 + } 95 + 96 + Ok(draft) 97 + } 98 + 90 99 fn db_save_draft(conn: &Connection, account_did: &str, input: &DraftInput) -> Result<Draft> { 91 100 let id = match &input.id { 92 101 Some(existing_id) => { ··· 164 173 Ok(()) 165 174 } 166 175 176 + fn db_delete_draft_for_account(conn: &Connection, id: &str, account_did: &str) -> Result<()> { 177 + let owner = conn 178 + .query_row("SELECT account_did FROM drafts WHERE id = ?1", params![id], |row| { 179 + row.get::<_, String>(0) 180 + }) 181 + .optional()?; 182 + 183 + match owner { 184 + None => { 185 + log::warn!("delete_draft: no draft found with id {id}"); 186 + Ok(()) 187 + } 188 + Some(owner_did) => { 189 + if owner_did != account_did { 190 + return Err(AppError::validation("draft does not belong to the active account")); 191 + } 192 + 193 + db_delete_draft(conn, id) 194 + } 195 + } 196 + } 197 + 198 + fn active_account_did(state: &AppState) -> Result<String> { 199 + state 200 + .active_session 201 + .read() 202 + .map_err(|error| AppError::state_poisoned(format!("active_session poisoned: {error}")))? 203 + .as_ref() 204 + .ok_or_else(|| AppError::validation("no active account")) 205 + .map(|session| session.did.clone()) 206 + } 207 + 167 208 pub fn list_drafts(account_did: &str, state: &AppState) -> Result<Vec<Draft>> { 209 + let active_did = active_account_did(state)?; 210 + if account_did != active_did { 211 + return Err(AppError::validation("account does not match the active account")); 212 + } 213 + 168 214 let conn = state.auth_store.lock_connection()?; 169 - db_list_drafts(&conn, account_did) 215 + db_list_drafts(&conn, &active_did) 170 216 } 171 217 172 218 pub fn get_draft(id: &str, state: &AppState) -> Result<Draft> { 219 + let active_did = active_account_did(state)?; 173 220 let conn = state.auth_store.lock_connection()?; 174 - db_get_draft(&conn, id) 221 + db_get_draft_for_account(&conn, id, &active_did) 175 222 } 176 223 177 224 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(); 225 + let account_did = active_account_did(state)?; 186 226 187 227 let conn = state.auth_store.lock_connection()?; 188 228 db_save_draft(&conn, &account_did, input) 189 229 } 190 230 191 231 pub fn delete_draft(id: &str, state: &AppState) -> Result<()> { 232 + let active_did = active_account_did(state)?; 192 233 let conn = state.auth_store.lock_connection()?; 193 - db_delete_draft(&conn, id) 234 + db_delete_draft_for_account(&conn, id, &active_did) 194 235 } 195 236 196 237 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(); 238 + let account_did = active_account_did(state)?; 205 239 206 240 let draft = { 207 241 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 242 + db_get_draft_for_account(&conn, &id, &account_did)? 213 243 }; 214 244 215 245 let reply_to = build_reply_ref(&draft)?; ··· 471 501 } 472 502 473 503 #[test] 504 + fn get_draft_for_account_rejects_foreign_draft() { 505 + let conn = draft_db(); 506 + let draft = insert_draft(&conn, "did:plc:alice", "alice secret"); 507 + 508 + let result = db_get_draft_for_account(&conn, &draft.id, "did:plc:bob"); 509 + assert!(result.is_err(), "should reject loading another account's draft"); 510 + } 511 + 512 + #[test] 474 513 fn delete_draft_removes_draft() { 475 514 let conn = draft_db(); 476 515 let draft = insert_draft(&conn, "did:plc:alice", "to be deleted"); ··· 486 525 let conn = draft_db(); 487 526 // Deleting a non-existent draft should not error 488 527 db_delete_draft(&conn, "ghost-id").expect("delete of missing draft should not error"); 528 + } 529 + 530 + #[test] 531 + fn delete_draft_for_account_rejects_foreign_draft() { 532 + let conn = draft_db(); 533 + let draft = insert_draft(&conn, "did:plc:alice", "alice only"); 534 + 535 + let delete_result = db_delete_draft_for_account(&conn, &draft.id, "did:plc:bob"); 536 + assert!(delete_result.is_err(), "should reject deleting another account's draft"); 537 + 538 + let still_exists = db_get_draft(&conn, &draft.id).expect("draft should remain after rejected delete"); 539 + assert_eq!(still_exists.account_did, "did:plc:alice"); 540 + } 541 + 542 + #[test] 543 + fn delete_draft_for_account_is_idempotent_for_missing_id() { 544 + let conn = draft_db(); 545 + db_delete_draft_for_account(&conn, "ghost-id", "did:plc:alice") 546 + .expect("delete of missing draft should not error"); 489 547 } 490 548 491 549 #[test]
+3
src/components/feeds/DraftsList.tsx
··· 11 11 accountDid: string; 12 12 composerHasContent: boolean; 13 13 open: boolean; 14 + refreshNonce: number; 14 15 onClose: () => void; 15 16 onLoadDraft: (draft: Draft) => void; 16 17 }; ··· 23 24 const [confirmLoadDraft, setConfirmLoadDraft] = createSignal<Draft | null>(null); 24 25 25 26 createEffect(() => { 27 + const refreshNonce = props.refreshNonce; 26 28 if (props.open) { 29 + void refreshNonce; 27 30 void fetchDrafts(); 28 31 } else { 29 32 setConfirmDeleteId(null);
+64 -16
src/components/feeds/FeedComposer.test.tsx
··· 7 7 (_, index) => ({ label: `@handle-${index + 1}.test`, type: "handle" as const }), 8 8 ); 9 9 10 + const BASE_PROPS = { 11 + activeHandle: "alice.test", 12 + open: true, 13 + pending: false, 14 + quoteTarget: null, 15 + replyTarget: null, 16 + suggestions: [], 17 + text: "", 18 + onApplySuggestion: () => {}, 19 + onClearQuote: () => {}, 20 + onClearReply: () => {}, 21 + onClose: () => {}, 22 + onSubmit: () => {}, 23 + onTextChange: () => {}, 24 + }; 25 + 10 26 describe("FeedComposer", () => { 11 27 it("renders a contained scroll region for typeahead suggestions", () => { 12 - render(() => ( 13 - <FeedComposer 14 - activeHandle="alice.test" 15 - open 16 - pending={false} 17 - quoteTarget={null} 18 - replyTarget={null} 19 - suggestions={suggestions} 20 - text="@ha" 21 - onApplySuggestion={() => {}} 22 - onClearQuote={() => {}} 23 - onClearReply={() => {}} 24 - onClose={() => {}} 25 - onSubmit={() => {}} 26 - onTextChange={() => {}} /> 27 - )); 28 + render(() => <FeedComposer {...BASE_PROPS} suggestions={suggestions} text="@ha" />); 28 29 29 30 expect(screen.getByText("@handle-12.test")).toBeInTheDocument(); 30 31 expect(screen.queryByText("@handle-13.test")).not.toBeInTheDocument(); ··· 32 33 const suggestionsHeading = screen.getByText("Suggestions"); 33 34 const scrollRegion = suggestionsHeading.nextElementSibling as HTMLElement; 34 35 expect(scrollRegion.className).toContain("overflow-y-auto"); 36 + }); 37 + 38 + it("shows 'Saving...' autosave indicator when status is saving", () => { 39 + render(() => <FeedComposer {...BASE_PROPS} autosaveStatus="saving" text="hello" />); 40 + 41 + expect(screen.getByText("Saving...")).toBeInTheDocument(); 42 + }); 43 + 44 + it("shows 'Saved' autosave indicator when status is saved", () => { 45 + render(() => <FeedComposer {...BASE_PROPS} autosaveStatus="saved" text="hello" />); 46 + 47 + expect(screen.getByText("Saved")).toBeInTheDocument(); 48 + }); 49 + 50 + it("does not show autosave indicator when status is idle", () => { 51 + render(() => <FeedComposer {...BASE_PROPS} autosaveStatus="idle" text="hello" />); 52 + 53 + expect(screen.queryByText("Saving...")).not.toBeInTheDocument(); 54 + expect(screen.queryByText("Saved")).not.toBeInTheDocument(); 55 + }); 56 + 57 + it("shows 'Save' button when onSaveDraft is provided", () => { 58 + render(() => <FeedComposer {...BASE_PROPS} onSaveDraft={() => {}} />); 59 + 60 + expect(screen.getByTitle("Save as draft (Ctrl+S)")).toBeInTheDocument(); 61 + }); 62 + 63 + it("does not show 'Save' button when onSaveDraft is not provided", () => { 64 + render(() => <FeedComposer {...BASE_PROPS} />); 65 + 66 + expect(screen.queryByTitle("Save as draft (Ctrl+S)")).not.toBeInTheDocument(); 67 + }); 68 + 69 + it("shows draft count badge on drafts button when draftCount is positive", () => { 70 + render(() => <FeedComposer {...BASE_PROPS} draftCount={3} onOpenDrafts={() => {}} />); 71 + 72 + expect(screen.getByText("3")).toBeInTheDocument(); 73 + }); 74 + 75 + it("does not show draft count badge when draftCount is zero", () => { 76 + render(() => <FeedComposer {...BASE_PROPS} draftCount={0} onOpenDrafts={() => {}} />); 77 + 78 + // The drafts button should exist but no badge (the badge span is conditionally rendered) 79 + const draftsButton = screen.getByTitle("Drafts (Ctrl+D)"); 80 + expect(draftsButton).toBeInTheDocument(); 81 + // Badge is only rendered when count > 0; with count=0 the badge span should be absent 82 + expect(draftsButton.textContent?.trim()).toBe(""); 35 83 }); 36 84 });
+49 -5
src/components/feeds/FeedComposer.tsx
··· 4 4 import type { PostView } from "$/lib/types"; 5 5 import { createMemo, For, Show } from "solid-js"; 6 6 import { Motion, Presence } from "solid-motionone"; 7 + import type { AutosaveStatus } from "./types"; 7 8 8 9 type ComposerSuggestion = { label: string; type: "handle" | "hashtag" }; 9 10 ··· 32 33 type FeedComposerProps = { 33 34 activeAvatar?: string | null; 34 35 activeHandle: string | null; 36 + autosaveStatus?: AutosaveStatus; 37 + draftCount?: number; 35 38 open: boolean; 36 39 pending: boolean; 37 40 quoteTarget: PostView | null; ··· 43 46 onClearReply: () => void; 44 47 onClose: () => void; 45 48 onOpenDrafts?: () => void; 49 + onSaveDraft?: () => void; 46 50 onSubmit: () => void; 47 51 onTextChange: (value: string) => void; 48 52 }; ··· 50 54 type ComposerSurfaceProps = Omit<FeedComposerProps, "open"> & { 51 55 layout?: "dialog" | "window"; 52 56 onOpenDrafts?: () => void; 57 + onSaveDraft?: () => void; 53 58 }; 54 59 55 60 export function FeedComposer(props: FeedComposerProps) { ··· 69 74 <ComposerSurface 70 75 activeAvatar={props.activeAvatar} 71 76 activeHandle={props.activeHandle} 77 + autosaveStatus={props.autosaveStatus} 78 + draftCount={props.draftCount} 72 79 layout="dialog" 73 80 pending={props.pending} 74 81 quoteTarget={props.quoteTarget} ··· 80 87 onClearReply={props.onClearReply} 81 88 onClose={props.onClose} 82 89 onOpenDrafts={props.onOpenDrafts} 90 + onSaveDraft={props.onSaveDraft} 83 91 onSubmit={props.onSubmit} 84 92 onTextChange={props.onTextChange} /> 85 93 </div> ··· 103 111 <ComposerHeader 104 112 activeAvatar={props.activeAvatar} 105 113 activeHandle={props.activeHandle} 114 + draftCount={props.draftCount} 106 115 pending={props.pending} 107 116 quoteTarget={props.quoteTarget} 108 117 text={props.text} 109 118 onClose={props.onClose} 110 119 onOpenDrafts={props.onOpenDrafts} 120 + onSaveDraft={props.onSaveDraft} 111 121 onSubmit={props.onSubmit} /> 112 122 <ComposerBody 113 123 activeAvatar={props.activeAvatar} ··· 120 130 onClearQuote={props.onClearQuote} 121 131 onClearReply={props.onClearReply} 122 132 onTextChange={props.onTextChange} /> 123 - <ComposerFooter count={count()} progress={progress()} /> 133 + <ComposerFooter autosaveStatus={props.autosaveStatus ?? "idle"} count={count()} progress={progress()} /> 124 134 </Motion.section> 125 135 </div> 126 136 ); ··· 152 162 props: { 153 163 activeAvatar?: string | null; 154 164 activeHandle: string | null; 165 + draftCount?: number; 155 166 pending: boolean; 156 167 quoteTarget: PostView | null; 157 168 text: string; 158 169 onClose: () => void; 159 170 onOpenDrafts?: () => void; 171 + onSaveDraft?: () => void; 160 172 onSubmit: () => void; 161 173 }, 162 174 ) { ··· 172 184 <ComposerTitle activeHandle={props.activeHandle} /> 173 185 </div> 174 186 <div class="flex items-center gap-2"> 175 - <ComposerDraftsButton onOpenDrafts={props.onOpenDrafts} /> 187 + <ComposerSaveDraftButton onSaveDraft={props.onSaveDraft} /> 188 + <ComposerDraftsButton draftCount={props.draftCount} onOpenDrafts={props.onOpenDrafts} /> 176 189 <ComposerSubmitButton 177 190 disabled={props.pending || (!props.text.trim() && !props.quoteTarget)} 178 191 pending={props.pending} ··· 182 195 ); 183 196 } 184 197 185 - function ComposerDraftsButton(props: { onOpenDrafts?: () => void }) { 198 + function ComposerSaveDraftButton(props: { onSaveDraft?: () => void }) { 199 + return ( 200 + <Show when={props.onSaveDraft}> 201 + <button 202 + class="inline-flex h-10 items-center justify-center gap-1.5 rounded-xl border-0 bg-transparent px-3 text-sm text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 203 + type="button" 204 + title="Save as draft (Ctrl+S)" 205 + onClick={() => props.onSaveDraft?.()}> 206 + <Icon aria-hidden="true" iconClass="i-ri-save-line" /> 207 + <span class="max-[520px]:hidden">Save</span> 208 + </button> 209 + </Show> 210 + ); 211 + } 212 + 213 + function ComposerDraftsButton(props: { draftCount?: number; onOpenDrafts?: () => void }) { 186 214 return ( 187 215 <Show when={props.onOpenDrafts}> 188 216 <button 189 - class="inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 217 + class="relative inline-flex h-10 w-10 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-white/5 hover:text-on-surface" 190 218 type="button" 191 219 title="Drafts (Ctrl+D)" 192 220 onClick={() => props.onOpenDrafts?.()}> 193 221 <Icon aria-hidden="true" iconClass="i-ri-draft-line" /> 222 + <Show when={(props.draftCount ?? 0) > 0}> 223 + <span class="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[0.6rem] font-semibold leading-none text-on-primary-fixed"> 224 + {props.draftCount} 225 + </span> 226 + </Show> 194 227 </button> 195 228 </Show> 196 229 ); ··· 365 398 ); 366 399 } 367 400 368 - function ComposerFooter(props: { count: number; progress: number }) { 401 + function ComposerFooter(props: { autosaveStatus: AutosaveStatus; count: number; progress: number }) { 369 402 return ( 370 403 <footer class="flex items-center justify-between border-t border-white/5 px-6 py-4"> 371 404 <ComposerToolbar /> 405 + <AutosaveIndicator status={props.autosaveStatus} /> 372 406 <ComposerCounter count={props.count} progress={props.progress} /> 373 407 </footer> 408 + ); 409 + } 410 + 411 + function AutosaveIndicator(props: { status: AutosaveStatus }) { 412 + return ( 413 + <Show when={props.status !== "idle"}> 414 + <span class="text-xs text-on-surface-variant"> 415 + <Show when={props.status === "saving"} fallback="Saved">Saving...</Show> 416 + </span> 417 + </Show> 374 418 ); 375 419 } 376 420
+9
src/components/feeds/FeedWorkspace.test.tsx
··· 81 81 invokeMock.mockReset(); 82 82 listenMock.mockReset(); 83 83 listenMock.mockResolvedValue(() => {}); 84 + globalThis.localStorage?.removeItem?.(`lazurite:autosave:${ACTIVE_SESSION.did}`); 84 85 globalThis.location.hash = "#/timeline"; 85 86 86 87 Object.defineProperty(globalThis, "IntersectionObserver", { ··· 120 121 121 122 if (command === "get_timeline" && args.cursor === "cursor-2") { 122 123 return nextPage.promise; 124 + } 125 + 126 + if (command === "list_drafts") { 127 + return Promise.resolve([]); 123 128 } 124 129 125 130 throw new Error(`unexpected invoke: ${command}`); ··· 170 175 171 176 if (command === "update_feed_view_pref") { 172 177 return Promise.resolve(null); 178 + } 179 + 180 + if (command === "list_drafts") { 181 + return Promise.resolve([]); 173 182 } 174 183 175 184 throw new Error(`unexpected invoke: ${command}`);
+48 -1
src/components/feeds/FeedWorkspace.tsx
··· 1 1 import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 2 + import { Icon } from "$/components/shared/Icon"; 2 3 import { useAppSession } from "$/contexts/app-session"; 4 + import { Show } from "solid-js"; 5 + import { Motion, Presence } from "solid-motionone"; 3 6 import { DraftsList } from "./DraftsList"; 4 7 import { FeedComposer } from "./FeedComposer"; 5 8 import { SavedFeedsDrawer } from "./FeedDrawer"; ··· 50 53 <FeedComposer 51 54 activeAvatar={session.activeAvatar} 52 55 activeHandle={session.activeHandle} 56 + autosaveStatus={controller.workspace.composer.autosaveStatus} 57 + draftCount={controller.workspace.draftCount} 53 58 open={controller.workspace.composer.open} 54 59 pending={controller.workspace.composer.pending} 55 60 quoteTarget={controller.workspace.composer.quoteTarget} ··· 59 64 onApplySuggestion={controller.applySuggestion} 60 65 onClearQuote={controller.clearQuoteComposer} 61 66 onClearReply={controller.clearReplyComposer} 62 - onClose={controller.resetComposer} 67 + onClose={() => void controller.resetComposer()} 63 68 onOpenDrafts={controller.openDraftsList} 69 + onSaveDraft={() => void controller.saveAndCloseComposer()} 64 70 onSubmit={() => void controller.submitPost()} 65 71 onTextChange={controller.setComposerText} /> 66 72 ··· 68 74 accountDid={activeSession().did} 69 75 composerHasContent={controller.composerHasContent()} 70 76 open={controller.workspace.showDraftsList} 77 + refreshNonce={controller.workspace.draftsListRefreshNonce} 71 78 onClose={controller.closeDraftsList} 72 79 onLoadDraft={controller.loadDraft} /> 80 + 81 + <Presence> 82 + <Show when={controller.workspace.restoreDraftId}> 83 + <RestoreDraftToast 84 + onDiscard={() => void controller.dismissRestore()} 85 + onRestore={() => void controller.restoreDraft()} /> 86 + </Show> 87 + </Presence> 73 88 </> 74 89 ); 75 90 } 91 + 92 + function RestoreDraftToast(props: { onRestore: () => void; onDiscard: () => void }) { 93 + return ( 94 + <Motion.div 95 + role="alert" 96 + aria-live="polite" 97 + class="fixed bottom-6 left-1/2 z-50 w-max max-w-[min(28rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-2xl bg-surface-container-high shadow-[0_24px_40px_rgba(0,0,0,0.45),0_0_0_1px_rgba(125,175,255,0.12)] backdrop-blur-[20px]" 98 + initial={{ opacity: 0, y: 20, scale: 0.96 }} 99 + animate={{ opacity: 1, y: 0, scale: 1 }} 100 + exit={{ opacity: 0, y: 16, scale: 0.94 }} 101 + transition={{ duration: 0.2 }}> 102 + <div class="flex items-center gap-3 px-4 py-3"> 103 + <Icon aria-hidden="true" iconClass="i-ri-draft-line" class="shrink-0 text-primary" /> 104 + <p class="m-0 min-w-0 text-[0.875rem] text-on-surface">You have an unsaved post. Restore?</p> 105 + <div class="flex shrink-0 items-center gap-2"> 106 + <button 107 + class="rounded-full border-0 bg-primary/20 px-3 py-1.5 text-xs font-medium text-primary transition hover:bg-primary/30" 108 + type="button" 109 + onClick={() => props.onRestore()}> 110 + Restore 111 + </button> 112 + <button 113 + class="rounded-full border-0 bg-transparent px-3 py-1.5 text-xs text-on-surface-variant transition hover:bg-white/5" 114 + type="button" 115 + onClick={() => props.onDiscard()}> 116 + Discard 117 + </button> 118 + </div> 119 + </div> 120 + </Motion.div> 121 + ); 122 + }
+18 -1
src/components/feeds/types.ts
··· 1 - import type { FeedGeneratorView, FeedViewPost, FeedViewPrefItem, PostView, UserPreferences } from "$/lib/types"; 1 + import type { 2 + FeedGeneratorView, 3 + FeedViewPost, 4 + FeedViewPrefItem, 5 + PostView, 6 + StrongRefInput, 7 + UserPreferences, 8 + } from "$/lib/types"; 9 + 10 + export type AutosaveStatus = "idle" | "saving" | "saved"; 2 11 3 12 export type FeedState = { 4 13 cursor: string | null; ··· 9 18 }; 10 19 11 20 type ComposerState = { 21 + autosaveStatus: AutosaveStatus; 22 + draftId: string | null; 12 23 open: boolean; 13 24 pending: boolean; 25 + quoteRef: StrongRefInput | null; 14 26 quoteTarget: PostView | null; 27 + replyParentRef: StrongRefInput | null; 28 + replyRootRef: StrongRefInput | null; 15 29 replyRoot: PostView | null; 16 30 replyTarget: PostView | null; 17 31 text: string; ··· 20 34 export type FeedWorkspaceState = { 21 35 activeFeedId: string | null; 22 36 composer: ComposerState; 37 + draftCount: number; 38 + draftsListRefreshNonce: number; 23 39 feedStates: Record<string, FeedState>; 24 40 feedScrollTops: Record<string, number>; 25 41 focusedIndex: number; 26 42 generators: Record<string, FeedGeneratorView>; 27 43 localPrefs: Record<string, FeedViewPrefItem>; 28 44 preferences: UserPreferences | null; 45 + restoreDraftId: string | null; 29 46 showDraftsList: boolean; 30 47 showFeedsDrawer: boolean; 31 48 };
+359 -22
src/components/feeds/useFeedWorkspaceController.test.tsx
··· 4 4 5 5 const invokeMock = vi.hoisted(() => vi.fn()); 6 6 const listenMock = vi.hoisted(() => vi.fn()); 7 + const onErrorMock = vi.hoisted(() => vi.fn()); 7 8 8 9 vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); 9 10 vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 11 + vi.mock("@tauri-apps/plugin-log", () => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn(), debug: vi.fn() })); 10 12 11 13 const ACTIVE_SESSION = { did: "did:plc:alice", handle: "alice.test" } as const; 12 14 const SAMPLE_POST = { ··· 21 23 viewer: {}, 22 24 } as const; 23 25 26 + const QUOTE_POST = { ...SAMPLE_POST, cid: "bafy-quote-cid", uri: "at://did:plc:quote/app.bsky.feed.post/123" } as const; 27 + 28 + const REPLY_PARENT_POST = { 29 + ...SAMPLE_POST, 30 + cid: "bafy-parent-cid", 31 + uri: "at://did:plc:reply-parent/app.bsky.feed.post/456", 32 + } as const; 33 + 34 + const REPLY_ROOT_POST = { 35 + ...SAMPLE_POST, 36 + cid: "bafy-root-cid", 37 + uri: "at://did:plc:reply-root/app.bsky.feed.post/789", 38 + } as const; 39 + 40 + const SAMPLE_DRAFT = { 41 + id: "draft-abc-123", 42 + accountDid: ACTIVE_SESSION.did, 43 + text: "Draft text", 44 + replyParentUri: null, 45 + replyParentCid: null, 46 + replyRootUri: null, 47 + replyRootCid: null, 48 + quoteUri: null, 49 + quoteCid: null, 50 + title: null, 51 + createdAt: "2026-03-28T10:00:00.000Z", 52 + updatedAt: "2026-03-28T10:00:00.000Z", 53 + } as const; 54 + 55 + const SAMPLE_REFERENCED_DRAFT = { 56 + ...SAMPLE_DRAFT, 57 + id: "draft-with-refs", 58 + quoteCid: "bafy-quote-cid", 59 + quoteUri: "at://did:plc:quote/app.bsky.feed.post/123", 60 + replyParentCid: "bafy-parent-cid", 61 + replyParentUri: "at://did:plc:reply-parent/app.bsky.feed.post/456", 62 + replyRootCid: "bafy-root-cid", 63 + replyRootUri: "at://did:plc:reply-root/app.bsky.feed.post/789", 64 + } as const; 65 + 66 + function defaultInvokeImplementation(command: string) { 67 + if (command === "get_preferences") { 68 + return Promise.resolve({ 69 + savedFeeds: [{ id: "following", pinned: true, type: "timeline", value: "following" }], 70 + feedViewPrefs: [], 71 + }); 72 + } 73 + 74 + if (command === "get_timeline") { 75 + return Promise.resolve({ cursor: null, feed: [] }); 76 + } 77 + 78 + if (command === "create_post") { 79 + return Promise.resolve({ cid: "cid-created", uri: "at://did:plc:alice/app.bsky.feed.post/new-post" }); 80 + } 81 + 82 + if (command === "list_drafts") { 83 + return Promise.resolve([]); 84 + } 85 + 86 + if (command === "save_draft") { 87 + return Promise.resolve(SAMPLE_DRAFT); 88 + } 89 + 90 + if (command === "delete_draft") { 91 + return Promise.resolve(); 92 + } 93 + 94 + if (command === "get_post_thread") { 95 + return Promise.resolve({ thread: { $type: "app.bsky.feed.defs#threadViewPost", post: SAMPLE_POST } }); 96 + } 97 + 98 + throw new Error(`unexpected invoke: ${command}`); 99 + } 100 + 24 101 function ControllerHarness() { 25 102 const controller = useFeedWorkspaceController({ 26 103 activeSession: ACTIVE_SESSION, 27 - onError: () => {}, 104 + onError: onErrorMock, 28 105 onOpenThread: () => {}, 29 106 }); 30 107 ··· 35 112 <button type="button" onClick={controller.clearReplyComposer}>Clear reply</button> 36 113 <button type="button" onClick={controller.clearQuoteComposer}>Clear quote</button> 37 114 <button type="button" onClick={() => void controller.submitPost()}>Submit</button> 115 + <button type="button" onClick={() => void controller.resetComposer()}>Discard</button> 116 + <button type="button" onClick={() => void controller.saveAndCloseComposer()}>Save draft</button> 117 + <button type="button" onClick={() => controller.loadDraft(SAMPLE_DRAFT)}>Load draft</button> 118 + <button type="button" onClick={() => controller.loadDraft(SAMPLE_REFERENCED_DRAFT)}>Load referenced draft</button> 119 + <button type="button" onClick={() => controller.setComposerText("hello world")}>Set text</button> 38 120 <p data-testid="active-feed">{controller.workspace.activeFeedId ?? "none"}</p> 39 121 <p data-testid="reply-state">{controller.workspace.composer.replyTarget ? "on" : "off"}</p> 40 122 <p data-testid="quote-state">{controller.workspace.composer.quoteTarget ? "on" : "off"}</p> 123 + <p data-testid="draft-id">{controller.workspace.composer.draftId ?? "none"}</p> 124 + <p data-testid="composer-open">{controller.workspace.composer.open ? "open" : "closed"}</p> 125 + <p data-testid="autosave-status">{controller.workspace.composer.autosaveStatus}</p> 126 + <p data-testid="drafts-open">{controller.workspace.showDraftsList ? "open" : "closed"}</p> 41 127 </div> 42 128 ); 43 129 } 44 130 131 + function setupTest() { 132 + invokeMock.mockReset(); 133 + listenMock.mockReset(); 134 + onErrorMock.mockReset(); 135 + globalThis.localStorage?.removeItem?.(`lazurite:autosave:${ACTIVE_SESSION.did}`); 136 + listenMock.mockResolvedValue(() => {}); 137 + invokeMock.mockImplementation(defaultInvokeImplementation); 138 + } 139 + 45 140 describe("useFeedWorkspaceController", () => { 46 141 it("keeps reply and quote state together and submits both", async () => { 47 - invokeMock.mockReset(); 48 - listenMock.mockReset(); 49 - listenMock.mockResolvedValue(() => {}); 50 - invokeMock.mockImplementation((command: string) => { 51 - if (command === "get_preferences") { 52 - return Promise.resolve({ 53 - savedFeeds: [{ id: "following", pinned: true, type: "timeline", value: "following" }], 54 - feedViewPrefs: [], 55 - }); 56 - } 57 - 58 - if (command === "get_timeline") { 59 - return Promise.resolve({ cursor: null, feed: [] }); 60 - } 61 - 62 - if (command === "create_post") { 63 - return Promise.resolve({ cid: "cid-created", uri: "at://did:plc:alice/app.bsky.feed.post/new-post" }); 64 - } 65 - 66 - throw new Error(`unexpected invoke: ${command}`); 67 - }); 142 + setupTest(); 68 143 69 144 render(() => <ControllerHarness />); 70 145 ··· 97 172 }, 98 173 text: "", 99 174 }); 175 + }); 176 + }); 177 + 178 + it("loadDraft sets draftId and text in composer state", async () => { 179 + setupTest(); 180 + 181 + render(() => <ControllerHarness />); 182 + await screen.findByText("following"); 183 + 184 + fireEvent.click(screen.getByRole("button", { name: "Load draft" })); 185 + 186 + expect(screen.getByTestId("draft-id")).toHaveTextContent(SAMPLE_DRAFT.id); 187 + expect(screen.getByTestId("composer-open")).toHaveTextContent("open"); 188 + }); 189 + 190 + it("resetComposer deletes the autosave draft when draftId is set", async () => { 191 + setupTest(); 192 + 193 + render(() => <ControllerHarness />); 194 + await screen.findByText("following"); 195 + 196 + fireEvent.click(screen.getByRole("button", { name: "Load draft" })); 197 + expect(screen.getByTestId("draft-id")).toHaveTextContent(SAMPLE_DRAFT.id); 198 + 199 + fireEvent.click(screen.getByRole("button", { name: "Discard" })); 200 + 201 + await waitFor(() => { 202 + expect(invokeMock).toHaveBeenCalledWith("delete_draft", { id: SAMPLE_DRAFT.id }); 203 + }); 204 + 205 + await waitFor(() => { 206 + expect(screen.getByTestId("draft-id")).toHaveTextContent("none"); 207 + expect(screen.getByTestId("composer-open")).toHaveTextContent("closed"); 208 + }, { timeout: 3000 }); 209 + }); 210 + 211 + it("saveAndCloseComposer saves draft then closes composer", async () => { 212 + setupTest(); 213 + 214 + render(() => <ControllerHarness />); 215 + await screen.findByText("following"); 216 + 217 + fireEvent.click(screen.getByRole("button", { name: "Reply" })); 218 + fireEvent.click(screen.getByRole("button", { name: "Set text" })); 219 + expect(screen.getByTestId("composer-open")).toHaveTextContent("open"); 220 + 221 + fireEvent.click(screen.getByRole("button", { name: "Save draft" })); 222 + 223 + await waitFor(() => { 224 + expect(invokeMock).toHaveBeenCalledWith("save_draft", expect.objectContaining({ input: expect.any(Object) })); 225 + }, { timeout: 3000 }); 226 + 227 + await waitFor(() => { 228 + expect(screen.getByTestId("composer-open")).toHaveTextContent("closed"); 229 + }, { timeout: 3000 }); 230 + }); 231 + 232 + it("submitPost deletes the draft after success when draftId is set", async () => { 233 + setupTest(); 234 + 235 + render(() => <ControllerHarness />); 236 + await screen.findByText("following"); 237 + 238 + // Load a draft to set draftId 239 + fireEvent.click(screen.getByRole("button", { name: "Load draft" })); 240 + expect(screen.getByTestId("draft-id")).toHaveTextContent(SAMPLE_DRAFT.id); 241 + 242 + fireEvent.click(screen.getByRole("button", { name: "Submit" })); 243 + 244 + await waitFor(() => { 245 + expect(invokeMock).toHaveBeenCalledWith("create_post", expect.any(Object)); 246 + }); 247 + 248 + await waitFor(() => { 249 + expect(invokeMock).toHaveBeenCalledWith("delete_draft", { id: SAMPLE_DRAFT.id }); 250 + }, { timeout: 3000 }); 251 + 252 + await waitFor(() => { 253 + expect(screen.getByTestId("draft-id")).toHaveTextContent("none"); 254 + expect(screen.getByTestId("composer-open")).toHaveTextContent("closed"); 255 + }, { timeout: 3000 }); 256 + }); 257 + 258 + it("submitPost keeps reply and quote references from a loaded draft", async () => { 259 + setupTest(); 260 + invokeMock.mockImplementation((command: string, args?: { uri?: string }) => { 261 + if (command === "get_post_thread" && args?.uri === SAMPLE_REFERENCED_DRAFT.quoteUri) { 262 + return Promise.resolve({ thread: { $type: "app.bsky.feed.defs#threadViewPost", post: QUOTE_POST } }); 263 + } 264 + 265 + if (command === "get_post_thread" && args?.uri === SAMPLE_REFERENCED_DRAFT.replyParentUri) { 266 + return Promise.resolve({ thread: { $type: "app.bsky.feed.defs#threadViewPost", post: REPLY_PARENT_POST } }); 267 + } 268 + 269 + if (command === "get_post_thread" && args?.uri === SAMPLE_REFERENCED_DRAFT.replyRootUri) { 270 + return Promise.resolve({ thread: { $type: "app.bsky.feed.defs#threadViewPost", post: REPLY_ROOT_POST } }); 271 + } 272 + 273 + return defaultInvokeImplementation(command); 274 + }); 275 + 276 + render(() => <ControllerHarness />); 277 + await screen.findByText("following"); 278 + 279 + fireEvent.click(screen.getByRole("button", { name: "Load referenced draft" })); 280 + 281 + await waitFor(() => { 282 + expect(screen.getByTestId("reply-state")).toHaveTextContent("on"); 283 + expect(screen.getByTestId("quote-state")).toHaveTextContent("on"); 284 + }); 285 + 286 + fireEvent.click(screen.getByRole("button", { name: "Submit" })); 287 + 288 + await waitFor(() => { 289 + expect(invokeMock).toHaveBeenCalledWith("create_post", { 290 + embed: { 291 + record: { cid: SAMPLE_REFERENCED_DRAFT.quoteCid, uri: SAMPLE_REFERENCED_DRAFT.quoteUri }, 292 + type: "record", 293 + }, 294 + replyTo: { 295 + parent: { cid: SAMPLE_REFERENCED_DRAFT.replyParentCid, uri: SAMPLE_REFERENCED_DRAFT.replyParentUri }, 296 + root: { cid: SAMPLE_REFERENCED_DRAFT.replyRootCid, uri: SAMPLE_REFERENCED_DRAFT.replyRootUri }, 297 + }, 298 + text: SAMPLE_REFERENCED_DRAFT.text, 299 + }); 300 + }); 301 + }); 302 + 303 + it("caches per-URI draft hydration across repeated loads", async () => { 304 + setupTest(); 305 + invokeMock.mockImplementation((command: string, args?: { uri?: string }) => { 306 + if (command === "get_post_thread" && args?.uri === SAMPLE_REFERENCED_DRAFT.quoteUri) { 307 + return Promise.resolve({ thread: { $type: "app.bsky.feed.defs#threadViewPost", post: QUOTE_POST } }); 308 + } 309 + 310 + if (command === "get_post_thread" && args?.uri === SAMPLE_REFERENCED_DRAFT.replyParentUri) { 311 + return Promise.resolve({ thread: { $type: "app.bsky.feed.defs#threadViewPost", post: REPLY_PARENT_POST } }); 312 + } 313 + 314 + if (command === "get_post_thread" && args?.uri === SAMPLE_REFERENCED_DRAFT.replyRootUri) { 315 + return Promise.resolve({ thread: { $type: "app.bsky.feed.defs#threadViewPost", post: REPLY_ROOT_POST } }); 316 + } 317 + 318 + return defaultInvokeImplementation(command); 319 + }); 320 + 321 + render(() => <ControllerHarness />); 322 + await screen.findByText("following"); 323 + 324 + fireEvent.click(screen.getByRole("button", { name: "Load referenced draft" })); 325 + await waitFor(() => { 326 + expect(screen.getByTestId("reply-state")).toHaveTextContent("on"); 327 + expect(screen.getByTestId("quote-state")).toHaveTextContent("on"); 328 + }); 329 + 330 + const firstHydrationCalls = invokeMock.mock.calls.filter((call) => call[0] === "get_post_thread").length; 331 + expect(firstHydrationCalls).toBe(3); 332 + 333 + fireEvent.click(screen.getByRole("button", { name: "Clear reply" })); 334 + fireEvent.click(screen.getByRole("button", { name: "Clear quote" })); 335 + expect(screen.getByTestId("reply-state")).toHaveTextContent("off"); 336 + expect(screen.getByTestId("quote-state")).toHaveTextContent("off"); 337 + 338 + fireEvent.click(screen.getByRole("button", { name: "Load referenced draft" })); 339 + await waitFor(() => { 340 + expect(screen.getByTestId("reply-state")).toHaveTextContent("on"); 341 + expect(screen.getByTestId("quote-state")).toHaveTextContent("on"); 342 + }); 343 + 344 + const secondHydrationCalls = invokeMock.mock.calls.filter((call) => call[0] === "get_post_thread").length; 345 + expect(secondHydrationCalls).toBe(firstHydrationCalls); 346 + }); 347 + 348 + it("autosave schedules a save after text changes with composer open", async () => { 349 + vi.useFakeTimers(); 350 + setupTest(); 351 + 352 + render(() => <ControllerHarness />); 353 + await screen.findByText("following"); 354 + 355 + fireEvent.click(screen.getByRole("button", { name: "Reply" })); 356 + expect(screen.getByTestId("composer-open")).toHaveTextContent("open"); 357 + 358 + fireEvent.click(screen.getByRole("button", { name: "Set text" })); 359 + expect(screen.getByTestId("autosave-status")).toHaveTextContent("idle"); 360 + 361 + vi.advanceTimersByTime(3100); 362 + 363 + await waitFor(() => { 364 + expect(invokeMock).toHaveBeenCalledWith("save_draft", expect.objectContaining({ input: expect.any(Object) })); 365 + }); 366 + 367 + vi.useRealTimers(); 368 + }); 369 + 370 + it("autosave schedules a save for quote-only composer state", async () => { 371 + vi.useFakeTimers(); 372 + setupTest(); 373 + 374 + render(() => <ControllerHarness />); 375 + await screen.findByText("following"); 376 + 377 + fireEvent.click(screen.getByRole("button", { name: "Quote" })); 378 + expect(screen.getByTestId("composer-open")).toHaveTextContent("open"); 379 + 380 + vi.advanceTimersByTime(3100); 381 + 382 + await waitFor(() => { 383 + expect(invokeMock).toHaveBeenCalledWith("save_draft", { 384 + input: expect.objectContaining({ quoteCid: SAMPLE_POST.cid, quoteUri: SAMPLE_POST.uri, text: "" }), 385 + }); 386 + }); 387 + 388 + vi.useRealTimers(); 389 + }); 390 + 391 + it("saveAndCloseComposer keeps composer open when save fails", async () => { 392 + setupTest(); 393 + invokeMock.mockImplementation((command: string) => { 394 + if (command === "save_draft") { 395 + return Promise.reject(new Error("db unavailable")); 396 + } 397 + 398 + return defaultInvokeImplementation(command); 399 + }); 400 + 401 + render(() => <ControllerHarness />); 402 + await screen.findByText("following"); 403 + 404 + fireEvent.click(screen.getByRole("button", { name: "Reply" })); 405 + fireEvent.click(screen.getByRole("button", { name: "Set text" })); 406 + fireEvent.click(screen.getByRole("button", { name: "Save draft" })); 407 + 408 + await waitFor(() => { 409 + expect(invokeMock).toHaveBeenCalledWith("save_draft", expect.objectContaining({ input: expect.any(Object) })); 410 + }); 411 + expect(screen.getByTestId("composer-open")).toHaveTextContent("open"); 412 + expect(onErrorMock).toHaveBeenCalledWith("Couldn't save your draft. Please try again."); 413 + }); 414 + 415 + it("opens drafts list on Ctrl/Cmd+D regardless of key casing", async () => { 416 + setupTest(); 417 + 418 + render(() => <ControllerHarness />); 419 + await screen.findByText("following"); 420 + expect(screen.getByTestId("drafts-open")).toHaveTextContent("closed"); 421 + 422 + globalThis.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, key: "D" })); 423 + expect(screen.getByTestId("drafts-open")).toHaveTextContent("open"); 424 + }); 425 + 426 + it("saves draft on Ctrl/Cmd+S regardless of key casing", async () => { 427 + setupTest(); 428 + 429 + render(() => <ControllerHarness />); 430 + await screen.findByText("following"); 431 + 432 + fireEvent.click(screen.getByRole("button", { name: "Reply" })); 433 + globalThis.dispatchEvent(new KeyboardEvent("keydown", { ctrlKey: true, key: "S" })); 434 + 435 + await waitFor(() => { 436 + expect(invokeMock).toHaveBeenCalledWith("save_draft", expect.objectContaining({ input: expect.any(Object) })); 100 437 }); 101 438 }); 102 439 });
+487 -20
src/components/feeds/useFeedWorkspaceController.ts
··· 1 1 import { usePostInteractions } from "$/components/posts/usePostInteractions"; 2 + import { deleteDraft, getDraft, listDrafts, saveDraft } from "$/lib/api/drafts"; 2 3 import { 3 4 createPost, 4 5 getFeedGenerators, 5 6 getFeedPage, 7 + getPostThread, 6 8 getPreferences, 7 9 updateFeedViewPref, 8 10 updateSavedFeeds, ··· 14 16 extractHashtags, 15 17 getFeedName, 16 18 getReplyRootPost, 19 + isThreadViewPost, 17 20 patchFeedItems, 18 21 toStrongRef, 19 22 } from "$/lib/feeds"; 20 - import type { Draft } from "$/lib/types"; 21 - import type { ActiveSession, EmbedInput, FeedViewPrefItem, PostView, ReplyRefInput, SavedFeedItem } from "$/lib/types"; 23 + import type { 24 + ActiveSession, 25 + Draft, 26 + DraftInput, 27 + EmbedInput, 28 + FeedViewPrefItem, 29 + PostView, 30 + ReplyRefInput, 31 + SavedFeedItem, 32 + StrongRefInput, 33 + ThreadNode, 34 + } from "$/lib/types"; 22 35 import { shouldIgnoreKey } from "$/lib/utils/events"; 23 36 import { escapeForRegex } from "$/lib/utils/text"; 37 + import { normalizeError } from "$/lib/utils/text"; 24 38 import { listen } from "@tauri-apps/api/event"; 39 + import * as logger from "@tauri-apps/plugin-log"; 25 40 import { createEffect, createMemo, onCleanup, onMount, untrack } from "solid-js"; 26 41 import { createStore, reconcile } from "solid-js/store"; 27 42 import type { FeedWorkspaceState } from "./types"; ··· 46 61 47 62 const DEFAULT_LIMIT = 30; 48 63 64 + type HydrationMaps = { inFlightByUri: Map<string, Promise<PostView | null>>; postByUri: Map<string, PostView | null> }; 65 + 66 + function toDraftStrongRef(uri: string | null, cid: string | null): StrongRefInput | null { 67 + if (!uri && !cid) { 68 + return null; 69 + } 70 + 71 + if (!uri || !cid) { 72 + return null; 73 + } 74 + 75 + return { cid, uri }; 76 + } 77 + 78 + function findPostInThread(node: ThreadNode | null | undefined, uri: string): PostView | null { 79 + if (!node || !isThreadViewPost(node)) { 80 + return null; 81 + } 82 + 83 + if (node.post.uri === uri) { 84 + return node.post; 85 + } 86 + 87 + const parentMatch = findPostInThread(node.parent, uri); 88 + if (parentMatch) { 89 + return parentMatch; 90 + } 91 + 92 + for (const reply of node.replies ?? []) { 93 + const replyMatch = findPostInThread(reply, uri); 94 + if (replyMatch) { 95 + return replyMatch; 96 + } 97 + } 98 + 99 + return null; 100 + } 101 + 102 + async function resolvePostByUri(uri: string): Promise<PostView | null> { 103 + try { 104 + const payload = await getPostThread(uri); 105 + const post = findPostInThread(payload.thread, uri); 106 + if (post) { 107 + return post; 108 + } 109 + 110 + logger.warn(`Hydration thread for ${uri} did not include the requested post`); 111 + return null; 112 + } catch (error) { 113 + logger.warn(`Failed to hydrate draft context for ${uri}: ${normalizeError(error)}`); 114 + return null; 115 + } 116 + } 117 + 118 + function createHydrationMaps(): HydrationMaps { 119 + return { inFlightByUri: new Map<string, Promise<PostView | null>>(), postByUri: new Map<string, PostView | null>() }; 120 + } 121 + 122 + function resolvePostByUriCached(uri: string, hydration: HydrationMaps): Promise<PostView | null> { 123 + const cached = hydration.postByUri.get(uri); 124 + if (cached !== undefined) { 125 + return Promise.resolve(cached); 126 + } 127 + 128 + const inFlight = hydration.inFlightByUri.get(uri); 129 + if (inFlight) { 130 + return inFlight; 131 + } 132 + 133 + const request = resolvePostByUri(uri).then((post) => { 134 + hydration.postByUri.set(uri, post); 135 + return post; 136 + }).finally(() => { 137 + hydration.inFlightByUri.delete(uri); 138 + }); 139 + hydration.inFlightByUri.set(uri, request); 140 + return request; 141 + } 142 + 143 + async function hydratePostsByUri(uris: string[], hydration: HydrationMaps): Promise<Map<string, PostView>> { 144 + const uniqueUris = [...new Set(uris)]; 145 + await Promise.all(uniqueUris.map((uri) => resolvePostByUriCached(uri, hydration))); 146 + 147 + const hydratedByUri = new Map<string, PostView>(); 148 + for (const uri of uniqueUris) { 149 + const post = hydration.postByUri.get(uri); 150 + if (post) { 151 + hydratedByUri.set(uri, post); 152 + } 153 + } 154 + 155 + return hydratedByUri; 156 + } 157 + 49 158 export function useFeedWorkspaceController(props: FeedWorkspaceProps) { 50 159 const [workspace, setWorkspace] = createStore<FeedWorkspaceState>(createInitialWorkspaceState()); 51 160 const interactions = usePostInteractions({ onError: props.onError, patchPost }); ··· 57 166 let sentinel: HTMLDivElement | undefined; 58 167 let lastFocusedUri: string | null = null; 59 168 const postRefs = new Map<string, HTMLElement>(); 169 + let autosaveTimerId: ReturnType<typeof setTimeout> | null = null; 170 + const hydration = createHydrationMaps(); 60 171 61 172 const savedFeeds = createMemo(() => { 62 173 const stored = workspace.preferences?.savedFeeds ?? []; ··· 78 189 const activeFeedState = createMemo(() => workspace.feedStates[activeFeed().id]); 79 190 const visibleItems = createMemo(() => applyFeedPreferences(activeFeedState()?.items ?? [], activePref())); 80 191 const composerHasContent = createMemo(() => { 81 - const { text, quoteTarget, replyTarget } = workspace.composer; 82 - return text.trim().length > 0 || quoteTarget !== null || replyTarget !== null; 192 + const { quoteRef, quoteTarget, replyParentRef, replyRootRef, replyTarget, text } = workspace.composer; 193 + return text.trim().length > 0 || quoteTarget !== null || replyTarget !== null || quoteRef !== null 194 + || (replyParentRef !== null && replyRootRef !== null); 83 195 }); 84 196 85 197 const composerToken = createMemo(() => { ··· 215 327 onCleanup(() => { 216 328 globalThis.removeEventListener("keydown", handleGlobalKeydown); 217 329 unlistenPostCreated?.(); 330 + if (autosaveTimerId !== null) { 331 + clearTimeout(autosaveTimerId); 332 + autosaveTimerId = null; 333 + } 334 + hydration.inFlightByUri.clear(); 335 + hydration.postByUri.clear(); 218 336 }); 219 337 }); 220 338 339 + function autosaveKey(): string { 340 + return `lazurite:autosave:${props.activeSession.did}`; 341 + } 342 + 343 + function getAutosaveId(): string | null { 344 + try { 345 + return localStorage.getItem(autosaveKey()); 346 + } catch { 347 + return null; 348 + } 349 + } 350 + 351 + function setAutosaveId(id: string): void { 352 + try { 353 + localStorage.setItem(autosaveKey(), id); 354 + } catch { 355 + logger.debug("failed to set autosave id (localStorage unavailable)", { keyValues: { id } }); 356 + } 357 + } 358 + 359 + function clearAutosaveId(): void { 360 + const k = autosaveKey(); 361 + try { 362 + localStorage.removeItem(k); 363 + } catch { 364 + logger.debug("failed to clear autosave id (localStorage unavailable)", { keyValues: { key: k } }); 365 + } 366 + } 367 + 368 + function composerHasDraftableContent(): boolean { 369 + const { quoteRef, quoteTarget, replyParentRef, replyRootRef, replyTarget, text } = workspace.composer; 370 + return text.trim().length > 0 || quoteTarget !== null || replyTarget !== null || quoteRef !== null 371 + || replyParentRef !== null || replyRootRef !== null; 372 + } 373 + 374 + function getCurrentComposerRefs(): { 375 + quoteRef: StrongRefInput | null; 376 + replyParentRef: StrongRefInput | null; 377 + replyRootRef: StrongRefInput | null; 378 + } { 379 + const quoteRef = workspace.composer.quoteTarget 380 + ? toStrongRef(workspace.composer.quoteTarget) 381 + : workspace.composer.quoteRef; 382 + const replyParentRef = workspace.composer.replyTarget 383 + ? toStrongRef(workspace.composer.replyTarget) 384 + : workspace.composer.replyParentRef; 385 + const replyRootRef = workspace.composer.replyRoot 386 + ? toStrongRef(workspace.composer.replyRoot) 387 + : workspace.composer.replyRootRef; 388 + return { quoteRef, replyParentRef, replyRootRef }; 389 + } 390 + 391 + async function saveCurrentDraft(options?: { manual?: boolean }): Promise<Draft | null> { 392 + if (!composerHasDraftableContent()) { 393 + setWorkspace("composer", "autosaveStatus", "idle"); 394 + return null; 395 + } 396 + 397 + setWorkspace("composer", "autosaveStatus", "saving"); 398 + 399 + const { draftId, text } = workspace.composer; 400 + const { quoteRef, replyParentRef, replyRootRef } = getCurrentComposerRefs(); 401 + if ((replyParentRef && !replyRootRef) || (!replyParentRef && replyRootRef)) { 402 + logger.warn("Skipping draft save because reply references are incomplete"); 403 + setWorkspace("composer", "autosaveStatus", "idle"); 404 + if (options?.manual) { 405 + props.onError("Couldn't save this draft because its reply context is incomplete."); 406 + } 407 + return null; 408 + } 409 + 410 + const input: DraftInput = { 411 + id: draftId ?? undefined, 412 + text, 413 + quoteCid: quoteRef?.cid ?? null, 414 + quoteUri: quoteRef?.uri ?? null, 415 + replyParentCid: replyParentRef?.cid ?? null, 416 + replyParentUri: replyParentRef?.uri ?? null, 417 + replyRootCid: replyRootRef?.cid ?? null, 418 + replyRootUri: replyRootRef?.uri ?? null, 419 + }; 420 + 421 + try { 422 + const result = await saveDraft(input); 423 + setWorkspace("composer", "draftId", result.id); 424 + setWorkspace("composer", "quoteRef", quoteRef); 425 + setWorkspace("composer", "replyParentRef", replyParentRef); 426 + setWorkspace("composer", "replyRootRef", replyRootRef); 427 + setWorkspace("composer", "autosaveStatus", "saved"); 428 + setAutosaveId(result.id); 429 + await refreshDraftCount(); 430 + bumpDraftsListRefresh(); 431 + return result; 432 + } catch (error) { 433 + logger.error(`Autosave failed: ${normalizeError(error)}`); 434 + setWorkspace("composer", "autosaveStatus", "idle"); 435 + if (options?.manual) { 436 + props.onError("Couldn't save your draft. Please try again."); 437 + } 438 + return null; 439 + } 440 + } 441 + 442 + function scheduleAutosave() { 443 + if (autosaveTimerId !== null) { 444 + clearTimeout(autosaveTimerId); 445 + autosaveTimerId = null; 446 + } 447 + 448 + setWorkspace("composer", "autosaveStatus", "idle"); 449 + if (!composerHasDraftableContent()) { 450 + return; 451 + } 452 + 453 + autosaveTimerId = setTimeout(() => { 454 + autosaveTimerId = null; 455 + void saveCurrentDraft(); 456 + }, 3000); 457 + } 458 + 459 + async function refreshDraftCount() { 460 + try { 461 + const drafts = await listDrafts(props.activeSession.did); 462 + setWorkspace("draftCount", drafts.length); 463 + } catch (error) { 464 + logger.error(`Failed to refresh draft count: ${normalizeError(error)}`); 465 + } 466 + } 467 + 468 + function bumpDraftsListRefresh() { 469 + setWorkspace("draftsListRefreshNonce", (current) => current + 1); 470 + } 471 + 472 + async function hydrateDraftTargets(draft: Draft) { 473 + const requestedUris = [draft.quoteUri, draft.replyParentUri, draft.replyRootUri].filter((value): value is string => 474 + typeof value === "string" && value.length > 0 475 + ); 476 + if (requestedUris.length === 0) { 477 + return; 478 + } 479 + 480 + const hydratedByUri = await hydratePostsByUri(requestedUris, hydration); 481 + 482 + if (workspace.composer.draftId !== draft.id || !workspace.composer.open) { 483 + return; 484 + } 485 + 486 + if (draft.quoteUri) { 487 + setWorkspace("composer", "quoteTarget", hydratedByUri.get(draft.quoteUri) ?? null); 488 + } 489 + 490 + if (draft.replyParentUri) { 491 + setWorkspace("composer", "replyTarget", hydratedByUri.get(draft.replyParentUri) ?? null); 492 + } 493 + 494 + if (draft.replyRootUri) { 495 + setWorkspace("composer", "replyRoot", hydratedByUri.get(draft.replyRootUri) ?? null); 496 + } 497 + } 498 + 499 + async function bootstrapDraftRestore() { 500 + const savedId = getAutosaveId(); 501 + if (!savedId) { 502 + return; 503 + } 504 + 505 + try { 506 + await getDraft(savedId); 507 + setWorkspace("restoreDraftId", savedId); 508 + } catch (error) { 509 + logger.error(`Autosave draft ${savedId} not found, clearing: ${normalizeError(error)}`); 510 + clearAutosaveId(); 511 + } 512 + } 513 + 221 514 function registerScroller(element: HTMLDivElement) { 222 515 scroller = element; 223 516 } ··· 241 534 } 242 535 243 536 function handleGlobalKeydown(event: KeyboardEvent) { 244 - if ((event.metaKey || event.ctrlKey) && event.key === "d") { 537 + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { 538 + if (workspace.composer.open) { 539 + event.preventDefault(); 540 + if (autosaveTimerId !== null) { 541 + clearTimeout(autosaveTimerId); 542 + autosaveTimerId = null; 543 + } 544 + void saveCurrentDraft({ manual: true }); 545 + } 546 + return; 547 + } 548 + 549 + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "d") { 245 550 event.preventDefault(); 246 551 openDraftsList(); 247 552 return; ··· 345 650 const nextActive = nextPreferences.savedFeeds.find((feed) => feed.pinned) ?? nextPreferences.savedFeeds[0] 346 651 ?? DEFAULT_TIMELINE; 347 652 setWorkspace("activeFeedId", nextActive.id); 653 + 654 + await Promise.all([bootstrapDraftRestore(), refreshDraftCount()]); 348 655 } catch (error) { 349 656 props.onError(`Failed to load feeds: ${String(error)}`); 350 657 } ··· 410 717 411 718 function setComposerText(text: string) { 412 719 setWorkspace("composer", "text", text); 720 + if (workspace.composer.open) { 721 + scheduleAutosave(); 722 + } 413 723 } 414 724 415 - function resetComposer() { 725 + function resetComposerState() { 416 726 setWorkspace( 417 727 "composer", 418 - (current) => ({ ...current, open: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }), 728 + (_current) => ({ 729 + autosaveStatus: "idle", 730 + draftId: null, 731 + open: false, 732 + pending: false, 733 + quoteRef: null, 734 + quoteTarget: null, 735 + replyParentRef: null, 736 + replyRoot: null, 737 + replyRootRef: null, 738 + replyTarget: null, 739 + text: "", 740 + }), 419 741 ); 420 742 } 421 743 744 + async function resetComposer() { 745 + const { draftId } = workspace.composer; 746 + if (autosaveTimerId !== null) { 747 + clearTimeout(autosaveTimerId); 748 + autosaveTimerId = null; 749 + } 750 + 751 + if (draftId) { 752 + try { 753 + await deleteDraft(draftId); 754 + await refreshDraftCount(); 755 + bumpDraftsListRefresh(); 756 + } catch (error) { 757 + logger.error(`Failed to delete autosave draft on discard: ${normalizeError(error)}`); 758 + } 759 + } 760 + 761 + clearAutosaveId(); 762 + resetComposerState(); 763 + } 764 + 765 + async function saveAndCloseComposer() { 766 + if (autosaveTimerId !== null) { 767 + clearTimeout(autosaveTimerId); 768 + autosaveTimerId = null; 769 + } 770 + 771 + const hadContent = composerHasDraftableContent(); 772 + const saved = await saveCurrentDraft({ manual: true }); 773 + if (!hadContent || saved) { 774 + clearAutosaveId(); 775 + resetComposerState(); 776 + } 777 + } 778 + 422 779 function openReplyComposer(post: PostView, root: PostView) { 423 - setWorkspace("composer", (current) => ({ ...current, open: true, replyRoot: root, replyTarget: post })); 780 + setWorkspace( 781 + "composer", 782 + (current) => ({ 783 + ...current, 784 + open: true, 785 + replyParentRef: toStrongRef(post), 786 + replyRoot: root, 787 + replyRootRef: toStrongRef(root), 788 + replyTarget: post, 789 + }), 790 + ); 791 + scheduleAutosave(); 424 792 } 425 793 426 794 function openQuoteComposer(post: PostView) { 427 - setWorkspace("composer", (current) => ({ ...current, open: true, quoteTarget: post })); 795 + setWorkspace("composer", (current) => ({ ...current, open: true, quoteRef: toStrongRef(post), quoteTarget: post })); 796 + scheduleAutosave(); 428 797 } 429 798 430 799 function clearQuoteComposer() { 431 800 setWorkspace("composer", "quoteTarget", null); 801 + setWorkspace("composer", "quoteRef", null); 802 + if (workspace.composer.open) { 803 + scheduleAutosave(); 804 + } 432 805 } 433 806 434 807 function clearReplyComposer() { 435 808 setWorkspace("composer", "replyTarget", null); 436 809 setWorkspace("composer", "replyRoot", null); 810 + setWorkspace("composer", "replyParentRef", null); 811 + setWorkspace("composer", "replyRootRef", null); 812 + if (workspace.composer.open) { 813 + scheduleAutosave(); 814 + } 437 815 } 438 816 439 817 function applySuggestion(value: string) { ··· 442 820 return; 443 821 } 444 822 445 - setWorkspace( 446 - "composer", 447 - "text", 448 - (current) => current.replace(new RegExp(`${escapeForRegex(token)}$`, "u"), `${value} `), 449 - ); 823 + const nextText = workspace.composer.text.replace(new RegExp(`${escapeForRegex(token)}$`, "u"), `${value} `); 824 + setComposerText(nextText); 450 825 } 451 826 452 827 async function submitPost() { ··· 454 829 const reply = workspace.composer.replyTarget; 455 830 const root = workspace.composer.replyRoot; 456 831 const quote = workspace.composer.quoteTarget; 832 + const draftId = workspace.composer.draftId; 833 + const replyParentRef = reply ? toStrongRef(reply) : workspace.composer.replyParentRef; 834 + const replyRootRef = root ? toStrongRef(root) : workspace.composer.replyRootRef; 835 + const quoteRef = quote ? toStrongRef(quote) : workspace.composer.quoteRef; 457 836 458 - const replyTo: ReplyRefInput | null = reply && root 459 - ? { parent: toStrongRef(reply), root: toStrongRef(root) } 837 + if ((replyParentRef && !replyRootRef) || (!replyParentRef && replyRootRef)) { 838 + props.onError("Couldn't submit this draft because its reply context is incomplete."); 839 + return; 840 + } 841 + 842 + const replyTo: ReplyRefInput | null = replyParentRef && replyRootRef 843 + ? { parent: replyParentRef, root: replyRootRef } 460 844 : null; 461 - const embed: EmbedInput | null = quote ? { type: "record", record: toStrongRef(quote) } : null; 845 + const embed: EmbedInput | null = quoteRef ? { record: quoteRef, type: "record" } : null; 462 846 463 847 setWorkspace("composer", "pending", true); 464 848 try { 465 849 await createPost(text, replyTo, embed); 466 - resetComposer(); 850 + 851 + if (autosaveTimerId !== null) { 852 + clearTimeout(autosaveTimerId); 853 + autosaveTimerId = null; 854 + } 855 + 856 + if (draftId) { 857 + try { 858 + await deleteDraft(draftId); 859 + await refreshDraftCount(); 860 + bumpDraftsListRefresh(); 861 + } catch (error) { 862 + logger.error(`Failed to delete draft after submit: ${normalizeError(error)}`); 863 + } 864 + } 865 + 866 + clearAutosaveId(); 867 + resetComposerState(); 467 868 await refreshActiveFeed(); 468 869 } catch (error) { 469 870 props.onError(`Failed to create post: ${String(error)}`); ··· 583 984 } 584 985 585 986 function loadDraft(draft: Draft) { 586 - setComposerText(draft.text); 587 - setWorkspace("composer", "open", true); 987 + if (autosaveTimerId !== null) { 988 + clearTimeout(autosaveTimerId); 989 + autosaveTimerId = null; 990 + } 991 + 992 + const replyParentRef = toDraftStrongRef(draft.replyParentUri, draft.replyParentCid); 993 + const replyRootRef = toDraftStrongRef(draft.replyRootUri, draft.replyRootCid); 994 + const quoteRef = toDraftStrongRef(draft.quoteUri, draft.quoteCid); 995 + if ( 996 + (draft.replyParentUri && !replyParentRef) || (draft.replyRootUri && !replyRootRef) 997 + || (draft.quoteUri && !quoteRef) 998 + ) { 999 + logger.warn(`Draft ${draft.id} has partial strong references; invalid references were dropped`); 1000 + } 1001 + 1002 + setWorkspace( 1003 + "composer", 1004 + (current) => ({ 1005 + ...current, 1006 + autosaveStatus: "idle", 1007 + draftId: draft.id, 1008 + open: true, 1009 + quoteRef, 1010 + quoteTarget: null, 1011 + replyParentRef, 1012 + replyRoot: null, 1013 + replyRootRef, 1014 + replyTarget: null, 1015 + text: draft.text, 1016 + }), 1017 + ); 588 1018 setWorkspace("showDraftsList", false); 1019 + void hydrateDraftTargets(draft); 1020 + } 1021 + 1022 + async function restoreDraft() { 1023 + const id = workspace.restoreDraftId; 1024 + if (!id) { 1025 + return; 1026 + } 1027 + 1028 + try { 1029 + const draft = await getDraft(id); 1030 + loadDraft(draft); 1031 + } catch (error) { 1032 + logger.error(`Failed to restore draft ${id}: ${normalizeError(error)}`); 1033 + } finally { 1034 + bumpDraftsListRefresh(); 1035 + setWorkspace("restoreDraftId", null); 1036 + } 1037 + } 1038 + 1039 + async function dismissRestore() { 1040 + const id = workspace.restoreDraftId; 1041 + setWorkspace("restoreDraftId", null); 1042 + clearAutosaveId(); 1043 + 1044 + if (id) { 1045 + try { 1046 + await deleteDraft(id); 1047 + await refreshDraftCount(); 1048 + bumpDraftsListRefresh(); 1049 + } catch (error) { 1050 + logger.error(`Failed to delete dismissed restore draft ${id}: ${normalizeError(error)}`); 1051 + } 1052 + } 589 1053 } 590 1054 591 1055 return { ··· 599 1063 closeFeedsDrawer, 600 1064 composerHasContent, 601 1065 composerSuggestions, 1066 + dismissRestore, 602 1067 drawerFeeds, 603 1068 loadDraft, 604 1069 openComposer, ··· 615 1080 rememberScrollTop, 616 1081 reorderPinnedFeeds, 617 1082 resetComposer, 1083 + restoreDraft, 1084 + saveAndCloseComposer, 618 1085 setFeedPref, 619 1086 setFocusedIndex, 620 1087 setComposerText,
+16 -1
src/components/feeds/workspace-state.ts
··· 19 19 export function createInitialWorkspaceState(): FeedWorkspaceState { 20 20 return { 21 21 activeFeedId: null, 22 - composer: { open: false, pending: false, quoteTarget: null, replyRoot: null, replyTarget: null, text: "" }, 22 + composer: { 23 + autosaveStatus: "idle", 24 + draftId: null, 25 + open: false, 26 + pending: false, 27 + quoteRef: null, 28 + quoteTarget: null, 29 + replyParentRef: null, 30 + replyRoot: null, 31 + replyRootRef: null, 32 + replyTarget: null, 33 + text: "", 34 + }, 35 + draftCount: 0, 36 + draftsListRefreshNonce: 0, 23 37 feedStates: {}, 24 38 feedScrollTops: {}, 25 39 focusedIndex: 0, 26 40 generators: {}, 27 41 localPrefs: {}, 28 42 preferences: null, 43 + restoreDraftId: null, 29 44 showDraftsList: false, 30 45 showFeedsDrawer: false, 31 46 };
+9 -1
src/lib/api/drafts.ts
··· 1 - import type { Draft } from "$/lib/types"; 1 + import type { Draft, DraftInput } from "$/lib/types"; 2 2 import { invoke } from "@tauri-apps/api/core"; 3 3 4 4 export function listDrafts(accountDid: string): Promise<Draft[]> { 5 5 return invoke("list_drafts", { accountDid }); 6 + } 7 + 8 + export function getDraft(id: string): Promise<Draft> { 9 + return invoke("get_draft", { id }); 10 + } 11 + 12 + export function saveDraft(input: DraftInput): Promise<Draft> { 13 + return invoke("save_draft", { input }); 6 14 } 7 15 8 16 export function deleteDraft(id: string): Promise<void> {
+12
src/lib/types.ts
··· 333 333 createdAt: string; 334 334 updatedAt: string; 335 335 }; 336 + 337 + export type DraftInput = { 338 + id?: string | null; 339 + text: string; 340 + replyParentUri?: string | null; 341 + replyParentCid?: string | null; 342 + replyRootUri?: string | null; 343 + replyRootCid?: string | null; 344 + quoteUri?: string | null; 345 + quoteCid?: string | null; 346 + title?: string | null; 347 + };
+5
src/test/setup.ts
··· 6 6 import { afterEach, vi } from "vitest"; 7 7 8 8 vi.mock( 9 + "@tauri-apps/plugin-log", 10 + () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn(), trace: vi.fn() }), 11 + ); 12 + 13 + vi.mock( 9 14 "solid-motionone", 10 15 () => ({ 11 16 Motion: new Proxy({}, {