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:
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]}>
|
||||
·
|
||||
</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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user