diff --git a/src/state/drafts/schema.ts b/src/state/drafts/schema.ts
index ffad7eb5a2..379876fc83 100644
--- a/src/state/drafts/schema.ts
+++ b/src/state/drafts/schema.ts
@@ -92,6 +92,21 @@ export type StoredDraft = {
syncStatus: 'local' | 'synced' | 'dirty'
}
+/**
+ * Post content for display in draft list
+ */
+export type DraftPostDisplay = {
+ id: string
+ /** Full text content */
+ text: string
+ /** Image URLs for display (local IDs that need to be loaded) */
+ images?: LocalMediaRef[]
+ /** Video reference */
+ video?: LocalMediaRef
+ /** GIF metadata */
+ gif?: StoredGif
+}
+
/**
* Draft summary for list display
*/
@@ -111,4 +126,6 @@ export type DraftSummary = {
replyToHandle?: string
/** ISO timestamp of last update */
updatedAt: string
+ /** All posts in the draft for full display */
+ posts: DraftPostDisplay[]
}
diff --git a/src/state/drafts/storage.ts b/src/state/drafts/storage.ts
index 8b9ebf3526..10ab020e14 100644
--- a/src/state/drafts/storage.ts
+++ b/src/state/drafts/storage.ts
@@ -11,7 +11,11 @@ import {
import {nanoid} from 'nanoid/non-secure'
import {logger} from '#/logger'
-import {type DraftSummary, type StoredDraft} from './schema'
+import {
+ type DraftPostDisplay,
+ type DraftSummary,
+ type StoredDraft,
+} from './schema'
const DRAFTS_DIR = 'bsky-drafts'
@@ -271,6 +275,8 @@ function createDraftSummary(draft: StoredDraft): DraftSummary {
let mediaCount = 0
let hasMedia = false
+ const posts: DraftPostDisplay[] = []
+
for (const post of draft.posts) {
if (post.images) {
mediaCount += post.images.length
@@ -284,6 +290,14 @@ function createDraftSummary(draft: StoredDraft): DraftSummary {
mediaCount += 1
hasMedia = true
}
+
+ posts.push({
+ id: post.id,
+ text: post.richtext.text,
+ images: post.images,
+ video: post.video,
+ gif: post.gif,
+ })
}
return {
@@ -295,6 +309,7 @@ function createDraftSummary(draft: StoredDraft): DraftSummary {
isReply: Boolean(draft.replyToUri),
replyToHandle: draft.replyToAuthor?.handle,
updatedAt: draft.updatedAt,
+ posts,
}
}
diff --git a/src/state/drafts/storage.web.ts b/src/state/drafts/storage.web.ts
index 79d574fb22..2169706a66 100644
--- a/src/state/drafts/storage.web.ts
+++ b/src/state/drafts/storage.web.ts
@@ -2,7 +2,11 @@ import {type DBSchema, type IDBPDatabase, openDB} from 'idb'
import {nanoid} from 'nanoid/non-secure'
import {logger} from '#/logger'
-import {type DraftSummary, type StoredDraft} from './schema'
+import {
+ type DraftPostDisplay,
+ type DraftSummary,
+ type StoredDraft,
+} from './schema'
const DB_NAME = 'bsky-drafts'
const DB_VERSION = 1
@@ -287,6 +291,8 @@ function createDraftSummary(draft: StoredDraft): DraftSummary {
let mediaCount = 0
let hasMedia = false
+ const posts: DraftPostDisplay[] = []
+
for (const post of draft.posts) {
if (post.images) {
mediaCount += post.images.length
@@ -300,6 +306,14 @@ function createDraftSummary(draft: StoredDraft): DraftSummary {
mediaCount += 1
hasMedia = true
}
+
+ posts.push({
+ id: post.id,
+ text: post.richtext.text,
+ images: post.images,
+ video: post.video,
+ gif: post.gif,
+ })
}
return {
@@ -311,6 +325,7 @@ function createDraftSummary(draft: StoredDraft): DraftSummary {
isReply: Boolean(draft.replyToUri),
replyToHandle: draft.replyToAuthor?.handle,
updatedAt: draft.updatedAt,
+ posts,
}
}
diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx
index 8964cb093d..95c30281f6 100644
--- a/src/view/com/composer/drafts/DraftItem.tsx
+++ b/src/view/com/composer/drafts/DraftItem.tsx
@@ -1,15 +1,25 @@
-import {Pressable, View} from 'react-native'
+import {useEffect, useState} from 'react'
+import {Image, Pressable, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
-import {type DraftSummary} from '#/state/drafts'
+import {isNative} from '#/platform/detection'
+import {type DraftPostDisplay, type DraftSummary} from '#/state/drafts'
+import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
+import {useSession} from '#/state/session'
+import {TimeElapsed} from '#/view/com/util/TimeElapsed'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
-import {Camera_Stroke2_Corner0_Rounded as MediaIcon} from '#/components/icons/Camera'
+import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Text} from '#/components/Typography'
+// Platform-specific storage import
+const storage = isNative
+ ? require('#/state/drafts/storage')
+ : require('#/state/drafts/storage.web')
+
export function DraftItem({
draft,
onSelect,
@@ -23,81 +33,286 @@ export function DraftItem({
}) {
const {_} = useLingui()
const t = useTheme()
- const getTimeAgo = useGetTimeAgo()
-
- const previewText = draft.previewText || _(msg`(No text)`)
- const timeAgo = getTimeAgo(new Date(draft.updatedAt), new Date())
return (
onSelect(draft)}
style={({pressed, hovered}) => [
- a.flex_row,
- a.align_center,
- a.gap_md,
- a.p_md,
a.rounded_md,
- t.atoms.bg_contrast_25,
- (pressed || hovered) && t.atoms.bg_contrast_50,
+ a.overflow_hidden,
+ t.atoms.bg,
+ (pressed || hovered) && t.atoms.bg_contrast_25,
]}>
-
+
{/* Reply indicator */}
{draft.isReply && draft.replyToHandle && (
Replying to @{draft.replyToHandle}
)}
- {/* Preview text */}
-
- {previewText}
-
-
- {/* Metadata row */}
-
- {/* Time ago */}
-
- {timeAgo}
-
-
- {/* Media indicator */}
- {draft.hasMedia && (
-
-
-
- {draft.mediaCount}
-
-
- )}
-
- {/* Thread indicator */}
- {draft.postCount > 1 && (
-
- {draft.postCount} posts
-
- )}
-
+ {/* Posts */}
+ {draft.posts.map((post, index) => (
+
+ ))}
-
- {/* Delete button */}
-
)
}
+
+function DraftPostRow({
+ post,
+ isFirst,
+ isLast,
+ timestamp,
+ draftId,
+ onDelete,
+ isDeleting,
+}: {
+ post: DraftPostDisplay
+ isFirst: boolean
+ isLast: boolean
+ timestamp: string
+ draftId: string
+ onDelete: (draftId: string) => void
+ isDeleting: boolean
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const profile = useCurrentAccountProfile()
+ const {currentAccount} = useSession()
+
+ const displayName =
+ profile?.displayName || profile?.handle || currentAccount?.handle || ''
+ const handle = profile?.handle || currentAccount?.handle || ''
+ const avatarUrl = profile?.avatar
+
+ // Nested posts (not first) have smaller styling
+ const avatarSize = isFirst ? 42 : 32
+ const textStyle = isFirst ? a.text_md : a.text_sm
+
+ return (
+
+ {/* Avatar column with thread line */}
+
+
+ {/* Thread line connecting posts */}
+ {!isLast && (
+
+ )}
+
+
+ {/* Content column */}
+
+ {/* Header row: name, handle, timestamp, menu */}
+
+
+ {displayName && (
+
+ {displayName}
+
+ )}
+
+ @{handle}
+
+
+ ·
+
+
+ {({timeElapsed}) => (
+
+ {timeElapsed}
+
+ )}
+
+
+
+ {/* Overflow menu (only on first post) */}
+ {isFirst && (
+
+ )}
+
+
+ {/* Post text - full, not truncated */}
+ {post.text ? (
+ {post.text}
+ ) : (
+
+ (No text)
+
+ )}
+
+ {/* Media preview */}
+
+
+ {/* Delete button (only on last post) */}
+ {isLast && (
+
+
+
+ )}
+
+
+ )
+}
+
+function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
+ const t = useTheme()
+ const {currentAccount} = useSession()
+ const [imageUrls, setImageUrls] = useState([])
+ const [gifUrl, setGifUrl] = useState(null)
+
+ useEffect(() => {
+ async function loadMedia() {
+ if (!currentAccount?.did) return
+
+ // Load images
+ if (post.images && post.images.length > 0) {
+ const urls: string[] = []
+ for (const image of post.images) {
+ try {
+ const url = await storage.loadMediaFromLocal(
+ currentAccount.did,
+ image.localId,
+ )
+ urls.push(url)
+ } catch (e) {
+ // Image might not exist anymore
+ console.warn('Failed to load draft image', e)
+ }
+ }
+ setImageUrls(urls)
+ }
+
+ // GIFs have a URL directly
+ if (post.gif) {
+ setGifUrl(post.gif.url)
+ }
+ }
+
+ loadMedia()
+ }, [currentAccount?.did, post.images, post.gif])
+
+ // Nothing to show
+ if (imageUrls.length === 0 && !gifUrl && !post.video) {
+ return null
+ }
+
+ return (
+
+ {/* Images grid */}
+ {imageUrls.length > 0 && (
+
+ {imageUrls.map((url, index) => (
+
+
+
+ ))}
+
+ )}
+
+ {/* GIF preview */}
+ {gifUrl && (
+
+
+
+ )}
+
+ {/* Video indicator */}
+ {post.video && (
+
+
+ Video attached
+
+
+ )}
+
+ )
+}
diff --git a/src/view/com/composer/drafts/DraftsListDialog.tsx b/src/view/com/composer/drafts/DraftsListDialog.tsx
index c3d8285f13..f4e401d2bd 100644
--- a/src/view/com/composer/drafts/DraftsListDialog.tsx
+++ b/src/view/com/composer/drafts/DraftsListDialog.tsx
@@ -13,7 +13,7 @@ import {
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
-import {PageX_Stroke2_Corner0_Rounded_Large} from '#/components/icons/PageX'
+import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
import {Loader} from '#/components/Loader'
import {EmptyState} from '../../util/EmptyState'
import {DraftItem} from './DraftItem'
@@ -79,17 +79,21 @@ export function DraftsListDialog({
const renderItem = useCallback(
({item}: {item: DraftSummary}) => {
return (
-
+
+
+
)
},
[handleSelectDraft, handleDeleteDraft, isDeleting],
)
+ const itemSeparator = useCallback(() => , [])
+
const emptyComponent = useMemo(() => {
if (isLoading) {
return (
@@ -98,12 +102,7 @@ export function DraftsListDialog({
)
}
- return (
-
- )
+ return
}, [isLoading, _])
return (
@@ -114,8 +113,10 @@ export function DraftsListDialog({
keyExtractor={item => item.id}
ListHeaderComponent={listHeader}
ListEmptyComponent={emptyComponent}
+ ItemSeparatorComponent={itemSeparator}
stickyHeaderIndices={[0]}
style={t.atoms.bg_contrast_25}
+ contentContainerStyle={[a.pb_lg]}
/>
)