Add drafts functionality to composer
- Add local storage layer for drafts (filesystem on native, IndexedDB on web) - Add "Drafts" button to composer top bar showing badge with draft count - Modify discard prompt to offer "Save Draft" option - Add `restore_from_draft` action to composer reducer - Support saving/restoring: text, facets, images, labels, threadgate, quote/link embeds - Add placeholder hooks for future server API integration - Add unit tests for draft serialization Note: Video/GIF restoration marked as TODO for future implementation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -172,6 +172,7 @@
|
|||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"hls.js": "^1.6.2",
|
"hls.js": "^1.6.2",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"js-sha256": "^0.9.0",
|
"js-sha256": "^0.9.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lande": "^1.0.10",
|
"lande": "^1.0.10",
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
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: ['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(['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,344 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {type ComposerImage} from '#/state/gallery'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {type ComposerOpts} from '#/state/shell/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'
|
||||||
|
|
||||||
|
// Import platform-specific storage
|
||||||
|
const storage = isNative ? require('./storage') : require('./storage.web')
|
||||||
|
|
||||||
|
const DRAFTS_QUERY_KEY_ROOT = 'drafts'
|
||||||
|
|
||||||
|
export function draftsQueryKey(did: string) {
|
||||||
|
return [DRAFTS_QUERY_KEY_ROOT, did]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to list all drafts for the current account
|
||||||
|
*/
|
||||||
|
export function useDrafts() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const did = currentAccount?.did
|
||||||
|
|
||||||
|
return useQuery<DraftSummary[]>({
|
||||||
|
queryKey: draftsQueryKey(did || ''),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!did) return []
|
||||||
|
return storage.listDrafts(did)
|
||||||
|
},
|
||||||
|
enabled: Boolean(did),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to load a specific draft
|
||||||
|
*/
|
||||||
|
export function useLoadDraft() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const did = currentAccount?.did
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
async (draftId: string): Promise<StoredDraft | null> => {
|
||||||
|
if (!did) return null
|
||||||
|
return storage.loadDraftMeta(did, draftId)
|
||||||
|
},
|
||||||
|
[did],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to save a draft
|
||||||
|
*/
|
||||||
|
export function useSaveDraft() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const did = currentAccount?.did
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
composerState,
|
||||||
|
replyTo,
|
||||||
|
existingDraftId,
|
||||||
|
}: {
|
||||||
|
composerState: ComposerState
|
||||||
|
replyTo?: ComposerOpts['replyTo']
|
||||||
|
existingDraftId?: string
|
||||||
|
}): Promise<StoredDraft> => {
|
||||||
|
if (!did) {
|
||||||
|
throw new Error('No account')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const draftId = existingDraftId || nanoid()
|
||||||
|
|
||||||
|
// If updating existing draft, delete old media first
|
||||||
|
if (existingDraftId) {
|
||||||
|
const existingDraft = await storage.loadDraftMeta(did, existingDraftId)
|
||||||
|
if (existingDraft) {
|
||||||
|
// Clean up old media that's no longer used
|
||||||
|
await cleanupOldMedia(did, existingDraft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the composer state
|
||||||
|
const posts: StoredPostDraft[] = []
|
||||||
|
|
||||||
|
for (const post of composerState.thread.posts) {
|
||||||
|
const storedPost = await serializePost(did, post)
|
||||||
|
posts.push(storedPost)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to delete a draft
|
||||||
|
*/
|
||||||
|
export function useDeleteDraft() {
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const did = currentAccount?.did
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (draftId: string) => {
|
||||||
|
if (!did) {
|
||||||
|
throw new Error('No account')
|
||||||
|
}
|
||||||
|
await storage.deleteDraft(did, draftId)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
if (did) {
|
||||||
|
queryClient.invalidateQueries({queryKey: draftsQueryKey(did)})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a post for storage
|
||||||
|
*/
|
||||||
|
async function serializePost(
|
||||||
|
accountDid: string,
|
||||||
|
post: PostDraft,
|
||||||
|
): 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,
|
||||||
|
)
|
||||||
|
} else if (post.embed.media.type === 'video') {
|
||||||
|
storedPost.video = await serializeVideo(
|
||||||
|
accountDid,
|
||||||
|
post.embed.media.video,
|
||||||
|
)
|
||||||
|
} 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[],
|
||||||
|
): Promise<LocalMediaRef[]> {
|
||||||
|
const refs: LocalMediaRef[] = []
|
||||||
|
|
||||||
|
for (const image of images) {
|
||||||
|
const path = image.transformed?.path || image.source.path
|
||||||
|
const 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,
|
||||||
|
): Promise<LocalMediaRef | undefined> {
|
||||||
|
// Only save videos that have been compressed (have a video file)
|
||||||
|
if (!videoState.video) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = videoState.video
|
||||||
|
const localId = await storage.saveMediaToLocal(
|
||||||
|
accountDid,
|
||||||
|
video.uri,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old media when updating a draft
|
||||||
|
*/
|
||||||
|
async function cleanupOldMedia(
|
||||||
|
accountDid: string,
|
||||||
|
draft: StoredDraft,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const post of draft.posts) {
|
||||||
|
if (post.images) {
|
||||||
|
for (const image of post.images) {
|
||||||
|
await storage.deleteMediaFromLocal(accountDid, image.localId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (post.video) {
|
||||||
|
await storage.deleteMediaFromLocal(accountDid, post.video.localId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './hooks'
|
||||||
|
export * from './schema'
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {type AppBskyFeedPostgate, type AppBskyRichtextFacet} from '@atproto/api'
|
||||||
|
|
||||||
|
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to locally stored media (image or video)
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
/** Alt text */
|
||||||
|
altText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable version of RichText
|
||||||
|
*/
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft summary for list display
|
||||||
|
*/
|
||||||
|
export type DraftSummary = {
|
||||||
|
id: string
|
||||||
|
/** First ~100 chars of first post */
|
||||||
|
previewText: string
|
||||||
|
/** Whether the draft has media */
|
||||||
|
hasMedia: boolean
|
||||||
|
/** Number of media items */
|
||||||
|
mediaCount: number
|
||||||
|
/** Number of posts in thread */
|
||||||
|
postCount: number
|
||||||
|
/** Whether this is a reply */
|
||||||
|
isReply: boolean
|
||||||
|
/** Reply to author handle (if reply) */
|
||||||
|
replyToHandle?: string
|
||||||
|
/** ISO timestamp of last update */
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
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 DraftSummary, type StoredDraft} from './schema'
|
||||||
|
|
||||||
|
const DRAFTS_DIR = 'bsky-drafts'
|
||||||
|
|
||||||
|
function joinPath(...segments: string[]): string {
|
||||||
|
return segments.join('/').replace(/\/+/g, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDraftsDirectory(accountDid: string): string {
|
||||||
|
return joinPath(documentDirectory!, DRAFTS_DIR, accountDid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMediaDirectory(accountDid: string): string {
|
||||||
|
return joinPath(getDraftsDirectory(accountDid), 'media')
|
||||||
|
}
|
||||||
|
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the drafts directories exist
|
||||||
|
*/
|
||||||
|
async function ensureDirectories(accountDid: string): Promise<void> {
|
||||||
|
await makeDirectoryAsync(getMediaDirectory(accountDid), {intermediates: true})
|
||||||
|
await makeDirectoryAsync(getDraftsMetaDirectory(accountDid), {
|
||||||
|
intermediates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a media file to local storage
|
||||||
|
* @returns The local ID for the saved media
|
||||||
|
*/
|
||||||
|
export async function saveMediaToLocal(
|
||||||
|
accountDid: string,
|
||||||
|
sourcePath: string,
|
||||||
|
_mimeType: string,
|
||||||
|
): Promise<string> {
|
||||||
|
await ensureDirectories(accountDid)
|
||||||
|
|
||||||
|
const localId = nanoid()
|
||||||
|
const destPath = getMediaPath(accountDid, localId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyAsync({from: sourcePath, to: destPath})
|
||||||
|
return localId
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save media to drafts storage', {
|
||||||
|
error,
|
||||||
|
sourcePath,
|
||||||
|
destPath,
|
||||||
|
})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a media file path from local storage
|
||||||
|
* @returns The file path for the saved media
|
||||||
|
*/
|
||||||
|
export async function loadMediaFromLocal(
|
||||||
|
accountDid: string,
|
||||||
|
localId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const path = getMediaPath(accountDid, localId)
|
||||||
|
const info = await getInfoAsync(path)
|
||||||
|
|
||||||
|
if (!info.exists) {
|
||||||
|
throw new Error(`Media file not found: ${localId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a media file from local storage
|
||||||
|
*/
|
||||||
|
export async function deleteMediaFromLocal(
|
||||||
|
accountDid: string,
|
||||||
|
localId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const path = getMediaPath(accountDid, localId)
|
||||||
|
await deleteAsync(path, {idempotent: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save draft metadata to local storage
|
||||||
|
*/
|
||||||
|
export async function saveDraftMeta(
|
||||||
|
accountDid: string,
|
||||||
|
draft: StoredDraft,
|
||||||
|
): Promise<void> {
|
||||||
|
await ensureDirectories(accountDid)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
if (!file.endsWith('.json')) continue
|
||||||
|
|
||||||
|
const draftId = file.replace('.json', '')
|
||||||
|
const draft = await loadDraftMeta(accountDid, draftId)
|
||||||
|
|
||||||
|
if (draft) {
|
||||||
|
summaries.push(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> {
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: draft.id,
|
||||||
|
previewText,
|
||||||
|
hasMedia,
|
||||||
|
mediaCount,
|
||||||
|
postCount: draft.posts.length,
|
||||||
|
isReply: Boolean(draft.replyToUri),
|
||||||
|
replyToHandle: draft.replyToAuthor?.handle,
|
||||||
|
updatedAt: draft.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import {type DBSchema, type IDBPDatabase, openDB} from 'idb'
|
||||||
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {type DraftSummary, type StoredDraft} from './schema'
|
||||||
|
|
||||||
|
const DB_NAME = 'bsky-drafts'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
|
||||||
|
interface DraftsDB extends DBSchema {
|
||||||
|
'draft-media': {
|
||||||
|
key: string // "{accountDid}:{localId}"
|
||||||
|
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
|
||||||
|
|
||||||
|
async function getDB(): Promise<IDBPDatabase<DraftsDB>> {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<DraftsDB>(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')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async function toBlob(input: string | Blob): Promise<Blob> {
|
||||||
|
if (input instanceof Blob) {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
const response = await fetch(input)
|
||||||
|
return response.blob()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a media file to IndexedDB
|
||||||
|
* @returns The local ID for the saved media
|
||||||
|
*/
|
||||||
|
export async function saveMediaToLocal(
|
||||||
|
accountDid: string,
|
||||||
|
source: string | Blob,
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = await getDB()
|
||||||
|
const localId = nanoid()
|
||||||
|
const blob = await toBlob(source)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.put(
|
||||||
|
'draft-media',
|
||||||
|
{
|
||||||
|
blob,
|
||||||
|
mimeType,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
mediaKey(accountDid, localId),
|
||||||
|
)
|
||||||
|
return localId
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save media to IndexedDB', {error})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a media file from IndexedDB
|
||||||
|
* @returns A blob URL for the saved media
|
||||||
|
*/
|
||||||
|
export async function loadMediaFromLocal(
|
||||||
|
accountDid: string,
|
||||||
|
localId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = await getDB()
|
||||||
|
const record = await db.get('draft-media', mediaKey(accountDid, localId))
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`Media file not found: ${localId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return URL.createObjectURL(record.blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a media file from IndexedDB
|
||||||
|
*/
|
||||||
|
export async function deleteMediaFromLocal(
|
||||||
|
accountDid: string,
|
||||||
|
localId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDB()
|
||||||
|
await db.delete('draft-media', mediaKey(accountDid, localId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save draft metadata to IndexedDB
|
||||||
|
*/
|
||||||
|
export async function saveDraftMeta(
|
||||||
|
accountDid: string,
|
||||||
|
draft: StoredDraft,
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDB()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: draft.id,
|
||||||
|
previewText,
|
||||||
|
hasMedia,
|
||||||
|
mediaCount,
|
||||||
|
postCount: draft.posts.length,
|
||||||
|
isReply: Boolean(draft.replyToUri),
|
||||||
|
replyToHandle: draft.replyToAuthor?.handle,
|
||||||
|
updatedAt: draft.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a blob URL when done with it (to prevent memory leaks)
|
||||||
|
*/
|
||||||
|
export function revokeMediaUrl(url: string): void {
|
||||||
|
if (url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* API query hooks for drafts
|
||||||
|
*
|
||||||
|
* These hooks interact with the server-side drafts API:
|
||||||
|
* - app.bsky.draft.createDraft()
|
||||||
|
* - app.bsky.draft.getDrafts()
|
||||||
|
* - app.bsky.draft.updateDraft()
|
||||||
|
* - app.bsky.draft.deleteDraft()
|
||||||
|
*
|
||||||
|
* Note: These are placeholder implementations. The actual API
|
||||||
|
* endpoints need to be implemented on the backend.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
|
||||||
|
const RQKEY_ROOT = 'server-drafts'
|
||||||
|
|
||||||
|
export function serverDraftsQueryKey(did: string) {
|
||||||
|
return [RQKEY_ROOT, did]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch drafts from the server
|
||||||
|
*/
|
||||||
|
export function useServerDraftsQuery(did: string) {
|
||||||
|
const _agent = useAgent()
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: serverDraftsQueryKey(did),
|
||||||
|
queryFn: async () => {
|
||||||
|
// TODO: Implement when API is available
|
||||||
|
// const res = await agent.app.bsky.draft.getDrafts()
|
||||||
|
// return res.data.drafts
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
enabled: Boolean(did),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a draft on the server
|
||||||
|
*/
|
||||||
|
export function useCreateServerDraftMutation() {
|
||||||
|
const _agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (_draft: {
|
||||||
|
text: string
|
||||||
|
// Add other fields as per API spec
|
||||||
|
}) => {
|
||||||
|
// TODO: Implement when API is available
|
||||||
|
// const res = await agent.app.bsky.draft.createDraft(draft)
|
||||||
|
// return res.data
|
||||||
|
throw new Error('Server drafts API not yet implemented')
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a draft on the server
|
||||||
|
*/
|
||||||
|
export function useUpdateServerDraftMutation() {
|
||||||
|
const _agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (_params: {
|
||||||
|
id: string
|
||||||
|
text: string
|
||||||
|
// Add other fields as per API spec
|
||||||
|
}) => {
|
||||||
|
// TODO: Implement when API is available
|
||||||
|
// const res = await agent.app.bsky.draft.updateDraft(params)
|
||||||
|
// return res.data
|
||||||
|
throw new Error('Server drafts API not yet implemented')
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a draft from the server
|
||||||
|
*/
|
||||||
|
export function useDeleteServerDraftMutation() {
|
||||||
|
const _agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (_id: string) => {
|
||||||
|
// TODO: Implement when API is available
|
||||||
|
// await agent.app.bsky.draft.deleteDraft({id})
|
||||||
|
throw new Error('Server drafts API not yet implemented')
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import {type BskyAgent} from '@atproto/api'
|
||||||
import {type QueryClient, useQuery} from '@tanstack/react-query'
|
import {type QueryClient, useQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
|
||||||
import {STALE} from '#/state/queries/index'
|
import {STALE} from '#/state/queries/index'
|
||||||
import {useAgent} from '../session'
|
import {useAgent} from '#/state/session'
|
||||||
|
import {type Gif} from './tenor'
|
||||||
|
|
||||||
const RQKEY_LINK_ROOT = 'resolve-link'
|
const RQKEY_LINK_ROOT = 'resolve-link'
|
||||||
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
|
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
|
||||||
@@ -9,13 +12,9 @@ export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
|
|||||||
const RQKEY_GIF_ROOT = 'resolve-gif'
|
const RQKEY_GIF_ROOT = 'resolve-gif'
|
||||||
export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url]
|
export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url]
|
||||||
|
|
||||||
import {type BskyAgent} from '@atproto/api'
|
|
||||||
|
|
||||||
import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
|
|
||||||
import {type Gif} from './tenor'
|
|
||||||
|
|
||||||
export function useResolveLinkQuery(url: string) {
|
export function useResolveLinkQuery(url: string) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
staleTime: STALE.HOURS.ONE,
|
staleTime: STALE.HOURS.ONE,
|
||||||
queryKey: RQKEY_LINK(url),
|
queryKey: RQKEY_LINK(url),
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ 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 {emitPostCreated} from '#/state/events'
|
import {emitPostCreated} from '#/state/events'
|
||||||
import {
|
import {
|
||||||
type ComposerImage,
|
type ComposerImage,
|
||||||
@@ -98,6 +99,7 @@ import {useComposerControls} from '#/state/shell/composer'
|
|||||||
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
|
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
|
||||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||||
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
|
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
|
||||||
|
import {DraftsButton} from '#/view/com/composer/drafts/DraftsButton'
|
||||||
import {
|
import {
|
||||||
ExternalEmbedGif,
|
ExternalEmbedGif,
|
||||||
ExternalEmbedLink,
|
ExternalEmbedLink,
|
||||||
@@ -189,6 +191,7 @@ export const ComposePost = ({
|
|||||||
const setLangPrefs = useLanguagePrefsApi()
|
const setLangPrefs = useLanguagePrefsApi()
|
||||||
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 {closeAllDialogs} = useDialogStateControlContext()
|
const {closeAllDialogs} = useDialogStateControlContext()
|
||||||
const {closeAllModals} = useModalControls()
|
const {closeAllModals} = useModalControls()
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
@@ -320,6 +323,23 @@ export const ComposePost = ({
|
|||||||
[composerDispatch],
|
[composerDispatch],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleSelectDraft = React.useCallback(
|
||||||
|
async (draft: StoredDraft) => {
|
||||||
|
if (!currentDid) return
|
||||||
|
|
||||||
|
// Load media from local storage
|
||||||
|
const loadedMedia = await loadDraftMedia(currentDid, draft)
|
||||||
|
|
||||||
|
// Dispatch restore action
|
||||||
|
composerDispatch({
|
||||||
|
type: 'restore_from_draft',
|
||||||
|
draft,
|
||||||
|
loadedMedia,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[currentDid, composerDispatch],
|
||||||
|
)
|
||||||
|
|
||||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
@@ -327,6 +347,19 @@ export const ComposePost = ({
|
|||||||
clearThumbnailCache(queryClient)
|
clearThumbnailCache(queryClient)
|
||||||
}, [closeComposer, queryClient])
|
}, [closeComposer, queryClient])
|
||||||
|
|
||||||
|
const handleSaveDraft = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await saveDraft({
|
||||||
|
composerState,
|
||||||
|
replyTo,
|
||||||
|
})
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to save draft', {error: e})
|
||||||
|
setError(_(msg`Failed to save draft`))
|
||||||
|
}
|
||||||
|
}, [saveDraft, composerState, replyTo, onClose, _])
|
||||||
|
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const viewStyles = useMemo(
|
const viewStyles = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -750,7 +783,8 @@ export const ComposePost = ({
|
|||||||
publishingStage={publishingStage}
|
publishingStage={publishingStage}
|
||||||
topBarAnimatedStyle={topBarAnimatedStyle}
|
topBarAnimatedStyle={topBarAnimatedStyle}
|
||||||
onCancel={onPressCancel}
|
onCancel={onPressCancel}
|
||||||
onPublish={onPressPublish}>
|
onPublish={onPressPublish}
|
||||||
|
onSelectDraft={handleSelectDraft}>
|
||||||
{missingAltError && <AltTextReminder error={missingAltError} />}
|
{missingAltError && <AltTextReminder error={missingAltError} />}
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={error}
|
error={error}
|
||||||
@@ -801,14 +835,25 @@ export const ComposePost = ({
|
|||||||
{!IS_WEBFooterSticky && footer}
|
{!IS_WEBFooterSticky && footer}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Prompt.Basic
|
<Prompt.Outer control={discardPromptControl}>
|
||||||
control={discardPromptControl}
|
<Prompt.TitleText>{_(msg`Discard draft?`)}</Prompt.TitleText>
|
||||||
title={_(msg`Discard draft?`)}
|
<Prompt.DescriptionText>
|
||||||
description={_(msg`Are you sure you'd like to discard this draft?`)}
|
{_(msg`You can save this draft to continue later.`)}
|
||||||
onConfirm={onClose}
|
</Prompt.DescriptionText>
|
||||||
confirmButtonCta={_(msg`Discard`)}
|
<Prompt.Actions>
|
||||||
confirmButtonColor="negative"
|
<Prompt.Action
|
||||||
|
cta={_(msg`Save Draft`)}
|
||||||
|
onPress={handleSaveDraft}
|
||||||
|
color="primary"
|
||||||
/>
|
/>
|
||||||
|
<Prompt.Action
|
||||||
|
cta={_(msg`Discard`)}
|
||||||
|
onPress={onClose}
|
||||||
|
color="negative"
|
||||||
|
/>
|
||||||
|
<Prompt.Cancel />
|
||||||
|
</Prompt.Actions>
|
||||||
|
</Prompt.Outer>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</BottomSheetPortalProvider>
|
</BottomSheetPortalProvider>
|
||||||
)
|
)
|
||||||
@@ -1027,6 +1072,7 @@ function ComposerTopBar({
|
|||||||
publishingStage,
|
publishingStage,
|
||||||
onCancel,
|
onCancel,
|
||||||
onPublish,
|
onPublish,
|
||||||
|
onSelectDraft,
|
||||||
topBarAnimatedStyle,
|
topBarAnimatedStyle,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -1038,6 +1084,7 @@ function ComposerTopBar({
|
|||||||
isThread: boolean
|
isThread: boolean
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onPublish: () => void
|
onPublish: () => void
|
||||||
|
onSelectDraft: (draft: StoredDraft) => void
|
||||||
topBarAnimatedStyle: StyleProp<ViewStyle>
|
topBarAnimatedStyle: StyleProp<ViewStyle>
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
@@ -1063,6 +1110,7 @@ function ComposerTopBar({
|
|||||||
<Trans>Cancel</Trans>
|
<Trans>Cancel</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
|
<DraftsButton onSelectDraft={onSelectDraft} />
|
||||||
<View style={a.flex_1} />
|
<View style={a.flex_1} />
|
||||||
{isPublishing ? (
|
{isPublishing ? (
|
||||||
<>
|
<>
|
||||||
@@ -1411,7 +1459,7 @@ function ComposerFooter({
|
|||||||
|
|
||||||
if (assets.length) {
|
if (assets.length) {
|
||||||
if (type === 'image') {
|
if (type === 'image') {
|
||||||
const images: ComposerImage[] = []
|
const selectedImages: ComposerImage[] = []
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
assets.map(async image => {
|
assets.map(async image => {
|
||||||
@@ -1421,7 +1469,7 @@ function ComposerFooter({
|
|||||||
height: image.height,
|
height: image.height,
|
||||||
mime: image.mimeType!,
|
mime: image.mimeType!,
|
||||||
})
|
})
|
||||||
images.push(composerImage)
|
selectedImages.push(composerImage)
|
||||||
}),
|
}),
|
||||||
).catch(e => {
|
).catch(e => {
|
||||||
logger.error(`createComposerImage failed`, {
|
logger.error(`createComposerImage failed`, {
|
||||||
@@ -1429,7 +1477,7 @@ function ComposerFooter({
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onImageAdd(images)
|
onImageAdd(selectedImages)
|
||||||
} else if (type === 'video') {
|
} else if (type === 'video') {
|
||||||
onSelectVideo(post.id, assets[0])
|
onSelectVideo(post.id, assets[0])
|
||||||
} else if (type === 'gif') {
|
} else if (type === 'gif') {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import {Pressable, View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
|
import {type DraftSummary} from '#/state/drafts'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
|
import {Camera_Stroke2_Corner0_Rounded as MediaIcon} from '#/components/icons/Camera'
|
||||||
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function DraftItem({
|
||||||
|
draft,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
draft: DraftSummary
|
||||||
|
onSelect: (draft: DraftSummary) => void
|
||||||
|
onDelete: (draftId: string) => void
|
||||||
|
isDeleting: boolean
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const getTimeAgo = useGetTimeAgo()
|
||||||
|
|
||||||
|
const previewText = draft.previewText || _(msg`(No text)`)
|
||||||
|
const timeAgo = getTimeAgo(new Date(draft.updatedAt), new Date())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Open draft: ${previewText}`)}
|
||||||
|
accessibilityHint={_(msg`Opens this draft in the composer`)}
|
||||||
|
onPress={() => onSelect(draft)}
|
||||||
|
style={({pressed, hovered}) => [
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_md,
|
||||||
|
a.p_md,
|
||||||
|
a.rounded_md,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
(pressed || hovered) && t.atoms.bg_contrast_50,
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
|
{/* Reply indicator */}
|
||||||
|
{draft.isReply && draft.replyToHandle && (
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, t.atoms.text_contrast_medium]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
<Trans>Replying to @{draft.replyToHandle}</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview text */}
|
||||||
|
<Text style={[a.text_md]} numberOfLines={2}>
|
||||||
|
{previewText}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Metadata row */}
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
{/* Time ago */}
|
||||||
|
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||||
|
{timeAgo}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Media indicator */}
|
||||||
|
{draft.hasMedia && (
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_2xs]}>
|
||||||
|
<MediaIcon size="xs" style={[t.atoms.text_contrast_medium]} />
|
||||||
|
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||||
|
{draft.mediaCount}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Thread indicator */}
|
||||||
|
{draft.postCount > 1 && (
|
||||||
|
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>{draft.postCount} posts</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<Button
|
||||||
|
label={_(msg`Delete draft`)}
|
||||||
|
variant="ghost"
|
||||||
|
color="negative"
|
||||||
|
shape="round"
|
||||||
|
size="small"
|
||||||
|
disabled={isDeleting}
|
||||||
|
onPress={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDelete(draft.id)
|
||||||
|
}}>
|
||||||
|
<ButtonIcon icon={TrashIcon} />
|
||||||
|
</Button>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {type StoredDraft, useDrafts} from '#/state/drafts'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {PageText_Stroke2_Corner0_Rounded as DraftIcon} from '#/components/icons/PageText'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {DraftsListDialog} from './DraftsListDialog'
|
||||||
|
|
||||||
|
export function DraftsButton({
|
||||||
|
onSelectDraft,
|
||||||
|
}: {
|
||||||
|
onSelectDraft: (draft: StoredDraft) => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const control = Dialog.useDialogControl()
|
||||||
|
const {data: drafts, isLoading} = useDrafts()
|
||||||
|
|
||||||
|
const hasDrafts = drafts && drafts.length > 0
|
||||||
|
|
||||||
|
if (isLoading || !hasDrafts) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
label={_(msg`See drafts`)}
|
||||||
|
variant="ghost"
|
||||||
|
color="primary"
|
||||||
|
shape="default"
|
||||||
|
size="small"
|
||||||
|
style={[a.rounded_full, a.py_xs, a.px_sm, a.ml_xs]}
|
||||||
|
onPress={() => control.open()}>
|
||||||
|
<DraftIcon size="sm" style={[t.atoms.text_contrast_medium]} />
|
||||||
|
<ButtonText style={[a.text_sm]}>
|
||||||
|
<Trans>Drafts</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.px_xs,
|
||||||
|
a.ml_2xs,
|
||||||
|
{backgroundColor: t.palette.primary_500},
|
||||||
|
]}>
|
||||||
|
<Text style={[a.text_xs, a.font_bold, {color: t.palette.white}]}>
|
||||||
|
{drafts.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Button>
|
||||||
|
<DraftsListDialog control={control} onSelectDraft={onSelectDraft} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DraftSummary,
|
||||||
|
type StoredDraft,
|
||||||
|
useDeleteDraft,
|
||||||
|
useDrafts,
|
||||||
|
useLoadDraft,
|
||||||
|
} from '#/state/drafts'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {DraftItem} from './DraftItem'
|
||||||
|
|
||||||
|
export function DraftsListDialog({
|
||||||
|
control,
|
||||||
|
onSelectDraft,
|
||||||
|
}: {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
onSelectDraft: (draft: StoredDraft) => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {data: drafts, isLoading} = useDrafts()
|
||||||
|
const loadDraft = useLoadDraft()
|
||||||
|
const {mutate: deleteDraft, isPending: isDeleting} = useDeleteDraft()
|
||||||
|
|
||||||
|
const handleSelectDraft = useCallback(
|
||||||
|
async (summary: DraftSummary) => {
|
||||||
|
const draft = await loadDraft(summary.id)
|
||||||
|
if (draft) {
|
||||||
|
control.close(() => {
|
||||||
|
onSelectDraft(draft)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadDraft, control, onSelectDraft],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDeleteDraft = useCallback(
|
||||||
|
(draftId: string) => {
|
||||||
|
deleteDraft(draftId)
|
||||||
|
},
|
||||||
|
[deleteDraft],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Dialog.ScrollableInner label={_(msg`Your Drafts`)}>
|
||||||
|
<View style={[a.gap_md]}>
|
||||||
|
<Text style={[a.text_2xl, a.font_semi_bold]}>
|
||||||
|
<Trans>Your Drafts</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={[a.py_xl, a.align_center]}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</View>
|
||||||
|
) : drafts && drafts.length > 0 ? (
|
||||||
|
<View style={[a.gap_sm]}>
|
||||||
|
{drafts.map(draft => (
|
||||||
|
<DraftItem
|
||||||
|
key={draft.id}
|
||||||
|
draft={draft}
|
||||||
|
onSelect={handleSelectDraft}
|
||||||
|
onDelete={handleDeleteDraft}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={[a.py_xl, a.align_center]}>
|
||||||
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>No drafts saved</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ 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'
|
||||||
@@ -122,6 +123,12 @@ export type ComposerAction =
|
|||||||
type: 'focus_post'
|
type: 'focus_post'
|
||||||
postId: string
|
postId: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'restore_from_draft'
|
||||||
|
draft: StoredDraft
|
||||||
|
/** Map of localId -> loaded media path/URL */
|
||||||
|
loadedMedia: Map<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
export const MAX_IMAGES = 4
|
export const MAX_IMAGES = 4
|
||||||
|
|
||||||
@@ -229,6 +236,76 @@ export function composerReducer(
|
|||||||
activePostIndex: nextActivePostIndex,
|
activePostIndex: nextActivePostIndex,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
activePostIndex: 0,
|
||||||
|
mutableNeedsFocusActive: true,
|
||||||
|
thread: {
|
||||||
|
posts,
|
||||||
|
postgate: draft.postgate || state.thread.postgate,
|
||||||
|
threadgate: draft.threadgate || state.thread.threadgate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12886,6 +12886,11 @@ idb-keyval@^6.2.2:
|
|||||||
resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.2.2.tgz#b0171b5f73944854a3291a5cdba8e12768c4854a"
|
resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.2.2.tgz#b0171b5f73944854a3291a5cdba8e12768c4854a"
|
||||||
integrity sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==
|
integrity sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==
|
||||||
|
|
||||||
|
idb@^8.0.3:
|
||||||
|
version "8.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/idb/-/idb-8.0.3.tgz#c91e558f15a8d53f1d7f53a094d226fc3ad71fd9"
|
||||||
|
integrity sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==
|
||||||
|
|
||||||
ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1:
|
ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
|
||||||
|
|||||||
Reference in New Issue
Block a user