Drafts UI
This commit is contained in:
committed by
Samuel Newman
parent
649e1fe1f0
commit
22e0edbdaa
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user