Drafts UI

This commit is contained in:
Alex Benzer
2025-12-22 12:51:37 -08:00
committed by Samuel Newman
parent 649e1fe1f0
commit 22e0edbdaa
7 changed files with 983 additions and 199 deletions
+2
View File
@@ -44,6 +44,8 @@ export interface ComposerOpts {
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
videoUri?: {uri: string; width: number; height: number}
openGallery?: boolean
/** ID of a saved draft to load */
draftId?: string
}
type StateContext = ComposerOpts | undefined
+9
View File
@@ -70,10 +70,19 @@ export type Account = {
/**
* Composer draft, saved when the user has unsent content in the post composer.
* Keyed by context (e.g., 'default', reply URI, etc.)
* @deprecated Use composerDrafts instead for multi-draft support
*/
composerDraft?: {
[context: string]: ComposerDraft
}
/**
* Composer drafts library, allowing users to save multiple drafts.
* Keyed by unique draft ID (UUID).
*/
composerDrafts?: {
[draftId: string]: ComposerDraft
}
}
export type ComposerDraft = {
+453 -115
View File
@@ -132,6 +132,7 @@ import * as Toast from '#/components/Toast'
import {Text as NewText} from '#/components/Typography'
import {account} from '#/storage'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import {DraftsView} from './DraftsDialog'
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
import {
type AssetType,
@@ -141,6 +142,7 @@ import {
import {
type ComposerAction,
composerReducer,
type ComposerState,
createComposerState,
type EmbedDraft,
MAX_IMAGES,
@@ -156,9 +158,52 @@ import {
} from './state/video'
import {type TextInputRef} from './text-input/TextInput.types'
import {useComposerDraft} from './useComposerDraft'
import {useDraftsList} from './useDraftsList'
import {getVideoMetadata} from './videos/pickVideo'
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
/**
* Serializes the relevant parts of composer state for comparison.
* Used to detect if a loaded draft has been modified.
*/
function serializeStateForComparison(state: ComposerState): string {
return JSON.stringify({
posts: state.thread.posts.map(post => ({
text: post.richtext.text,
labels: post.labels,
hasQuote: !!post.embed.quote,
quoteUri: post.embed.quote?.uri,
hasLink: !!post.embed.link,
linkUri: post.embed.link?.uri,
mediaType: post.embed.media?.type,
// For images, compare paths and alts
images:
post.embed.media?.type === 'images'
? post.embed.media.images.map(img => ({
path: img.source.path,
alt: img.alt,
}))
: undefined,
// For gif, compare id and alt
gif:
post.embed.media?.type === 'gif'
? {id: post.embed.media.gif.id, alt: post.embed.media.alt}
: undefined,
// For video, compare blobRef (if done)
video:
post.embed.media?.type === 'video' &&
post.embed.media.video.status === 'done'
? {
blobRef: (post.embed.media.video as any).pendingPublish?.blobRef,
alt: post.embed.media.video.altText,
}
: undefined,
})),
postgate: state.thread.postgate,
threadgate: state.thread.threadgate,
})
}
type CancelRef = {
onPressCancel: () => void
}
@@ -175,6 +220,7 @@ export const ComposePost = ({
imageUris: initImageUris,
videoUri: initVideoUri,
openGallery,
draftId: initDraftId,
cancelRef,
}: Props & {
cancelRef?: React.RefObject<CancelRef | null>
@@ -190,6 +236,8 @@ export const ComposePost = ({
const setLangPrefs = useLanguagePrefsApi()
const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const saveBeforeDraftsPromptControl = Prompt.usePromptControl()
const updateBeforeDraftsPromptControl = Prompt.usePromptControl()
const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls()
const {data: preferences} = usePreferencesQuery()
@@ -199,6 +247,7 @@ export const ComposePost = ({
const [isPublishing, setIsPublishing] = useState(false)
const [publishingStage, setPublishingStage] = useState('')
const [error, setError] = useState('')
const [viewMode, setViewMode] = useState<'compose' | 'drafts'>('compose')
/**
* A temporary local reference to a language suggestion that the user has
@@ -238,9 +287,7 @@ export const ComposePost = ({
setReplyToLanguages([])
}
// Check for draft before initializing composer
const draftContext = replyTo ? `reply:${replyTo.uri}` : 'default'
// Load draft by ID if provided
const loadInitialDraft = useCallback(() => {
if (!currentAccount) return null
@@ -253,55 +300,54 @@ export const ComposePost = ({
if (hasInitialContent) return null
try {
const allDrafts = account.get([currentAccount.did, 'composerDraft'])
if (!allDrafts) return null
// If a draftId is provided, load that specific draft
if (initDraftId) {
try {
const allDrafts = account.get([currentAccount.did, 'composerDrafts'])
if (!allDrafts) return null
const draft = allDrafts[draftContext]
if (!draft) return null
const draft = allDrafts[initDraftId]
if (!draft) return null
if (draft.version !== 1) return null
if (draft.version !== 1) return null
// Check age
const age = Date.now() - draft.timestamp
if (age > 7 * 24 * 60 * 60 * 1000) {
// Remove old draft
const remainingDrafts = Object.fromEntries(
Object.entries(allDrafts).filter(([key]) => key !== draftContext),
)
if (Object.keys(remainingDrafts).length > 0) {
account.set([currentAccount.did, 'composerDraft'], remainingDrafts)
} else {
account.remove([currentAccount.did, 'composerDraft'])
// Check age
const age = Date.now() - draft.timestamp
if (age > 7 * 24 * 60 * 60 * 1000) {
return null
}
logger.info('Composer: loading draft by ID', {
draftId: initDraftId,
textLength: draft.thread.posts[0]?.text.length || 0,
})
// Construct video URLs from blobRefs if we have videos in the draft
const parsed = JSON.parse(JSON.stringify(draft)) // Deep clone
if (parsed.thread?.posts) {
parsed.thread.posts = parsed.thread.posts.map((post: any) => {
if (post.embed?.video?.blobRef?.ref?.$link) {
const cid = post.embed.video.blobRef.ref.$link
post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(currentAccount.did)}/${cid}/playlist.m3u8`
}
return post
})
}
return parsed
} catch (e) {
logger.error('Failed to load draft by ID', {
error: e,
draftId: initDraftId,
})
return null
}
logger.info('Composer: loading initial draft', {
textLength: draft.thread.posts[0]?.text.length || 0,
})
// Construct video URLs from blobRefs if we have videos in the draft
const parsed = JSON.parse(JSON.stringify(draft)) // Deep clone
if (parsed.thread?.posts) {
parsed.thread.posts = parsed.thread.posts.map((post: any) => {
if (post.embed?.video?.blobRef?.ref?.$link) {
const cid = post.embed.video.blobRef.ref.$link
// Use the video.bsky.app CDN with HLS playlist
post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(currentAccount.did)}/${cid}/playlist.m3u8`
}
return post
})
}
return parsed
} catch (e) {
logger.error('Failed to load initial draft', {error: e})
return null
}
return null
}, [
currentAccount,
draftContext,
initDraftId,
initText,
initMention,
initImageUris,
@@ -322,13 +368,156 @@ export const ComposePost = ({
createComposerState,
)
// Draft persistence
const {clearDraft} = useComposerDraft(
composerState,
replyTo ? `reply:${replyTo.uri}` : 'default',
// Track current draft ID - can be updated when user selects a draft from the list
const [currentDraftId, setCurrentDraftId] = useState<string | undefined>(
!replyTo ? initDraftId : undefined,
)
// Draft persistence - only for top-level posts (not replies)
const {clearDraft, saveImmediate} = useComposerDraft(
composerState,
currentDraftId,
)
// Track if we're editing an existing draft (either from initial load or from list selection)
const [isEditingExistingDraft, setIsEditingExistingDraft] =
useState(!!initDraftId)
// Snapshot of the draft content when loaded, for detecting changes
const [loadedDraftSnapshot, setLoadedDraftSnapshot] = useState<string | null>(
() => {
// If loading from initDraftId, capture initial snapshot
if (initDraftId && composerState.thread.posts.length > 0) {
return serializeStateForComparison(composerState)
}
return null
},
)
// Drafts list for the drafts dialog
const {draftsCount} = useDraftsList()
const thread = composerState.thread
// Check if composer has content
const hasContent = useMemo(() => {
return thread.posts.some(
post =>
post.richtext.text.trim().length > 0 ||
post.embed.quote ||
post.embed.link ||
post.embed.media,
)
}, [thread.posts])
// Check if an existing draft has been modified since loading
const hasUnsavedChanges = useMemo(() => {
if (!isEditingExistingDraft || !loadedDraftSnapshot) {
return false
}
const currentSnapshot = serializeStateForComparison(composerState)
return currentSnapshot !== loadedDraftSnapshot
}, [isEditingExistingDraft, loadedDraftSnapshot, composerState])
// Handler for selecting a draft from the list
const onSelectDraft = useCallback(
(selectedDraftId: string) => {
if (!currentAccount) return
try {
const allDrafts = account.get([currentAccount.did, 'composerDrafts'])
if (!allDrafts) return
const draft = allDrafts[selectedDraftId]
if (!draft || draft.version !== 1) return
// Deep clone and construct video URLs if needed
const parsed = JSON.parse(JSON.stringify(draft))
if (parsed.thread?.posts) {
parsed.thread.posts = parsed.thread.posts.map((post: any) => {
if (post.embed?.video?.blobRef?.ref?.$link) {
const cid = post.embed.video.blobRef.ref.$link
post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(currentAccount.did)}/${cid}/playlist.m3u8`
}
return post
})
}
// Convert to ComposerState
const newState = createComposerState({
initText: undefined,
initMention: undefined,
initImageUris: undefined,
initQuoteUri: undefined,
initInteractionSettings: preferences?.postInteractionSettings,
initDraft: parsed,
})
// Update draft tracking state
setCurrentDraftId(selectedDraftId)
setIsEditingExistingDraft(true)
setLoadedDraftSnapshot(serializeStateForComparison(newState))
// Load the draft and switch back to compose mode
composerDispatch({type: 'load_draft', draft: newState})
setViewMode('compose')
logger.info('Loaded draft into composer', {draftId: selectedDraftId})
} catch (e) {
logger.error('Failed to load draft', {
error: e,
draftId: selectedDraftId,
})
}
},
[currentAccount, preferences?.postInteractionSettings],
)
// Handler for opening drafts view
const onPressDrafts = useCallback(() => {
if (hasContent && !isEditingExistingDraft) {
// New unsaved content - prompt to save first
saveBeforeDraftsPromptControl.open()
} else if (isEditingExistingDraft && hasUnsavedChanges) {
// Editing existing draft with changes - prompt to update first
updateBeforeDraftsPromptControl.open()
} else {
// No content or no changes - just show drafts
setViewMode('drafts')
}
}, [
hasContent,
isEditingExistingDraft,
hasUnsavedChanges,
saveBeforeDraftsPromptControl,
updateBeforeDraftsPromptControl,
])
// Handler for "Save" in the save-before-drafts prompt
const onSaveBeforeDrafts = useCallback(() => {
saveImmediate(composerState)
setViewMode('drafts')
}, [saveImmediate, composerState])
// Handler for "Don't save" in the save-before-drafts prompt
const onDiscardBeforeDrafts = useCallback(() => {
clearDraft()
setViewMode('drafts')
}, [clearDraft])
// Handler for when a draft is deleted from the drafts list
const onDraftDeleted = useCallback(
(deletedDraftId: string) => {
// If the deleted draft is the one we're currently editing, reset to new draft state
if (currentDraftId === deletedDraftId) {
setCurrentDraftId(undefined)
setIsEditingExistingDraft(false)
setLoadedDraftSnapshot(null)
}
},
[currentDraftId],
)
const activePost = thread.posts[composerState.activePostIndex]
const nextPost: PostDraft | undefined =
thread.posts[composerState.activePostIndex + 1]
@@ -402,10 +591,10 @@ export const ComposePost = ({
const [publishOnUpload, setPublishOnUpload] = useState(false)
const onClose = useCallback(() => {
clearDraft()
// Don't clear draft - it's auto-saved and will be kept
closeComposer()
clearThumbnailCache(queryClient)
}, [clearDraft, closeComposer, queryClient])
}, [closeComposer, queryClient])
const insets = useSafeAreaInsets()
const viewStyles = useMemo(
@@ -427,7 +616,23 @@ export const ComposePost = ({
const onPressCancel = useCallback(() => {
if (textInput.current?.maybeClosePopup()) {
return
} else if (
}
// For existing drafts, only prompt if there are unsaved changes
if (isEditingExistingDraft) {
if (hasUnsavedChanges) {
closeAllDialogs()
Keyboard.dismiss()
discardPromptControl.open()
} else {
// No changes made, just close without prompt
onClose()
}
return
}
// For new content, prompt if there's any content
if (
thread.posts.some(
post =>
post.shortenedGraphemeLength > 0 ||
@@ -441,7 +646,14 @@ export const ComposePost = ({
} else {
onClose()
}
}, [thread, closeAllDialogs, discardPromptControl, onClose])
}, [
thread,
closeAllDialogs,
discardPromptControl,
onClose,
isEditingExistingDraft,
hasUnsavedChanges,
])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -820,74 +1032,175 @@ export const ComposePost = ({
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<ComposerTopBar
canPost={canPost}
isReply={!!replyTo}
isPublishQueued={publishOnUpload}
isPublishing={isPublishing}
isThread={thread.posts.length > 1}
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={onPressPublish}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
videoState={erroredVideo}
clearError={() => setError('')}
clearVideo={
erroredVideoPostId
? () => clearVideo(erroredVideoPostId)
: () => {}
}
{viewMode === 'drafts' ? (
<DraftsView
onSelectDraft={onSelectDraft}
onBack={() => setViewMode('compose')}
onDeleteDraft={onDraftDeleted}
/>
</ComposerTopBar>
<Animated.ScrollView
ref={scrollViewRef}
layout={native(LinearTransition)}
onScroll={scrollHandler}
contentContainerStyle={a.flex_grow}
style={a.flex_1}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{thread.posts.map((post, index) => (
<React.Fragment key={post.id}>
<ComposerPost
post={post}
dispatch={composerDispatch}
textInput={post.id === activePost.id ? textInput : null}
isFirstPost={index === 0}
isLastPost={index === thread.posts.length - 1}
isPartOfThread={thread.posts.length > 1}
isReply={index > 0 || !!replyTo}
isActive={post.id === activePost.id}
canRemovePost={thread.posts.length > 1}
canRemoveQuote={index > 0 || !initQuote}
onSelectVideo={selectVideo}
onClearVideo={clearVideo}
onPublish={onComposerPostPublish}
onError={setError}
) : (
<>
<ComposerTopBar
canPost={canPost}
isReply={!!replyTo}
isPublishQueued={publishOnUpload}
isPublishing={isPublishing}
isThread={thread.posts.length > 1}
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={onPressPublish}
onPressDrafts={onPressDrafts}
draftsCount={draftsCount}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
videoState={erroredVideo}
clearError={() => setError('')}
clearVideo={
erroredVideoPostId
? () => clearVideo(erroredVideoPostId)
: () => {}
}
/>
{isWebFooterSticky && post.id === activePost.id && (
<View style={styles.stickyFooterWeb}>{footer}</View>
)}
</React.Fragment>
))}
</Animated.ScrollView>
{!isWebFooterSticky && footer}
</ComposerTopBar>
<Animated.ScrollView
ref={scrollViewRef}
layout={native(LinearTransition)}
onScroll={scrollHandler}
contentContainerStyle={a.flex_grow}
style={a.flex_1}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{thread.posts.map((post, index) => (
<React.Fragment key={post.id}>
<ComposerPost
post={post}
dispatch={composerDispatch}
textInput={post.id === activePost.id ? textInput : null}
isFirstPost={index === 0}
isLastPost={index === thread.posts.length - 1}
isPartOfThread={thread.posts.length > 1}
isReply={index > 0 || !!replyTo}
isActive={post.id === activePost.id}
canRemovePost={thread.posts.length > 1}
canRemoveQuote={index > 0 || !initQuote}
onSelectVideo={selectVideo}
onClearVideo={clearVideo}
onPublish={onComposerPostPublish}
onError={setError}
/>
{isWebFooterSticky && post.id === activePost.id && (
<View style={styles.stickyFooterWeb}>{footer}</View>
)}
</React.Fragment>
))}
</Animated.ScrollView>
{!isWebFooterSticky && footer}
</>
)}
</View>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
/>
{isEditingExistingDraft ? (
<Prompt.Outer control={discardPromptControl}>
<Prompt.TitleText>
<Trans>Update draft?</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>Update draft and save it to post at a later time.</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Update`)}
onPress={() => {
// Explicitly save the changes before closing
saveImmediate(composerState)
closeComposer()
clearThumbnailCache(queryClient)
}}
/>
<Prompt.Action
cta={_(msg`Don't update`)}
color="negative"
onPress={() => {
// Just close without saving - preserve the original draft
closeComposer()
clearThumbnailCache(queryClient)
}}
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
) : (
<Prompt.Outer control={discardPromptControl}>
<Prompt.TitleText>
<Trans>Save to drafts?</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>Save to drafts to edit and post at a later time.</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action cta={_(msg`Save`)} onPress={onClose} />
<Prompt.Action
cta={_(msg`Don't save`)}
color="negative"
onPress={() => {
clearDraft()
closeComposer()
clearThumbnailCache(queryClient)
}}
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
)}
<Prompt.Outer control={saveBeforeDraftsPromptControl}>
<Prompt.TitleText>
<Trans>Save to drafts?</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>Save to drafts to edit and post at a later time.</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action cta={_(msg`Save`)} onPress={onSaveBeforeDrafts} />
<Prompt.Action
cta={_(msg`Don't save`)}
color="negative"
onPress={onDiscardBeforeDrafts}
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
<Prompt.Outer control={updateBeforeDraftsPromptControl}>
<Prompt.TitleText>
<Trans>Update draft?</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>Update draft and save it to post at a later time.</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Update`)}
onPress={() => {
saveImmediate(composerState)
setViewMode('drafts')
}}
/>
<Prompt.Action
cta={_(msg`Don't update`)}
color="negative"
onPress={() => {
setViewMode('drafts')
}}
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
</KeyboardAvoidingView>
</BottomSheetPortalProvider>
)
@@ -1106,6 +1419,8 @@ function ComposerTopBar({
publishingStage,
onCancel,
onPublish,
onPressDrafts,
draftsCount,
topBarAnimatedStyle,
children,
}: {
@@ -1117,11 +1432,14 @@ function ComposerTopBar({
isThread: boolean
onCancel: () => void
onPublish: () => void
onPressDrafts?: () => void
draftsCount?: number
topBarAnimatedStyle: StyleProp<ViewStyle>
children?: React.ReactNode
}) {
const pal = usePalette('default')
const {_} = useLingui()
const showDraftsButton = !isReply && draftsCount !== undefined
return (
<Animated.View
style={topBarAnimatedStyle}
@@ -1143,6 +1461,26 @@ function ComposerTopBar({
</ButtonText>
</Button>
<View style={a.flex_1} />
{showDraftsButton && (
<Button
testID="composerDraftsBtn"
label={_(msg`Drafts`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[
a.rounded_full,
a.py_sm,
{paddingLeft: 7, paddingRight: 7},
a.mr_sm,
]}
onPress={onPressDrafts}>
<ButtonText style={[a.text_md]}>
<Trans>Drafts</Trans>
</ButtonText>
</Button>
)}
{isPublishing ? (
<>
<Text style={pal.textLight}>{publishingStage}</Text>
+250
View File
@@ -0,0 +1,250 @@
import React, {useCallback} from 'react'
import {ScrollView, View} from 'react-native'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography'
import {type ComposerDraft} from '#/storage'
import {type DraftItem, useDraftsList} from './useDraftsList'
/**
* Inline drafts view - renders directly in the composer
*/
export function DraftsView({
onSelectDraft,
onBack,
onDeleteDraft,
}: {
onSelectDraft: (draftId: string) => void
onBack: () => void
onDeleteDraft?: (draftId: string) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {drafts, deleteDraft} = useDraftsList()
const handleDeleteDraft = useCallback(
(draftId: string) => {
deleteDraft(draftId)
onDeleteDraft?.(draftId)
},
[deleteDraft, onDeleteDraft],
)
return (
<View style={[a.flex_1]}>
<View
style={[a.flex_row, a.align_center, a.px_sm, a.gap_xs, {height: 54}]}>
<Button
label={_(msg`Back`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm, {paddingLeft: 7, paddingRight: 7}]}
onPress={onBack}>
<ButtonText style={[a.text_md]}>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={[a.flex_1]} />
</View>
<ScrollView style={[a.flex_1]} contentContainerStyle={[a.p_lg, a.gap_lg]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Drafts</Trans>
</Text>
{drafts.length === 0 ? (
<View style={[a.py_xl, a.align_center, a.gap_xs]}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>No drafts yet</Trans>
</Text>
<Text style={[t.atoms.text_contrast_low, a.text_center]}>
<Trans>
When you close the composer, your post will be saved here.
</Trans>
</Text>
</View>
) : (
<View
style={[
a.rounded_lg,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
]}>
{drafts.map((item, index) => (
<React.Fragment key={item.id}>
<DraftListItem
item={item}
onSelect={onSelectDraft}
onDelete={handleDeleteDraft}
/>
{index < drafts.length - 1 && (
<View style={[a.border_b, t.atoms.border_contrast_low]} />
)}
</React.Fragment>
))}
</View>
)}
</ScrollView>
</View>
)
}
function DraftListItem({
item,
onSelect,
onDelete,
}: {
item: DraftItem
onSelect: (draftId: string) => void
onDelete: (draftId: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const getTimeAgo = useGetTimeAgo()
const handleSelect = useCallback(() => {
onSelect(item.id)
}, [item.id, onSelect])
const handleDelete = useCallback(() => {
onDelete(item.id)
}, [item.id, onDelete])
const previewText = getPreviewText(item.draft, _)
const timeAgo = getTimeAgo(item.draft.timestamp, Date.now())
return (
<Button
testID={`draftItem-${item.id}`}
style={[a.w_full]}
onPress={handleSelect}
label={_(msg`Load draft: ${previewText}`)}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.p_lg,
a.gap_sm,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<UserAvatar avatar={profile?.avatar} size={48} type="user" />
<View style={[a.flex_1, a.gap_2xs, a.pr_md]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text
style={[a.font_medium, a.leading_tight, a.text_md]}
numberOfLines={1}>
{profile?.displayName || currentAccount?.handle}
</Text>
<Text style={[t.atoms.text_contrast_medium, a.text_sm]}>
{timeAgo}
</Text>
</View>
<Text
style={[a.leading_tight, t.atoms.text_contrast_high]}
numberOfLines={2}>
{previewText}
</Text>
</View>
<Menu.Root>
<Menu.Trigger label={_(msg`Draft options`)}>
{({props, state}) => (
<Button
{...props}
label={_(msg`Draft options`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
style={[
a.justify_center,
a.align_center,
{width: 34, height: 34},
(state.hovered || state.pressed) && t.atoms.bg_contrast_50,
]}>
<ButtonIcon icon={DotsIcon} size="md" />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Item
label={_(msg`Delete draft`)}
onPress={handleDelete}
testID="deleteDraftBtn">
<Menu.ItemIcon icon={TrashIcon} />
<Menu.ItemText>
<Trans>Delete</Trans>
</Menu.ItemText>
</Menu.Item>
</Menu.Outer>
</Menu.Root>
</View>
)}
</Button>
)
}
function getPreviewText(
draft: ComposerDraft,
_: ReturnType<typeof useLingui>['_'],
): string {
// Get text from first post
const firstPost = draft.thread.posts[0]
if (!firstPost) return ''
const text = firstPost.text.trim()
if (text) {
// Truncate to ~100 chars
if (text.length > 100) {
return text.substring(0, 100) + '...'
}
return text
}
// If no text, describe the media
if (firstPost.embed.images && firstPost.embed.images.length > 0) {
const count = firstPost.embed.images.length
return _(
plural(count, {
one: '1 image',
other: `${count} images`,
}),
)
}
if (firstPost.embed.gif) {
return _(msg`GIF`)
}
if (firstPost.embed.video) {
return _(msg`Video`)
}
if (firstPost.embed.quoteUri) {
return _(msg`Quote post`)
}
if (firstPost.embed.linkUri) {
return _(msg`Link`)
}
return ''
}
+7
View File
@@ -122,6 +122,10 @@ export type ComposerAction =
type: 'focus_post'
postId: string
}
| {
type: 'load_draft'
draft: ComposerState
}
export const MAX_IMAGES = 4
@@ -229,6 +233,9 @@ export function composerReducer(
activePostIndex: nextActivePostIndex,
}
}
case 'load_draft': {
return action.draft
}
}
}
+113 -84
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useRef} from 'react'
import {useCallback, useEffect, useMemo, useRef} from 'react'
import {RichText} from '@atproto/api'
import {type SelfLabel} from '#/lib/moderation'
@@ -10,13 +10,17 @@ import {type ComposerState} from './state/composer'
const AUTOSAVE_DELAY_MS = 1000 // 1 second debounce
const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
function removeDraftContext(
function generateDraftId(): string {
return `draft-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
function removeDraft(
allDrafts: Record<string, ComposerDraft>,
contextToRemove: string,
draftIdToRemove: string,
): Record<string, ComposerDraft> | null {
const result: Record<string, ComposerDraft> = {}
for (const key of Object.keys(allDrafts)) {
if (key !== contextToRemove) {
if (key !== draftIdToRemove) {
result[key] = allDrafts[key]
}
}
@@ -122,7 +126,9 @@ function serializeDraft(state: ComposerState): SerializedDraft {
}
}
function deserializeDraft(data: SerializedDraft): Partial<ComposerState> {
export function deserializeDraft(
data: SerializedDraft,
): Partial<ComposerState> {
return {
thread: {
posts: data.thread.posts.map(post => {
@@ -214,15 +220,25 @@ function deserializeDraft(data: SerializedDraft): Partial<ComposerState> {
}
}
/**
* Hook for managing a single draft in the composer.
* Auto-saves the draft as the user types, and provides methods to load/clear.
*
* @param composerState The current composer state to auto-save
* @param draftId Optional draft ID to load an existing draft. If not provided, a new ID is generated.
*/
export function useComposerDraft(
composerState: ComposerState,
context: string = 'default',
draftId?: string,
) {
const {currentAccount} = useSession()
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
)
// Generate or use provided draft ID
const currentDraftId = useMemo(() => draftId || generateDraftId(), [draftId])
const accountDid = currentAccount?.did
// Check if draft has any content worth saving
@@ -236,6 +252,55 @@ export function useComposerDraft(
)
}, [])
// Core save logic (used by both debounced and immediate save)
const performSave = useCallback(
(state: ComposerState) => {
if (!accountDid) return
try {
const allDrafts = account.get([accountDid, 'composerDrafts']) ?? {}
if (hasContent(state)) {
const serialized = serializeDraft(state)
logger.debug('Draft serialized successfully', {
draftId: currentDraftId,
hasPosts: serialized.thread.posts.length > 0,
hasVideo: !!serialized.thread.posts[0]?.embed?.video,
})
// Update the draft
account.set([accountDid, 'composerDrafts'], {
...allDrafts,
[currentDraftId]: serialized,
})
logger.info('Composer draft saved', {
draftId: currentDraftId,
textLength: state.thread.posts[0]?.richtext.text.length || 0,
})
} else {
// If no content, remove this draft
if (allDrafts[currentDraftId]) {
const remainingDrafts = removeDraft(allDrafts, currentDraftId)
if (remainingDrafts) {
account.set([accountDid, 'composerDrafts'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDrafts'])
}
logger.debug('Empty draft removed', {draftId: currentDraftId})
}
}
} catch (e) {
logger.error('Failed to save composer draft', {
error: e,
message: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
})
}
},
[accountDid, currentDraftId, hasContent],
)
// Save draft to storage (debounced)
const saveDraft = useCallback(
(state: ComposerState) => {
@@ -248,59 +313,33 @@ export function useComposerDraft(
// Debounce the save
saveTimeoutRef.current = setTimeout(() => {
try {
const allDrafts = account.get([accountDid, 'composerDraft']) ?? {}
if (hasContent(state)) {
const serialized = serializeDraft(state)
logger.debug('Draft serialized successfully', {
hasPosts: serialized.thread.posts.length > 0,
hasVideo: !!serialized.thread.posts[0]?.embed?.video,
})
// Update the draft for this context
account.set([accountDid, 'composerDraft'], {
...allDrafts,
[context]: serialized,
})
logger.info('Composer draft saved', {
context,
textLength: state.thread.posts[0]?.richtext.text.length || 0,
})
} else {
// If no content, remove this context's draft
if (allDrafts[context]) {
const remainingDrafts = removeDraftContext(allDrafts, context)
if (remainingDrafts) {
account.set([accountDid, 'composerDraft'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDraft'])
}
logger.debug('Empty draft removed', {context})
}
}
} catch (e) {
logger.error('Failed to save composer draft', {
error: e,
message: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
})
}
performSave(state)
}, AUTOSAVE_DELAY_MS)
},
[accountDid, context, hasContent],
[accountDid, performSave],
)
// Save draft immediately (bypasses debounce)
const saveImmediate = useCallback(
(state: ComposerState) => {
// Clear any pending debounced save
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
performSave(state)
},
[performSave],
)
// Load draft from storage
const loadDraft = useCallback((): Partial<ComposerState> | null => {
if (!accountDid) return null
if (!accountDid || !draftId) return null
try {
const allDrafts = account.get([accountDid, 'composerDraft'])
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (!allDrafts) return null
const draft = allDrafts[context]
const draft = allDrafts[draftId]
if (!draft) return null
// Check version compatibility
@@ -308,12 +347,11 @@ export function useComposerDraft(
logger.warn('Incompatible draft version, discarding', {
version: draft.version,
})
// Remove this context's draft
const remainingDrafts = removeDraftContext(allDrafts, context)
const remainingDrafts = removeDraft(allDrafts, draftId)
if (remainingDrafts) {
account.set([accountDid, 'composerDraft'], remainingDrafts)
account.set([accountDid, 'composerDrafts'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDraft'])
account.remove([accountDid, 'composerDrafts'])
}
return null
}
@@ -322,55 +360,54 @@ export function useComposerDraft(
const age = Date.now() - draft.timestamp
if (age > MAX_DRAFT_AGE_MS) {
logger.debug('Draft too old, discarding', {age})
// Remove this context's draft
const remainingDrafts = removeDraftContext(allDrafts, context)
const remainingDrafts = removeDraft(allDrafts, draftId)
if (remainingDrafts) {
account.set([accountDid, 'composerDraft'], remainingDrafts)
account.set([accountDid, 'composerDrafts'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDraft'])
account.remove([accountDid, 'composerDrafts'])
}
return null
}
logger.info('Composer draft loaded', {
context,
draftId,
textLength: draft.thread.posts[0]?.text.length || 0,
})
return deserializeDraft(draft)
} catch (e) {
logger.error('Failed to load composer draft', {error: e})
// Remove corrupted drafts
try {
account.remove([accountDid, 'composerDraft'])
} catch {}
return null
}
}, [accountDid, context])
}, [accountDid, draftId])
// Clear draft from storage
const clearDraft = useCallback(() => {
if (!accountDid) return
try {
const allDrafts = account.get([accountDid, 'composerDraft'])
if (allDrafts && allDrafts[context]) {
const remainingDrafts = removeDraftContext(allDrafts, context)
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (allDrafts && allDrafts[currentDraftId]) {
const remainingDrafts = removeDraft(allDrafts, currentDraftId)
if (remainingDrafts) {
account.set([accountDid, 'composerDraft'], remainingDrafts)
account.set([accountDid, 'composerDrafts'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDraft'])
account.remove([accountDid, 'composerDrafts'])
}
logger.debug('Composer draft cleared', {context})
logger.debug('Composer draft cleared', {draftId: currentDraftId})
}
} catch (e) {
logger.error('Failed to clear composer draft', {error: e})
}
}, [accountDid, context])
}, [accountDid, currentDraftId])
// Auto-save on state changes
// Auto-save on state changes (only for new drafts, not when editing existing ones)
// For existing drafts, we only save on explicit user action (Update button)
const isExisting = !!draftId
useEffect(() => {
saveDraft(composerState)
}, [composerState, saveDraft])
if (!isExisting) {
saveDraft(composerState)
}
}, [composerState, saveDraft, isExisting])
// Cleanup timeout on unmount
useEffect(() => {
@@ -381,19 +418,11 @@ export function useComposerDraft(
}
}, [])
const checkHasStoredDraft = useCallback(() => {
if (!accountDid) return false
try {
const allDrafts = account.get([accountDid, 'composerDraft'])
return allDrafts ? !!allDrafts[context] : false
} catch {
return false
}
}, [accountDid, context])
return {
draftId: currentDraftId,
loadDraft,
clearDraft,
hasStoredDraft: checkHasStoredDraft,
saveImmediate,
isExistingDraft: !!draftId,
}
}
+149
View File
@@ -0,0 +1,149 @@
import {useCallback, useMemo, useState} from 'react'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {account, type ComposerDraft} from '#/storage'
const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export type DraftItem = {
id: string
draft: ComposerDraft
}
/**
* Hook for managing the drafts list.
* Provides access to all saved drafts and methods to delete them.
*/
export function useDraftsList() {
const {currentAccount} = useSession()
const accountDid = currentAccount?.did
// State to force re-computation of drafts list after modifications
const [refreshKey, setRefreshKey] = useState(0)
// Get all drafts, sorted by timestamp (newest first)
const drafts = useMemo((): DraftItem[] => {
// refreshKey is used to trigger re-computation after deletions
const _refresh = refreshKey
if (!accountDid) return []
try {
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (!allDrafts) return []
const now = Date.now()
const items: DraftItem[] = []
for (const [id, draft] of Object.entries(allDrafts)) {
// Skip invalid or too old drafts
if (draft.version !== 1) continue
if (now - draft.timestamp > MAX_DRAFT_AGE_MS) continue
items.push({id, draft})
}
// Sort by timestamp, newest first
items.sort((a, b) => b.draft.timestamp - a.draft.timestamp)
return items
} catch (e) {
logger.error('Failed to get drafts list', {error: e})
return []
}
}, [accountDid, refreshKey])
const draftsCount = drafts.length
// Delete a specific draft
const deleteDraft = useCallback(
(draftId: string) => {
if (!accountDid) return
try {
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (!allDrafts || !allDrafts[draftId]) return
const remainingDrafts: Record<string, ComposerDraft> = {}
for (const [id, draft] of Object.entries(allDrafts)) {
if (id !== draftId) {
remainingDrafts[id] = draft
}
}
if (Object.keys(remainingDrafts).length > 0) {
account.set([accountDid, 'composerDrafts'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDrafts'])
}
// Trigger re-render to update the list
setRefreshKey(k => k + 1)
logger.debug('Draft deleted', {draftId})
} catch (e) {
logger.error('Failed to delete draft', {error: e, draftId})
}
},
[accountDid],
)
// Get a specific draft by ID
const getDraft = useCallback(
(draftId: string): ComposerDraft | null => {
if (!accountDid) return null
try {
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (!allDrafts) return null
return allDrafts[draftId] ?? null
} catch (e) {
logger.error('Failed to get draft', {error: e, draftId})
return null
}
},
[accountDid],
)
// Clean up old drafts
const cleanupOldDrafts = useCallback(() => {
if (!accountDid) return
try {
const allDrafts = account.get([accountDid, 'composerDrafts'])
if (!allDrafts) return
const now = Date.now()
const remainingDrafts: Record<string, ComposerDraft> = {}
let removedCount = 0
for (const [id, draft] of Object.entries(allDrafts)) {
if (draft.version === 1 && now - draft.timestamp <= MAX_DRAFT_AGE_MS) {
remainingDrafts[id] = draft
} else {
removedCount++
}
}
if (removedCount > 0) {
if (Object.keys(remainingDrafts).length > 0) {
account.set([accountDid, 'composerDrafts'], remainingDrafts)
} else {
account.remove([accountDid, 'composerDrafts'])
}
logger.debug('Cleaned up old drafts', {removedCount})
}
} catch (e) {
logger.error('Failed to cleanup old drafts', {error: e})
}
}, [accountDid])
return {
drafts,
draftsCount,
deleteDraft,
getDraft,
cleanupOldDrafts,
}
}