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 - + {/* Web-only X button in top left */} @@ -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 && ( + + )} + + )} {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} - + + )} + + + {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 ( + <> + + + + + + + + {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, _], + ) + + 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 && ( - + 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"