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:
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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<DraftSummary[]>({
|
||||
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<StoredDraft | null> => {
|
||||
if (!did) return null
|
||||
return storage.loadDraftMeta(did, draftId)
|
||||
async (
|
||||
draftId: string,
|
||||
): 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
|
||||
*/
|
||||
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<string, string> // localId -> path/url
|
||||
}): Promise<StoredDraft> => {
|
||||
if (!did) {
|
||||
throw new Error('No account')
|
||||
}
|
||||
}): Promise<string> => {
|
||||
// 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<string, string>()
|
||||
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<string> = 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<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: () => {
|
||||
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<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
|
||||
}
|
||||
// Re-export utilities for use in composer
|
||||
export {draftToComposerPosts, threadgateToUISettings}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './api'
|
||||
export * from './hooks'
|
||||
export * from './schema'
|
||||
|
||||
+26
-91
@@ -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 */
|
||||
|
||||
+59
-266
@@ -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<void> {
|
||||
await makeDirectoryAsync(getMediaDirectory(accountDid), {intermediates: true})
|
||||
await makeDirectoryAsync(getDraftsMetaDirectory(accountDid), {
|
||||
intermediates: true,
|
||||
})
|
||||
async function ensureDirectory(): Promise<void> {
|
||||
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<string> {
|
||||
await ensureDirectories(accountDid)
|
||||
): Promise<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await ensureDirectories(accountDid)
|
||||
const mediaExistsCache = new Map<string, boolean>()
|
||||
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<StoredDraft | null> {
|
||||
const path = getDraftMetaPath(accountDid, draftId)
|
||||
const info = await getInfoAsync(path)
|
||||
|
||||
if (!info.exists) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function populateCache(): Promise<void> {
|
||||
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) {
|
||||
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<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
|
||||
export function clearMediaCache(): void {
|
||||
mediaExistsCache.clear()
|
||||
cachePopulated = false
|
||||
}
|
||||
|
||||
+58
-278
@@ -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<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) {
|
||||
dbPromise = openDB<DraftsDB>(DB_NAME, DB_VERSION, {
|
||||
dbPromise = openDB<DraftMediaDB>(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<IDBPDatabase<DraftsDB>> {
|
||||
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> {
|
||||
if (input instanceof Blob) {
|
||||
return input
|
||||
}
|
||||
const response = await fetch(input)
|
||||
async function toBlob(sourcePath: string): Promise<Blob> {
|
||||
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<string> {
|
||||
localRefPath: string,
|
||||
sourcePath: string,
|
||||
): Promise<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const db = await getDB()
|
||||
const mediaExistsCache = new Map<string, boolean>()
|
||||
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<void> {
|
||||
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()
|
||||
|
||||
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<DraftSummary[]> {
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
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<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)
|
||||
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<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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<TextInputRef>(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<void>
|
||||
onDiscard: () => void
|
||||
isEmpty: boolean
|
||||
@@ -1174,13 +1184,16 @@ function ComposerTopBar({
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<View style={a.flex_1} />
|
||||
<DraftsButton
|
||||
onSelectDraft={onSelectDraft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onDiscard={onDiscard}
|
||||
isEmpty={isEmpty}
|
||||
isDirty={isDirty}
|
||||
/>
|
||||
{/* Drafts not supported for replies */}
|
||||
{!isReply && (
|
||||
<DraftsButton
|
||||
onSelectDraft={onSelectDraft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onDiscard={onDiscard}
|
||||
isEmpty={isEmpty}
|
||||
isDirty={isDirty}
|
||||
/>
|
||||
)}
|
||||
{isPublishing ? (
|
||||
<>
|
||||
<Text style={pal.textLight}>{publishingStage}</Text>
|
||||
|
||||
@@ -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,
|
||||
]}>
|
||||
<View style={[a.p_md, a.gap_sm]}>
|
||||
{/* Reply indicator */}
|
||||
{draft.isReply && draft.replyToHandle && (
|
||||
<Text
|
||||
style={[a.text_xs, t.atoms.text_contrast_medium, a.pb_2xs]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Replying to @{draft.replyToHandle}</Trans>
|
||||
</Text>
|
||||
{/* Missing media warning */}
|
||||
{draft.hasMissingMedia && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
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>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 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<LoadedImage[]>([])
|
||||
const [gifUrl, setGifUrl] = useState<string | null>(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<AppBskyEmbedImages.ViewImage[]>(() => {
|
||||
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 && <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 */}
|
||||
{gifUrl && (
|
||||
{post.gif && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
@@ -282,13 +288,13 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
aspectRatio:
|
||||
post.gif?.width && post.gif?.height
|
||||
post.gif.width && post.gif.height
|
||||
? post.gif.width / post.gif.height
|
||||
: 16 / 9,
|
||||
},
|
||||
]}>
|
||||
<Image
|
||||
source={{uri: gifUrl}}
|
||||
source={{uri: post.gif.url}}
|
||||
style={[a.flex_1]}
|
||||
contentFit="cover"
|
||||
accessibilityIgnoresInvertColors
|
||||
@@ -305,15 +311,14 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
aspectRatio:
|
||||
post.video.width && post.video.height
|
||||
? post.video.width / post.video.height
|
||||
: 16 / 9,
|
||||
},
|
||||
{aspectRatio: 16 / 9},
|
||||
]}>
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Video attached</Trans>
|
||||
{post.video.exists ? (
|
||||
<Trans>Video attached</Trans>
|
||||
) : (
|
||||
<Trans>Video not available</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -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<void>
|
||||
onDiscard: () => void
|
||||
isEmpty: boolean
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
| {
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user