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 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-01-15 18:53:12 +02:00
parent 2dbee371f3
commit 666e1318b2
12 changed files with 853 additions and 1648 deletions
@@ -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<string, string>()
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<string, string>()
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<string, string>()
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<string, string>()
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<string, string>()
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<string, string>([
['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<string, string>()
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<string, string>()
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<string, string>()
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<string, string>()
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')
})
})
})
+460
View File
@@ -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<string, string>
} {
const localRefPaths = new Map<string, string>()
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<string, string>,
): 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<string, string>,
): 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<string, string>,
): 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<string, string>,
): 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)
}
+126 -336
View File
@@ -1,61 +1,95 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {type AppBskyDraftDefs} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
import {type ComposerImage} from '#/state/gallery' import {logger} from '#/logger'
import {useSession} from '#/state/session' import {useAgent} from '#/state/session'
import {type ComposerOpts} from '#/state/shell/composer' import {type ComposerState} from '#/view/com/composer/state/composer'
import { import {
type ComposerState, composerStateToDraft,
type PostDraft, draftToComposerPosts,
} from '#/view/com/composer/state/composer' draftViewToSummary,
import {type VideoState} from '#/view/com/composer/state/video' threadgateToUISettings,
import { } from './api'
type DraftSummary, import {type DraftSummary} from './schema'
type LocalMediaRef,
type StoredDraft,
type StoredGif,
type StoredPostDraft,
type StoredRichText,
} from './schema'
import * as storage from './storage' import * as storage from './storage'
const DRAFTS_QUERY_KEY_ROOT = 'drafts' const DRAFTS_QUERY_KEY = ['drafts']
export function draftsQueryKey(did: string) {
return [DRAFTS_QUERY_KEY_ROOT, did]
}
/** /**
* Hook to list all drafts for the current account * Hook to list all drafts for the current account
*/ */
export function useDrafts() { export function useDrafts() {
const {currentAccount} = useSession() const agent = useAgent()
const did = currentAccount?.did
return useQuery<DraftSummary[]>({ return useQuery<DraftSummary[]>({
queryKey: draftsQueryKey(did || ''), queryKey: DRAFTS_QUERY_KEY,
queryFn: async () => { queryFn: async () => {
if (!did) return [] const res = await agent.app.bsky.draft.getDrafts({})
return storage.listDrafts(did) 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() { export function useLoadDraft() {
const {currentAccount} = useSession() const agent = useAgent()
const did = currentAccount?.did
return useCallback( return useCallback(
async (draftId: string): Promise<StoredDraft | null> => { async (
if (!did) return null draftId: string,
return storage.loadDraftMeta(did, draftId) ): Promise<{
draft: AppBskyDraftDefs.Draft
loadedMedia: Map<string, string>
} | 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<string, string>()
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 * Hook to save a draft
*/ */
export function useSaveDraft() { export function useSaveDraft() {
const {currentAccount} = useSession() const agent = useAgent()
const did = currentAccount?.did
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({
composerState, composerState,
replyTo,
existingDraftId, existingDraftId,
loadedMediaMap,
}: { }: {
composerState: ComposerState composerState: ComposerState
replyTo?: ComposerOpts['replyTo']
existingDraftId?: string existingDraftId?: string
loadedMediaMap?: Map<string, string> // localId -> path/url }): Promise<string> => {
}): Promise<StoredDraft> => { // Convert composer state to server draft format
if (!did) { const {draft, localRefPaths} = composerStateToDraft(composerState)
throw new Error('No account')
}
const now = new Date().toISOString() // Save media files locally
const draftId = existingDraftId || nanoid() for (const [localRefPath, sourcePath] of localRefPaths) {
// Check if this media is already saved (re-saving existing draft)
// Build a reverse map (path -> localId) for identifying reusable media if (!storage.mediaExists(localRefPath)) {
const pathToLocalId = new Map<string, string>() await storage.saveMediaToLocal(localRefPath, sourcePath)
if (loadedMediaMap) {
for (const [localId, path] of loadedMediaMap) {
pathToLocalId.set(path, localId)
} }
} }
// Collect old media localIds for cleanup
let oldMediaLocalIds: Set<string> = new Set()
if (existingDraftId) { if (existingDraftId) {
const existingDraft = await storage.loadDraftMeta(did, existingDraftId) // Update existing draft
if (existingDraft) { await agent.app.bsky.draft.updateDraft({
oldMediaLocalIds = collectMediaLocalIds(existingDraft) 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<string>()
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: () => { onSuccess: () => {
if (did) { queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
queryClient.invalidateQueries({queryKey: draftsQueryKey(did)}) },
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 * Hook to delete a draft
*/ */
export function useDeleteDraft() { export function useDeleteDraft() {
const {currentAccount} = useSession() const agent = useAgent()
const did = currentAccount?.did
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (draftId: string) => { mutationFn: async (draftId: string) => {
if (!did) { // First fetch the draft to get media paths for cleanup
throw new Error('No account') 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)
} }
await storage.deleteDraft(did, draftId) }
if (post.embedVideos) {
for (const vid of post.embedVideos) {
await storage.deleteMediaFromLocal(vid.localRef.path)
}
}
}
}
// Delete from server
await agent.app.bsky.draft.deleteDraft({id: draftId})
}, },
onSuccess: () => { onSuccess: () => {
if (did) { queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
queryClient.invalidateQueries({queryKey: draftsQueryKey(did)})
}
}, },
}) })
} }
/** // Re-export utilities for use in composer
* Collect all media localIds from a draft export {draftToComposerPosts, threadgateToUISettings}
*/
function collectMediaLocalIds(draft: StoredDraft): Set<string> {
const localIds = new Set<string>()
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<string, string>,
reusedLocalIds: Set<string>,
): Promise<StoredPostDraft> {
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<string, string>,
reusedLocalIds: Set<string>,
): Promise<LocalMediaRef[]> {
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<string, string>,
reusedLocalIds: Set<string>,
): Promise<LocalMediaRef | undefined> {
// 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<string, {url: string; dims: number[]}>
}
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<Map<string, string>> {
const mediaMap = new Map<string, string>()
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
}
+1
View File
@@ -1,2 +1,3 @@
export * from './api'
export * from './hooks' export * from './hooks'
export * from './schema' export * from './schema'
+26 -91
View File
@@ -1,95 +1,30 @@
import {type AppBskyFeedPostgate, type AppBskyRichtextFacet} from '@atproto/api' /**
* Types for draft display and local media tracking.
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' * 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 = { export type LocalMediaDisplay = {
/** UUID for local storage key */ /** Path stored in server draft (used as key for local lookup) */
localId: string localPath: 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
/** Alt text */ /** Alt text */
altText: string 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 = { export type GifDisplay = {
text: string /** Full URL with dimensions */
facets?: AppBskyRichtextFacet.Main[] url: string
} /** Width */
width: number
/** /** Height */
* Serializable version of PostDraft for storage height: number
*/
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'
} }
/** /**
@@ -99,12 +34,12 @@ export type DraftPostDisplay = {
id: string id: string
/** Full text content */ /** Full text content */
text: string text: string
/** Image URLs for display (local IDs that need to be loaded) */ /** Image references for display */
images?: LocalMediaRef[] images?: LocalMediaDisplay[]
/** Video reference */ /** Video reference */
video?: LocalMediaRef video?: LocalMediaDisplay
/** GIF metadata */ /** GIF data (from URL) */
gif?: StoredGif gif?: GifDisplay
} }
/** /**
@@ -116,14 +51,14 @@ export type DraftSummary = {
previewText: string previewText: string
/** Whether the draft has media */ /** Whether the draft has media */
hasMedia: boolean hasMedia: boolean
/** Whether some media is missing (saved on another device) */
hasMissingMedia?: boolean
/** Number of media items */ /** Number of media items */
mediaCount: number mediaCount: number
/** Number of posts in thread */ /** Number of posts in thread */
postCount: number postCount: number
/** Whether this is a reply */ /** Whether this is a reply (always false - replies not supported) */
isReply: boolean isReply: boolean
/** Reply to author handle (if reply) */
replyToHandle?: string
/** ISO timestamp of last update */ /** ISO timestamp of last update */
updatedAt: string updatedAt: string
/** All posts in the draft for full display */ /** All posts in the draft for full display */
+60 -267
View File
@@ -1,78 +1,61 @@
/**
* Native file system storage for draft media.
* Media is stored by localRefPath key (unique identifier stored in server draft).
*/
import { import {
copyAsync, copyAsync,
deleteAsync, deleteAsync,
documentDirectory, documentDirectory,
getInfoAsync, getInfoAsync,
makeDirectoryAsync, makeDirectoryAsync,
readAsStringAsync,
readDirectoryAsync,
writeAsStringAsync,
} from 'expo-file-system/legacy' } from 'expo-file-system/legacy'
import {nanoid} from 'nanoid/non-secure'
import {logger} from '#/logger' 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 { function joinPath(...segments: string[]): string {
return segments.join('/').replace(/\/+/g, '/') return segments.join('/').replace(/\/+/g, '/')
} }
function getDraftsDirectory(accountDid: string): string { function getMediaDirectory(): string {
return joinPath(documentDirectory!, DRAFTS_DIR, accountDid) return joinPath(documentDirectory!, MEDIA_DIR)
} }
function getMediaDirectory(accountDid: string): string { function getMediaPath(localRefPath: string): string {
return joinPath(getDraftsDirectory(accountDid), 'media') // Use localRefPath as filename (replace unsafe chars)
const safeFilename = localRefPath.replace(/[/:]/g, '_')
return joinPath(getMediaDirectory(), safeFilename)
} }
function getMediaPath(accountDid: string, localId: string): string { let dirCreated = false
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`)
}
/** /**
* Ensure the drafts directories exist * Ensure the media directory exists
*/ */
async function ensureDirectories(accountDid: string): Promise<void> { async function ensureDirectory(): Promise<void> {
await makeDirectoryAsync(getMediaDirectory(accountDid), {intermediates: true}) if (dirCreated) return
await makeDirectoryAsync(getDraftsMetaDirectory(accountDid), { await makeDirectoryAsync(getMediaDirectory(), {intermediates: true})
intermediates: true, dirCreated = true
})
} }
/** /**
* Save a media file to local storage * Save a media file to local storage by localRefPath key
* @returns The local ID for the saved media
*/ */
export async function saveMediaToLocal( export async function saveMediaToLocal(
accountDid: string, localRefPath: string,
sourcePath: string, sourcePath: string,
_mimeType: string, ): Promise<void> {
): Promise<string> { await ensureDirectory()
await ensureDirectories(accountDid)
const localId = nanoid() const destPath = getMediaPath(localRefPath)
const destPath = getMediaPath(accountDid, localId)
try { try {
await copyAsync({from: sourcePath, to: destPath}) await copyAsync({from: sourcePath, to: destPath})
return localId
} catch (error) { } catch (error) {
logger.error('Failed to save media to drafts storage', { logger.error('Failed to save media to drafts storage', {
error, error,
localRefPath,
sourcePath, sourcePath,
destPath, destPath,
}) })
@@ -85,14 +68,13 @@ export async function saveMediaToLocal(
* @returns The file path for the saved media * @returns The file path for the saved media
*/ */
export async function loadMediaFromLocal( export async function loadMediaFromLocal(
accountDid: string, localRefPath: string,
localId: string,
): Promise<string> { ): Promise<string> {
const path = getMediaPath(accountDid, localId) const path = getMediaPath(localRefPath)
const info = await getInfoAsync(path) const info = await getInfoAsync(path)
if (!info.exists) { if (!info.exists) {
throw new Error(`Media file not found: ${localId}`) throw new Error(`Media file not found: ${localRefPath}`)
} }
return path return path
@@ -102,244 +84,55 @@ export async function loadMediaFromLocal(
* Delete a media file from local storage * Delete a media file from local storage
*/ */
export async function deleteMediaFromLocal( export async function deleteMediaFromLocal(
accountDid: string, localRefPath: string,
localId: string,
): Promise<void> { ): Promise<void> {
const path = getMediaPath(accountDid, localId) const path = getMediaPath(localRefPath)
await deleteAsync(path, {idempotent: true}) 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( const mediaExistsCache = new Map<string, boolean>()
accountDid: string, let cachePopulated = false
draft: StoredDraft,
): Promise<void> {
await ensureDirectories(accountDid)
const path = getDraftMetaPath(accountDid, draft.id) 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
}
async function populateCache(): Promise<void> {
try { try {
await writeAsStringAsync(path, JSON.stringify(draft)) const {readDirectoryAsync} = await import('expo-file-system/legacy')
} catch (error) { const dir = getMediaDirectory()
logger.error('Failed to save draft metadata', {error, draftId: draft.id}) const info = await getInfoAsync(dir)
throw error if (info.exists) {
} const files = await readDirectoryAsync(dir)
}
/**
* Load draft metadata from local storage
*/
export async function loadDraftMeta(
accountDid: string,
draftId: string,
): Promise<StoredDraft | null> {
const path = getDraftMetaPath(accountDid, draftId)
const info = await getInfoAsync(path)
if (!info.exists) {
return null
}
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<DraftSummary[]> {
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) { for (const file of files) {
if (!file.endsWith('.json')) continue // Reverse the safe filename transformation
const localRefPath = file.replace(/_/g, ':').replace(/_/g, '/')
const draftId = file.replace('.json', '') mediaExistsCache.set(localRefPath, true)
const draft = await loadDraftMeta(accountDid, draftId)
if (draft) {
summaries.push(createDraftSummary(draft))
} }
} }
cachePopulated = true
// Sort by updatedAt descending (most recent first) } catch (e) {
summaries.sort( logger.warn('Failed to populate media cache', {error: e})
(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 * Clear the media exists cache (call when media is added/deleted)
*/ */
export async function deleteDraft( export function clearMediaCache(): void {
accountDid: string, mediaExistsCache.clear()
draftId: string, cachePopulated = false
): Promise<void> {
// 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<void> {
const draftsDir = getDraftsDirectory(accountDid)
await deleteAsync(draftsDir, {idempotent: true})
}
/**
* Get the total storage size used by drafts
*/
export async function getDraftsStorageSize(
accountDid: string,
): Promise<number> {
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<boolean> {
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
} }
+58 -278
View File
@@ -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 {type DBSchema, type IDBPDatabase, openDB} from 'idb'
import {nanoid} from 'nanoid/non-secure'
import {logger} from '#/logger' 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 const DB_VERSION = 1
interface DraftsDB extends DBSchema { interface DraftMediaDB extends DBSchema {
'draft-media': { media: {
key: string // "{accountDid}:{localId}" key: string // localRefPath
value: { value: {
blob: Blob blob: Blob
mimeType: string
createdAt: string createdAt: string
} }
} }
'draft-meta': {
key: string // "{accountDid}:{draftId}"
value: StoredDraft
indexes: {
'by-account': string
'by-updated': string
}
}
} }
let dbPromise: Promise<IDBPDatabase<DraftsDB>> | null = null let dbPromise: Promise<IDBPDatabase<DraftMediaDB>> | null = null
async function getDB(): Promise<IDBPDatabase<DraftsDB>> { async function getDB(): Promise<IDBPDatabase<DraftMediaDB>> {
if (!dbPromise) { if (!dbPromise) {
dbPromise = openDB<DraftsDB>(DB_NAME, DB_VERSION, { dbPromise = openDB<DraftMediaDB>(DB_NAME, DB_VERSION, {
upgrade(db) { upgrade(db) {
// Create media store if (!db.objectStoreNames.contains('media')) {
if (!db.objectStoreNames.contains('draft-media')) { db.createObjectStore('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')
} }
}, },
}) })
@@ -53,51 +34,37 @@ async function getDB(): Promise<IDBPDatabase<DraftsDB>> {
return dbPromise 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<Blob> { async function toBlob(sourcePath: string): Promise<Blob> {
if (input instanceof Blob) { const response = await fetch(sourcePath)
return input
}
const response = await fetch(input)
return response.blob() return response.blob()
} }
/** /**
* Save a media file to IndexedDB * Save a media file to IndexedDB by localRefPath key
* @returns The local ID for the saved media
*/ */
export async function saveMediaToLocal( export async function saveMediaToLocal(
accountDid: string, localRefPath: string,
source: string | Blob, sourcePath: string,
mimeType: string, ): Promise<void> {
): Promise<string> {
const db = await getDB() const db = await getDB()
const localId = nanoid() const blob = await toBlob(sourcePath)
const blob = await toBlob(source)
try { try {
await db.put( await db.put(
'draft-media', 'media',
{ {
blob, blob,
mimeType,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}, },
mediaKey(accountDid, localId), localRefPath,
) )
return localId // Update cache
mediaExistsCache.set(localRefPath, true)
} catch (error) { } catch (error) {
logger.error('Failed to save media to IndexedDB', {error}) logger.error('Failed to save media to IndexedDB', {error, localRefPath})
throw error throw error
} }
} }
@@ -107,14 +74,13 @@ export async function saveMediaToLocal(
* @returns A blob URL for the saved media * @returns A blob URL for the saved media
*/ */
export async function loadMediaFromLocal( export async function loadMediaFromLocal(
accountDid: string, localRefPath: string,
localId: string,
): Promise<string> { ): Promise<string> {
const db = await getDB() const db = await getDB()
const record = await db.get('draft-media', mediaKey(accountDid, localId)) const record = await db.get('media', localRefPath)
if (!record) { if (!record) {
throw new Error(`Media file not found: ${localId}`) throw new Error(`Media file not found: ${localRefPath}`)
} }
return URL.createObjectURL(record.blob) return URL.createObjectURL(record.blob)
@@ -124,221 +90,49 @@ export async function loadMediaFromLocal(
* Delete a media file from IndexedDB * Delete a media file from IndexedDB
*/ */
export async function deleteMediaFromLocal( export async function deleteMediaFromLocal(
accountDid: string, localRefPath: string,
localId: string,
): Promise<void> { ): Promise<void> {
const db = await getDB() 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( const mediaExistsCache = new Map<string, boolean>()
accountDid: string, let cachePopulated = false
draft: StoredDraft,
): Promise<void> {
const db = await getDB()
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<void> {
try { 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<StoredDraft | null> {
const db = await getDB() const db = await getDB()
const keys = await db.getAllKeys('media')
try { for (const key of keys) {
const draft = await db.get('draft-meta', draftKey(accountDid, draftId)) mediaExistsCache.set(key, true)
return draft || null }
} catch (error) { cachePopulated = true
logger.error('Failed to load draft metadata', {error, draftId}) } catch (e) {
return null logger.warn('Failed to populate media cache', {error: e})
} }
} }
/** /**
* List all drafts for an account * Clear the media exists cache (call when media is added/deleted)
*/ */
export async function listDrafts(accountDid: string): Promise<DraftSummary[]> { export function clearMediaCache(): void {
const db = await getDB() mediaExistsCache.clear()
cachePopulated = false
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<void> {
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)
}
}
}
}
}
// 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<void> {
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)
}
}
/**
* Get the total storage size used by drafts (approximate)
*/
export async function getDraftsStorageSize(
accountDid: string,
): Promise<number> {
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<boolean> {
const db = await getDB()
const record = await db.get('draft-media', mediaKey(accountDid, localId))
return record !== undefined
} }
/** /**
@@ -349,17 +143,3 @@ export function revokeMediaUrl(url: string): void {
URL.revokeObjectURL(url) 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
}
+31 -18
View File
@@ -76,7 +76,13 @@ import {cleanError} from '#/lib/strings/errors'
import {colors} from '#/lib/styles' import {colors} from '#/lib/styles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs' 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 {emitPostCreated} from '#/state/events'
import { import {
type ComposerImage, type ComposerImage,
@@ -192,6 +198,7 @@ export const ComposePost = ({
const textInput = useRef<TextInputRef>(null) const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl() const discardPromptControl = Prompt.usePromptControl()
const {mutateAsync: saveDraft, isPending: _isSavingDraft} = useSaveDraft() const {mutateAsync: saveDraft, isPending: _isSavingDraft} = useSaveDraft()
const loadDraft = useLoadDraft()
const {closeAllDialogs} = useDialogStateControlContext() const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls() const {closeAllModals} = useModalControls()
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
@@ -324,20 +331,27 @@ export const ComposePost = ({
) )
const handleSelectDraft = React.useCallback( const handleSelectDraft = React.useCallback(
async (draft: StoredDraft) => { async (draftSummary: DraftSummary) => {
if (!currentDid) return // Load full draft from server with media
const result = await loadDraft(draftSummary.id)
if (!result) return
// Load media from local storage const {draft, loadedMedia} = result
const loadedMedia = await loadDraftMedia(currentDid, draft)
// 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) // Dispatch restore action (this also sets draftId in state)
composerDispatch({ composerDispatch({
type: 'restore_from_draft', type: 'restore_from_draft',
draft, draftId: draftSummary.id,
posts,
threadgate,
loadedMedia, loadedMedia,
}) })
}, },
[currentDid, composerDispatch], [loadDraft, composerDispatch],
) )
const [publishOnUpload, setPublishOnUpload] = useState(false) const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -349,30 +363,26 @@ export const ComposePost = ({
const handleSaveDraft = React.useCallback(async () => { const handleSaveDraft = React.useCallback(async () => {
try { try {
const savedDraft = await saveDraft({ const draftId = await saveDraft({
composerState, composerState,
replyTo,
existingDraftId: composerState.draftId, existingDraftId: composerState.draftId,
loadedMediaMap: composerState.loadedMediaMap,
}) })
composerDispatch({type: 'mark_saved', draftId: savedDraft.id}) composerDispatch({type: 'mark_saved', draftId})
onClose() onClose()
} catch (e) { } catch (e) {
logger.error('Failed to save draft', {error: e}) logger.error('Failed to save draft', {error: e})
setError(_(msg`Failed to save draft`)) setError(_(msg`Failed to save draft`))
} }
}, [saveDraft, composerState, replyTo, composerDispatch, onClose, _]) }, [saveDraft, composerState, composerDispatch, onClose, _])
// Save without closing - for use by DraftsButton // Save without closing - for use by DraftsButton
const saveCurrentDraft = React.useCallback(async () => { const saveCurrentDraft = React.useCallback(async () => {
const savedDraft = await saveDraft({ const draftId = await saveDraft({
composerState, composerState,
replyTo,
existingDraftId: composerState.draftId, existingDraftId: composerState.draftId,
loadedMediaMap: composerState.loadedMediaMap,
}) })
composerDispatch({type: 'mark_saved', draftId: savedDraft.id}) composerDispatch({type: 'mark_saved', draftId})
}, [saveDraft, composerState, replyTo, composerDispatch]) }, [saveDraft, composerState, composerDispatch])
// Check if composer is empty (no content to save) // Check if composer is empty (no content to save)
const isComposerEmpty = React.useMemo(() => { const isComposerEmpty = React.useMemo(() => {
@@ -1143,7 +1153,7 @@ function ComposerTopBar({
isThread: boolean isThread: boolean
onCancel: () => void onCancel: () => void
onPublish: () => void onPublish: () => void
onSelectDraft: (draft: StoredDraft) => void onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<void> onSaveDraft: () => Promise<void>
onDiscard: () => void onDiscard: () => void
isEmpty: boolean isEmpty: boolean
@@ -1174,6 +1184,8 @@ function ComposerTopBar({
</ButtonText> </ButtonText>
</Button> </Button>
<View style={a.flex_1} /> <View style={a.flex_1} />
{/* Drafts not supported for replies */}
{!isReply && (
<DraftsButton <DraftsButton
onSelectDraft={onSelectDraft} onSelectDraft={onSelectDraft}
onSaveDraft={onSaveDraft} onSaveDraft={onSaveDraft}
@@ -1181,6 +1193,7 @@ function ComposerTopBar({
isEmpty={isEmpty} isEmpty={isEmpty}
isDirty={isDirty} isDirty={isDirty}
/> />
)}
{isPublishing ? ( {isPublishing ? (
<> <>
<Text style={pal.textLight}>{publishingStage}</Text> <Text style={pal.textLight}>{publishingStage}</Text>
+45 -40
View File
@@ -9,7 +9,7 @@ import {isNative} from '#/platform/detection'
import { import {
type DraftPostDisplay, type DraftPostDisplay,
type DraftSummary, type DraftSummary,
type LocalMediaRef, type LocalMediaDisplay,
} from '#/state/drafts' } from '#/state/drafts'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -62,13 +62,20 @@ export function DraftItem({
(pressed || hovered) && t.atoms.bg_contrast_25, (pressed || hovered) && t.atoms.bg_contrast_25,
]}> ]}>
<View style={[a.p_md, a.gap_sm]}> <View style={[a.p_md, a.gap_sm]}>
{/* Reply indicator */} {/* Missing media warning */}
{draft.isReply && draft.replyToHandle && ( {draft.hasMissingMedia && (
<Text <View
style={[a.text_xs, t.atoms.text_contrast_medium, a.pb_2xs]} style={[
numberOfLines={1}> a.rounded_sm,
<Trans>Replying to @{draft.replyToHandle}</Trans> a.px_sm,
a.py_xs,
a.mb_xs,
t.atoms.bg_contrast_100,
]}>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>Some media unavailable (saved on another device)</Trans>
</Text> </Text>
</View>
)} )}
{/* Posts */} {/* Posts */}
@@ -207,61 +214,50 @@ function DraftPostRow({
type LoadedImage = { type LoadedImage = {
url: string url: string
meta: LocalMediaRef meta: LocalMediaDisplay
} }
function DraftMediaPreview({post}: {post: DraftPostDisplay}) { function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession()
const [loadedImages, setLoadedImages] = useState<LoadedImage[]>([]) const [loadedImages, setLoadedImages] = useState<LoadedImage[]>([])
const [gifUrl, setGifUrl] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
async function loadMedia() { async function loadMedia() {
if (!currentAccount?.did) return // Load images that exist locally
// Load images
if (post.images && post.images.length > 0) { if (post.images && post.images.length > 0) {
const loaded: LoadedImage[] = [] const loaded: LoadedImage[] = []
for (const image of post.images) { for (const image of post.images) {
if (image.exists) {
try { try {
const url = await storage.loadMediaFromLocal( const url = await storage.loadMediaFromLocal(image.localPath)
currentAccount.did,
image.localId,
)
loaded.push({url, meta: image}) loaded.push({url, meta: image})
} catch (e) { } catch (e) {
// Image might not exist anymore
console.warn('Failed to load draft image', e) console.warn('Failed to load draft image', e)
} }
} }
}
setLoadedImages(loaded) setLoadedImages(loaded)
} }
// GIFs have a URL directly
if (post.gif) {
setGifUrl(post.gif.url)
}
} }
loadMedia() loadMedia()
}, [currentAccount?.did, post.images, post.gif]) }, [post.images])
// Convert loaded images to ViewImage format for the embed components // Convert loaded images to ViewImage format for the embed components
const viewImages = useMemo<AppBskyEmbedImages.ViewImage[]>(() => { const viewImages = useMemo<AppBskyEmbedImages.ViewImage[]>(() => {
return loadedImages.map(({url, meta}) => ({ return loadedImages.map(({url}) => ({
thumb: url, thumb: url,
fullsize: url, fullsize: url,
alt: meta.altText || '', alt: '',
aspectRatio: aspectRatio: undefined, // No dimensions stored in new schema
meta.width && meta.height
? {width: meta.width, height: meta.height}
: undefined,
})) }))
}, [loadedImages]) }, [loadedImages])
// Count missing images
const missingImageCount = post.images?.filter(img => !img.exists).length ?? 0
// Nothing to show // Nothing to show
if (viewImages.length === 0 && !gifUrl && !post.video) { if (viewImages.length === 0 && !post.gif && !post.video) {
return null return null
} }
@@ -273,8 +269,18 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
)} )}
{viewImages.length > 1 && <ImageLayoutGrid images={viewImages} />} {viewImages.length > 1 && <ImageLayoutGrid images={viewImages} />}
{/* Missing images note */}
{missingImageCount > 0 && (
<Text style={[a.text_xs, t.atoms.text_contrast_medium, a.mt_xs]}>
<Trans>
{missingImageCount} image{missingImageCount > 1 ? 's' : ''} not
available
</Trans>
</Text>
)}
{/* GIF preview */} {/* GIF preview */}
{gifUrl && ( {post.gif && (
<View <View
style={[ style={[
a.rounded_md, a.rounded_md,
@@ -282,13 +288,13 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
{ {
aspectRatio: aspectRatio:
post.gif?.width && post.gif?.height post.gif.width && post.gif.height
? post.gif.width / post.gif.height ? post.gif.width / post.gif.height
: 16 / 9, : 16 / 9,
}, },
]}> ]}>
<Image <Image
source={{uri: gifUrl}} source={{uri: post.gif.url}}
style={[a.flex_1]} style={[a.flex_1]}
contentFit="cover" contentFit="cover"
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
@@ -305,15 +311,14 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
a.align_center, a.align_center,
a.justify_center, a.justify_center,
t.atoms.bg_contrast_50, t.atoms.bg_contrast_50,
{ {aspectRatio: 16 / 9},
aspectRatio:
post.video.width && post.video.height
? post.video.width / post.video.height
: 16 / 9,
},
]}> ]}>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}> <Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
{post.video.exists ? (
<Trans>Video attached</Trans> <Trans>Video attached</Trans>
) : (
<Trans>Video not available</Trans>
)}
</Text> </Text>
</View> </View>
)} )}
@@ -1,7 +1,7 @@
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@@ -15,7 +15,7 @@ export function DraftsButton({
isEmpty, isEmpty,
isDirty, isDirty,
}: { }: {
onSelectDraft: (draft: StoredDraft) => void onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<void> onSaveDraft: () => Promise<void>
onDiscard: () => void onDiscard: () => void
isEmpty: boolean isEmpty: boolean
@@ -4,13 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import { import {type DraftSummary, useDeleteDraft, useDrafts} from '#/state/drafts'
type DraftSummary,
type StoredDraft,
useDeleteDraft,
useDrafts,
useLoadDraft,
} from '#/state/drafts'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@@ -24,24 +18,20 @@ export function DraftsListDialog({
onSelectDraft, onSelectDraft,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
onSelectDraft: (draft: StoredDraft) => void onSelectDraft: (draft: DraftSummary) => void
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {data: drafts, isLoading} = useDrafts() const {data: drafts, isLoading} = useDrafts()
const loadDraft = useLoadDraft()
const {mutate: deleteDraft} = useDeleteDraft() const {mutate: deleteDraft} = useDeleteDraft()
const handleSelectDraft = useCallback( const handleSelectDraft = useCallback(
async (summary: DraftSummary) => { (summary: DraftSummary) => {
const draft = await loadDraft(summary.id)
if (draft) {
control.close(() => { control.close(() => {
onSelectDraft(draft) onSelectDraft(summary)
}) })
}
}, },
[loadDraft, control, onSelectDraft], [control, onSelectDraft],
) )
const handleDeleteDraft = useCallback( const handleDeleteDraft = useCallback(
+24 -61
View File
@@ -15,7 +15,6 @@ import {
postUriToRelativePath, postUriToRelativePath,
toBskyAppUrl, toBskyAppUrl,
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {type StoredDraft} from '#/state/drafts/schema'
import {type ComposerImage, createInitialImages} from '#/state/gallery' import {type ComposerImage, createInitialImages} from '#/state/gallery'
import {createPostgateRecord} from '#/state/queries/postgate/util' import {createPostgateRecord} from '#/state/queries/postgate/util'
import {type Gif} from '#/state/queries/tenor' import {type Gif} from '#/state/queries/tenor'
@@ -131,8 +130,10 @@ export type ComposerAction =
} }
| { | {
type: 'restore_from_draft' type: 'restore_from_draft'
draft: StoredDraft draftId: string
/** Map of localId -> loaded media path/URL */ posts: PostDraft[]
threadgate: Array<{type: string; list?: string}>
/** Map of localRefPath -> loaded media path/URL */
loadedMedia: Map<string, string> loadedMedia: Map<string, string>
} }
| { | {
@@ -255,75 +256,37 @@ export function composerReducer(
} }
} }
case 'restore_from_draft': { case 'restore_from_draft': {
const {draft, loadedMedia} = action const {draftId, posts, threadgate, loadedMedia} = action
const posts: PostDraft[] = draft.posts.map(storedPost => {
// Reconstruct RichText
const richtext = new RichText({
text: storedPost.richtext.text,
facets: storedPost.richtext.facets,
})
// Reconstruct embed // Convert threadgate to UI settings format
const embed: EmbedDraft = { const threadgateSettings: ThreadgateAllowUISetting[] = threadgate.map(
quote: storedPost.quoteUri rule => {
? {type: 'link', uri: storedPost.quoteUri} if (rule.type === 'mention') {
: undefined, return {type: 'mention'} as ThreadgateAllowUISetting
link: storedPost.linkUri } else if (rule.type === 'following') {
? {type: 'link', uri: storedPost.linkUri} return {type: 'following'} as ThreadgateAllowUISetting
: undefined, } else if (rule.type === 'followers') {
media: undefined, return {type: 'followers'} as ThreadgateAllowUISetting
} else if (rule.type === 'list' && rule.list) {
return {type: 'list', list: rule.list} as ThreadgateAllowUISetting
} }
return {type: 'mention'} as ThreadgateAllowUISetting // fallback
// 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}
}
}
// 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 { return {
activePostIndex: 0, activePostIndex: 0,
mutableNeedsFocusActive: true, mutableNeedsFocusActive: true,
draftId: draft.id, draftId,
isDirty: false, isDirty: false,
loadedMediaMap: loadedMedia, loadedMediaMap: loadedMedia,
thread: { thread: {
posts, posts,
postgate: draft.postgate || state.thread.postgate, postgate: state.thread.postgate,
threadgate: draft.threadgate || state.thread.threadgate, threadgate:
threadgateSettings.length > 0
? threadgateSettings
: state.thread.threadgate,
}, },
} }
} }