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]} /> )