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 management ui (listing and deletion)

+457 -11
+6 -6
docs/tasks/14-drafts.md
··· 18 18 19 19 ### Frontend - Drafts List Panel 20 20 21 - - [ ] Drafts list panel component with `Presence` slide-up from composer 22 - - [ ] Draft cards: title or text preview, reply/quote context indicator, relative timestamp, delete button 23 - - [ ] Tap draft to load into composer (confirmation if composer has content) 24 - - [ ] Delete with confirmation 25 - - [ ] Empty state: *"No drafts yet. Saved posts will appear here."* 26 - - [ ] `Ctrl/Cmd+D` keyboard shortcut to open drafts list 21 + - [x] Drafts list panel component with `Presence` slide-up from composer 22 + - [x] Draft cards: title or text preview, reply/quote context indicator, relative timestamp, delete button 23 + - [x] Tap draft to load into composer (confirmation if composer has content) 24 + - [x] Delete with confirmation 25 + - [x] Empty state: *"No drafts yet. Saved posts will appear here."* 26 + - [x] `Ctrl/Cmd+D` keyboard shortcut to open drafts list 27 27 28 28 ### Frontend - Composer Integration 29 29
+356
src/components/feeds/DraftsList.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { deleteDraft, listDrafts } from "$/lib/api/drafts"; 3 + import { formatRelativeTime } from "$/lib/feeds"; 4 + import type { Draft } from "$/lib/types"; 5 + import { normalizeError } from "$/lib/utils/text"; 6 + import * as logger from "@tauri-apps/plugin-log"; 7 + import { createEffect, createSignal, For, Show } from "solid-js"; 8 + import { Motion, Presence } from "solid-motionone"; 9 + 10 + type DraftsListProps = { 11 + accountDid: string; 12 + composerHasContent: boolean; 13 + open: boolean; 14 + onClose: () => void; 15 + onLoadDraft: (draft: Draft) => void; 16 + }; 17 + 18 + export function DraftsList(props: DraftsListProps) { 19 + const [drafts, setDrafts] = createSignal<Draft[]>([]); 20 + const [loading, setLoading] = createSignal(false); 21 + const [error, setError] = createSignal<string | null>(null); 22 + const [confirmDeleteId, setConfirmDeleteId] = createSignal<string | null>(null); 23 + const [confirmLoadDraft, setConfirmLoadDraft] = createSignal<Draft | null>(null); 24 + 25 + createEffect(() => { 26 + if (props.open) { 27 + void fetchDrafts(); 28 + } else { 29 + setConfirmDeleteId(null); 30 + setConfirmLoadDraft(null); 31 + } 32 + }); 33 + 34 + async function fetchDrafts() { 35 + setLoading(true); 36 + setError(null); 37 + try { 38 + const result = await listDrafts(props.accountDid); 39 + setDrafts(result); 40 + } catch (err) { 41 + logger.error(`Failed to load drafts: ${normalizeError(err)}`); 42 + setError("Couldn't load your drafts. Try again."); 43 + } finally { 44 + setLoading(false); 45 + } 46 + } 47 + 48 + function handleTapDraft(draft: Draft) { 49 + if (props.composerHasContent) { 50 + setConfirmLoadDraft(draft); 51 + } else { 52 + props.onLoadDraft(draft); 53 + } 54 + } 55 + 56 + function handleConfirmLoad() { 57 + const draft = confirmLoadDraft(); 58 + if (draft) { 59 + props.onLoadDraft(draft); 60 + } 61 + setConfirmLoadDraft(null); 62 + } 63 + 64 + async function handleConfirmDelete() { 65 + const id = confirmDeleteId(); 66 + if (!id) { 67 + return; 68 + } 69 + 70 + try { 71 + await deleteDraft(id); 72 + setDrafts((prev) => prev.filter((d) => d.id !== id)); 73 + } catch (err) { 74 + logger.error(`Failed to delete draft ${id}: ${normalizeError(err)}`); 75 + } finally { 76 + setConfirmDeleteId(null); 77 + } 78 + } 79 + 80 + return ( 81 + <Presence> 82 + <Show when={props.open}> 83 + <div class="fixed inset-0 z-60"> 84 + <Motion.button 85 + class="absolute inset-0 h-full w-full border-0 bg-black/75 backdrop-blur-[20px]" 86 + initial={{ opacity: 0 }} 87 + animate={{ opacity: 1 }} 88 + exit={{ opacity: 0 }} 89 + transition={{ duration: 0.18 }} 90 + type="button" 91 + onClick={() => props.onClose()} /> 92 + <DraftsPanel 93 + confirmLoadDraft={confirmLoadDraft()} 94 + confirmDeleteId={confirmDeleteId()} 95 + drafts={drafts()} 96 + error={error()} 97 + loading={loading()} 98 + onCancelDelete={() => setConfirmDeleteId(null)} 99 + onCancelLoad={() => setConfirmLoadDraft(null)} 100 + onClose={props.onClose} 101 + onConfirmDelete={() => void handleConfirmDelete()} 102 + onConfirmLoad={handleConfirmLoad} 103 + onDeleteDraft={setConfirmDeleteId} 104 + onTapDraft={handleTapDraft} /> 105 + </div> 106 + </Show> 107 + </Presence> 108 + ); 109 + } 110 + 111 + type DraftsPanelProps = { 112 + confirmDeleteId: string | null; 113 + confirmLoadDraft: Draft | null; 114 + drafts: Draft[]; 115 + error: string | null; 116 + loading: boolean; 117 + onCancelDelete: () => void; 118 + onCancelLoad: () => void; 119 + onClose: () => void; 120 + onConfirmDelete: () => void; 121 + onConfirmLoad: () => void; 122 + onDeleteDraft: (id: string) => void; 123 + onTapDraft: (draft: Draft) => void; 124 + }; 125 + 126 + function DraftsPanel(props: DraftsPanelProps) { 127 + return ( 128 + <div class="relative z-10 flex min-h-screen items-end justify-center p-4 pt-16"> 129 + <Motion.section 130 + class="grid w-full max-w-xl grid-rows-[auto_minmax(0,1fr)] overflow-hidden rounded-[1.8rem] bg-surface-container-high shadow-[0_25px_70px_rgba(0,0,0,0.7),0_0_0_1px_rgba(125,175,255,0.14)] max-h-[calc(100vh-5rem)]" 131 + initial={{ opacity: 0, y: 24 }} 132 + animate={{ opacity: 1, y: 0 }} 133 + exit={{ opacity: 0, y: 20 }} 134 + transition={{ duration: 0.22, easing: [0.22, 1, 0.36, 1] }}> 135 + <DraftsListHeader count={props.drafts.length} onClose={props.onClose} /> 136 + <DraftsListBody {...props} /> 137 + </Motion.section> 138 + </div> 139 + ); 140 + } 141 + 142 + function DraftsListHeader(props: { count: number; onClose: () => void }) { 143 + return ( 144 + <header class="flex items-center justify-between border-b border-white/5 px-6 py-4"> 145 + <div class="flex items-baseline gap-2"> 146 + <h2 class="m-0 text-base font-semibold text-on-surface">Drafts</h2> 147 + <Show when={props.count > 0}> 148 + <p class="m-0 text-xs text-on-surface-variant">{props.count}</p> 149 + </Show> 150 + </div> 151 + <button 152 + 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" 153 + type="button" 154 + onClick={() => props.onClose()}> 155 + <Icon aria-hidden="true" iconClass="i-ri-close-line" /> 156 + </button> 157 + </header> 158 + ); 159 + } 160 + 161 + type DraftsListBodyProps = Omit<DraftsPanelProps, "onClose">; 162 + 163 + function DraftsListBody(props: DraftsListBodyProps) { 164 + const isEmpty = () => !props.loading && !props.error && props.drafts.length === 0; 165 + 166 + return ( 167 + <div class="min-h-0 overflow-y-auto overscroll-contain"> 168 + <Show when={props.confirmLoadDraft}> 169 + {(draft) => <LoadConfirmBanner draft={draft()} onConfirm={props.onConfirmLoad} onCancel={props.onCancelLoad} />} 170 + </Show> 171 + <Show when={props.loading}> 172 + <DraftsLoading /> 173 + </Show> 174 + <Show when={props.error}>{(msg) => <DraftsError message={msg()} />}</Show> 175 + <Show when={isEmpty()}> 176 + <DraftsEmptyState /> 177 + </Show> 178 + <div class="grid gap-2 p-4"> 179 + <For each={props.drafts}> 180 + {(draft) => ( 181 + <DraftCard 182 + draft={draft} 183 + confirmDeleteId={props.confirmDeleteId} 184 + onTap={() => props.onTapDraft(draft)} 185 + onDelete={() => props.onDeleteDraft(draft.id)} 186 + onConfirmDelete={props.onConfirmDelete} 187 + onCancelDelete={props.onCancelDelete} /> 188 + )} 189 + </For> 190 + </div> 191 + </div> 192 + ); 193 + } 194 + 195 + function LoadConfirmBanner(props: { draft: Draft; onConfirm: () => void; onCancel: () => void }) { 196 + const preview = () => props.draft.title ?? props.draft.text.slice(0, 60); 197 + 198 + return ( 199 + <div class="border-b border-white/5 bg-primary/8 px-4 py-3"> 200 + <p class="m-0 mb-2 text-sm text-on-surface"> 201 + Replace your current post with <span class="font-medium">"{preview()}"</span>? 202 + </p> 203 + <div class="flex gap-2"> 204 + <button 205 + class="rounded-full border-0 bg-primary/20 px-3 py-1.5 text-xs font-medium text-primary transition hover:bg-primary/30" 206 + type="button" 207 + onClick={() => props.onConfirm()}> 208 + Replace 209 + </button> 210 + <button 211 + class="rounded-full border-0 bg-transparent px-3 py-1.5 text-xs text-on-surface-variant transition hover:bg-white/5" 212 + type="button" 213 + onClick={() => props.onCancel()}> 214 + Keep current 215 + </button> 216 + </div> 217 + </div> 218 + ); 219 + } 220 + 221 + function DraftsLoading() { 222 + return ( 223 + <div class="flex items-center justify-center px-6 py-12"> 224 + <Icon aria-hidden="true" kind="loader" class="text-on-surface-variant" /> 225 + </div> 226 + ); 227 + } 228 + 229 + function DraftsError(props: { message: string }) { 230 + return ( 231 + <div class="flex flex-col items-center gap-2 px-6 py-12 text-center"> 232 + <Icon aria-hidden="true" kind="danger" class="text-error" /> 233 + <p class="m-0 text-sm text-on-surface-variant">{props.message}</p> 234 + </div> 235 + ); 236 + } 237 + 238 + function DraftsEmptyState() { 239 + return ( 240 + <div class="px-6 py-12 text-center"> 241 + <p class="m-0 text-sm text-on-surface-variant">No drafts yet. Saved posts will appear here.</p> 242 + </div> 243 + ); 244 + } 245 + 246 + type DraftCardProps = { 247 + draft: Draft; 248 + confirmDeleteId: string | null; 249 + onTap: () => void; 250 + onDelete: () => void; 251 + onConfirmDelete: () => void; 252 + onCancelDelete: () => void; 253 + }; 254 + 255 + function DraftCard(props: DraftCardProps) { 256 + const isConfirming = () => props.confirmDeleteId === props.draft.id; 257 + 258 + return ( 259 + <div class="overflow-hidden rounded-2xl bg-white/4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.06)] transition duration-150 ease-out hover:bg-white/6"> 260 + <Show when={isConfirming()} fallback={<DraftCardNormal {...props} />}> 261 + <DraftCardDeleteConfirm onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} /> 262 + </Show> 263 + </div> 264 + ); 265 + } 266 + 267 + function DraftCardNormal(props: DraftCardProps) { 268 + return ( 269 + <div class="flex items-start gap-1 p-1"> 270 + <button 271 + class="min-w-0 flex-1 rounded-xl border-0 bg-transparent px-3 py-2.5 text-left transition duration-150 ease-out hover:bg-white/4" 272 + type="button" 273 + onClick={() => props.onTap()}> 274 + <DraftCardContent draft={props.draft} /> 275 + </button> 276 + <DraftDeleteButton onDelete={props.onDelete} /> 277 + </div> 278 + ); 279 + } 280 + 281 + function DraftCardContent(props: { draft: Draft }) { 282 + const preview = () => (props.draft.title ?? props.draft.text.slice(0, 120)) || "Empty draft"; 283 + const timestamp = () => formatRelativeTime(props.draft.updatedAt); 284 + 285 + return ( 286 + <div class="flex flex-col gap-1.5"> 287 + <p class="m-0 line-clamp-2 text-sm leading-snug text-on-surface">{preview()}</p> 288 + <DraftCardFooter draft={props.draft} timestamp={timestamp()} /> 289 + </div> 290 + ); 291 + } 292 + 293 + function DraftCardFooter(props: { draft: Draft; timestamp: string }) { 294 + return ( 295 + <div class="flex flex-wrap items-center gap-2"> 296 + <DraftContextBadges draft={props.draft} /> 297 + <time class="text-xs text-on-surface-variant">{props.timestamp}</time> 298 + </div> 299 + ); 300 + } 301 + 302 + function DraftContextBadges(props: { draft: Draft }) { 303 + return ( 304 + <> 305 + <Show when={props.draft.replyParentUri}> 306 + <DraftContextBadge icon="i-ri-reply-line" label="Reply" /> 307 + </Show> 308 + <Show when={props.draft.quoteUri}> 309 + <DraftContextBadge icon="i-ri-chat-quote-line" label="Quote" /> 310 + </Show> 311 + </> 312 + ); 313 + } 314 + 315 + function DraftContextBadge(props: { icon: string; label: string }) { 316 + return ( 317 + <span class="inline-flex items-center gap-1 rounded-full bg-white/8 px-2 py-0.5 text-[0.7rem] text-on-surface-variant"> 318 + <Icon aria-hidden="true" iconClass={props.icon} /> 319 + <span>{props.label}</span> 320 + </span> 321 + ); 322 + } 323 + 324 + function DraftDeleteButton(props: { onDelete: () => void }) { 325 + return ( 326 + <button 327 + class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border-0 bg-transparent text-on-surface-variant transition duration-150 ease-out hover:bg-error/15 hover:text-error" 328 + type="button" 329 + title="Delete draft" 330 + onClick={() => props.onDelete()}> 331 + <Icon aria-hidden="true" iconClass="i-ri-delete-bin-line" /> 332 + </button> 333 + ); 334 + } 335 + 336 + function DraftCardDeleteConfirm(props: { onConfirm: () => void; onCancel: () => void }) { 337 + return ( 338 + <div class="flex items-center justify-between gap-3 px-4 py-3"> 339 + <p class="m-0 text-sm text-on-surface">Delete this draft?</p> 340 + <div class="flex gap-2"> 341 + <button 342 + class="rounded-full border-0 bg-error/15 px-3 py-1.5 text-xs font-medium text-error transition hover:bg-error/25" 343 + type="button" 344 + onClick={() => props.onConfirm()}> 345 + Delete 346 + </button> 347 + <button 348 + class="rounded-full border-0 bg-transparent px-3 py-1.5 text-xs text-on-surface-variant transition hover:bg-white/5" 349 + type="button" 350 + onClick={() => props.onCancel()}> 351 + Cancel 352 + </button> 353 + </div> 354 + </div> 355 + ); 356 + }
+29 -5
src/components/feeds/FeedComposer.tsx
··· 42 42 onClearQuote: () => void; 43 43 onClearReply: () => void; 44 44 onClose: () => void; 45 + onOpenDrafts?: () => void; 45 46 onSubmit: () => void; 46 47 onTextChange: (value: string) => void; 47 48 }; 48 49 49 - type ComposerSurfaceProps = Omit<FeedComposerProps, "open"> & { layout?: "dialog" | "window" }; 50 + type ComposerSurfaceProps = Omit<FeedComposerProps, "open"> & { 51 + layout?: "dialog" | "window"; 52 + onOpenDrafts?: () => void; 53 + }; 50 54 51 55 export function FeedComposer(props: FeedComposerProps) { 52 56 return ( ··· 75 79 onClearQuote={props.onClearQuote} 76 80 onClearReply={props.onClearReply} 77 81 onClose={props.onClose} 82 + onOpenDrafts={props.onOpenDrafts} 78 83 onSubmit={props.onSubmit} 79 84 onTextChange={props.onTextChange} /> 80 85 </div> ··· 102 107 quoteTarget={props.quoteTarget} 103 108 text={props.text} 104 109 onClose={props.onClose} 110 + onOpenDrafts={props.onOpenDrafts} 105 111 onSubmit={props.onSubmit} /> 106 112 <ComposerBody 107 113 activeAvatar={props.activeAvatar} ··· 150 156 quoteTarget: PostView | null; 151 157 text: string; 152 158 onClose: () => void; 159 + onOpenDrafts?: () => void; 153 160 onSubmit: () => void; 154 161 }, 155 162 ) { ··· 164 171 </button> 165 172 <ComposerTitle activeHandle={props.activeHandle} /> 166 173 </div> 167 - <ComposerSubmitButton 168 - disabled={props.pending || (!props.text.trim() && !props.quoteTarget)} 169 - pending={props.pending} 170 - onSubmit={props.onSubmit} /> 174 + <div class="flex items-center gap-2"> 175 + <ComposerDraftsButton onOpenDrafts={props.onOpenDrafts} /> 176 + <ComposerSubmitButton 177 + disabled={props.pending || (!props.text.trim() && !props.quoteTarget)} 178 + pending={props.pending} 179 + onSubmit={props.onSubmit} /> 180 + </div> 171 181 </header> 182 + ); 183 + } 184 + 185 + function ComposerDraftsButton(props: { onOpenDrafts?: () => void }) { 186 + return ( 187 + <Show when={props.onOpenDrafts}> 188 + <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" 190 + type="button" 191 + title="Drafts (Ctrl+D)" 192 + onClick={() => props.onOpenDrafts?.()}> 193 + <Icon aria-hidden="true" iconClass="i-ri-draft-line" /> 194 + </button> 195 + </Show> 172 196 ); 173 197 } 174 198
+9
src/components/feeds/FeedWorkspace.tsx
··· 1 1 import { useThreadOverlayNavigation } from "$/components/posts/useThreadOverlayNavigation"; 2 2 import { useAppSession } from "$/contexts/app-session"; 3 + import { DraftsList } from "./DraftsList"; 3 4 import { FeedComposer } from "./FeedComposer"; 4 5 import { SavedFeedsDrawer } from "./FeedDrawer"; 5 6 import { FeedPane } from "./FeedPane"; ··· 59 60 onClearQuote={controller.clearQuoteComposer} 60 61 onClearReply={controller.clearReplyComposer} 61 62 onClose={controller.resetComposer} 63 + onOpenDrafts={controller.openDraftsList} 62 64 onSubmit={() => void controller.submitPost()} 63 65 onTextChange={controller.setComposerText} /> 66 + 67 + <DraftsList 68 + accountDid={activeSession().did} 69 + composerHasContent={controller.composerHasContent()} 70 + open={controller.workspace.showDraftsList} 71 + onClose={controller.closeDraftsList} 72 + onLoadDraft={controller.loadDraft} /> 64 73 </> 65 74 ); 66 75 }
+1
src/components/feeds/types.ts
··· 26 26 generators: Record<string, FeedGeneratorView>; 27 27 localPrefs: Record<string, FeedViewPrefItem>; 28 28 preferences: UserPreferences | null; 29 + showDraftsList: boolean; 29 30 showFeedsDrawer: boolean; 30 31 };
+30
src/components/feeds/useFeedWorkspaceController.ts
··· 17 17 patchFeedItems, 18 18 toStrongRef, 19 19 } from "$/lib/feeds"; 20 + import type { Draft } from "$/lib/types"; 20 21 import type { ActiveSession, EmbedInput, FeedViewPrefItem, PostView, ReplyRefInput, SavedFeedItem } from "$/lib/types"; 21 22 import { shouldIgnoreKey } from "$/lib/utils/events"; 22 23 import { escapeForRegex } from "$/lib/utils/text"; ··· 76 77 }); 77 78 const activeFeedState = createMemo(() => workspace.feedStates[activeFeed().id]); 78 79 const visibleItems = createMemo(() => applyFeedPreferences(activeFeedState()?.items ?? [], activePref())); 80 + const composerHasContent = createMemo(() => { 81 + const { text, quoteTarget, replyTarget } = workspace.composer; 82 + return text.trim().length > 0 || quoteTarget !== null || replyTarget !== null; 83 + }); 84 + 79 85 const composerToken = createMemo(() => { 80 86 const match = /(^|\s)([@#][^\s@#]*)$/u.exec(workspace.composer.text); 81 87 return match?.[2] ?? null; ··· 235 241 } 236 242 237 243 function handleGlobalKeydown(event: KeyboardEvent) { 244 + if ((event.metaKey || event.ctrlKey) && event.key === "d") { 245 + event.preventDefault(); 246 + openDraftsList(); 247 + return; 248 + } 249 + 238 250 if (workspace.composer.open || shouldIgnoreKey(event)) { 239 251 return; 240 252 } ··· 562 574 setWorkspace("showFeedsDrawer", false); 563 575 } 564 576 577 + function openDraftsList() { 578 + setWorkspace("showDraftsList", true); 579 + } 580 + 581 + function closeDraftsList() { 582 + setWorkspace("showDraftsList", false); 583 + } 584 + 585 + function loadDraft(draft: Draft) { 586 + setComposerText(draft.text); 587 + setWorkspace("composer", "open", true); 588 + setWorkspace("showDraftsList", false); 589 + } 590 + 565 591 return { 566 592 activeFeed, 567 593 activeFeedState, ··· 569 595 applySuggestion, 570 596 clearQuoteComposer, 571 597 clearReplyComposer, 598 + closeDraftsList, 572 599 closeFeedsDrawer, 600 + composerHasContent, 573 601 composerSuggestions, 574 602 drawerFeeds, 603 + loadDraft, 575 604 openComposer, 605 + openDraftsList, 576 606 openThread, 577 607 openQuoteComposer, 578 608 openReplyComposer,
+1
src/components/feeds/workspace-state.ts
··· 26 26 generators: {}, 27 27 localPrefs: {}, 28 28 preferences: null, 29 + showDraftsList: false, 29 30 showFeedsDrawer: false, 30 31 }; 31 32 }
+10
src/lib/api/drafts.ts
··· 1 + import type { Draft } from "$/lib/types"; 2 + import { invoke } from "@tauri-apps/api/core"; 3 + 4 + export function listDrafts(accountDid: string): Promise<Draft[]> { 5 + return invoke("list_drafts", { accountDid }); 6 + } 7 + 8 + export function deleteDraft(id: string): Promise<void> { 9 + return invoke("delete_draft", { id }); 10 + }
+15
src/lib/types.ts
··· 318 318 export type GetConvoForMembersResponse = { convo: ConvoView }; 319 319 320 320 export type GetMessagesResponse = { messages: Array<MessageView | DeletedMessageView>; cursor?: string | null }; 321 + 322 + export type Draft = { 323 + id: string; 324 + accountDid: string; 325 + text: string; 326 + replyParentUri: string | null; 327 + replyParentCid: string | null; 328 + replyRootUri: string | null; 329 + replyRootCid: string | null; 330 + quoteUri: string | null; 331 + quoteCid: string | null; 332 + title: string | null; 333 + createdAt: string; 334 + updatedAt: string; 335 + };