From 666e1318b2e6041a7ed0eb26fc82296213629d6f Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 15 Jan 2026 18:53:12 +0200 Subject: [PATCH] Migrate drafts from local storage to server API Replace local-only draft storage with the new `app.bsky.draft.*` server API: - getDrafts, createDraft, updateDraft, deleteDraft endpoints Key changes: - Add api.ts with type converters (ComposerState <-> server Draft) - Update hooks.ts to use server API instead of local storage - Simplify storage.ts/storage.web.ts for local media caching only - Media stored locally via localRef pattern (filepath in server draft) - GIFs stored as external embeds with Tenor URL + dimensions - Hide drafts button when replying (reply drafts not supported) - Show "different device" note when media is missing locally Co-Authored-By: Claude Opus 4.5 --- .../drafts/__tests__/serialization.test.ts | 525 ------------------ src/state/drafts/api.ts | 460 +++++++++++++++ src/state/drafts/hooks.ts | 462 +++++---------- src/state/drafts/index.ts | 1 + src/state/drafts/schema.ts | 117 +--- src/state/drafts/storage.ts | 325 ++--------- src/state/drafts/storage.web.ts | 336 ++--------- src/view/com/composer/Composer.tsx | 63 ++- src/view/com/composer/drafts/DraftItem.tsx | 97 ++-- src/view/com/composer/drafts/DraftsButton.tsx | 4 +- .../com/composer/drafts/DraftsListDialog.tsx | 24 +- src/view/com/composer/state/composer.ts | 87 +-- 12 files changed, 853 insertions(+), 1648 deletions(-) delete mode 100644 src/state/drafts/__tests__/serialization.test.ts create mode 100644 src/state/drafts/api.ts diff --git a/src/state/drafts/__tests__/serialization.test.ts b/src/state/drafts/__tests__/serialization.test.ts deleted file mode 100644 index c80c387c9c..0000000000 --- a/src/state/drafts/__tests__/serialization.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -import {describe, expect, it} from '@jest/globals' - -import { - composerReducer, - createComposerState, -} from '#/view/com/composer/state/composer' -import {type StoredDraft} from '../schema' - -describe('Draft serialization', () => { - describe('restore_from_draft action', () => { - it('restores a simple text draft', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-123', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: { - text: 'Hello, world!', - facets: [], - }, - labels: [], - }, - ], - syncStatus: 'local', - } - - const loadedMedia = new Map() - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - expect(newState.thread.posts).toHaveLength(1) - expect(newState.thread.posts[0].richtext.text).toBe('Hello, world!') - expect(newState.thread.posts[0].id).toBe('post-1') - expect(newState.activePostIndex).toBe(0) - }) - - it('restores a draft with multiple posts (thread)', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-456', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: {text: 'First post in thread', facets: []}, - labels: [], - }, - { - id: 'post-2', - richtext: {text: 'Second post in thread', facets: []}, - labels: [], - }, - { - id: 'post-3', - richtext: {text: 'Third post in thread', facets: []}, - labels: [], - }, - ], - syncStatus: 'local', - } - - const loadedMedia = new Map() - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - expect(newState.thread.posts).toHaveLength(3) - expect(newState.thread.posts[0].richtext.text).toBe( - 'First post in thread', - ) - expect(newState.thread.posts[1].richtext.text).toBe( - 'Second post in thread', - ) - expect(newState.thread.posts[2].richtext.text).toBe( - 'Third post in thread', - ) - }) - - it('restores a draft with labels', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-789', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: {text: 'Content with labels', facets: []}, - labels: ['sexual', 'graphic-media'], - }, - ], - syncStatus: 'local', - } - - const loadedMedia = new Map() - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - expect(newState.thread.posts[0].labels).toEqual([ - 'sexual', - 'graphic-media', - ]) - }) - - it('restores a draft with quote URI', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-quote', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: {text: 'Quoting another post', facets: []}, - labels: [], - quoteUri: 'at://did:plc:xyz/app.bsky.feed.post/abc123', - }, - ], - syncStatus: 'local', - } - - const loadedMedia = new Map() - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - expect(newState.thread.posts[0].embed.quote).toEqual({ - type: 'link', - uri: 'at://did:plc:xyz/app.bsky.feed.post/abc123', - }) - }) - - it('restores a draft with external link', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-link', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: {text: 'Check out this link', facets: []}, - labels: [], - linkUri: 'https://example.com', - }, - ], - syncStatus: 'local', - } - - const loadedMedia = new Map() - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - expect(newState.thread.posts[0].embed.link).toEqual({ - type: 'link', - uri: 'https://example.com', - }) - }) - - it('restores a draft with images when media is available', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-images', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: {text: 'Post with images', facets: []}, - labels: [], - images: [ - { - localId: 'img-1', - type: 'image', - mimeType: 'image/jpeg', - width: 800, - height: 600, - altText: 'A beautiful sunset', - }, - { - localId: 'img-2', - type: 'image', - mimeType: 'image/png', - width: 1024, - height: 768, - altText: 'A mountain landscape', - }, - ], - }, - ], - syncStatus: 'local', - } - - // Simulate loaded media paths - const loadedMedia = new Map([ - ['img-1', '/path/to/image1.jpg'], - ['img-2', '/path/to/image2.png'], - ]) - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - expect(newState.thread.posts[0].embed.media?.type).toBe('images') - if (newState.thread.posts[0].embed.media?.type === 'images') { - expect(newState.thread.posts[0].embed.media.images).toHaveLength(2) - expect(newState.thread.posts[0].embed.media.images[0].alt).toBe( - 'A beautiful sunset', - ) - expect(newState.thread.posts[0].embed.media.images[0].source.path).toBe( - '/path/to/image1.jpg', - ) - expect(newState.thread.posts[0].embed.media.images[1].alt).toBe( - 'A mountain landscape', - ) - } - }) - - it('skips images when media file is not available', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-missing-images', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: {text: 'Post with missing images', facets: []}, - labels: [], - images: [ - { - localId: 'missing-img', - type: 'image', - mimeType: 'image/jpeg', - width: 800, - height: 600, - altText: 'Missing image', - }, - ], - }, - ], - syncStatus: 'local', - } - - // Empty media map simulates missing files - const loadedMedia = new Map() - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - // Should not have media since the image file is missing - expect(newState.thread.posts[0].embed.media).toBeUndefined() - }) - - it('restores a draft with facets (mentions, links)', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-facets', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: { - text: 'Hey @alice check out https://example.com', - facets: [ - { - index: {byteStart: 4, byteEnd: 10}, - features: [ - { - $type: 'app.bsky.richtext.facet#mention', - did: 'did:plc:alice123', - }, - ], - }, - { - index: {byteStart: 21, byteEnd: 40}, - features: [ - { - $type: 'app.bsky.richtext.facet#link', - uri: 'https://example.com', - }, - ], - }, - ], - }, - labels: [], - }, - ], - syncStatus: 'local', - } - - const loadedMedia = new Map() - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - expect(newState.thread.posts[0].richtext.facets).toHaveLength(2) - expect( - newState.thread.posts[0].richtext.facets?.[0].features[0].$type, - ).toBe('app.bsky.richtext.facet#mention') - }) - - it('restores threadgate settings from draft', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-threadgate', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: {text: 'Post with restricted replies', facets: []}, - labels: [], - }, - ], - threadgate: [{type: 'nobody'}], - syncStatus: 'local', - } - - const loadedMedia = new Map() - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - expect(newState.thread.threadgate).toEqual([{type: 'nobody'}]) - }) - - it('restores reply information', () => { - const initialState = createComposerState({ - initText: undefined, - initMention: undefined, - initImageUris: undefined, - initQuoteUri: undefined, - initInteractionSettings: undefined, - }) - - const storedDraft: StoredDraft = { - id: 'draft-reply', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - replyToUri: 'at://did:plc:xyz/app.bsky.feed.post/parent123', - replyToAuthor: { - did: 'did:plc:xyz', - handle: 'alice.bsky.social', - displayName: 'Alice', - }, - posts: [ - { - id: 'post-1', - richtext: {text: 'This is a reply', facets: []}, - labels: [], - }, - ], - syncStatus: 'local', - } - - const loadedMedia = new Map() - - const newState = composerReducer(initialState, { - type: 'restore_from_draft', - draft: storedDraft, - loadedMedia, - }) - - // The reply info is stored in the draft but handled by the composer opener - // The reducer restores the post content - expect(newState.thread.posts[0].richtext.text).toBe('This is a reply') - }) - }) - - describe('DraftSummary creation', () => { - it('creates correct summary from draft', () => { - // This tests the createDraftSummary function indirectly through the storage layer - const draft: StoredDraft = { - id: 'draft-summary-test', - accountDid: 'did:plc:abc123', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-02T00:00:00Z', - posts: [ - { - id: 'post-1', - richtext: { - text: 'This is a longer post that should be truncated in the preview to show only the first 100 characters or so', - facets: [], - }, - labels: [], - images: [ - { - localId: 'img-1', - type: 'image', - mimeType: 'image/jpeg', - width: 800, - height: 600, - altText: '', - }, - ], - }, - { - id: 'post-2', - richtext: {text: 'Second post', facets: []}, - labels: [], - }, - ], - replyToUri: 'at://did:plc:xyz/app.bsky.feed.post/parent123', - replyToAuthor: { - did: 'did:plc:xyz', - handle: 'alice.bsky.social', - }, - syncStatus: 'local', - } - - // Verify the draft structure is correct - expect(draft.posts).toHaveLength(2) - expect(draft.posts[0].images).toHaveLength(1) - expect(draft.replyToAuthor?.handle).toBe('alice.bsky.social') - }) - }) -}) diff --git a/src/state/drafts/api.ts b/src/state/drafts/api.ts new file mode 100644 index 0000000000..476a874505 --- /dev/null +++ b/src/state/drafts/api.ts @@ -0,0 +1,460 @@ +/** + * Type converters for Draft API - convert between ComposerState and server Draft types. + */ +import {type AppBskyDraftDefs} from '@atproto/api' +import {nanoid} from 'nanoid/non-secure' + +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 {type DraftPostDisplay, type DraftSummary} from './schema' + +const TENOR_HOSTNAME = 'media.tenor.com' + +/** + * Convert ComposerState to server Draft format for saving. + * Returns both the draft and a map of localRef paths to their source paths. + */ +export function composerStateToDraft(state: ComposerState): { + draft: AppBskyDraftDefs.Draft + localRefPaths: Map +} { + const localRefPaths = new Map() + + const posts: AppBskyDraftDefs.DraftPost[] = 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, + // TODO: Add postgate embedding rules if needed + } + + return {draft, localRefPaths} +} + +/** + * Convert a single PostDraft to server DraftPost format. + */ +function postDraftToServerPost( + post: PostDraft, + localRefPaths: Map, +): AppBskyDraftDefs.DraftPost { + 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 = 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. + */ +function serializeImages( + images: ComposerImage[], + localRefPaths: Map, +): AppBskyDraftDefs.DraftEmbedImage[] { + return images.map(image => { + const sourcePath = image.transformed?.path || image.source.path + // Use a unique key for the localRef path + const localRefPath = `image:${nanoid()}` + localRefPaths.set(localRefPath, 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. + */ +function serializeVideo( + videoState: VideoState, + localRefPaths: Map, +): AppBskyDraftDefs.DraftEmbedVideo | undefined { + // Only save videos that have been compressed (have a video file) + if (!videoState.video) { + return undefined + } + + const localRefPath = `video:${nanoid()}` + localRefPaths.set(localRefPath, videoState.video.uri) + + return { + $type: 'app.bsky.draft.defs#draftEmbedVideo', + localRef: { + $type: 'app.bsky.draft.defs#draftEmbedLocalRef', + path: localRefPath, + }, + alt: videoState.altText || undefined, + // TODO: Add captions if needed + } +} + +/** + * Serialize GIF to server format as external embed. + * URL format: https://media.tenor.com/{id}/{filename}.gif?hh=HEIGHT&ww=WIDTH + */ +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 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])) + } + + 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, + previewText, + hasMedia, + hasMissingMedia, + mediaCount, + postCount: view.draft.posts.length, + isReply: false, // Reply drafts not supported + updatedAt: view.updatedAt, + posts, + } +} + +/** + * Parse GIF data from a Tenor URL. + * URL format: https://media.tenor.com/{id}/{filename}.gif?hh=HEIGHT&ww=WIDTH + */ +function parseGifFromUrl( + uri: string, +): {url: string; width: number; height: number} | 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) + + if (!height || !width) { + return undefined + } + + return {url: uri, width, height} + } catch { + return undefined + } +} + +/** + * Convert server Draft back to composer-compatible format for restoration. + * Returns partial state that can be merged with initial composer state. + */ +export function draftToComposerPosts( + draft: AppBskyDraftDefs.Draft, + loadedMedia: Map, +): PostDraft[] { + // Import these dynamically to avoid circular dependencies + const {RichText} = require('@atproto/api') + + return draft.posts.map((post, index) => { + const richtext = new RichText({text: post.text || ''}) + + const embed: EmbedDraft = { + quote: undefined, + link: undefined, + media: undefined, + } + + // Restore images + if (post.embedImages && post.embedImages.length > 0) { + const images: ComposerImage[] = [] + for (const img of post.embedImages) { + const path = loadedMedia.get(img.localRef.path) + if (path) { + images.push({ + alt: img.alt || '', + source: { + id: nanoid(), + path, + width: 0, // Will be recalculated when loaded + height: 0, + mime: 'image/jpeg', // Default, will be detected + }, + }) + } + } + 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 minimal Gif object + // The full Gif object will need to be re-fetched from Tenor if needed + embed.media = { + type: 'gif', + gif: { + id: '', + media_formats: { + gif: { + url: gifData.url, + dims: [gifData.width, gifData.height], + }, + }, + } as Gif, + alt: '', + } + break + } + } + } + + // 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 as {values: {val: string}[]}).values) { + labels.push(val.val) + } + } + + return { + id: `draft-post-${index}`, + richtext, + shortenedGraphemeLength: richtext.graphemeLength, + labels, + embed, + } as PostDraft + }) +} + +/** + * 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) +} diff --git a/src/state/drafts/hooks.ts b/src/state/drafts/hooks.ts index 5b60849e15..906cce6f28 100644 --- a/src/state/drafts/hooks.ts +++ b/src/state/drafts/hooks.ts @@ -1,61 +1,95 @@ import {useCallback} from 'react' +import {type AppBskyDraftDefs} from '@atproto/api' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' -import {nanoid} from 'nanoid/non-secure' -import {type ComposerImage} from '#/state/gallery' -import {useSession} from '#/state/session' -import {type ComposerOpts} from '#/state/shell/composer' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {type ComposerState} from '#/view/com/composer/state/composer' import { - type ComposerState, - type PostDraft, -} from '#/view/com/composer/state/composer' -import {type VideoState} from '#/view/com/composer/state/video' -import { - type DraftSummary, - type LocalMediaRef, - type StoredDraft, - type StoredGif, - type StoredPostDraft, - type StoredRichText, -} from './schema' + composerStateToDraft, + draftToComposerPosts, + draftViewToSummary, + threadgateToUISettings, +} from './api' +import {type DraftSummary} from './schema' import * as storage from './storage' -const DRAFTS_QUERY_KEY_ROOT = 'drafts' - -export function draftsQueryKey(did: string) { - return [DRAFTS_QUERY_KEY_ROOT, did] -} +const DRAFTS_QUERY_KEY = ['drafts'] /** * Hook to list all drafts for the current account */ export function useDrafts() { - const {currentAccount} = useSession() - const did = currentAccount?.did + const agent = useAgent() return useQuery({ - queryKey: draftsQueryKey(did || ''), + queryKey: DRAFTS_QUERY_KEY, queryFn: async () => { - if (!did) return [] - return storage.listDrafts(did) + const res = await agent.app.bsky.draft.getDrafts({}) + return res.data.drafts.map(view => + draftViewToSummary(view, path => storage.mediaExists(path)), + ) }, - enabled: Boolean(did), }) } /** - * Hook to load a specific draft + * Hook to load a specific draft for editing */ export function useLoadDraft() { - const {currentAccount} = useSession() - const did = currentAccount?.did + const agent = useAgent() return useCallback( - async (draftId: string): Promise => { - if (!did) return null - return storage.loadDraftMeta(did, draftId) + async ( + draftId: string, + ): Promise<{ + draft: AppBskyDraftDefs.Draft + loadedMedia: Map + } | null> => { + // Fetch the draft from server + const res = await agent.app.bsky.draft.getDrafts({}) + const draftView = res.data.drafts.find(d => d.id === draftId) + + if (!draftView) { + return null + } + + // Load local media files + const loadedMedia = new Map() + for (const post of draftView.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 {draft: draftView.draft, loadedMedia} }, - [did], + [agent], ) } @@ -63,97 +97,56 @@ export function useLoadDraft() { * Hook to save a draft */ export function useSaveDraft() { - const {currentAccount} = useSession() - const did = currentAccount?.did + const agent = useAgent() const queryClient = useQueryClient() return useMutation({ mutationFn: async ({ composerState, - replyTo, existingDraftId, - loadedMediaMap, }: { composerState: ComposerState - replyTo?: ComposerOpts['replyTo'] existingDraftId?: string - loadedMediaMap?: Map // localId -> path/url - }): Promise => { - if (!did) { - throw new Error('No account') - } + }): Promise => { + // Convert composer state to server draft format + const {draft, localRefPaths} = composerStateToDraft(composerState) - const now = new Date().toISOString() - const draftId = existingDraftId || nanoid() - - // Build a reverse map (path -> localId) for identifying reusable media - const pathToLocalId = new Map() - if (loadedMediaMap) { - for (const [localId, path] of loadedMediaMap) { - pathToLocalId.set(path, localId) + // Save media files locally + for (const [localRefPath, sourcePath] of localRefPaths) { + // Check if this media is already saved (re-saving existing draft) + if (!storage.mediaExists(localRefPath)) { + await storage.saveMediaToLocal(localRefPath, sourcePath) } } - // Collect old media localIds for cleanup - let oldMediaLocalIds: Set = new Set() if (existingDraftId) { - const existingDraft = await storage.loadDraftMeta(did, existingDraftId) - if (existingDraft) { - oldMediaLocalIds = collectMediaLocalIds(existingDraft) - } + // Update existing draft + await agent.app.bsky.draft.updateDraft({ + draft: { + id: existingDraftId, + draft, + }, + }) + return existingDraftId + } else { + // Create new draft + const res = await agent.app.bsky.draft.createDraft({draft}) + return res.data.id } - - // Serialize the composer state, tracking which localIds are reused - const reusedLocalIds = new Set() - const posts: StoredPostDraft[] = [] - - for (const post of composerState.thread.posts) { - const storedPost = await serializePost( - did, - post, - pathToLocalId, - reusedLocalIds, - ) - posts.push(storedPost) - } - - // Clean up old media that wasn't reused - for (const oldLocalId of oldMediaLocalIds) { - if (!reusedLocalIds.has(oldLocalId)) { - await storage.deleteMediaFromLocal(did, oldLocalId) - } - } - - const draft: StoredDraft = { - id: draftId, - accountDid: did, - createdAt: existingDraftId - ? (await storage.loadDraftMeta(did, existingDraftId))?.createdAt || - now - : now, - updatedAt: now, - replyToUri: replyTo?.uri, - replyToAuthor: replyTo?.author - ? { - did: replyTo.author.did, - handle: replyTo.author.handle, - displayName: replyTo.author.displayName, - } - : undefined, - posts, - postgate: composerState.thread.postgate, - threadgate: composerState.thread.threadgate, - syncStatus: 'local', - } - - // Save the draft - await storage.saveDraftMeta(did, draft) - - return draft }, onSuccess: () => { - if (did) { - queryClient.invalidateQueries({queryKey: draftsQueryKey(did)}) + queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY}) + }, + onError: error => { + // Check for draft limit error + if ( + error && + typeof error === 'object' && + 'error' in error && + (error as {error: string}).error === 'DraftLimitReached' + ) { + logger.error('Draft limit reached', {error}) + // Error will be handled by caller } }, }) @@ -163,242 +156,39 @@ export function useSaveDraft() { * Hook to delete a draft */ export function useDeleteDraft() { - const {currentAccount} = useSession() - const did = currentAccount?.did + const agent = useAgent() const queryClient = useQueryClient() return useMutation({ mutationFn: async (draftId: string) => { - if (!did) { - throw new Error('No account') + // First fetch the draft to get media paths for cleanup + const res = await agent.app.bsky.draft.getDrafts({}) + const draftView = res.data.drafts.find(d => d.id === draftId) + + if (draftView) { + // Delete local media files + for (const post of draftView.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) + } + } + } } - await storage.deleteDraft(did, draftId) + + // Delete from server + await agent.app.bsky.draft.deleteDraft({id: draftId}) }, onSuccess: () => { - if (did) { - queryClient.invalidateQueries({queryKey: draftsQueryKey(did)}) - } + queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY}) }, }) } -/** - * Collect all media localIds from a draft - */ -function collectMediaLocalIds(draft: StoredDraft): Set { - const localIds = new Set() - for (const post of draft.posts) { - if (post.images) { - for (const image of post.images) { - localIds.add(image.localId) - } - } - if (post.video) { - localIds.add(post.video.localId) - } - } - return localIds -} - -/** - * Serialize a post for storage - */ -async function serializePost( - accountDid: string, - post: PostDraft, - pathToLocalId: Map, - reusedLocalIds: Set, -): Promise { - const richtext: StoredRichText = { - text: post.richtext.text, - facets: post.richtext.facets, - } - - const storedPost: StoredPostDraft = { - id: post.id, - richtext, - labels: post.labels, - quoteUri: post.embed.quote?.uri, - linkUri: post.embed.link?.uri, - } - - // Serialize media - if (post.embed.media) { - if (post.embed.media.type === 'images') { - storedPost.images = await serializeImages( - accountDid, - post.embed.media.images, - pathToLocalId, - reusedLocalIds, - ) - } else if (post.embed.media.type === 'video') { - storedPost.video = await serializeVideo( - accountDid, - post.embed.media.video, - pathToLocalId, - reusedLocalIds, - ) - } else if (post.embed.media.type === 'gif') { - storedPost.gif = serializeGif(post.embed.media) - } - } - - return storedPost -} - -/** - * Serialize images for storage - */ -async function serializeImages( - accountDid: string, - images: ComposerImage[], - pathToLocalId: Map, - reusedLocalIds: Set, -): Promise { - const refs: LocalMediaRef[] = [] - - for (const image of images) { - const path = image.transformed?.path || image.source.path - - // Check if this image is already in drafts storage - // First try the pathToLocalId map (works for both native and web) - let existingLocalId: string | null | undefined = pathToLocalId.get(path) - - // On native, also check if the path is in the media directory - if (!existingLocalId) { - existingLocalId = storage.extractLocalIdFromPath(accountDid, path) - } - - let localId: string - if (existingLocalId) { - // Reuse existing media - localId = existingLocalId - reusedLocalIds.add(localId) - } else { - // Save new media - localId = await storage.saveMediaToLocal( - accountDid, - path, - image.source.mime, - ) - } - - refs.push({ - localId, - type: 'image', - mimeType: image.source.mime, - width: image.transformed?.width || image.source.width, - height: image.transformed?.height || image.source.height, - altText: image.alt, - }) - } - - return refs -} - -/** - * Serialize video for storage - */ -async function serializeVideo( - accountDid: string, - videoState: VideoState, - pathToLocalId: Map, - reusedLocalIds: Set, -): Promise { - // Only save videos that have been compressed (have a video file) - if (!videoState.video) { - return undefined - } - - const video = videoState.video - const path = video.uri - - // Check if this video is already in drafts storage - let existingLocalId: string | null | undefined = pathToLocalId.get(path) - - if (!existingLocalId) { - existingLocalId = storage.extractLocalIdFromPath(accountDid, path) - } - - let localId: string - if (existingLocalId) { - // Reuse existing media - localId = existingLocalId - reusedLocalIds.add(localId) - } else { - // Save new media - localId = await storage.saveMediaToLocal(accountDid, path, video.mimeType) - } - - return { - localId, - type: 'video', - mimeType: video.mimeType, - width: videoState.asset?.width || 0, - height: videoState.asset?.height || 0, - altText: videoState.altText || '', - } -} - -/** - * Serialize GIF for storage (just metadata, no file) - */ -function serializeGif(gifMedia: { - type: 'gif' - gif: { - id: string - media_formats: Record - } - alt: string -}): StoredGif { - const gif = gifMedia.gif - const gifFormat = gif.media_formats.gif || gif.media_formats.mediumgif - - return { - tenorId: gif.id, - url: gifFormat?.url || '', - width: gifFormat?.dims?.[0] || 0, - height: gifFormat?.dims?.[1] || 0, - altText: gifMedia.alt, - } -} - -/** - * Load media from storage and return paths/URLs for use in composer - */ -export async function loadDraftMedia( - accountDid: string, - draft: StoredDraft, -): Promise> { - const mediaMap = new Map() - - for (const post of draft.posts) { - if (post.images) { - for (const image of post.images) { - try { - const path = await storage.loadMediaFromLocal( - accountDid, - image.localId, - ) - mediaMap.set(image.localId, path) - } catch (e) { - // Media file may have been deleted - console.warn(`Failed to load image ${image.localId}`, e) - } - } - } - if (post.video) { - try { - const path = await storage.loadMediaFromLocal( - accountDid, - post.video.localId, - ) - mediaMap.set(post.video.localId, path) - } catch (e) { - console.warn(`Failed to load video ${post.video.localId}`, e) - } - } - } - - return mediaMap -} +// Re-export utilities for use in composer +export {draftToComposerPosts, threadgateToUISettings} diff --git a/src/state/drafts/index.ts b/src/state/drafts/index.ts index e0fa362715..a6a0003e2e 100644 --- a/src/state/drafts/index.ts +++ b/src/state/drafts/index.ts @@ -1,2 +1,3 @@ +export * from './api' export * from './hooks' export * from './schema' diff --git a/src/state/drafts/schema.ts b/src/state/drafts/schema.ts index 379876fc83..62be4e55d5 100644 --- a/src/state/drafts/schema.ts +++ b/src/state/drafts/schema.ts @@ -1,95 +1,30 @@ -import {type AppBskyFeedPostgate, type AppBskyRichtextFacet} from '@atproto/api' - -import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' +/** + * Types for draft display and local media tracking. + * Server draft types come from @atproto/api. + */ /** - * Reference to locally stored media (image or video) + * Reference to locally cached media file for display */ -export type LocalMediaRef = { - /** UUID for local storage key */ - localId: string - type: 'image' | 'video' - mimeType: string - width: number - height: number - altText: string -} - -/** - * Stored GIF metadata (re-fetchable from Tenor) - */ -export type StoredGif = { - /** Tenor GIF ID */ - tenorId: string - /** URL for the GIF */ - url: string - /** Dimensions */ - width: number - height: number +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 } /** - * Serializable version of RichText + * GIF display data (parsed from external embed URL) */ -export type StoredRichText = { - text: string - facets?: AppBskyRichtextFacet.Main[] -} - -/** - * Serializable version of PostDraft for storage - */ -export type StoredPostDraft = { - id: string - richtext: StoredRichText - labels: string[] - /** Quote post URI */ - quoteUri?: string - /** External link URI (for link card) */ - linkUri?: string - /** Locally stored images */ - images?: LocalMediaRef[] - /** Locally stored video */ - video?: LocalMediaRef & { - /** Captions for the video */ - captions?: Array<{lang: string; localId: string}> - } - /** GIF metadata (re-fetchable from Tenor) */ - gif?: StoredGif -} - -/** - * Full draft including thread structure - */ -export type StoredDraft = { - /** Local draft UUID */ - id: string - /** Owner account DID */ - accountDid: string - /** ISO timestamp of creation */ - createdAt: string - /** ISO timestamp of last update */ - updatedAt: string - /** If this is a reply, the URI of the parent post */ - replyToUri?: string - /** Reply parent author info (for display) */ - replyToAuthor?: { - did: string - handle: string - displayName?: string - } - /** Thread posts */ - posts: StoredPostDraft[] - /** Post interaction settings */ - postgate?: AppBskyFeedPostgate.Record - /** Thread interaction settings */ - threadgate?: ThreadgateAllowUISetting[] - /** Server draft ID (if synced) */ - serverDraftId?: string - /** Sync status */ - syncStatus: 'local' | 'synced' | 'dirty' +export type GifDisplay = { + /** Full URL with dimensions */ + url: string + /** Width */ + width: number + /** Height */ + height: number } /** @@ -99,12 +34,12 @@ export type DraftPostDisplay = { id: string /** Full text content */ text: string - /** Image URLs for display (local IDs that need to be loaded) */ - images?: LocalMediaRef[] + /** Image references for display */ + images?: LocalMediaDisplay[] /** Video reference */ - video?: LocalMediaRef - /** GIF metadata */ - gif?: StoredGif + video?: LocalMediaDisplay + /** GIF data (from URL) */ + gif?: GifDisplay } /** @@ -116,14 +51,14 @@ export type DraftSummary = { 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 - /** Whether this is a reply */ + /** Whether this is a reply (always false - replies not supported) */ isReply: boolean - /** Reply to author handle (if reply) */ - replyToHandle?: string /** ISO timestamp of last update */ updatedAt: string /** All posts in the draft for full display */ diff --git a/src/state/drafts/storage.ts b/src/state/drafts/storage.ts index 1e6e3c2685..95864e4246 100644 --- a/src/state/drafts/storage.ts +++ b/src/state/drafts/storage.ts @@ -1,78 +1,61 @@ +/** + * Native file system storage for draft media. + * Media is stored by localRefPath key (unique identifier stored in server draft). + */ import { copyAsync, deleteAsync, documentDirectory, getInfoAsync, makeDirectoryAsync, - readAsStringAsync, - readDirectoryAsync, - writeAsStringAsync, } from 'expo-file-system/legacy' -import {nanoid} from 'nanoid/non-secure' import {logger} from '#/logger' -import { - type DraftPostDisplay, - type DraftSummary, - type StoredDraft, -} from './schema' -const DRAFTS_DIR = 'bsky-drafts' +const MEDIA_DIR = 'bsky-draft-media' function joinPath(...segments: string[]): string { return segments.join('/').replace(/\/+/g, '/') } -function getDraftsDirectory(accountDid: string): string { - return joinPath(documentDirectory!, DRAFTS_DIR, accountDid) +function getMediaDirectory(): string { + return joinPath(documentDirectory!, MEDIA_DIR) } -function getMediaDirectory(accountDid: string): string { - return joinPath(getDraftsDirectory(accountDid), 'media') +function getMediaPath(localRefPath: string): string { + // Use localRefPath as filename (replace unsafe chars) + const safeFilename = localRefPath.replace(/[/:]/g, '_') + return joinPath(getMediaDirectory(), safeFilename) } -function getMediaPath(accountDid: string, localId: string): string { - return joinPath(getMediaDirectory(accountDid), localId) -} - -function getDraftsMetaDirectory(accountDid: string): string { - return joinPath(getDraftsDirectory(accountDid), 'drafts') -} - -function getDraftMetaPath(accountDid: string, draftId: string): string { - return joinPath(getDraftsMetaDirectory(accountDid), `${draftId}.json`) -} +let dirCreated = false /** - * Ensure the drafts directories exist + * Ensure the media directory exists */ -async function ensureDirectories(accountDid: string): Promise { - await makeDirectoryAsync(getMediaDirectory(accountDid), {intermediates: true}) - await makeDirectoryAsync(getDraftsMetaDirectory(accountDid), { - intermediates: true, - }) +async function ensureDirectory(): Promise { + if (dirCreated) return + await makeDirectoryAsync(getMediaDirectory(), {intermediates: true}) + dirCreated = true } /** - * Save a media file to local storage - * @returns The local ID for the saved media + * Save a media file to local storage by localRefPath key */ export async function saveMediaToLocal( - accountDid: string, + localRefPath: string, sourcePath: string, - _mimeType: string, -): Promise { - await ensureDirectories(accountDid) +): Promise { + await ensureDirectory() - const localId = nanoid() - const destPath = getMediaPath(accountDid, localId) + const destPath = getMediaPath(localRefPath) try { await copyAsync({from: sourcePath, to: destPath}) - return localId } catch (error) { logger.error('Failed to save media to drafts storage', { error, + localRefPath, sourcePath, destPath, }) @@ -85,14 +68,13 @@ export async function saveMediaToLocal( * @returns The file path for the saved media */ export async function loadMediaFromLocal( - accountDid: string, - localId: string, + localRefPath: string, ): Promise { - const path = getMediaPath(accountDid, localId) + const path = getMediaPath(localRefPath) const info = await getInfoAsync(path) if (!info.exists) { - throw new Error(`Media file not found: ${localId}`) + throw new Error(`Media file not found: ${localRefPath}`) } return path @@ -102,244 +84,55 @@ export async function loadMediaFromLocal( * Delete a media file from local storage */ export async function deleteMediaFromLocal( - accountDid: string, - localId: string, + localRefPath: string, ): Promise { - const path = getMediaPath(accountDid, localId) + const path = getMediaPath(localRefPath) await deleteAsync(path, {idempotent: true}) } /** - * Save draft metadata to local storage + * Check if a media file exists in local storage (synchronous check using cache) + * Note: This uses a cached directory listing for performance */ -export async function saveDraftMeta( - accountDid: string, - draft: StoredDraft, -): Promise { - await ensureDirectories(accountDid) +const mediaExistsCache = new Map() +let cachePopulated = false - const path = getDraftMetaPath(accountDid, draft.id) - - try { - await writeAsStringAsync(path, JSON.stringify(draft)) - } catch (error) { - logger.error('Failed to save draft metadata', {error, draftId: draft.id}) - throw error +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 exists (will fail on load if not) + if (mediaExistsCache.has(localRefPath)) { + return mediaExistsCache.get(localRefPath)! } + // If cache not populated yet, trigger async population and return true optimistically + if (!cachePopulated) { + populateCache() + } + return false // Conservative: assume doesn't exist if not in cache } -/** - * Load draft metadata from local storage - */ -export async function loadDraftMeta( - accountDid: string, - draftId: string, -): Promise { - const path = getDraftMetaPath(accountDid, draftId) - const info = await getInfoAsync(path) - - if (!info.exists) { - return null - } - +async function populateCache(): Promise { try { - const content = await readAsStringAsync(path) - return JSON.parse(content) as StoredDraft - } catch (error) { - logger.error('Failed to load draft metadata', {error, draftId}) - return null - } -} - -/** - * List all drafts for an account - */ -export async function listDrafts(accountDid: string): Promise { - const draftsDir = getDraftsMetaDirectory(accountDid) - const info = await getInfoAsync(draftsDir) - - if (!info.exists) { - return [] - } - - try { - const files = await readDirectoryAsync(draftsDir) - const summaries: DraftSummary[] = [] - - for (const file of files) { - if (!file.endsWith('.json')) continue - - const draftId = file.replace('.json', '') - const draft = await loadDraftMeta(accountDid, draftId) - - if (draft) { - summaries.push(createDraftSummary(draft)) + const {readDirectoryAsync} = await import('expo-file-system/legacy') + const dir = getMediaDirectory() + const info = await getInfoAsync(dir) + if (info.exists) { + const files = await readDirectoryAsync(dir) + for (const file of files) { + // Reverse the safe filename transformation + const localRefPath = file.replace(/_/g, ':').replace(/_/g, '/') + mediaExistsCache.set(localRefPath, true) } } - - // Sort by updatedAt descending (most recent first) - summaries.sort( - (a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), - ) - - return summaries - } catch (error) { - logger.error('Failed to list drafts', {error, accountDid}) - return [] + cachePopulated = true + } catch (e) { + logger.warn('Failed to populate media cache', {error: e}) } } /** - * Delete a draft and all its associated media + * Clear the media exists cache (call when media is added/deleted) */ -export async function deleteDraft( - accountDid: string, - draftId: string, -): Promise { - // First, load the draft to find associated media - const draft = await loadDraftMeta(accountDid, draftId) - - if (draft) { - // Delete all associated media - for (const post of draft.posts) { - if (post.images) { - for (const image of post.images) { - await deleteMediaFromLocal(accountDid, image.localId) - } - } - if (post.video) { - await deleteMediaFromLocal(accountDid, post.video.localId) - // Delete caption files too - if (post.video.captions) { - for (const caption of post.video.captions) { - await deleteMediaFromLocal(accountDid, caption.localId) - } - } - } - } - } - - // Delete the draft metadata - const path = getDraftMetaPath(accountDid, draftId) - await deleteAsync(path, {idempotent: true}) -} - -/** - * Delete all drafts for an account - */ -export async function deleteAllDrafts(accountDid: string): Promise { - const draftsDir = getDraftsDirectory(accountDid) - await deleteAsync(draftsDir, {idempotent: true}) -} - -/** - * Get the total storage size used by drafts - */ -export async function getDraftsStorageSize( - accountDid: string, -): Promise { - const mediaDir = getMediaDirectory(accountDid) - const info = await getInfoAsync(mediaDir) - - if (!info.exists) { - return 0 - } - - try { - const files = await readDirectoryAsync(mediaDir) - let totalSize = 0 - - for (const file of files) { - const filePath = joinPath(mediaDir, file) - const fileInfo = await getInfoAsync(filePath) - if (fileInfo.exists && fileInfo.size) { - totalSize += fileInfo.size - } - } - - return totalSize - } catch (error) { - logger.error('Failed to calculate drafts storage size', {error, accountDid}) - return 0 - } -} - -/** - * Create a summary from a full draft - */ -function createDraftSummary(draft: StoredDraft): DraftSummary { - const firstPost = draft.posts[0] - const previewText = firstPost?.richtext.text.slice(0, 100) || '' - - let mediaCount = 0 - let hasMedia = false - - const posts: DraftPostDisplay[] = [] - - for (const post of draft.posts) { - if (post.images) { - mediaCount += post.images.length - hasMedia = true - } - if (post.video) { - mediaCount += 1 - hasMedia = true - } - if (post.gif) { - mediaCount += 1 - hasMedia = true - } - - posts.push({ - id: post.id, - text: post.richtext.text, - images: post.images, - video: post.video, - gif: post.gif, - }) - } - - return { - id: draft.id, - previewText, - hasMedia, - mediaCount, - postCount: draft.posts.length, - isReply: Boolean(draft.replyToUri), - replyToHandle: draft.replyToAuthor?.handle, - updatedAt: draft.updatedAt, - posts, - } -} - -/** - * Check if a media file exists in local storage - */ -export async function mediaExists( - accountDid: string, - localId: string, -): Promise { - const path = getMediaPath(accountDid, localId) - const info = await getInfoAsync(path) - return info.exists -} - -/** - * Extract the localId from a path if it's already in drafts media storage - * Returns null if the path is not in drafts storage - */ -export function extractLocalIdFromPath( - accountDid: string, - path: string, -): string | null { - const mediaDir = getMediaDirectory(accountDid) - if (path.startsWith(mediaDir)) { - // Extract the localId from the path (it's the filename) - const localId = path.slice(mediaDir.length).replace(/^\//, '') - if (localId && !localId.includes('/')) { - return localId - } - } - return null +export function clearMediaCache(): void { + mediaExistsCache.clear() + cachePopulated = false } diff --git a/src/state/drafts/storage.web.ts b/src/state/drafts/storage.web.ts index 75f3f956b8..64c0befd6d 100644 --- a/src/state/drafts/storage.web.ts +++ b/src/state/drafts/storage.web.ts @@ -1,51 +1,32 @@ +/** + * Web IndexedDB storage for draft media. + * Media is stored by localRefPath key (unique identifier stored in server draft). + */ import {type DBSchema, type IDBPDatabase, openDB} from 'idb' -import {nanoid} from 'nanoid/non-secure' import {logger} from '#/logger' -import { - type DraftPostDisplay, - type DraftSummary, - type StoredDraft, -} from './schema' -const DB_NAME = 'bsky-drafts' +const DB_NAME = 'bsky-draft-media' const DB_VERSION = 1 -interface DraftsDB extends DBSchema { - 'draft-media': { - key: string // "{accountDid}:{localId}" +interface DraftMediaDB extends DBSchema { + media: { + key: string // localRefPath value: { blob: Blob - mimeType: string createdAt: string } } - 'draft-meta': { - key: string // "{accountDid}:{draftId}" - value: StoredDraft - indexes: { - 'by-account': string - 'by-updated': string - } - } } -let dbPromise: Promise> | null = null +let dbPromise: Promise> | null = null -async function getDB(): Promise> { +async function getDB(): Promise> { if (!dbPromise) { - dbPromise = openDB(DB_NAME, DB_VERSION, { + dbPromise = openDB(DB_NAME, DB_VERSION, { upgrade(db) { - // Create media store - if (!db.objectStoreNames.contains('draft-media')) { - db.createObjectStore('draft-media') - } - - // Create meta store with indexes - if (!db.objectStoreNames.contains('draft-meta')) { - const metaStore = db.createObjectStore('draft-meta') - metaStore.createIndex('by-account', 'accountDid') - metaStore.createIndex('by-updated', 'updatedAt') + if (!db.objectStoreNames.contains('media')) { + db.createObjectStore('media') } }, }) @@ -53,51 +34,37 @@ async function getDB(): Promise> { return dbPromise } -function mediaKey(accountDid: string, localId: string): string { - return `${accountDid}:${localId}` -} - -function draftKey(accountDid: string, draftId: string): string { - return `${accountDid}:${draftId}` -} - /** - * Convert a data URI or blob URL to a Blob + * Convert a path/URL to a Blob */ -async function toBlob(input: string | Blob): Promise { - if (input instanceof Blob) { - return input - } - const response = await fetch(input) +async function toBlob(sourcePath: string): Promise { + const response = await fetch(sourcePath) return response.blob() } /** - * Save a media file to IndexedDB - * @returns The local ID for the saved media + * Save a media file to IndexedDB by localRefPath key */ export async function saveMediaToLocal( - accountDid: string, - source: string | Blob, - mimeType: string, -): Promise { + localRefPath: string, + sourcePath: string, +): Promise { const db = await getDB() - const localId = nanoid() - const blob = await toBlob(source) + const blob = await toBlob(sourcePath) try { await db.put( - 'draft-media', + 'media', { blob, - mimeType, createdAt: new Date().toISOString(), }, - mediaKey(accountDid, localId), + localRefPath, ) - return localId + // Update cache + mediaExistsCache.set(localRefPath, true) } catch (error) { - logger.error('Failed to save media to IndexedDB', {error}) + logger.error('Failed to save media to IndexedDB', {error, localRefPath}) throw error } } @@ -107,14 +74,13 @@ export async function saveMediaToLocal( * @returns A blob URL for the saved media */ export async function loadMediaFromLocal( - accountDid: string, - localId: string, + localRefPath: string, ): Promise { const db = await getDB() - const record = await db.get('draft-media', mediaKey(accountDid, localId)) + const record = await db.get('media', localRefPath) if (!record) { - throw new Error(`Media file not found: ${localId}`) + throw new Error(`Media file not found: ${localRefPath}`) } return URL.createObjectURL(record.blob) @@ -124,221 +90,49 @@ export async function loadMediaFromLocal( * Delete a media file from IndexedDB */ export async function deleteMediaFromLocal( - accountDid: string, - localId: string, + localRefPath: string, ): Promise { const db = await getDB() - await db.delete('draft-media', mediaKey(accountDid, localId)) + await db.delete('media', localRefPath) + mediaExistsCache.delete(localRefPath) } /** - * Save draft metadata to IndexedDB + * Check if a media file exists in IndexedDB (synchronous check using cache) */ -export async function saveDraftMeta( - accountDid: string, - draft: StoredDraft, -): Promise { - const db = await getDB() +const mediaExistsCache = new Map() +let cachePopulated = false +export function mediaExists(localRefPath: string): boolean { + if (mediaExistsCache.has(localRefPath)) { + return mediaExistsCache.get(localRefPath)! + } + // If cache not populated yet, trigger async population + if (!cachePopulated) { + populateCache() + } + return false // Conservative: assume doesn't exist if not in cache +} + +async function populateCache(): Promise { try { - await db.put('draft-meta', draft, draftKey(accountDid, draft.id)) - } catch (error) { - logger.error('Failed to save draft metadata', {error, draftId: draft.id}) - throw error - } -} - -/** - * Load draft metadata from IndexedDB - */ -export async function loadDraftMeta( - accountDid: string, - draftId: string, -): Promise { - const db = await getDB() - - try { - const draft = await db.get('draft-meta', draftKey(accountDid, draftId)) - return draft || null - } catch (error) { - logger.error('Failed to load draft metadata', {error, draftId}) - return null - } -} - -/** - * List all drafts for an account - */ -export async function listDrafts(accountDid: string): Promise { - const db = await getDB() - - try { - const allDrafts = await db.getAllFromIndex( - 'draft-meta', - 'by-account', - accountDid, - ) - - const summaries: DraftSummary[] = allDrafts.map(draft => - createDraftSummary(draft), - ) - - // Sort by updatedAt descending (most recent first) - summaries.sort( - (a, b) => - new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), - ) - - return summaries - } catch (error) { - logger.error('Failed to list drafts', {error, accountDid}) - return [] - } -} - -/** - * Delete a draft and all its associated media - */ -export async function deleteDraft( - accountDid: string, - draftId: string, -): Promise { - const db = await getDB() - - // First, load the draft to find associated media - const draft = await loadDraftMeta(accountDid, draftId) - - if (draft) { - // Delete all associated media - for (const post of draft.posts) { - if (post.images) { - for (const image of post.images) { - await deleteMediaFromLocal(accountDid, image.localId) - } - } - if (post.video) { - await deleteMediaFromLocal(accountDid, post.video.localId) - // Delete caption files too - if (post.video.captions) { - for (const caption of post.video.captions) { - await deleteMediaFromLocal(accountDid, caption.localId) - } - } - } + const db = await getDB() + const keys = await db.getAllKeys('media') + for (const key of keys) { + mediaExistsCache.set(key, true) } - } - - // Delete the draft metadata - await db.delete('draft-meta', draftKey(accountDid, draftId)) -} - -/** - * Delete all drafts for an account - */ -export async function deleteAllDrafts(accountDid: string): Promise { - const db = await getDB() - - // Get all drafts for this account - const drafts = await db.getAllFromIndex( - 'draft-meta', - 'by-account', - accountDid, - ) - - // Delete each draft and its media - for (const draft of drafts) { - await deleteDraft(accountDid, draft.id) + cachePopulated = true + } catch (e) { + logger.warn('Failed to populate media cache', {error: e}) } } /** - * Get the total storage size used by drafts (approximate) + * Clear the media exists cache (call when media is added/deleted) */ -export async function getDraftsStorageSize( - accountDid: string, -): Promise { - const db = await getDB() - - try { - // This is an approximation - we sum the blob sizes - const tx = db.transaction('draft-media', 'readonly') - const store = tx.objectStore('draft-media') - let cursor = await store.openCursor() - let totalSize = 0 - - while (cursor) { - const key = cursor.key as string - if (key.startsWith(`${accountDid}:`)) { - totalSize += cursor.value.blob.size - } - cursor = await cursor.continue() - } - - return totalSize - } catch (error) { - logger.error('Failed to calculate drafts storage size', {error, accountDid}) - return 0 - } -} - -/** - * Create a summary from a full draft - */ -function createDraftSummary(draft: StoredDraft): DraftSummary { - const firstPost = draft.posts[0] - const previewText = firstPost?.richtext.text.slice(0, 100) || '' - - let mediaCount = 0 - let hasMedia = false - - const posts: DraftPostDisplay[] = [] - - for (const post of draft.posts) { - if (post.images) { - mediaCount += post.images.length - hasMedia = true - } - if (post.video) { - mediaCount += 1 - hasMedia = true - } - if (post.gif) { - mediaCount += 1 - hasMedia = true - } - - posts.push({ - id: post.id, - text: post.richtext.text, - images: post.images, - video: post.video, - gif: post.gif, - }) - } - - return { - id: draft.id, - previewText, - hasMedia, - mediaCount, - postCount: draft.posts.length, - isReply: Boolean(draft.replyToUri), - replyToHandle: draft.replyToAuthor?.handle, - updatedAt: draft.updatedAt, - posts, - } -} - -/** - * Check if a media file exists in IndexedDB - */ -export async function mediaExists( - accountDid: string, - localId: string, -): Promise { - const db = await getDB() - const record = await db.get('draft-media', mediaKey(accountDid, localId)) - return record !== undefined +export function clearMediaCache(): void { + mediaExistsCache.clear() + cachePopulated = false } /** @@ -349,17 +143,3 @@ export function revokeMediaUrl(url: string): void { URL.revokeObjectURL(url) } } - -/** - * Extract the localId from a path if it's already in drafts media storage - * For web, this always returns null since blob URLs don't contain localId - * The hooks layer handles tracking of web localIds separately - */ -export function extractLocalIdFromPath( - _accountDid: string, - _path: string, -): string | null { - // Web uses blob URLs which don't contain the localId - // Tracking is done via loadedMediaMap in hooks.ts - return null -} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index a388f4e900..bc02167517 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -76,7 +76,13 @@ import {cleanError} from '#/lib/strings/errors' import {colors} from '#/lib/styles' import {logger} from '#/logger' import {useDialogStateControlContext} from '#/state/dialogs' -import {loadDraftMedia, type StoredDraft, useSaveDraft} from '#/state/drafts' +import { + type DraftSummary, + draftToComposerPosts, + threadgateToUISettings, + useLoadDraft, + useSaveDraft, +} from '#/state/drafts' import {emitPostCreated} from '#/state/events' import { type ComposerImage, @@ -192,6 +198,7 @@ export const ComposePost = ({ const textInput = useRef(null) const discardPromptControl = Prompt.usePromptControl() const {mutateAsync: saveDraft, isPending: _isSavingDraft} = useSaveDraft() + const loadDraft = useLoadDraft() const {closeAllDialogs} = useDialogStateControlContext() const {closeAllModals} = useModalControls() const {data: preferences} = usePreferencesQuery() @@ -324,20 +331,27 @@ export const ComposePost = ({ ) const handleSelectDraft = React.useCallback( - async (draft: StoredDraft) => { - if (!currentDid) return + async (draftSummary: DraftSummary) => { + // Load full draft from server with media + const result = await loadDraft(draftSummary.id) + if (!result) return - // Load media from local storage - const loadedMedia = await loadDraftMedia(currentDid, draft) + const {draft, loadedMedia} = result + + // Convert server draft to composer posts + const posts = draftToComposerPosts(draft, loadedMedia) + const threadgate = threadgateToUISettings(draft.threadgateAllow) // Dispatch restore action (this also sets draftId in state) composerDispatch({ type: 'restore_from_draft', - draft, + draftId: draftSummary.id, + posts, + threadgate, loadedMedia, }) }, - [currentDid, composerDispatch], + [loadDraft, composerDispatch], ) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -349,30 +363,26 @@ export const ComposePost = ({ const handleSaveDraft = React.useCallback(async () => { try { - const savedDraft = await saveDraft({ + const draftId = await saveDraft({ composerState, - replyTo, existingDraftId: composerState.draftId, - loadedMediaMap: composerState.loadedMediaMap, }) - composerDispatch({type: 'mark_saved', draftId: savedDraft.id}) + composerDispatch({type: 'mark_saved', draftId}) onClose() } catch (e) { logger.error('Failed to save draft', {error: e}) setError(_(msg`Failed to save draft`)) } - }, [saveDraft, composerState, replyTo, composerDispatch, onClose, _]) + }, [saveDraft, composerState, composerDispatch, onClose, _]) // Save without closing - for use by DraftsButton const saveCurrentDraft = React.useCallback(async () => { - const savedDraft = await saveDraft({ + const draftId = await saveDraft({ composerState, - replyTo, existingDraftId: composerState.draftId, - loadedMediaMap: composerState.loadedMediaMap, }) - composerDispatch({type: 'mark_saved', draftId: savedDraft.id}) - }, [saveDraft, composerState, replyTo, composerDispatch]) + composerDispatch({type: 'mark_saved', draftId}) + }, [saveDraft, composerState, composerDispatch]) // Check if composer is empty (no content to save) const isComposerEmpty = React.useMemo(() => { @@ -1143,7 +1153,7 @@ function ComposerTopBar({ isThread: boolean onCancel: () => void onPublish: () => void - onSelectDraft: (draft: StoredDraft) => void + onSelectDraft: (draft: DraftSummary) => void onSaveDraft: () => Promise onDiscard: () => void isEmpty: boolean @@ -1174,13 +1184,16 @@ function ComposerTopBar({ - + {/* Drafts not supported for replies */} + {!isReply && ( + + )} {isPublishing ? ( <> {publishingStage} diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx index 6ff93a6c17..a1b74a5954 100644 --- a/src/view/com/composer/drafts/DraftItem.tsx +++ b/src/view/com/composer/drafts/DraftItem.tsx @@ -9,7 +9,7 @@ import {isNative} from '#/platform/detection' import { type DraftPostDisplay, type DraftSummary, - type LocalMediaRef, + type LocalMediaDisplay, } from '#/state/drafts' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' import {useSession} from '#/state/session' @@ -62,13 +62,20 @@ export function DraftItem({ (pressed || hovered) && t.atoms.bg_contrast_25, ]}> - {/* Reply indicator */} - {draft.isReply && draft.replyToHandle && ( - - Replying to @{draft.replyToHandle} - + {/* Missing media warning */} + {draft.hasMissingMedia && ( + + + Some media unavailable (saved on another device) + + )} {/* Posts */} @@ -207,61 +214,50 @@ function DraftPostRow({ type LoadedImage = { url: string - meta: LocalMediaRef + meta: LocalMediaDisplay } function DraftMediaPreview({post}: {post: DraftPostDisplay}) { const t = useTheme() - const {currentAccount} = useSession() const [loadedImages, setLoadedImages] = useState([]) - const [gifUrl, setGifUrl] = useState(null) useEffect(() => { async function loadMedia() { - if (!currentAccount?.did) return - - // Load images + // Load images that exist locally if (post.images && post.images.length > 0) { const loaded: LoadedImage[] = [] for (const image of post.images) { - try { - const url = await storage.loadMediaFromLocal( - currentAccount.did, - image.localId, - ) - loaded.push({url, meta: image}) - } catch (e) { - // Image might not exist anymore - console.warn('Failed to load draft image', e) + if (image.exists) { + try { + const url = await storage.loadMediaFromLocal(image.localPath) + loaded.push({url, meta: image}) + } catch (e) { + console.warn('Failed to load draft image', e) + } } } setLoadedImages(loaded) } - - // GIFs have a URL directly - if (post.gif) { - setGifUrl(post.gif.url) - } } loadMedia() - }, [currentAccount?.did, post.images, post.gif]) + }, [post.images]) // Convert loaded images to ViewImage format for the embed components const viewImages = useMemo(() => { - return loadedImages.map(({url, meta}) => ({ + return loadedImages.map(({url}) => ({ thumb: url, fullsize: url, - alt: meta.altText || '', - aspectRatio: - meta.width && meta.height - ? {width: meta.width, height: meta.height} - : undefined, + alt: '', + aspectRatio: undefined, // No dimensions stored in new schema })) }, [loadedImages]) + // Count missing images + const missingImageCount = post.images?.filter(img => !img.exists).length ?? 0 + // Nothing to show - if (viewImages.length === 0 && !gifUrl && !post.video) { + if (viewImages.length === 0 && !post.gif && !post.video) { return null } @@ -273,8 +269,18 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) { )} {viewImages.length > 1 && } + {/* Missing images note */} + {missingImageCount > 0 && ( + + + {missingImageCount} image{missingImageCount > 1 ? 's' : ''} not + available + + + )} + {/* GIF preview */} - {gifUrl && ( + {post.gif && ( - Video attached + {post.video.exists ? ( + Video attached + ) : ( + Video not available + )} )} diff --git a/src/view/com/composer/drafts/DraftsButton.tsx b/src/view/com/composer/drafts/DraftsButton.tsx index 7aa253ee7b..3cf21d4200 100644 --- a/src/view/com/composer/drafts/DraftsButton.tsx +++ b/src/view/com/composer/drafts/DraftsButton.tsx @@ -1,7 +1,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {type StoredDraft, useSaveDraft} from '#/state/drafts' +import {type DraftSummary, useSaveDraft} from '#/state/drafts' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -15,7 +15,7 @@ export function DraftsButton({ isEmpty, isDirty, }: { - onSelectDraft: (draft: StoredDraft) => void + onSelectDraft: (draft: DraftSummary) => void onSaveDraft: () => Promise onDiscard: () => void isEmpty: boolean diff --git a/src/view/com/composer/drafts/DraftsListDialog.tsx b/src/view/com/composer/drafts/DraftsListDialog.tsx index f66d19571f..77fc9d8c39 100644 --- a/src/view/com/composer/drafts/DraftsListDialog.tsx +++ b/src/view/com/composer/drafts/DraftsListDialog.tsx @@ -4,13 +4,7 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {isNative} from '#/platform/detection' -import { - type DraftSummary, - type StoredDraft, - useDeleteDraft, - useDrafts, - useLoadDraft, -} from '#/state/drafts' +import {type DraftSummary, useDeleteDraft, useDrafts} from '#/state/drafts' import {atoms as a, useTheme, web} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -24,24 +18,20 @@ export function DraftsListDialog({ onSelectDraft, }: { control: Dialog.DialogControlProps - onSelectDraft: (draft: StoredDraft) => void + onSelectDraft: (draft: DraftSummary) => void }) { const {_} = useLingui() const t = useTheme() const {data: drafts, isLoading} = useDrafts() - const loadDraft = useLoadDraft() const {mutate: deleteDraft} = useDeleteDraft() const handleSelectDraft = useCallback( - async (summary: DraftSummary) => { - const draft = await loadDraft(summary.id) - if (draft) { - control.close(() => { - onSelectDraft(draft) - }) - } + (summary: DraftSummary) => { + control.close(() => { + onSelectDraft(summary) + }) }, - [loadDraft, control, onSelectDraft], + [control, onSelectDraft], ) const handleDeleteDraft = useCallback( diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 52cfc54a74..b71a435259 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -15,7 +15,6 @@ import { postUriToRelativePath, toBskyAppUrl, } from '#/lib/strings/url-helpers' -import {type StoredDraft} from '#/state/drafts/schema' import {type ComposerImage, createInitialImages} from '#/state/gallery' import {createPostgateRecord} from '#/state/queries/postgate/util' import {type Gif} from '#/state/queries/tenor' @@ -131,8 +130,10 @@ export type ComposerAction = } | { type: 'restore_from_draft' - draft: StoredDraft - /** Map of localId -> loaded media path/URL */ + draftId: string + posts: PostDraft[] + threadgate: Array<{type: string; list?: string}> + /** Map of localRefPath -> loaded media path/URL */ loadedMedia: Map } | { @@ -255,75 +256,37 @@ export function composerReducer( } } case 'restore_from_draft': { - const {draft, loadedMedia} = action - const posts: PostDraft[] = draft.posts.map(storedPost => { - // Reconstruct RichText - const richtext = new RichText({ - text: storedPost.richtext.text, - facets: storedPost.richtext.facets, - }) + const {draftId, posts, threadgate, loadedMedia} = action - // Reconstruct embed - const embed: EmbedDraft = { - quote: storedPost.quoteUri - ? {type: 'link', uri: storedPost.quoteUri} - : undefined, - link: storedPost.linkUri - ? {type: 'link', uri: storedPost.linkUri} - : undefined, - media: undefined, - } - - // Restore images - if (storedPost.images && storedPost.images.length > 0) { - const images: ComposerImage[] = storedPost.images - .map(img => { - const path = loadedMedia.get(img.localId) - if (!path) return null - return { - alt: img.altText, - source: { - id: nanoid(), - path, - width: img.width, - height: img.height, - mime: img.mimeType, - }, - } - }) - .filter((img): img is ComposerImage => img !== null) - - if (images.length > 0) { - embed.media = {type: 'images', images} + // Convert threadgate to UI settings format + const threadgateSettings: ThreadgateAllowUISetting[] = threadgate.map( + rule => { + if (rule.type === 'mention') { + return {type: 'mention'} as ThreadgateAllowUISetting + } else if (rule.type === 'following') { + return {type: 'following'} as ThreadgateAllowUISetting + } else if (rule.type === 'followers') { + return {type: 'followers'} as ThreadgateAllowUISetting + } else if (rule.type === 'list' && rule.list) { + return {type: 'list', list: rule.list} as ThreadgateAllowUISetting } - } - - // Note: Videos require re-upload, so we store the path but mark as needing processing - // For now, we skip restoring videos as they'd need re-compression and upload - // TODO: Implement video restoration with re-upload flow - - // Note: GIFs could be restored by re-fetching from Tenor using the stored ID - // TODO: Implement GIF restoration - - return { - id: storedPost.id, - richtext, - shortenedGraphemeLength: getShortenedLength(richtext), - labels: storedPost.labels as SelfLabel[], - embed, - } - }) + return {type: 'mention'} as ThreadgateAllowUISetting // fallback + }, + ) return { activePostIndex: 0, mutableNeedsFocusActive: true, - draftId: draft.id, + draftId, isDirty: false, loadedMediaMap: loadedMedia, thread: { posts, - postgate: draft.postgate || state.thread.postgate, - threadgate: draft.threadgate || state.thread.threadgate, + postgate: state.thread.postgate, + threadgate: + threadgateSettings.length > 0 + ? threadgateSettings + : state.thread.threadgate, }, } }