Redesign drafts list to show full thread preview

- Display all posts in a draft thread, not just the first
- First post uses larger avatar (42px), subsequent posts nested with
  smaller avatar (32px) and thread connector line
- Show author avatar, display name, handle, and relative timestamp
- Add overflow menu button (placeholder) on first post
- Display full text instead of truncated preview
- Add media preview component for images, GIFs, and videos
- Card layout with rounded corners and proper spacing
- Add gap separators between draft cards in list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-13 16:55:09 +02:00
parent 092528dc05
commit b7563a4127
5 changed files with 339 additions and 76 deletions
+17
View File
@@ -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[]
}
+16 -1
View File
@@ -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,
}
}
+16 -1
View File
@@ -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,
}
}
+276 -61
View File
@@ -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 (
<Pressable
accessibilityRole="button"
accessibilityLabel={_(msg`Open draft: ${previewText}`)}
accessibilityLabel={_(msg`Open draft`)}
accessibilityHint={_(msg`Opens this draft in the composer`)}
onPress={() => 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,
]}>
<View style={[a.flex_1, a.gap_xs]}>
<View style={[a.p_md, a.gap_sm]}>
{/* Reply indicator */}
{draft.isReply && draft.replyToHandle && (
<Text
style={[a.text_xs, t.atoms.text_contrast_medium]}
style={[a.text_xs, t.atoms.text_contrast_medium, a.pb_2xs]}
numberOfLines={1}>
<Trans>Replying to @{draft.replyToHandle}</Trans>
</Text>
)}
{/* Preview text */}
<Text style={[a.text_md]} numberOfLines={2}>
{previewText}
</Text>
{/* Metadata row */}
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
{/* Time ago */}
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{timeAgo}
</Text>
{/* Media indicator */}
{draft.hasMedia && (
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
<MediaIcon size="xs" style={[t.atoms.text_contrast_medium]} />
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{draft.mediaCount}
</Text>
</View>
)}
{/* Thread indicator */}
{draft.postCount > 1 && (
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>{draft.postCount} posts</Trans>
</Text>
)}
</View>
{/* Posts */}
{draft.posts.map((post, index) => (
<DraftPostRow
key={post.id}
post={post}
isFirst={index === 0}
isLast={index === draft.posts.length - 1}
timestamp={draft.updatedAt}
draftId={draft.id}
onDelete={onDelete}
isDeleting={isDeleting}
/>
))}
</View>
{/* Delete button */}
<Button
label={_(msg`Delete draft`)}
variant="ghost"
color="negative"
shape="round"
size="small"
disabled={isDeleting}
onPress={e => {
e.stopPropagation()
onDelete(draft.id)
}}>
<ButtonIcon icon={TrashIcon} />
</Button>
</Pressable>
)
}
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 (
<View style={[a.flex_row, a.gap_sm, !isFirst && [a.ml_xl, a.pt_xs]]}>
{/* Avatar column with thread line */}
<View style={[a.align_center]}>
<UserAvatar type="user" size={avatarSize} avatar={avatarUrl} />
{/* Thread line connecting posts */}
{!isLast && (
<View
style={[
a.flex_1,
a.mt_xs,
{
width: 2,
backgroundColor: t.palette.contrast_100,
minHeight: 8,
},
]}
/>
)}
</View>
{/* Content column */}
<View style={[a.flex_1, a.gap_xs]}>
{/* Header row: name, handle, timestamp, menu */}
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<View style={[a.flex_row, a.align_center, a.flex_1, a.gap_xs]}>
{displayName && (
<Text
style={[textStyle, a.font_bold, t.atoms.text]}
numberOfLines={1}>
{displayName}
</Text>
)}
<Text
style={[textStyle, t.atoms.text_contrast_medium]}
numberOfLines={1}>
@{handle}
</Text>
<Text style={[textStyle, t.atoms.text_contrast_medium]}>
&middot;
</Text>
<TimeElapsed timestamp={timestamp}>
{({timeElapsed}) => (
<Text
style={[textStyle, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
</View>
{/* Overflow menu (only on first post) */}
{isFirst && (
<Button
label={_(msg`More options`)}
variant="ghost"
color="secondary"
shape="round"
size="tiny"
onPress={e => {
e.stopPropagation()
// TODO: Show menu with options
}}>
<ButtonIcon icon={DotsIcon} />
</Button>
)}
</View>
{/* Post text - full, not truncated */}
{post.text ? (
<Text style={[textStyle, t.atoms.text]}>{post.text}</Text>
) : (
<Text style={[textStyle, t.atoms.text_contrast_medium, a.italic]}>
<Trans>(No text)</Trans>
</Text>
)}
{/* Media preview */}
<DraftMediaPreview post={post} />
{/* Delete button (only on last post) */}
{isLast && (
<View style={[a.flex_row, a.justify_end, a.pt_xs]}>
<Button
label={_(msg`Delete draft`)}
variant="ghost"
color="negative"
shape="round"
size="small"
disabled={isDeleting}
onPress={e => {
e.stopPropagation()
onDelete(draftId)
}}>
<ButtonIcon icon={TrashIcon} />
</Button>
</View>
)}
</View>
</View>
)
}
function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
const t = useTheme()
const {currentAccount} = useSession()
const [imageUrls, setImageUrls] = useState<string[]>([])
const [gifUrl, setGifUrl] = useState<string | null>(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 (
<View style={[a.gap_xs, a.pt_xs]}>
{/* Images grid */}
{imageUrls.length > 0 && (
<View style={[a.flex_row, a.gap_xs, a.flex_wrap]}>
{imageUrls.map((url, index) => (
<View
key={index}
style={[
a.rounded_sm,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{
width: imageUrls.length === 1 ? '100%' : '48%',
aspectRatio: imageUrls.length === 1 ? 16 / 9 : 1,
},
]}>
<Image
source={{uri: url}}
style={[a.flex_1]}
resizeMode="cover"
accessibilityIgnoresInvertColors
/>
</View>
))}
</View>
)}
{/* GIF preview */}
{gifUrl && (
<View
style={[
a.rounded_sm,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{aspectRatio: 16 / 9},
]}>
<Image
source={{uri: gifUrl}}
style={[a.flex_1]}
resizeMode="cover"
accessibilityIgnoresInvertColors
/>
</View>
)}
{/* Video indicator */}
{post.video && (
<View
style={[
a.rounded_sm,
a.p_md,
a.align_center,
a.justify_center,
t.atoms.bg_contrast_50,
{aspectRatio: 16 / 9},
]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
<Trans>Video attached</Trans>
</Text>
</View>
)}
</View>
)
}
@@ -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 (
<DraftItem
draft={item}
onSelect={handleSelectDraft}
onDelete={handleDeleteDraft}
isDeleting={isDeleting}
/>
<View style={[a.px_lg]}>
<DraftItem
draft={item}
onSelect={handleSelectDraft}
onDelete={handleDeleteDraft}
isDeleting={isDeleting}
/>
</View>
)
},
[handleSelectDraft, handleDeleteDraft, isDeleting],
)
const itemSeparator = useCallback(() => <View style={[{height: 12}]} />, [])
const emptyComponent = useMemo(() => {
if (isLoading) {
return (
@@ -98,12 +102,7 @@ export function DraftsListDialog({
</View>
)
}
return (
<EmptyState
icon={PageX_Stroke2_Corner0_Rounded_Large}
message={_(msg`No drafts yet`)}
/>
)
return <EmptyState icon={PageXIcon} message={_(msg`No drafts yet`)} />
}, [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]}
/>
</Dialog.Outer>
)