diff --git a/CLAUDE.md b/CLAUDE.md
index 9ce958790a..ea77e350d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,7 +29,7 @@ yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
# Internationalization
-yarn intl:extract # Extract translation strings
+yarn intl:extract # Extract translation strings (you don't typically need to run this manually, we have CI for it)
yarn intl:compile # Compile translations for runtime
# Build
@@ -119,7 +119,7 @@ if (gtMobile) {
### Naming Conventions
-- Spacing: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `xxl` (t-shirt sizes)
+- Spacing: `2xs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl` (t-shirt sizes)
- Text: `text_xs`, `text_sm`, `text_md`, `text_lg`, `text_xl`
- Gaps/Padding: `gap_sm`, `p_md`, `px_lg`, `py_xl`
- Flex: `flex_row`, `flex_1`, `align_center`, `justify_between`
@@ -144,7 +144,8 @@ function MyFeature() {
- {/* Native drag handle */}
+ {/* Typically the inner part is in its own component */}
+ {/* Native-only drag handle */}
Title
@@ -152,9 +153,10 @@ function MyFeature() {
Dialog content here
-
>
@@ -215,7 +217,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
// Icon-only button
-
+
// Ghost variant (deprecated - use color prop)
@@ -225,7 +227,7 @@ import {Button, ButtonText, ButtonIcon} from '#/components/Button'
```
**Button Props:**
-- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'`
+- `color`: `'primary'` | `'secondary'` | `'negative'` | `'primary_subtle'` | `'negative_subtle'` | `'secondary_inverted'`
- `size`: `'tiny'` | `'small'` | `'large'`
- `shape`: `'default'` (pill) | `'round'` | `'square'` | `'rectangular'`
- `variant`: `'solid'` | `'outline'` | `'ghost'` (deprecated, use `color`)
@@ -339,6 +341,16 @@ export function useUpdateProfile() {
onSuccess: (_, variables) => {
queryClient.invalidateQueries({queryKey: RQKEY(variables.did)})
},
+ onError: (error) => {
+ if (isNetworkError(error)) {
+ // don't log, but inform user
+ } else if (error instanceof AppBskyExampleProcedure.ExampleError) {
+ // XRPC APIs often have typed errors, allows nicer handling
+ } else {
+ // Log unexpected errors to Sentry
+ logger.error('Error updating profile', {safeMessage: error})
+ }
+ }
})
}
```
@@ -352,6 +364,26 @@ STALE.HOURS.ONE // 1 hour
STALE.INFINITY // Never stale
```
+**Paginated APIs:** Many atproto APIs return paginated results with a `cursor`. Use `useInfiniteQuery` for these:
+
+```tsx
+export function useDraftsQuery() {
+ const agent = useAgent()
+
+ return useInfiniteQuery({
+ queryKey: ['drafts'],
+ queryFn: async ({pageParam}) => {
+ const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
+ return res.data
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: page => page.cursor,
+ })
+}
+```
+
+To get all items from pages: `data?.pages.flatMap(page => page.items) ?? []`
+
### Preferences (React Context)
```tsx
@@ -437,7 +469,19 @@ Example from Dialog:
- `src/components/Dialog/index.tsx` - Native (uses BottomSheet)
- `src/components/Dialog/index.web.tsx` - Web (uses modal with Radix primitives)
-Platform detection:
+**Important:** The bundler automatically resolves platform-specific files. Just import normally:
+
+```tsx
+// CORRECT - bundler picks storage.ts or storage.web.ts automatically
+import * as storage from '#/state/drafts/storage'
+
+// WRONG - don't use require() or conditional imports for platform files
+const storage = IS_NATIVE
+ ? require('#/state/drafts/storage')
+ : require('#/state/drafts/storage.web')
+```
+
+Platform detection (for runtime logic, not imports):
```tsx
import {IS_WEB, IS_NATIVE, IS_IOS, IS_ANDROID} from '#/env'
diff --git a/assets/icons/pageX_stroke2_corner0_rounded_large.svg b/assets/icons/pageX_stroke2_corner0_rounded_large.svg
new file mode 100644
index 0000000000..4a319a8da4
--- /dev/null
+++ b/assets/icons/pageX_stroke2_corner0_rounded_large.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index ce8ba8430f..e8baad5c50 100644
--- a/package.json
+++ b/package.json
@@ -166,6 +166,7 @@
"expo-task-manager": "~14.0.9",
"expo-updates": "~29.0.14",
"expo-video": "~3.0.15",
+ "expo-video-thumbnails": "^10.0.8",
"expo-web-browser": "~15.0.10",
"fast-deep-equal": "^3.1.3",
"fast-text-encoding": "^1.0.6",
diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx
index d8d2e430f2..acd4b313fb 100644
--- a/src/components/MediaPreview.tsx
+++ b/src/components/MediaPreview.tsx
@@ -135,6 +135,7 @@ export function VideoItem({
{maxWidth: 100},
a.justify_center,
a.align_center,
+ a.rounded_xs,
]}>
diff --git a/src/components/Post/Embed/ExternalEmbed/Gif.tsx b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
index cbfafcf3a2..33a1c66f24 100644
--- a/src/components/Post/Embed/ExternalEmbed/Gif.tsx
+++ b/src/components/Post/Embed/ExternalEmbed/Gif.tsx
@@ -114,11 +114,10 @@ export function GifEmbed({
let aspectRatio = 1
if (params.dimensions) {
- aspectRatio = clamp(
- params.dimensions.width / params.dimensions.height,
- 0.75,
- 4,
- )
+ const ratio = params.dimensions.width / params.dimensions.height
+ if (!isNaN(ratio) && isFinite(ratio)) {
+ aspectRatio = clamp(ratio, 0.75, 4)
+ }
}
return (
diff --git a/src/components/icons/PageX.tsx b/src/components/icons/PageX.tsx
new file mode 100644
index 0000000000..90c3c81ca4
--- /dev/null
+++ b/src/components/icons/PageX.tsx
@@ -0,0 +1,6 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PageX_Stroke2_Corner0_Rounded_Large = createSinglePathSVG({
+ viewBox: '0 0 64 64',
+ path: 'M32.457 7c1.68 0 3.29.668 4.478 1.855L49.813 21.73a6.33 6.33 0 0 1 1.854 4.479v24.458A6.333 6.333 0 0 1 45.333 57H18.666a6.334 6.334 0 0 1-6.333-6.333V13.333A6.334 6.334 0 0 1 18.666 7h13.791ZM18.666 9a4.334 4.334 0 0 0-4.333 4.333v37.334A4.334 4.334 0 0 0 18.666 55h26.667a4.333 4.333 0 0 0 4.333-4.333V26.209c0-.418-.061-.829-.177-1.223a1 1 0 0 1-.155.014H40a6.334 6.334 0 0 1-6.325-6.008l-.008-.326V9.333q0-.08.013-.156A4.3 4.3 0 0 0 32.457 9H18.666Zm18.627 22.293a1 1 0 1 1 1.414 1.414L33.414 38l5.293 5.293a1 1 0 1 1-1.414 1.414L32 39.414l-5.293 5.293a1 1 0 1 1-1.414-1.414L30.586 38l-5.293-5.293a1 1 0 1 1 1.414-1.414L32 36.586l5.293-5.293Zm-1.626-12.627.006.224A4.333 4.333 0 0 0 40 23h8.253L35.667 10.414v8.252Z',
+})
diff --git a/src/components/moderation/ContentHider.tsx b/src/components/moderation/ContentHider.tsx
index 778c7e93ae..6ba98ff4d7 100644
--- a/src/components/moderation/ContentHider.tsx
+++ b/src/components/moderation/ContentHider.tsx
@@ -4,7 +4,11 @@ import {type ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {ADULT_CONTENT_LABELS, isJustAMute} from '#/lib/moderation'
+import {
+ ADULT_CONTENT_LABELS,
+ type AdultSelfLabel,
+ isJustAMute,
+} from '#/lib/moderation'
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
import {getDefinition, getLabelStrings} from '#/lib/moderation/useLabelInfo'
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
@@ -101,7 +105,7 @@ function ContentHiderActive({
if (cause.source.type !== 'user') {
return false
}
- if (ADULT_CONTENT_LABELS.includes(cause.label.val)) {
+ if (ADULT_CONTENT_LABELS.includes(cause.label.val as AdultSelfLabel)) {
if (hasAdultContentLabel) {
return false
}
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index d33e10a129..75b4b4c3b0 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -14,9 +14,12 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {type AppModerationCause} from '#/components/Pills'
-export const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn']
-export const OTHER_SELF_LABELS = ['graphic-media']
-export const SELF_LABELS = [...ADULT_CONTENT_LABELS, ...OTHER_SELF_LABELS]
+export const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn'] as const
+export const OTHER_SELF_LABELS = ['graphic-media'] as const
+export const SELF_LABELS = [
+ ...ADULT_CONTENT_LABELS,
+ ...OTHER_SELF_LABELS,
+] as const
export type AdultSelfLabel = (typeof ADULT_CONTENT_LABELS)[number]
export type OtherSelfLabel = (typeof OTHER_SELF_LABELS)[number]
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index c2d2f3b065..951c3318f4 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -558,6 +558,16 @@ export function parseTenorGif(urlp: URL):
width: Number(w),
}
+ // Validate dimensions are valid positive numbers
+ if (
+ isNaN(dimensions.height) ||
+ isNaN(dimensions.width) ||
+ dimensions.height <= 0 ||
+ dimensions.width <= 0
+ ) {
+ return {success: false}
+ }
+
if (IS_WEB) {
if (IS_WEB_SAFARI) {
id = id.replace('AAAAC', 'AAAP1')
diff --git a/src/logger/types.ts b/src/logger/types.ts
index ab9707b41b..826a1bcc1e 100644
--- a/src/logger/types.ts
+++ b/src/logger/types.ts
@@ -15,6 +15,7 @@ export enum LogContext {
AgeAssurance = 'age-assurance',
PolicyUpdate = 'policy-update',
Geolocation = 'geolocation',
+ Drafts = 'drafts',
/**
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
diff --git a/src/state/gallery.ts b/src/state/gallery.ts
index 5b5eedd8b8..09c2e84328 100644
--- a/src/state/gallery.ts
+++ b/src/state/gallery.ts
@@ -1,5 +1,6 @@
import {
cacheDirectory,
+ copyAsync,
deleteAsync,
makeDirectoryAsync,
moveAsync,
@@ -18,7 +19,7 @@ import {openCropper} from '#/lib/media/picker'
import {type PickerImage} from '#/lib/media/picker.shared'
import {getDataUriSize} from '#/lib/media/util'
import {isCancelledError} from '#/lib/strings/errors'
-import {IS_NATIVE} from '#/env'
+import {IS_NATIVE, IS_WEB} from '#/env'
export type ImageTransformation = {
crop?: ActionCrop['crop']
@@ -38,6 +39,8 @@ export type ImageSource = ImageMeta & {
type ComposerImageBase = {
alt: string
source: ImageSource
+ /** Original localRef path from draft, if editing an existing draft. Used to reuse the same storage key. */
+ localRefPath?: string
}
type ComposerImageWithoutTransformation = ComposerImageBase & {
transformed?: undefined
@@ -69,7 +72,8 @@ export async function createComposerImage(
alt: '',
source: {
id: nanoid(),
- path: await moveIfNecessary(raw.path),
+ // Copy to cache to ensure file survives OS temporary file cleanup
+ path: await copyToCache(raw.path),
width: raw.width,
height: raw.height,
mime: raw.mime,
@@ -258,6 +262,70 @@ async function moveIfNecessary(from: string) {
return from
}
+/**
+ * Copy a file from a potentially temporary location to our cache directory.
+ * This ensures picker files are available for draft saving even if the original
+ * temporary files are cleaned up by the OS.
+ *
+ * On web, converts blob URLs to data URIs immediately to prevent revocation issues.
+ */
+async function copyToCache(from: string): Promise {
+ // Handle web blob URLs - convert to data URI immediately before they can be revoked
+ if (IS_WEB && from.startsWith('blob:')) {
+ try {
+ const response = await fetch(from)
+ const blob = await response.blob()
+ return await blobToDataUri(blob)
+ } catch (e) {
+ // If fetch fails, the blob URL was likely already revoked
+ // Return as-is and let downstream code handle the error
+ return from
+ }
+ }
+
+ // Data URIs don't need any conversion
+ if (from.startsWith('data:')) {
+ return from
+ }
+
+ const cacheDir = IS_WEB && getImageCacheDirectory()
+
+ // On web (non-blob URLs) or if already in cache dir, no need to copy
+ if (!cacheDir || from.startsWith(cacheDir)) {
+ return from
+ }
+
+ const to = joinPath(cacheDir, nanoid(36))
+ await makeDirectoryAsync(cacheDir, {intermediates: true})
+
+ // Normalize the source path for expo-file-system
+ let normalizedFrom = from
+ if (!from.startsWith('file://') && from.startsWith('/')) {
+ normalizedFrom = `file://${from}`
+ }
+
+ await copyAsync({from: normalizedFrom, to})
+ return to
+}
+
+/**
+ * Convert a Blob to a data URI
+ */
+function blobToDataUri(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onloadend = () => {
+ if (typeof reader.result === 'string') {
+ resolve(reader.result)
+ } else {
+ reject(new Error('Failed to convert blob to data URI'))
+ }
+ }
+ reader.onerror = () => reject(reader.error)
+ reader.readAsDataURL(blob)
+ })
+}
+
/** Purge files that were created to accomodate image manipulation */
export async function purgeTemporaryImageFiles() {
const cacheDir = IS_NATIVE && getImageCacheDirectory()
diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts
index 09dc739c00..d4c14252ca 100644
--- a/src/state/queries/resolve-link.ts
+++ b/src/state/queries/resolve-link.ts
@@ -1,7 +1,10 @@
+import {type BskyAgent} from '@atproto/api'
import {type QueryClient, useQuery} from '@tanstack/react-query'
+import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
import {STALE} from '#/state/queries/index'
-import {useAgent} from '../session'
+import {useAgent} from '#/state/session'
+import {type Gif} from './tenor'
const RQKEY_LINK_ROOT = 'resolve-link'
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
@@ -9,13 +12,9 @@ export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
const RQKEY_GIF_ROOT = 'resolve-gif'
export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url]
-import {type BskyAgent} from '@atproto/api'
-
-import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
-import {type Gif} from './tenor'
-
export function useResolveLinkQuery(url: string) {
const agent = useAgent()
+
return useQuery({
staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_LINK(url),
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index f7d76558b3..e42dce8cd7 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -42,6 +42,7 @@ import Animated, {
ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import * as FileSystem from 'expo-file-system'
import {type ImagePickerAsset} from 'expo-image-picker'
import {
AppBskyUnspeccedDefs,
@@ -50,7 +51,6 @@ import {
type BskyAgent,
type RichText,
} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -68,7 +68,6 @@ import {
} from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {mimeToExt} from '#/lib/media/video/util'
import {type NavigationProp} from '#/lib/routes/types'
@@ -98,6 +97,7 @@ import {useComposerControls} from '#/state/shell/composer'
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
+import {DraftsButton} from '#/view/com/composer/drafts/DraftsButton'
import {
ExternalEmbedGif,
ExternalEmbedLink,
@@ -116,9 +116,9 @@ import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
-import {Text} from '#/view/com/util/text/Text'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
+import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
@@ -127,10 +127,21 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti
import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
-import {Text as NewText} from '#/components/Typography'
+import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
+import {
+ draftToComposerPosts,
+ extractLocalRefs,
+ type RestoredVideo,
+} from './drafts/state/api'
+import {
+ loadDraft,
+ useCleanupPublishedDraftMutation,
+ useSaveDraftMutation,
+} from './drafts/state/queries'
+import {type DraftSummary} from './drafts/state/schema'
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
import {
type AssetType,
@@ -189,6 +200,9 @@ export const ComposePost = ({
const setLangPrefs = useLanguagePrefsApi()
const textInput = useRef(null)
const discardPromptControl = Prompt.usePromptControl()
+ const {mutateAsync: saveDraft, isPending: _isSavingDraft} =
+ useSaveDraftMutation()
+ const {mutate: cleanupPublishedDraft} = useCleanupPublishedDraftMutation()
const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls()
const {data: preferences} = usePreferencesQuery()
@@ -307,7 +321,7 @@ export const ComposePost = ({
onInitVideo()
}, [onInitVideo])
- const clearVideo = React.useCallback(
+ const clearVideo = useCallback(
(postId: string) => {
composerDispatch({
type: 'update_post',
@@ -320,6 +334,171 @@ export const ComposePost = ({
[composerDispatch],
)
+ const restoreVideo = useCallback(
+ async (postId: string, videoInfo: RestoredVideo) => {
+ try {
+ logger.debug('restoring video from draft', {
+ postId,
+ videoUri: videoInfo.uri,
+ altText: videoInfo.altText,
+ captionCount: videoInfo.captions.length,
+ })
+
+ let asset: ImagePickerAsset
+
+ if (IS_WEB) {
+ // Web: Convert blob URL to a File, then get video metadata (returns data URL)
+ const response = await fetch(videoInfo.uri)
+ const blob = await response.blob()
+ const file = new File([blob], 'restored-video', {
+ type: videoInfo.mimeType,
+ })
+ asset = await getVideoMetadata(file)
+ } else {
+ let uri = videoInfo.uri
+ if (IS_ANDROID) {
+ // Android: expo-file-system double-encodes filenames with special chars.
+ // The file exists, but react-native-compressor's MediaMetadataRetriever
+ // can't handle the double-encoded URI. Copy to a temp file with a simple name.
+ const sourceFile = new FileSystem.File(videoInfo.uri)
+ const tempFileName = `draft-video-${Date.now()}.${mimeToExt(videoInfo.mimeType)}`
+ const tempFile = new FileSystem.File(
+ FileSystem.Paths.cache,
+ tempFileName,
+ )
+ sourceFile.copy(tempFile)
+ logger.debug('restoreVideo: copied to temp file', {
+ source: videoInfo.uri,
+ temp: tempFile.uri,
+ })
+ uri = tempFile.uri
+ }
+ asset = await getVideoMetadata(uri)
+ }
+
+ // Start video processing using existing flow
+ const abortController = new AbortController()
+ composerDispatch({
+ type: 'update_post',
+ postId,
+ postAction: {
+ type: 'embed_add_video',
+ asset,
+ abortController,
+ },
+ })
+
+ // Restore alt text immediately
+ if (videoInfo.altText) {
+ composerDispatch({
+ type: 'update_post',
+ postId,
+ postAction: {
+ type: 'embed_update_video',
+ videoAction: {
+ type: 'update_alt_text',
+ altText: videoInfo.altText,
+ signal: abortController.signal,
+ },
+ },
+ })
+ }
+
+ // Restore captions (web only - captions use File objects)
+ if (IS_WEB && videoInfo.captions.length > 0) {
+ const captionTracks = videoInfo.captions.map(c => ({
+ lang: c.lang,
+ file: new File([c.content], `caption-${c.lang}.vtt`, {
+ type: 'text/vtt',
+ }),
+ }))
+ composerDispatch({
+ type: 'update_post',
+ postId,
+ postAction: {
+ type: 'embed_update_video',
+ videoAction: {
+ type: 'update_captions',
+ updater: () => captionTracks,
+ signal: abortController.signal,
+ },
+ },
+ })
+ }
+
+ // Start video compression and upload
+ processVideo(
+ asset,
+ videoAction => {
+ composerDispatch({
+ type: 'update_post',
+ postId,
+ postAction: {
+ type: 'embed_update_video',
+ videoAction,
+ },
+ })
+ },
+ agent,
+ currentDid,
+ abortController.signal,
+ _,
+ )
+ } catch (e) {
+ logger.error('Failed to restore video from draft', {
+ postId,
+ error: e,
+ })
+ }
+ },
+ [_, agent, currentDid, composerDispatch],
+ )
+
+ const handleSelectDraft = React.useCallback(
+ async (draftSummary: DraftSummary) => {
+ logger.debug('loading draft for editing', {
+ draftId: draftSummary.id,
+ })
+
+ // Load local media files for the draft
+ const {loadedMedia} = await loadDraft(draftSummary.draft)
+
+ // Extract original localRefs for orphan detection on save
+ const originalLocalRefs = extractLocalRefs(draftSummary.draft)
+
+ logger.debug('draft loaded', {
+ draftId: draftSummary.id,
+ loadedMediaCount: loadedMedia.size,
+ originalLocalRefCount: originalLocalRefs.size,
+ })
+
+ // Convert server draft to composer posts (videos returned separately)
+ const {posts, restoredVideos} = await draftToComposerPosts(
+ draftSummary.draft,
+ loadedMedia,
+ )
+
+ // Dispatch restore action (this also sets draftId in state)
+ composerDispatch({
+ type: 'restore_from_draft',
+ draftId: draftSummary.id,
+ posts,
+ threadgateAllow: draftSummary.draft.threadgateAllow,
+ postgateEmbeddingRules: draftSummary.draft.postgateEmbeddingRules,
+ loadedMedia,
+ originalLocalRefs,
+ })
+
+ // Initiate video processing for any restored videos
+ // This is async but we don't await - videos process in the background
+ for (const [postIndex, videoInfo] of restoredVideos) {
+ const postId = posts[postIndex].id
+ restoreVideo(postId, videoInfo)
+ }
+ },
+ [composerDispatch, restoreVideo],
+ )
+
const [publishOnUpload, setPublishOnUpload] = useState(false)
const onClose = useCallback(() => {
@@ -327,6 +506,55 @@ export const ComposePost = ({
clearThumbnailCache(queryClient)
}, [closeComposer, queryClient])
+ const handleSaveDraft = React.useCallback(async () => {
+ try {
+ const result = await saveDraft({
+ composerState,
+ existingDraftId: composerState.draftId,
+ })
+ composerDispatch({type: 'mark_saved', draftId: result.draftId})
+ onClose()
+ } catch (e) {
+ logger.error('Failed to save draft', {error: e})
+ setError(_(msg`Failed to save draft`))
+ }
+ }, [saveDraft, composerState, composerDispatch, onClose, _])
+
+ // Save without closing - for use by DraftsButton
+ const saveCurrentDraft = React.useCallback(async () => {
+ const result = await saveDraft({
+ composerState,
+ existingDraftId: composerState.draftId,
+ })
+ composerDispatch({type: 'mark_saved', draftId: result.draftId})
+ }, [saveDraft, composerState, composerDispatch])
+
+ // Check if composer is empty (no content to save)
+ const isComposerEmpty = React.useMemo(() => {
+ // Has multiple posts means it's not empty
+ if (thread.posts.length > 1) return false
+
+ const firstPost = thread.posts[0]
+ // Has text
+ if (firstPost.richtext.text.trim().length > 0) return false
+ // Has media
+ if (firstPost.embed.media) return false
+ // Has quote
+ if (firstPost.embed.quote) return false
+ // Has link
+ if (firstPost.embed.link) return false
+
+ return true
+ }, [thread.posts])
+
+ // Clear the composer (discard current content)
+ const handleClearComposer = React.useCallback(() => {
+ composerDispatch({
+ type: 'clear',
+ initInteractionSettings: preferences?.postInteractionSettings,
+ })
+ }, [composerDispatch, preferences?.postInteractionSettings])
+
const insets = useSafeAreaInsets()
const viewStyles = useMemo(
() => ({
@@ -347,21 +575,31 @@ export const ComposePost = ({
const onPressCancel = useCallback(() => {
if (textInput.current?.maybeClosePopup()) {
return
- } else if (
- thread.posts.some(
- post =>
- post.shortenedGraphemeLength > 0 ||
- post.embed.media ||
- post.embed.link,
- )
- ) {
+ }
+
+ const hasContent = thread.posts.some(
+ post =>
+ post.shortenedGraphemeLength > 0 || post.embed.media || post.embed.link,
+ )
+
+ // Show discard prompt if there's content AND either:
+ // - No draft is loaded (new composition)
+ // - Draft is loaded but has been modified
+ if (hasContent && (!composerState.draftId || composerState.isDirty)) {
closeAllDialogs()
Keyboard.dismiss()
discardPromptControl.open()
} else {
onClose()
}
- }, [thread, closeAllDialogs, discardPromptControl, onClose])
+ }, [
+ thread,
+ composerState.draftId,
+ composerState.isDirty,
+ closeAllDialogs,
+ discardPromptControl,
+ onClose,
+ ])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -546,6 +784,17 @@ export const ComposePost = ({
if (postUri && !replyTo) {
emitPostCreated()
}
+ // Clean up draft and its media after successful publish
+ if (composerState.draftId && composerState.originalLocalRefs) {
+ logger.debug('post published, cleaning up draft', {
+ draftId: composerState.draftId,
+ mediaFileCount: composerState.originalLocalRefs.size,
+ })
+ cleanupPublishedDraft({
+ draftId: composerState.draftId,
+ originalLocalRefs: composerState.originalLocalRefs,
+ })
+ }
setLangPrefs.savePostLanguageToHistory()
if (initQuote) {
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
@@ -609,6 +858,9 @@ export const ComposePost = ({
setLangPrefs,
queryClient,
navigation,
+ composerState.draftId,
+ composerState.originalLocalRefs,
+ cleanupPublishedDraft,
])
// Preserves the referential identity passed to each post item.
@@ -750,7 +1002,13 @@ export const ComposePost = ({
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
- onPublish={onPressPublish}>
+ onPublish={onPressPublish}
+ onSelectDraft={handleSelectDraft}
+ onSaveDraft={saveCurrentDraft}
+ onDiscard={handleClearComposer}
+ isEmpty={isComposerEmpty}
+ isDirty={composerState.isDirty}
+ isEditingDraft={!!composerState.draftId}>
{missingAltError && }
-
+
+
+
+ {composerState.draftId ? (
+ Save changes?
+ ) : (
+ Save draft?
+ )}
+
+
+ {composerState.draftId
+ ? _(
+ msg`You have unsaved changes to this draft, would you like to save them?`,
+ )
+ : _(msg`Would you like to save this as a draft to edit later?`)}
+
+
+
+
+
+
+
+
)
@@ -923,7 +1208,7 @@ let ComposerPost = React.memo(function ComposerPost({
a.mb_sm,
!isActive && isLastPost && a.mb_lg,
!isActive && styles.inactivePost,
- isTextOnly && IS_NATIVE && a.flex_grow,
+ isTextOnly && isLastPost && IS_NATIVE && a.flex_grow,
]}>
void
onPublish: () => void
+ onSelectDraft: (draft: DraftSummary) => void
+ onSaveDraft: () => Promise
+ onDiscard: () => void
+ isEmpty: boolean
+ isDirty: boolean
+ isEditingDraft: boolean
topBarAnimatedStyle: StyleProp
children?: React.ReactNode
}) {
- const pal = usePalette('default')
+ const t = useTheme()
const {_} = useLingui()
return (
{isPublishing ? (
<>
- {publishingStage}
+
+ {publishingStage}
+
>
) : (
-
-
- {isReply ? (
- Reply
- ) : isThread ? (
- Post All
- ) : (
- Post
- )}
-
-
+ <>
+ {!isReply && (
+
+ )}
+
+
+ {isReply ? (
+ Reply
+ ) : isThread ? (
+ Post All
+ ) : (
+ Post
+ )}
+
+
+ >
)}
{children}
@@ -1132,18 +1441,10 @@ function ComposerTopBar({
}
function AltTextReminder({error}: {error: string}) {
- const pal = usePalette('default')
return (
-
-
-
-
- {error}
-
+
+ {error}
+
)
}
@@ -1411,7 +1712,7 @@ function ComposerFooter({
if (assets.length) {
if (type === 'image') {
- const images: ComposerImage[] = []
+ const selectedImages: ComposerImage[] = []
await Promise.all(
assets.map(async image => {
@@ -1421,7 +1722,7 @@ function ComposerFooter({
height: image.height,
mime: image.mimeType!,
})
- images.push(composerImage)
+ selectedImages.push(composerImage)
}),
).catch(e => {
logger.error(`createComposerImage failed`, {
@@ -1429,7 +1730,7 @@ function ComposerFooter({
})
})
- onImageAdd(images)
+ onImageAdd(selectedImages)
} else if (type === 'video') {
onSelectVideo(post.id, assets[0])
} else if (type === 'gif') {
@@ -1810,9 +2111,9 @@ function ErrorBanner({
]}>
-
+
{error}
-
+
{videoError && videoState.jobId && (
-
Job ID: {videoState.jobId}
-
+
)}
@@ -1922,7 +2223,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
progress={wheelProgress}
/>
- {text}
+ {text}
)
}
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index e4bdabac32..cc7b3ee150 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -37,7 +37,13 @@ export const ExternalEmbedGif = ({
)
const loadingStyle: ViewStyle = {
- aspectRatio: gif.media_formats.gif.dims[0] / gif.media_formats.gif.dims[1],
+ aspectRatio: (() => {
+ const dims = gif.media_formats.gif?.dims
+ if (dims && dims[0] > 0 && dims[1] > 0) {
+ return dims[0] / dims[1]
+ }
+ return 16 / 9 // Default aspect ratio
+ })(),
width: '100%',
}
diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx
new file mode 100644
index 0000000000..e647473300
--- /dev/null
+++ b/src/view/com/composer/drafts/DraftItem.tsx
@@ -0,0 +1,289 @@
+import {useCallback, useEffect, useState} from 'react'
+import {Pressable, View} from 'react-native'
+import * as VideoThumbnails from 'expo-video-thumbnails'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
+import {logger} from '#/view/com/composer/drafts/state/logger'
+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 {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
+import * as MediaPreview from '#/components/MediaPreview'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
+import {IS_WEB} from '#/env'
+import {type DraftPostDisplay, type DraftSummary} from './state/schema'
+import * as storage from './state/storage'
+
+export function DraftItem({
+ draft,
+ onSelect,
+ onDelete,
+}: {
+ draft: DraftSummary
+ onSelect: (draft: DraftSummary) => void
+ onDelete: (draft: DraftSummary) => void
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const discardPromptControl = Prompt.usePromptControl()
+
+ const handleDelete = useCallback(() => {
+ onDelete(draft)
+ }, [onDelete, draft])
+
+ return (
+ <>
+ onSelect(draft)}
+ style={({pressed, hovered}) => [
+ a.rounded_md,
+ a.overflow_hidden,
+ a.border,
+ t.atoms.bg,
+ t.atoms.border_contrast_low,
+ t.atoms.shadow_sm,
+ (pressed || hovered) && t.atoms.bg_contrast_25,
+ ]}>
+
+ {draft.hasMissingMedia && (
+
+
+ Some media unavailable (saved on another device)
+
+
+ )}
+
+ {draft.posts.map((post, index) => (
+
+ ))}
+
+
+
+
+ >
+ )
+}
+
+function DraftPostRow({
+ post,
+ isFirst,
+ isLast,
+ timestamp,
+ discardPromptControl,
+}: {
+ post: DraftPostDisplay
+ isFirst: boolean
+ isLast: boolean
+ timestamp: string
+ discardPromptControl: Prompt.PromptControlProps
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const profile = useCurrentAccountProfile()
+
+ return (
+
+
+
+ {!isLast && (
+
+ )}
+
+
+
+
+
+ {profile && (
+ <>
+
+ {createSanitizedDisplayName(profile)}
+
+
+ {sanitizeHandle(profile.handle)}
+
+
+ ·
+
+ >
+ )}
+
+ {({timeElapsed}) => (
+
+ {timeElapsed}
+
+ )}
+
+
+
+ {isFirst && (
+ {
+ e.stopPropagation()
+ discardPromptControl.open()
+ }}>
+
+
+ )}
+
+
+ {post.text ? (
+
+ {post.text}
+
+ ) : (
+
+ (No text)
+
+ )}
+
+
+
+
+ )
+}
+
+type LoadedImage = {
+ url: string
+ alt: string
+}
+
+function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
+ const [loadedImages, setLoadedImages] = useState([])
+ const [videoThumbnail, setVideoThumbnail] = useState()
+
+ useEffect(() => {
+ async function loadMedia() {
+ if (post.images && post.images.length > 0) {
+ const loaded: LoadedImage[] = []
+ for (const image of post.images) {
+ try {
+ const url = await storage.loadMediaFromLocal(image.localPath)
+ loaded.push({url, alt: image.altText || ''})
+ } catch (e) {
+ // Image doesn't exist locally, skip it
+ }
+ }
+ setLoadedImages(loaded)
+ }
+
+ if (post.video?.exists && post.video.localPath) {
+ try {
+ const url = await storage.loadMediaFromLocal(post.video.localPath)
+ if (IS_WEB) {
+ // can't generate thumbnails on web
+ setVideoThumbnail("yep, there's a video")
+ } else {
+ logger.debug('generating thumbnail of ', {url})
+ const thumbnail = await VideoThumbnails.getThumbnailAsync(url, {
+ time: 0,
+ quality: 0.2,
+ })
+ logger.debug('thumbnail generated', {thumbnail})
+ setVideoThumbnail(thumbnail.uri)
+ }
+ } catch (e) {
+ // Video doesn't exist locally
+ }
+ }
+ }
+
+ void loadMedia()
+ }, [post.images, post.video])
+
+ // Nothing to show
+ if (loadedImages.length === 0 && !post.gif && !post.video) {
+ return null
+ }
+
+ return (
+
+ {loadedImages.map((image, i) => (
+
+ ))}
+ {post.gif && (
+
+ )}
+ {post.video && videoThumbnail && (
+
+ )}
+
+ )
+}
diff --git a/src/view/com/composer/drafts/DraftsButton.tsx b/src/view/com/composer/drafts/DraftsButton.tsx
new file mode 100644
index 0000000000..c0a4bce9a3
--- /dev/null
+++ b/src/view/com/composer/drafts/DraftsButton.tsx
@@ -0,0 +1,111 @@
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+import {DraftsListDialog} from './DraftsListDialog'
+import {useSaveDraftMutation} from './state/queries'
+import {type DraftSummary} from './state/schema'
+
+export function DraftsButton({
+ onSelectDraft,
+ onSaveDraft,
+ onDiscard,
+ isEmpty,
+ isDirty,
+ isEditingDraft,
+}: {
+ onSelectDraft: (draft: DraftSummary) => void
+ onSaveDraft: () => Promise
+ onDiscard: () => void
+ isEmpty: boolean
+ isDirty: boolean
+ isEditingDraft: boolean
+}) {
+ const {_} = useLingui()
+ const draftsDialogControl = Dialog.useDialogControl()
+ const savePromptControl = Prompt.usePromptControl()
+ const {isPending: isSaving} = useSaveDraftMutation()
+
+ const handlePress = () => {
+ if (isEmpty || !isDirty) {
+ // Composer is empty or has no unsaved changes, go directly to drafts list
+ draftsDialogControl.open()
+ } else {
+ // Composer has unsaved changes, ask what to do
+ savePromptControl.open()
+ }
+ }
+
+ const handleSaveAndOpen = async () => {
+ await onSaveDraft()
+ draftsDialogControl.open()
+ }
+
+ const handleDiscardAndOpen = () => {
+ onDiscard()
+ draftsDialogControl.open()
+ }
+
+ return (
+ <>
+
+
+ Drafts
+
+
+
+
+
+
+
+
+ {isEditingDraft ? (
+ Save changes?
+ ) : (
+ Save draft?
+ )}
+
+
+
+ {isEditingDraft ? (
+
+ You have unsaved changes. Would you like to save them before
+ viewing your drafts?
+
+ ) : (
+
+ Would you like to save this as a draft before viewing your drafts?
+
+ )}
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/view/com/composer/drafts/DraftsListDialog.tsx b/src/view/com/composer/drafts/DraftsListDialog.tsx
new file mode 100644
index 0000000000..ca8fcbf820
--- /dev/null
+++ b/src/view/com/composer/drafts/DraftsListDialog.tsx
@@ -0,0 +1,148 @@
+import {useCallback, useMemo} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {EmptyState} from '#/view/com/util/EmptyState'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
+import {ListFooter} from '#/components/Lists'
+import {Loader} from '#/components/Loader'
+import {IS_NATIVE} from '#/env'
+import {DraftItem} from './DraftItem'
+import {useDeleteDraftMutation, useDraftsQuery} from './state/queries'
+import {type DraftSummary} from './state/schema'
+
+export function DraftsListDialog({
+ control,
+ onSelectDraft,
+}: {
+ control: Dialog.DialogControlProps
+ onSelectDraft: (draft: DraftSummary) => void
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} =
+ useDraftsQuery()
+ const {mutate: deleteDraft} = useDeleteDraftMutation()
+
+ const drafts = useMemo(
+ () => data?.pages.flatMap(page => page.drafts) ?? [],
+ [data],
+ )
+
+ const handleSelectDraft = useCallback(
+ (summary: DraftSummary) => {
+ control.close(() => {
+ onSelectDraft(summary)
+ })
+ },
+ [control, onSelectDraft],
+ )
+
+ const handleDeleteDraft = useCallback(
+ (draftSummary: DraftSummary) => {
+ deleteDraft({draftId: draftSummary.id, draft: draftSummary.draft})
+ },
+ [deleteDraft],
+ )
+
+ const backButton = useCallback(
+ () => (
+ control.close()}
+ size="small"
+ color="primary"
+ variant="ghost">
+
+ Back
+
+
+ ),
+ [control, _],
+ )
+
+ const renderItem = useCallback(
+ ({item}: {item: DraftSummary}) => {
+ return (
+
+
+
+ )
+ },
+ [handleSelectDraft, handleDeleteDraft],
+ )
+
+ const header = useMemo(
+ () => (
+
+
+ Drafts
+
+
+ ),
+ [backButton],
+ )
+
+ const onEndReached = useCallback(() => {
+ if (hasNextPage && !isFetchingNextPage) {
+ fetchNextPage()
+ }
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage])
+
+ const emptyComponent = useMemo(() => {
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+ )
+ }, [isLoading, _])
+
+ const footerComponent = useMemo(
+ () => (
+
+ ),
+ [isFetchingNextPage, hasNextPage],
+ )
+
+ return (
+
+ {/* We really really need to figure out a nice, consistent API for doing a header cross-platform -sfn */}
+ {IS_NATIVE && header}
+ item.id}
+ ListHeaderComponent={web(header)}
+ stickyHeaderIndices={web([0])}
+ ListEmptyComponent={emptyComponent}
+ ListFooterComponent={footerComponent}
+ onEndReached={onEndReached}
+ onEndReachedThreshold={0.5}
+ style={[t.atoms.bg_contrast_50, a.px_0, web({minHeight: 500})]}
+ webInnerContentContainerStyle={[a.py_0]}
+ contentContainerStyle={[a.pb_xl]}
+ />
+
+ )
+}
diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts
new file mode 100644
index 0000000000..7b4e686eb7
--- /dev/null
+++ b/src/view/com/composer/drafts/state/api.ts
@@ -0,0 +1,630 @@
+/**
+ * Type converters for Draft API - convert between ComposerState and server Draft types.
+ */
+import {type AppBskyDraftDefs, RichText} from '@atproto/api'
+import {nanoid} from 'nanoid/non-secure'
+
+import {getImageDim} from '#/lib/media/manip'
+import {mimeToExt} from '#/lib/media/video/util'
+import {type ComposerImage} from '#/state/gallery'
+import {type Gif} from '#/state/queries/tenor'
+import {
+ type ComposerState,
+ type EmbedDraft,
+ type PostDraft,
+} from '#/view/com/composer/state/composer'
+import {type VideoState} from '#/view/com/composer/state/video'
+import {logger} from './logger'
+import {type DraftPostDisplay, type DraftSummary} from './schema'
+
+const TENOR_HOSTNAME = 'media.tenor.com'
+
+/**
+ * Video data from a draft that needs to be restored by re-processing.
+ * Contains the local file URI, alt text, mime type, and captions to restore.
+ */
+export type RestoredVideo = {
+ uri: string
+ altText: string
+ mimeType: string
+ localRefPath: string
+ captions: Array<{lang: string; content: string}>
+}
+
+/**
+ * Parse mime type from video localRefPath.
+ * Format: `video:${mimeType}:${nanoid()}` (new) or `video:${nanoid()}` (legacy)
+ */
+function parseVideoMimeType(localRefPath: string): string {
+ const parts = localRefPath.split(':')
+ // New format: video:video/mp4:abc123 -> parts[1] is mime type
+ // Legacy format: video:abc123 -> no mime type, default to video/mp4
+ if (parts.length >= 3 && parts[1].includes('/')) {
+ return parts[1]
+ }
+ return 'video/mp4' // Default for legacy drafts
+}
+
+/**
+ * Convert ComposerState to server Draft format for saving.
+ * Returns both the draft and a map of localRef paths to their source paths.
+ */
+export async function composerStateToDraft(state: ComposerState): Promise<{
+ draft: AppBskyDraftDefs.Draft
+ localRefPaths: Map
+}> {
+ const localRefPaths = new Map()
+
+ const posts: AppBskyDraftDefs.DraftPost[] = await Promise.all(
+ state.thread.posts.map(post => {
+ return postDraftToServerPost(post, localRefPaths)
+ }),
+ )
+
+ // Convert threadgate settings to server format
+ const threadgateAllow: AppBskyDraftDefs.Draft['threadgateAllow'] = []
+ for (const setting of state.thread.threadgate) {
+ if (setting.type === 'mention') {
+ threadgateAllow.push({
+ $type: 'app.bsky.feed.threadgate#mentionRule' as const,
+ })
+ } else if (setting.type === 'following') {
+ threadgateAllow.push({
+ $type: 'app.bsky.feed.threadgate#followingRule' as const,
+ })
+ } else if (setting.type === 'followers') {
+ threadgateAllow.push({
+ $type: 'app.bsky.feed.threadgate#followerRule' as const,
+ })
+ } else if (setting.type === 'list') {
+ threadgateAllow.push({
+ $type: 'app.bsky.feed.threadgate#listRule' as const,
+ list: setting.list,
+ })
+ }
+ }
+
+ const draft: AppBskyDraftDefs.Draft = {
+ $type: 'app.bsky.draft.defs#draft',
+ posts,
+ threadgateAllow: threadgateAllow.length > 0 ? threadgateAllow : undefined,
+ postgateEmbeddingRules:
+ state.thread.postgate.embeddingRules &&
+ state.thread.postgate.embeddingRules.length > 0
+ ? state.thread.postgate.embeddingRules
+ : undefined,
+ }
+
+ return {draft, localRefPaths}
+}
+
+/**
+ * Convert a single PostDraft to server DraftPost format.
+ */
+async function postDraftToServerPost(
+ post: PostDraft,
+ localRefPaths: Map,
+): Promise {
+ const draftPost: AppBskyDraftDefs.DraftPost = {
+ $type: 'app.bsky.draft.defs#draftPost',
+ text: post.richtext.text,
+ }
+
+ // Add labels if present
+ if (post.labels.length > 0) {
+ draftPost.labels = {
+ $type: 'com.atproto.label.defs#selfLabels',
+ values: post.labels.map(label => ({val: label})),
+ }
+ }
+
+ // Add embeds
+ if (post.embed.media) {
+ if (post.embed.media.type === 'images') {
+ draftPost.embedImages = serializeImages(
+ post.embed.media.images,
+ localRefPaths,
+ )
+ } else if (post.embed.media.type === 'video') {
+ const video = await serializeVideo(post.embed.media.video, localRefPaths)
+ if (video) {
+ draftPost.embedVideos = [video]
+ }
+ } else if (post.embed.media.type === 'gif') {
+ const external = serializeGif(post.embed.media)
+ if (external) {
+ draftPost.embedExternals = [external]
+ }
+ }
+ }
+
+ // Add quote record embed
+ if (post.embed.quote) {
+ draftPost.embedRecords = [
+ {
+ $type: 'app.bsky.draft.defs#draftEmbedRecord',
+ record: {
+ uri: post.embed.quote.uri,
+ cid: '', // We don't have the CID at draft time
+ },
+ },
+ ]
+ }
+
+ // Add external link embed (only if no media, otherwise it's ignored)
+ if (post.embed.link && !post.embed.media) {
+ draftPost.embedExternals = [
+ {
+ $type: 'app.bsky.draft.defs#draftEmbedExternal',
+ uri: post.embed.link.uri,
+ },
+ ]
+ }
+
+ return draftPost
+}
+
+/**
+ * Serialize images to server format with localRef paths.
+ * Reuses existing localRefPath if present (when editing a draft),
+ * otherwise generates a new one.
+ */
+function serializeImages(
+ images: ComposerImage[],
+ localRefPaths: Map,
+): AppBskyDraftDefs.DraftEmbedImage[] {
+ return images.map(image => {
+ const sourcePath = image.transformed?.path || image.source.path
+ // Reuse existing localRefPath if present (editing draft), otherwise generate new
+ const isReusing = !!image.localRefPath
+ const localRefPath = image.localRefPath || `image:${nanoid()}`
+ localRefPaths.set(localRefPath, sourcePath)
+
+ logger.debug('serializing image', {
+ localRefPath,
+ isReusing,
+ sourcePath,
+ })
+
+ return {
+ $type: 'app.bsky.draft.defs#draftEmbedImage',
+ localRef: {
+ $type: 'app.bsky.draft.defs#draftEmbedLocalRef',
+ path: localRefPath,
+ },
+ alt: image.alt || undefined,
+ }
+ })
+}
+
+/**
+ * Serialize video to server format with localRef path.
+ * The localRef path encodes the mime type: `video:${mimeType}:${nanoid()}`
+ */
+async function serializeVideo(
+ videoState: VideoState,
+ localRefPaths: Map,
+): Promise {
+ // Only save videos that have been compressed (have a video file)
+ if (!videoState.video) {
+ return undefined
+ }
+
+ // Encode mime type in the path for restoration
+ const mimeType = videoState.video.mimeType || 'video/mp4'
+ const ext = mimeToExt(mimeType)
+ const localRefPath = `video:${mimeType}:${nanoid()}.${ext}`
+ localRefPaths.set(localRefPath, videoState.video.uri)
+
+ // Read caption file contents as text
+ const captions: AppBskyDraftDefs.DraftEmbedCaption[] = []
+ for (const caption of videoState.captions) {
+ if (caption.lang) {
+ const content = await caption.file.text()
+ captions.push({
+ $type: 'app.bsky.draft.defs#draftEmbedCaption',
+ lang: caption.lang,
+ content,
+ })
+ }
+ }
+
+ return {
+ $type: 'app.bsky.draft.defs#draftEmbedVideo',
+ localRef: {
+ $type: 'app.bsky.draft.defs#draftEmbedLocalRef',
+ path: localRefPath,
+ },
+ alt: videoState.altText || undefined,
+ captions: captions.length > 0 ? captions : undefined,
+ }
+}
+
+/**
+ * Serialize GIF to server format as external embed.
+ * URL format: https://media.tenor.com/{id}/{filename}.gif?hh=HEIGHT&ww=WIDTH&alt=ALT_TEXT
+ */
+function serializeGif(gifMedia: {
+ type: 'gif'
+ gif: Gif
+ alt: string
+}): AppBskyDraftDefs.DraftEmbedExternal | undefined {
+ const gif = gifMedia.gif
+ const gifFormat = gif.media_formats.gif || gif.media_formats.tinygif
+
+ if (!gifFormat?.url) {
+ return undefined
+ }
+
+ // Build URL with dimensions and alt text in query params
+ const url = new URL(gifFormat.url)
+ if (gifFormat.dims) {
+ url.searchParams.set('ww', String(gifFormat.dims[0]))
+ url.searchParams.set('hh', String(gifFormat.dims[1]))
+ }
+ // Store alt text if present
+ if (gifMedia.alt) {
+ url.searchParams.set('alt', gifMedia.alt)
+ }
+
+ return {
+ $type: 'app.bsky.draft.defs#draftEmbedExternal',
+ uri: url.toString(),
+ }
+}
+
+/**
+ * Convert server DraftView to DraftSummary for list display.
+ * Also checks which media files exist locally.
+ */
+export function draftViewToSummary(
+ view: AppBskyDraftDefs.DraftView,
+ localMediaExists: (path: string) => boolean,
+): DraftSummary {
+ const firstPost = view.draft.posts[0]
+ const previewText = firstPost?.text?.slice(0, 100) || ''
+
+ let mediaCount = 0
+ let hasMedia = false
+ let hasMissingMedia = false
+
+ const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => {
+ const images: DraftPostDisplay['images'] = []
+ const videos: DraftPostDisplay['video'][] = []
+ let gif: DraftPostDisplay['gif']
+
+ // Process images
+ if (post.embedImages) {
+ for (const img of post.embedImages) {
+ mediaCount++
+ hasMedia = true
+ const exists = localMediaExists(img.localRef.path)
+ if (!exists) {
+ hasMissingMedia = true
+ }
+ images.push({
+ localPath: img.localRef.path,
+ altText: img.alt || '',
+ exists,
+ })
+ }
+ }
+
+ // Process videos
+ if (post.embedVideos) {
+ for (const vid of post.embedVideos) {
+ mediaCount++
+ hasMedia = true
+ const exists = localMediaExists(vid.localRef.path)
+ if (!exists) {
+ hasMissingMedia = true
+ }
+ videos.push({
+ localPath: vid.localRef.path,
+ altText: vid.alt || '',
+ exists,
+ })
+ }
+ }
+
+ // Process externals (check for GIFs)
+ if (post.embedExternals) {
+ for (const ext of post.embedExternals) {
+ const gifData = parseGifFromUrl(ext.uri)
+ if (gifData) {
+ mediaCount++
+ hasMedia = true
+ gif = gifData
+ }
+ }
+ }
+
+ return {
+ id: `post-${index}`,
+ text: post.text || '',
+ images: images.length > 0 ? images : undefined,
+ video: videos[0], // Only one video per post
+ gif,
+ }
+ })
+
+ return {
+ id: view.id,
+ draft: view.draft,
+ previewText,
+ hasMedia,
+ hasMissingMedia,
+ mediaCount,
+ postCount: view.draft.posts.length,
+ updatedAt: view.updatedAt,
+ posts,
+ }
+}
+
+/**
+ * Parse GIF data from a Tenor URL.
+ * URL format: https://media.tenor.com/{id}/{filename}.gif?hh=HEIGHT&ww=WIDTH&alt=ALT_TEXT
+ */
+function parseGifFromUrl(
+ uri: string,
+): {url: string; width: number; height: number; alt: string} | undefined {
+ try {
+ const url = new URL(uri)
+ if (url.hostname !== TENOR_HOSTNAME) {
+ return undefined
+ }
+
+ const height = parseInt(url.searchParams.get('hh') || '', 10)
+ const width = parseInt(url.searchParams.get('ww') || '', 10)
+ const alt = url.searchParams.get('alt') || ''
+
+ if (!height || !width) {
+ return undefined
+ }
+
+ // Strip our custom params to get clean base URL
+ // This prevents double query strings when resolveGif() adds params again
+ url.searchParams.delete('ww')
+ url.searchParams.delete('hh')
+ url.searchParams.delete('alt')
+
+ return {url: url.toString(), width, height, alt}
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * Convert server Draft back to composer-compatible format for restoration.
+ * Returns posts and a map of videos that need to be restored by re-processing.
+ *
+ * Videos cannot be restored synchronously like images because they need to go through
+ * the compression and upload pipeline. The caller should handle the restoredVideos
+ * by initiating video processing for each entry.
+ */
+export async function draftToComposerPosts(
+ draft: AppBskyDraftDefs.Draft,
+ loadedMedia: Map,
+): Promise<{posts: PostDraft[]; restoredVideos: Map}> {
+ const restoredVideos = new Map()
+
+ const posts = await Promise.all(
+ draft.posts.map(async (post, index) => {
+ const richtext = new RichText({text: post.text || ''})
+ richtext.detectFacetsWithoutResolution()
+
+ const embed: EmbedDraft = {
+ quote: undefined,
+ link: undefined,
+ media: undefined,
+ }
+
+ // Restore images
+ if (post.embedImages && post.embedImages.length > 0) {
+ const imagePromises = post.embedImages.map(async img => {
+ const path = loadedMedia.get(img.localRef.path)
+ if (!path) {
+ return null
+ }
+
+ let width = 0
+ let height = 0
+ try {
+ const dims = await getImageDim(path)
+ width = dims.width
+ height = dims.height
+ } catch (e) {
+ logger.warn('Failed to get image dimensions', {
+ path,
+ error: e,
+ })
+ }
+
+ logger.debug('restoring image with localRefPath', {
+ localRefPath: img.localRef.path,
+ loadedPath: path,
+ width,
+ height,
+ })
+
+ return {
+ alt: img.alt || '',
+ // Preserve the original localRefPath for reuse when saving
+ localRefPath: img.localRef.path,
+ source: {
+ id: nanoid(),
+ path,
+ width,
+ height,
+ mime: 'image/jpeg',
+ },
+ } as ComposerImage
+ })
+
+ const images = (await Promise.all(imagePromises)).filter(
+ (img): img is ComposerImage => img !== null,
+ )
+ if (images.length > 0) {
+ embed.media = {type: 'images', images}
+ }
+ }
+
+ // Restore GIF from external embed
+ if (post.embedExternals) {
+ for (const ext of post.embedExternals) {
+ const gifData = parseGifFromUrl(ext.uri)
+ if (gifData) {
+ // Reconstruct a Gif object with all required properties
+ const mediaObject = {
+ url: gifData.url,
+ dims: [gifData.width, gifData.height] as [number, number],
+ duration: 0,
+ size: 0,
+ }
+ embed.media = {
+ type: 'gif',
+ gif: {
+ id: '',
+ created: 0,
+ hasaudio: false,
+ hascaption: false,
+ flags: '',
+ tags: [],
+ title: '',
+ content_description: gifData.alt || '',
+ itemurl: '',
+ url: gifData.url, // Required for useResolveGifQuery
+ media_formats: {
+ gif: mediaObject,
+ tinygif: mediaObject,
+ preview: mediaObject,
+ },
+ } as Gif,
+ alt: gifData.alt,
+ }
+ break
+ }
+ }
+ }
+
+ // Collect video for restoration (processed async by caller)
+ if (post.embedVideos && post.embedVideos.length > 0) {
+ const vid = post.embedVideos[0]
+ const videoUri = loadedMedia.get(vid.localRef.path)
+ if (videoUri) {
+ const mimeType = parseVideoMimeType(vid.localRef.path)
+ logger.debug('found video to restore', {
+ localRefPath: vid.localRef.path,
+ videoUri,
+ altText: vid.alt,
+ mimeType,
+ captionCount: vid.captions?.length ?? 0,
+ })
+ restoredVideos.set(index, {
+ uri: videoUri,
+ altText: vid.alt || '',
+ mimeType,
+ localRefPath: vid.localRef.path,
+ captions:
+ vid.captions?.map(c => ({lang: c.lang, content: c.content})) ??
+ [],
+ })
+ }
+ }
+
+ // Restore quote embed
+ if (post.embedRecords && post.embedRecords.length > 0) {
+ const record = post.embedRecords[0]
+ embed.quote = {type: 'link', uri: record.record.uri}
+ }
+
+ // Restore link embed (only if not a GIF)
+ if (post.embedExternals && !embed.media) {
+ for (const ext of post.embedExternals) {
+ const gifData = parseGifFromUrl(ext.uri)
+ if (!gifData) {
+ embed.link = {type: 'link', uri: ext.uri}
+ break
+ }
+ }
+ }
+
+ // Parse labels
+ const labels: string[] = []
+ if (post.labels && 'values' in post.labels) {
+ for (const val of post.labels.values) {
+ labels.push(val.val)
+ }
+ }
+
+ return {
+ id: `draft-post-${index}`,
+ richtext,
+ shortenedGraphemeLength: richtext.graphemeLength,
+ labels,
+ embed,
+ } as PostDraft
+ }),
+ )
+
+ return {posts, restoredVideos}
+}
+
+/**
+ * Convert server threadgate rules back to UI settings.
+ */
+export function threadgateToUISettings(
+ threadgateAllow?: AppBskyDraftDefs.Draft['threadgateAllow'],
+): Array<{type: string; list?: string}> {
+ if (!threadgateAllow) {
+ return []
+ }
+
+ return threadgateAllow
+ .map(rule => {
+ if ('$type' in rule) {
+ if (rule.$type === 'app.bsky.feed.threadgate#mentionRule') {
+ return {type: 'mention'}
+ }
+ if (rule.$type === 'app.bsky.feed.threadgate#followingRule') {
+ return {type: 'following'}
+ }
+ if (rule.$type === 'app.bsky.feed.threadgate#followerRule') {
+ return {type: 'followers'}
+ }
+ if (
+ rule.$type === 'app.bsky.feed.threadgate#listRule' &&
+ 'list' in rule
+ ) {
+ return {type: 'list', list: (rule as {list: string}).list}
+ }
+ }
+ return null
+ })
+ .filter((s): s is {type: string; list?: string} => s !== null)
+}
+
+/**
+ * Extract all localRef paths from a draft.
+ * Used to identify which media files belong to a draft for cleanup.
+ */
+export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set {
+ const refs = new Set()
+ for (const post of draft.posts) {
+ if (post.embedImages) {
+ for (const img of post.embedImages) {
+ refs.add(img.localRef.path)
+ }
+ }
+ if (post.embedVideos) {
+ for (const vid of post.embedVideos) {
+ refs.add(vid.localRef.path)
+ }
+ }
+ }
+ logger.debug('extracted localRefs from draft', {
+ count: refs.size,
+ refs: Array.from(refs),
+ })
+ return refs
+}
diff --git a/src/view/com/composer/drafts/state/logger.ts b/src/view/com/composer/drafts/state/logger.ts
new file mode 100644
index 0000000000..8cf9c10609
--- /dev/null
+++ b/src/view/com/composer/drafts/state/logger.ts
@@ -0,0 +1,3 @@
+import {Logger} from '#/logger'
+
+export const logger = Logger.create(Logger.Context.Drafts)
diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts
new file mode 100644
index 0000000000..3d2651c05b
--- /dev/null
+++ b/src/view/com/composer/drafts/state/queries.ts
@@ -0,0 +1,271 @@
+import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
+import {
+ useInfiniteQuery,
+ useMutation,
+ useQueryClient,
+} from '@tanstack/react-query'
+
+import {isNetworkError} from '#/lib/strings/errors'
+import {useAgent} from '#/state/session'
+import {type ComposerState} from '#/view/com/composer/state/composer'
+import {composerStateToDraft, draftViewToSummary} from './api'
+import {logger} from './logger'
+import * as storage from './storage'
+
+const DRAFTS_QUERY_KEY = ['drafts']
+
+/**
+ * Hook to list all drafts for the current account
+ */
+export function useDraftsQuery() {
+ const agent = useAgent()
+
+ return useInfiniteQuery({
+ queryKey: DRAFTS_QUERY_KEY,
+ queryFn: async ({pageParam}) => {
+ // Ensure media cache is populated before checking which media exists
+ await storage.ensureMediaCachePopulated()
+ const res = await agent.app.bsky.draft.getDrafts({cursor: pageParam})
+ return {
+ cursor: res.data.cursor,
+ drafts: res.data.drafts.map(view =>
+ draftViewToSummary(view, path => storage.mediaExists(path)),
+ ),
+ }
+ },
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: page => page.cursor || undefined,
+ })
+}
+
+/**
+ * Load a draft's local media for editing.
+ * Takes the full Draft object (from DraftSummary) to avoid re-fetching.
+ */
+export async function loadDraft(draft: AppBskyDraftDefs.Draft): Promise<{
+ loadedMedia: Map
+}> {
+ // Load local media files
+ const loadedMedia = new Map()
+ for (const post of draft.posts) {
+ // Load images
+ if (post.embedImages) {
+ for (const img of post.embedImages) {
+ try {
+ const url = await storage.loadMediaFromLocal(img.localRef.path)
+ loadedMedia.set(img.localRef.path, url)
+ } catch (e) {
+ logger.warn('Failed to load draft image', {
+ path: img.localRef.path,
+ error: e,
+ })
+ }
+ }
+ }
+ // Load videos
+ if (post.embedVideos) {
+ for (const vid of post.embedVideos) {
+ try {
+ const url = await storage.loadMediaFromLocal(vid.localRef.path)
+ loadedMedia.set(vid.localRef.path, url)
+ } catch (e) {
+ logger.warn('Failed to load draft video', {
+ path: vid.localRef.path,
+ error: e,
+ })
+ }
+ }
+ }
+ }
+
+ return {loadedMedia}
+}
+
+/**
+ * Hook to save a draft.
+ *
+ * IMPORTANT: Network operations happen first in mutationFn.
+ * Local storage operations (save new media, delete orphaned media) happen in onSuccess.
+ * This ensures we don't lose data if the network request fails.
+ */
+export function useSaveDraftMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ composerState,
+ existingDraftId,
+ }: {
+ composerState: ComposerState
+ existingDraftId?: string
+ }): Promise<{
+ draftId: string
+ localRefPaths: Map
+ originalLocalRefs: Set | undefined
+ }> => {
+ // Convert composer state to server draft format
+ const {draft, localRefPaths} = await composerStateToDraft(composerState)
+
+ logger.debug('saving draft', {
+ existingDraftId,
+ localRefPathCount: localRefPaths.size,
+ originalLocalRefCount: composerState.originalLocalRefs?.size ?? 0,
+ })
+
+ // 1. NETWORK FIRST - Update/create server draft
+ let draftId: string
+ if (existingDraftId) {
+ // Update existing draft
+ logger.debug('updating existing draft on server', {
+ draftId: existingDraftId,
+ })
+ await agent.app.bsky.draft.updateDraft({
+ draft: {
+ id: existingDraftId,
+ draft,
+ },
+ })
+ draftId = existingDraftId
+ } else {
+ // Create new draft
+ logger.debug('creating new draft on server')
+ const res = await agent.app.bsky.draft.createDraft({draft})
+ draftId = res.data.id
+ logger.debug('created new draft', {draftId})
+ }
+
+ // Return data needed for onSuccess
+ return {
+ draftId,
+ localRefPaths,
+ originalLocalRefs: composerState.originalLocalRefs,
+ }
+ },
+ onSuccess: async ({draftId, localRefPaths, originalLocalRefs}) => {
+ // 2. LOCAL STORAGE ONLY AFTER NETWORK SUCCEEDS
+ logger.debug('network save succeeded, processing local storage', {
+ draftId,
+ })
+
+ // Save new/changed media files
+ for (const [localRefPath, sourcePath] of localRefPaths) {
+ // Only save if this media doesn't already exist (reusing localRefPath)
+ if (!storage.mediaExists(localRefPath)) {
+ logger.debug('saving new media file', {localRefPath})
+ await storage.saveMediaToLocal(localRefPath, sourcePath)
+ } else {
+ logger.debug('skipping existing media file', {localRefPath})
+ }
+ }
+
+ // Delete orphaned media (old refs not in new)
+ if (originalLocalRefs) {
+ const newLocalRefs = new Set(localRefPaths.keys())
+ for (const oldRef of originalLocalRefs) {
+ if (!newLocalRefs.has(oldRef)) {
+ logger.debug('deleting orphaned media file', {
+ localRefPath: oldRef,
+ })
+ await storage.deleteMediaFromLocal(oldRef)
+ }
+ }
+ }
+
+ queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
+ },
+ onError: error => {
+ // Check for draft limit error
+ if (error instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) {
+ logger.error('Draft limit reached', {safeMessage: error.message})
+ // Error will be handled by caller
+ } else if (!isNetworkError(error)) {
+ logger.error('Could not create draft (reason unknown)', {
+ safeMessage: error.message,
+ })
+ }
+ },
+ })
+}
+
+/**
+ * Hook to delete a draft.
+ * Takes the full draft data to avoid re-fetching for media cleanup.
+ */
+export function useDeleteDraftMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ draftId,
+ }: {
+ draftId: string
+ draft: AppBskyDraftDefs.Draft
+ }) => {
+ // Delete from server first - if this fails, we keep local media for retry
+ await agent.app.bsky.draft.deleteDraft({id: draftId})
+ },
+ onSuccess: async (_, {draft}) => {
+ // Only delete local media after server deletion succeeds
+ for (const post of draft.posts) {
+ if (post.embedImages) {
+ for (const img of post.embedImages) {
+ await storage.deleteMediaFromLocal(img.localRef.path)
+ }
+ }
+ if (post.embedVideos) {
+ for (const vid of post.embedVideos) {
+ await storage.deleteMediaFromLocal(vid.localRef.path)
+ }
+ }
+ }
+ queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
+ },
+ })
+}
+
+/**
+ * Hook to clean up a draft after it has been published.
+ * Deletes the draft from server and all associated local media.
+ * Takes draftId and originalLocalRefs from composer state.
+ */
+export function useCleanupPublishedDraftMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ draftId,
+ originalLocalRefs,
+ }: {
+ draftId: string
+ originalLocalRefs: Set
+ }) => {
+ logger.debug('cleaning up published draft', {
+ draftId,
+ mediaFileCount: originalLocalRefs.size,
+ })
+ // Delete from server first
+ await agent.app.bsky.draft.deleteDraft({id: draftId})
+ logger.debug('deleted draft from server', {draftId})
+ },
+ onSuccess: async (_, {originalLocalRefs}) => {
+ // Delete all local media files for this draft
+ for (const localRef of originalLocalRefs) {
+ logger.debug('deleting media file after publish', {
+ localRefPath: localRef,
+ })
+ await storage.deleteMediaFromLocal(localRef)
+ }
+ queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
+ logger.debug('cleanup after publish complete')
+ },
+ onError: error => {
+ // Log but don't throw - the post was already published successfully
+ logger.warn('Failed to clean up published draft', {
+ safeMessage: error instanceof Error ? error.message : String(error),
+ })
+ },
+ })
+}
diff --git a/src/view/com/composer/drafts/state/schema.ts b/src/view/com/composer/drafts/state/schema.ts
new file mode 100644
index 0000000000..d0e94ded71
--- /dev/null
+++ b/src/view/com/composer/drafts/state/schema.ts
@@ -0,0 +1,69 @@
+/**
+ * Types for draft display and local media tracking.
+ * Server draft types come from @atproto/api.
+ */
+import {type AppBskyDraftDefs} from '@atproto/api'
+
+/**
+ * Reference to locally cached media file for display
+ */
+export type LocalMediaDisplay = {
+ /** Path stored in server draft (used as key for local lookup) */
+ localPath: string
+ /** Alt text */
+ altText: string
+ /** Whether the local file exists on this device */
+ exists: boolean
+}
+
+/**
+ * GIF display data (parsed from external embed URL)
+ */
+export type GifDisplay = {
+ /** Full URL with dimensions */
+ url: string
+ /** Width */
+ width: number
+ /** Height */
+ height: number
+ /** Alt text */
+ alt: string
+}
+
+/**
+ * Post content for display in draft list
+ */
+export type DraftPostDisplay = {
+ id: string
+ /** Full text content */
+ text: string
+ /** Image references for display */
+ images?: LocalMediaDisplay[]
+ /** Video reference */
+ video?: LocalMediaDisplay
+ /** GIF data (from URL) */
+ gif?: GifDisplay
+}
+
+/**
+ * Draft summary for list display
+ */
+export type DraftSummary = {
+ id: string
+ /** The full draft data from the server */
+ draft: AppBskyDraftDefs.Draft
+ /** First ~100 chars of first post */
+ previewText: string
+ /** Whether the draft has media */
+ hasMedia: boolean
+ /** Whether some media is missing (saved on another device) */
+ hasMissingMedia?: boolean
+ /** Number of media items */
+ mediaCount: number
+ /** Number of posts in thread */
+ postCount: number
+ /** ISO timestamp of last update */
+ updatedAt: string
+ /** All posts in the draft for full display */
+ posts: DraftPostDisplay[]
+}
diff --git a/src/view/com/composer/drafts/state/storage.ts b/src/view/com/composer/drafts/state/storage.ts
new file mode 100644
index 0000000000..6aa40b44c2
--- /dev/null
+++ b/src/view/com/composer/drafts/state/storage.ts
@@ -0,0 +1,156 @@
+/**
+ * Native file system storage for draft media.
+ * Media is stored by localRefPath key (unique identifier stored in server draft).
+ */
+import {Directory, File, Paths} from 'expo-file-system'
+
+import {logger} from './logger'
+
+const MEDIA_DIR = 'bsky-draft-media'
+
+function getMediaDirectory(): Directory {
+ return new Directory(Paths.document, MEDIA_DIR)
+}
+
+function getMediaFile(localRefPath: string): File {
+ const safeFilename = encodeURIComponent(localRefPath)
+ return new File(getMediaDirectory(), safeFilename)
+}
+
+let dirCreated = false
+
+/**
+ * Ensure the media directory exists
+ */
+function ensureDirectory(): void {
+ if (dirCreated) return
+ const dir = getMediaDirectory()
+ if (!dir.exists) {
+ dir.create()
+ }
+ dirCreated = true
+}
+
+/**
+ * Save a media file to local storage by localRefPath key
+ */
+export async function saveMediaToLocal(
+ localRefPath: string,
+ sourcePath: string,
+): Promise {
+ ensureDirectory()
+
+ const destFile = getMediaFile(localRefPath)
+
+ // Ensure source path has file:// prefix for expo-file-system
+ let normalizedSource = sourcePath
+ if (!sourcePath.startsWith('file://') && sourcePath.startsWith('/')) {
+ normalizedSource = `file://${sourcePath}`
+ }
+
+ try {
+ const sourceFile = new File(normalizedSource)
+ sourceFile.copy(destFile)
+ // Update cache after successful save
+ mediaExistsCache.set(localRefPath, true)
+ } catch (error) {
+ logger.error('Failed to save media to drafts storage', {
+ error,
+ localRefPath,
+ sourcePath: normalizedSource,
+ destPath: destFile.uri,
+ })
+ throw error
+ }
+}
+
+/**
+ * Load a media file path from local storage
+ * @returns The file URI for the saved media
+ */
+export async function loadMediaFromLocal(
+ localRefPath: string,
+): Promise {
+ const file = getMediaFile(localRefPath)
+
+ if (!file.exists) {
+ throw new Error(`Media file not found: ${localRefPath}`)
+ }
+
+ return file.uri
+}
+
+/**
+ * Delete a media file from local storage
+ */
+export async function deleteMediaFromLocal(
+ localRefPath: string,
+): Promise {
+ const file = getMediaFile(localRefPath)
+ // Idempotent: only delete if file exists
+ if (file.exists) {
+ file.delete()
+ }
+}
+
+/**
+ * Check if a media file exists in local storage (synchronous check using cache)
+ * Note: This uses a cached directory listing for performance
+ */
+const mediaExistsCache = new Map()
+let cachePopulated = false
+
+export function mediaExists(localRefPath: string): boolean {
+ // For native, we need an async check but the API requires sync
+ // Use cached result if available, otherwise assume doesn't exist
+ if (mediaExistsCache.has(localRefPath)) {
+ return mediaExistsCache.get(localRefPath)!
+ }
+ // If cache not populated yet, trigger async population
+ if (!cachePopulated && !populateCachePromise) {
+ populateCachePromise = populateCacheInternal()
+ }
+ return false // Conservative: assume doesn't exist if not in cache
+}
+
+let populateCachePromise: Promise | null = null
+
+function populateCacheInternal(): Promise {
+ return new Promise(resolve => {
+ try {
+ const dir = getMediaDirectory()
+ if (dir.exists) {
+ const items = dir.list()
+ for (const item of items) {
+ // Reverse the URL encoding to get the original localRefPath
+ const localRefPath = decodeURIComponent(item.name)
+ mediaExistsCache.set(localRefPath, true)
+ }
+ }
+ cachePopulated = true
+ } catch (e) {
+ logger.warn('Failed to populate media cache', {error: e})
+ }
+ resolve()
+ })
+}
+
+/**
+ * Ensure the media cache is populated. Call this before checking mediaExists.
+ */
+export async function ensureMediaCachePopulated(): Promise {
+ if (cachePopulated) return
+ if (!populateCachePromise) {
+ populateCachePromise = populateCacheInternal()
+ }
+ await populateCachePromise
+}
+
+/**
+ * Clear the media exists cache (call when media is added/deleted)
+ */
+export function clearMediaCache(): void {
+ mediaExistsCache.clear()
+ cachePopulated = false
+ populateCachePromise = null
+}
diff --git a/src/view/com/composer/drafts/state/storage.web.ts b/src/view/com/composer/drafts/state/storage.web.ts
new file mode 100644
index 0000000000..67c46d89bf
--- /dev/null
+++ b/src/view/com/composer/drafts/state/storage.web.ts
@@ -0,0 +1,170 @@
+/**
+ * Web IndexedDB storage for draft media.
+ * Media is stored by localRefPath key (unique identifier stored in server draft).
+ */
+import {createStore, del, get, keys, set} from 'idb-keyval'
+
+import {logger} from './logger'
+
+const DB_NAME = 'bsky-draft-media'
+const STORE_NAME = 'media'
+
+type MediaRecord = {
+ blob: Blob
+ createdAt: string
+}
+
+const store = createStore(DB_NAME, STORE_NAME)
+
+/**
+ * Convert a path/URL to a Blob
+ */
+async function toBlob(sourcePath: string): Promise {
+ // Handle data URIs directly
+ if (sourcePath.startsWith('data:')) {
+ const response = await fetch(sourcePath)
+ return response.blob()
+ }
+
+ // Handle blob URLs
+ if (sourcePath.startsWith('blob:')) {
+ try {
+ const response = await fetch(sourcePath)
+ return response.blob()
+ } catch (e) {
+ logger.error('Failed to fetch blob URL - it may have been revoked', {
+ error: e,
+ sourcePath,
+ })
+ throw e
+ }
+ }
+
+ // Handle regular URLs
+ const response = await fetch(sourcePath)
+ if (!response.ok) {
+ throw new Error(`Failed to fetch media: ${response.status}`)
+ }
+ return response.blob()
+}
+
+/**
+ * Save a media file to IndexedDB by localRefPath key
+ */
+export async function saveMediaToLocal(
+ localRefPath: string,
+ sourcePath: string,
+): Promise {
+ let blob: Blob
+ try {
+ blob = await toBlob(sourcePath)
+ } catch (error) {
+ logger.error('Failed to convert source to blob', {
+ error,
+ localRefPath,
+ sourcePath,
+ })
+ throw error
+ }
+
+ try {
+ await set(
+ localRefPath,
+ {
+ blob,
+ createdAt: new Date().toISOString(),
+ },
+ store,
+ )
+ // Update cache
+ mediaExistsCache.set(localRefPath, true)
+ } catch (error) {
+ logger.error('Failed to save media to IndexedDB', {error, localRefPath})
+ throw error
+ }
+}
+
+/**
+ * Load a media file from IndexedDB
+ * @returns A blob URL for the saved media
+ */
+export async function loadMediaFromLocal(
+ localRefPath: string,
+): Promise {
+ const record = await get(localRefPath, store)
+
+ if (!record) {
+ throw new Error(`Media file not found: ${localRefPath}`)
+ }
+
+ return URL.createObjectURL(record.blob)
+}
+
+/**
+ * Delete a media file from IndexedDB
+ */
+export async function deleteMediaFromLocal(
+ localRefPath: string,
+): Promise {
+ await del(localRefPath, store)
+ mediaExistsCache.delete(localRefPath)
+}
+
+/**
+ * Check if a media file exists in IndexedDB (synchronous check using cache)
+ */
+const mediaExistsCache = new Map()
+let cachePopulated = false
+let populateCachePromise: Promise | null = null
+
+export function mediaExists(localRefPath: string): boolean {
+ if (mediaExistsCache.has(localRefPath)) {
+ return mediaExistsCache.get(localRefPath)!
+ }
+ // If cache not populated yet, trigger async population
+ if (!cachePopulated && !populateCachePromise) {
+ populateCachePromise = populateCacheInternal()
+ }
+ return false // Conservative: assume doesn't exist if not in cache
+}
+
+async function populateCacheInternal(): Promise {
+ try {
+ const allKeys = await keys(store)
+ for (const key of allKeys) {
+ mediaExistsCache.set(key as string, true)
+ }
+ cachePopulated = true
+ } catch (e) {
+ logger.warn('Failed to populate media cache', {error: e})
+ }
+}
+
+/**
+ * Ensure the media cache is populated. Call this before checking mediaExists.
+ */
+export async function ensureMediaCachePopulated(): Promise {
+ if (cachePopulated) return
+ if (!populateCachePromise) {
+ populateCachePromise = populateCacheInternal()
+ }
+ await populateCachePromise
+}
+
+/**
+ * Clear the media exists cache (call when media is added/deleted)
+ */
+export function clearMediaCache(): void {
+ mediaExistsCache.clear()
+ cachePopulated = false
+ populateCachePromise = null
+}
+
+/**
+ * Revoke a blob URL when done with it (to prevent memory leaks)
+ */
+export function revokeMediaUrl(url: string): void {
+ if (url.startsWith('blob:')) {
+ URL.revokeObjectURL(url)
+ }
+}
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index 6d897933b0..82a902c80c 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -33,7 +33,9 @@ export function LabelsBtn({
const updateAdultLabels = (newLabels: AdultSelfLabel[]) => {
const newLabel = newLabels[newLabels.length - 1]
- const filtered = labels.filter(l => !ADULT_CONTENT_LABELS.includes(l))
+ const filtered = labels.filter(
+ l => !ADULT_CONTENT_LABELS.includes(l as AdultSelfLabel),
+ )
onChange([
...new Set([...filtered, newLabel].filter(Boolean) as SelfLabel[]),
])
@@ -41,7 +43,9 @@ export function LabelsBtn({
const updateOtherLabels = (newLabels: OtherSelfLabel[]) => {
const newLabel = newLabels[newLabels.length - 1]
- const filtered = labels.filter(l => !OTHER_SELF_LABELS.includes(l))
+ const filtered = labels.filter(
+ l => !OTHER_SELF_LABELS.includes(l as OtherSelfLabel),
+ )
onChange([
...new Set([...filtered, newLabel].filter(Boolean) as SelfLabel[]),
])
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index c673f21341..ea4399568d 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -1,8 +1,9 @@
import {type ImagePickerAsset} from 'expo-image-picker'
import {
+ type AppBskyActorDefs,
+ type AppBskyDraftDefs,
type AppBskyFeedPostgate,
AppBskyRichtextFacet,
- type BskyPreferences,
RichText,
} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
@@ -101,6 +102,14 @@ export type ComposerState = {
thread: ThreadDraft
activePostIndex: number
mutableNeedsFocusActive: boolean
+ /** ID of the draft being edited, if any. Used to update existing draft on save. */
+ draftId?: string
+ /** Whether the composer has been modified since loading a draft. */
+ isDirty: boolean
+ /** Map of localId -> loaded media path/URL for the current draft. Used for re-saving without re-copying media. */
+ loadedMediaMap?: Map
+ /** Set of original localRef paths from the draft being edited. Used to identify orphaned media on save. */
+ originalLocalRefs?: Set
}
export type ComposerAction =
@@ -122,6 +131,28 @@ export type ComposerAction =
type: 'focus_post'
postId: string
}
+ | {
+ type: 'restore_from_draft'
+ draftId: string
+ posts: PostDraft[]
+ threadgateAllow: AppBskyDraftDefs.Draft['threadgateAllow']
+ postgateEmbeddingRules: AppBskyDraftDefs.Draft['postgateEmbeddingRules']
+
+ /** Map of localRefPath -> loaded media path/URL */
+ loadedMedia: Map
+ /** Set of original localRef paths from the draft. Used to identify orphaned media on save. */
+ originalLocalRefs: Set
+ }
+ | {
+ type: 'clear'
+ initInteractionSettings:
+ | AppBskyActorDefs.PostInteractionSettingsPref
+ | undefined
+ }
+ | {
+ type: 'mark_saved'
+ draftId: string
+ }
export const MAX_IMAGES = 4
@@ -133,6 +164,7 @@ export function composerReducer(
case 'update_postgate': {
return {
...state,
+ isDirty: true,
thread: {
...state.thread,
postgate: action.postgate,
@@ -142,6 +174,7 @@ export function composerReducer(
case 'update_threadgate': {
return {
...state,
+ isDirty: true,
thread: {
...state.thread,
threadgate: action.threadgate,
@@ -162,6 +195,7 @@ export function composerReducer(
}
return {
...state,
+ isDirty: true,
thread: {
...state.thread,
posts: nextPosts,
@@ -184,6 +218,7 @@ export function composerReducer(
})
return {
...state,
+ isDirty: true,
thread: {
...state.thread,
posts: nextPosts,
@@ -209,6 +244,7 @@ export function composerReducer(
}
return {
...state,
+ isDirty: true,
activePostIndex: nextActivePostIndex,
mutableNeedsFocusActive: true,
thread: {
@@ -229,6 +265,54 @@ export function composerReducer(
activePostIndex: nextActivePostIndex,
}
}
+ case 'restore_from_draft': {
+ const {
+ draftId,
+ posts,
+ threadgateAllow,
+ postgateEmbeddingRules,
+ loadedMedia,
+ originalLocalRefs,
+ } = action
+
+ return {
+ activePostIndex: 0,
+ mutableNeedsFocusActive: true,
+ draftId,
+ isDirty: false,
+ loadedMediaMap: loadedMedia,
+ originalLocalRefs,
+ thread: {
+ posts,
+ postgate: createPostgateRecord({
+ post: '',
+ embeddingRules: postgateEmbeddingRules,
+ }),
+ threadgate: threadgateRecordToAllowUISetting({
+ $type: 'app.bsky.feed.threadgate',
+ post: '',
+ createdAt: new Date().toString(),
+ allow: threadgateAllow,
+ }),
+ },
+ }
+ }
+ case 'clear': {
+ return createComposerState({
+ initText: undefined,
+ initMention: undefined,
+ initImageUris: [],
+ initQuoteUri: undefined,
+ initInteractionSettings: action.initInteractionSettings,
+ })
+ }
+ case 'mark_saved': {
+ return {
+ ...state,
+ isDirty: false,
+ draftId: action.draftId,
+ }
+ }
}
}
@@ -494,7 +578,7 @@ export function createComposerState({
initImageUris: ComposerOpts['imageUris']
initQuoteUri: string | undefined
initInteractionSettings:
- | BskyPreferences['postInteractionSettings']
+ | AppBskyActorDefs.PostInteractionSettingsPref
| undefined
}): ComposerState {
let media: ImagesMedia | undefined
@@ -591,6 +675,7 @@ export function createComposerState({
return {
activePostIndex: 0,
mutableNeedsFocusActive: false,
+ isDirty: false,
thread: {
posts: [
{
diff --git a/src/view/com/composer/videos/pickVideo.ts b/src/view/com/composer/videos/pickVideo.ts
index a55b69c1d8..f1650258ff 100644
--- a/src/view/com/composer/videos/pickVideo.ts
+++ b/src/view/com/composer/videos/pickVideo.ts
@@ -1,3 +1,4 @@
+import {getVideoMetaData} from 'react-native-compressor'
import {
type ImagePickerAsset,
launchImageLibraryAsync,
@@ -5,6 +6,7 @@ import {
} from 'expo-image-picker'
import {VIDEO_MAX_DURATION_MS} from '#/lib/constants'
+import {extToMime} from '#/lib/media/video/util'
export async function pickVideo() {
return await launchImageLibraryAsync({
@@ -18,6 +20,24 @@ export async function pickVideo() {
})
}
-export const getVideoMetadata = (_file: File): Promise => {
- throw new Error('getVideoMetadata is web only')
+/**
+ * Gets video metadata from a file or uri, depending on the platform
+ *
+ * @param file File on web, uri on native
+ */
+export async function getVideoMetadata(
+ file: File | string,
+): Promise {
+ if (typeof file !== 'string')
+ throw new Error(
+ 'getVideoMetadata was passed a File, when on native it should be a uri',
+ )
+ const metadata = await getVideoMetaData(file)
+ return {
+ uri: file,
+ mimeType: extToMime(metadata.extension),
+ width: metadata.width,
+ height: metadata.height,
+ duration: metadata.duration,
+ }
}
diff --git a/src/view/com/composer/videos/pickVideo.web.ts b/src/view/com/composer/videos/pickVideo.web.ts
index c358727ef7..aa53702a24 100644
--- a/src/view/com/composer/videos/pickVideo.web.ts
+++ b/src/view/com/composer/videos/pickVideo.web.ts
@@ -39,7 +39,13 @@ export async function pickVideo(): Promise {
// lets us use the ImagePickerAsset type, which the rest of the code expects.
// We should unwind this and just pass the ArrayBuffer/objectUrl through the system
// instead of a string -sfn
-export const getVideoMetadata = (file: File): Promise => {
+export function getVideoMetadata(
+ file: File | string,
+): Promise {
+ if (typeof file === 'string')
+ throw new Error(
+ 'getVideoMetadata was passed a uri, when on web it should be a File',
+ )
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
diff --git a/src/view/com/util/EmptyState.tsx b/src/view/com/util/EmptyState.tsx
index b14f426ec9..5300767b45 100644
--- a/src/view/com/util/EmptyState.tsx
+++ b/src/view/com/util/EmptyState.tsx
@@ -95,12 +95,13 @@ export function EmptyState({
a.leading_snug,
a.text_center,
a.self_center,
+ !button && a.mb_5xl,
textStyle,
]}>
{message}
{button && (
-
+
{button.text}
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index 5610f6b258..67853f68a6 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -12,8 +12,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
import {clamp} from '#/lib/numbers'
-import {ios, useBreakpoints, useTheme} from '#/alf'
-import {atoms as a} from '#/alf'
+import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf'
import {IS_WEB} from '#/env'
export interface FABProps extends ComponentProps {
@@ -61,7 +60,6 @@ export function FABInner({testID, icon, onPress, style, ...props}: FABProps) {
{backgroundColor: t.palette.primary_500},
a.align_center,
a.justify_center,
- a.shadow_sm,
style,
]}
{...props}>
diff --git a/yarn.lock b/yarn.lock
index 9f9a48d2ac..830107d7f7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11687,6 +11687,11 @@ expo-updates@~29.0.14:
ignore "^5.3.1"
resolve-from "^5.0.0"
+expo-video-thumbnails@^10.0.8:
+ version "10.0.8"
+ resolved "https://registry.yarnpkg.com/expo-video-thumbnails/-/expo-video-thumbnails-10.0.8.tgz#a6313cea8e58dd0d5041d389a4fe4fa182eab176"
+ integrity sha512-nPUtP7ERLf5DY5V2A6gquRP5rP3Uvq6+FVkDwG9R3KKhFeTYkWZ5Ce1iQ7Yt5qDNQqcUcgEqmRpGCbJmn9ckKA==
+
expo-video@~3.0.15:
version "3.0.15"
resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-3.0.15.tgz#38921dab5bc877572b64728acb58097716239aa7"