move state to colo with composer

This commit is contained in:
Samuel Newman
2026-01-16 11:46:28 +02:00
parent c6539dd033
commit 5a67dc9110
11 changed files with 29 additions and 22 deletions
+7 -7
View File
@@ -76,13 +76,6 @@ import {cleanError} from '#/lib/strings/errors'
import {colors} from '#/lib/styles'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
import {
type DraftSummary,
draftToComposerPosts,
threadgateToUISettings,
useLoadDraft,
useSaveDraft,
} from '#/state/drafts'
import {emitPostCreated} from '#/state/events'
import {
type ComposerImage,
@@ -139,6 +132,13 @@ import {Text as NewText} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import {
draftToComposerPosts,
threadgateToUISettings,
useLoadDraft,
useSaveDraft,
} from './drafts/state/hooks'
import {type DraftSummary} from './drafts/state/schema'
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
import {
type AssetType,
+17 -9
View File
@@ -5,12 +5,6 @@ import {type AppBskyEmbedImages} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type DraftPostDisplay,
type DraftSummary,
type LocalMediaDisplay,
} from '#/state/drafts'
import * as storage from '#/state/drafts/storage'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
@@ -22,6 +16,12 @@ import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {
type DraftPostDisplay,
type DraftSummary,
type LocalMediaDisplay,
} from './state/schema'
import * as storage from './state/storage'
export function DraftItem({
draft,
@@ -193,9 +193,17 @@ function DraftPostRow({
{/* Post text - full, not truncated */}
{post.text ? (
<Text style={[a.text_md, t.atoms.text]}>{post.text}</Text>
<Text style={[a.text_md, a.leading_snug, t.atoms.text]}>
{post.text}
</Text>
) : (
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.italic]}>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
a.italic,
]}>
<Trans>(No text)</Trans>
</Text>
)}
@@ -267,7 +275,7 @@ function DraftMediaPreview({post}: {post: DraftPostDisplay}) {
}
return (
<View style={[a.pt_xs, a.pointer_events_none]}>
<View style={[a.pt_sm, a.pointer_events_none]}>
{/* Images - use real embed components */}
{viewImages.length === 1 && (
<AutoSizedImage image={viewImages[0]} hideBadge />
@@ -1,12 +1,13 @@
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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'
import * as Prompt from '#/components/Prompt'
import {DraftsListDialog} from './DraftsListDialog'
import {useSaveDraft} from './state/hooks'
import {type DraftSummary} from './state/schema'
export function DraftsButton({
onSelectDraft,
@@ -4,14 +4,15 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {type DraftSummary, useDeleteDraft, useDrafts} from '#/state/drafts'
import {EmptyState} from '#/view/com/util/EmptyState'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
import {Loader} from '#/components/Loader'
import {EmptyState} from '../../util/EmptyState'
import {DraftItem} from './DraftItem'
import {useDeleteDraft, useDrafts} from './state/hooks'
import {type DraftSummary} from './state/schema'
export function DraftsListDialog({
control,
+478
View File
@@ -0,0 +1,478 @@
/**
* 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&alt=ALT_TEXT
*/
function serializeGif(gifMedia: {
type: 'gif'
gif: Gif
alt: string
}): AppBskyDraftDefs.DraftEmbedExternal | undefined {
const gif = gifMedia.gif
const gifFormat = gif.media_formats.gif || gif.media_formats.tinygif
if (!gifFormat?.url) {
return undefined
}
// Build URL with dimensions and alt text in query params
const url = new URL(gifFormat.url)
if (gifFormat.dims) {
url.searchParams.set('ww', String(gifFormat.dims[0]))
url.searchParams.set('hh', String(gifFormat.dims[1]))
}
// Store alt text if present
if (gifMedia.alt) {
url.searchParams.set('alt', gifMedia.alt)
}
return {
$type: 'app.bsky.draft.defs#draftEmbedExternal',
uri: url.toString(),
}
}
/**
* Convert server DraftView to DraftSummary for list display.
* Also checks which media files exist locally.
*/
export function draftViewToSummary(
view: AppBskyDraftDefs.DraftView,
localMediaExists: (path: string) => boolean,
): DraftSummary {
const firstPost = view.draft.posts[0]
const previewText = firstPost?.text?.slice(0, 100) || ''
let mediaCount = 0
let hasMedia = false
let hasMissingMedia = false
const posts: DraftPostDisplay[] = view.draft.posts.map((post, index) => {
const images: DraftPostDisplay['images'] = []
const videos: DraftPostDisplay['video'][] = []
let gif: DraftPostDisplay['gif']
// Process images
if (post.embedImages) {
for (const img of post.embedImages) {
mediaCount++
hasMedia = true
const exists = localMediaExists(img.localRef.path)
if (!exists) {
hasMissingMedia = true
}
images.push({
localPath: img.localRef.path,
altText: img.alt || '',
exists,
})
}
}
// Process videos
if (post.embedVideos) {
for (const vid of post.embedVideos) {
mediaCount++
hasMedia = true
const exists = localMediaExists(vid.localRef.path)
if (!exists) {
hasMissingMedia = true
}
videos.push({
localPath: vid.localRef.path,
altText: vid.alt || '',
exists,
})
}
}
// Process externals (check for GIFs)
if (post.embedExternals) {
for (const ext of post.embedExternals) {
const gifData = parseGifFromUrl(ext.uri)
if (gifData) {
mediaCount++
hasMedia = true
gif = gifData
}
}
}
return {
id: `post-${index}`,
text: post.text || '',
images: images.length > 0 ? images : undefined,
video: videos[0], // Only one video per post
gif,
}
})
return {
id: view.id,
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&alt=ALT_TEXT
*/
function parseGifFromUrl(
uri: string,
): {url: string; width: number; height: number; alt: string} | undefined {
try {
const url = new URL(uri)
if (url.hostname !== TENOR_HOSTNAME) {
return undefined
}
const height = parseInt(url.searchParams.get('hh') || '', 10)
const width = parseInt(url.searchParams.get('ww') || '', 10)
const alt = url.searchParams.get('alt') || ''
if (!height || !width) {
return undefined
}
return {url: uri, width, height, alt}
} 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 Gif object with all required properties
const mediaObject = {
url: gifData.url,
dims: [gifData.width, gifData.height] as [number, number],
duration: 0,
size: 0,
}
embed.media = {
type: 'gif',
gif: {
id: '',
created: 0,
hasaudio: false,
hascaption: false,
flags: '',
tags: [],
title: '',
content_description: gifData.alt || '',
itemurl: '',
url: gifData.url, // Required for useResolveGifQuery
media_formats: {
gif: mediaObject,
tinygif: mediaObject,
preview: mediaObject,
},
} as Gif,
alt: gifData.alt,
}
break
}
}
}
// 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)
}
+196
View File
@@ -0,0 +1,196 @@
import {useCallback} from 'react'
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {isNetworkError} from '#/lib/strings/errors'
import {useAgent} from '#/state/session'
import {type ComposerState} from '#/view/com/composer/state/composer'
import {
composerStateToDraft,
draftToComposerPosts,
draftViewToSummary,
threadgateToUISettings,
} from './api'
import {logger} from './logger'
import {type DraftSummary} from './schema'
import * as storage from './storage'
const DRAFTS_QUERY_KEY = ['drafts']
/**
* Hook to list all drafts for the current account
*/
export function useDrafts() {
const agent = useAgent()
return useQuery<DraftSummary[]>({
queryKey: DRAFTS_QUERY_KEY,
queryFn: async () => {
// Ensure media cache is populated before checking which media exists
await storage.ensureMediaCachePopulated()
const res = await agent.app.bsky.draft.getDrafts({})
return res.data.drafts.map(view =>
draftViewToSummary(view, path => storage.mediaExists(path)),
)
},
})
}
/**
* Hook to load a specific draft for editing
*/
export function useLoadDraft() {
const agent = useAgent()
return useCallback(
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}
},
[agent],
)
}
/**
* Hook to save a draft
*/
export function useSaveDraft() {
const agent = useAgent()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
composerState,
existingDraftId,
}: {
composerState: ComposerState
existingDraftId?: string
}): Promise<string> => {
// Convert composer state to server draft format
const {draft, localRefPaths} = composerStateToDraft(composerState)
// 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)
}
}
if (existingDraftId) {
// 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
}
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
},
onError: error => {
// Check for draft limit error
if (error instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) {
logger.error('Draft limit reached', {safeMessage: error.message})
// Error will be handled by caller
} else if (!isNetworkError(error)) {
logger.error('Could not create draft (reason unknown)', {
safeMessage: error.message,
})
}
},
})
}
/**
* Hook to delete a draft
*/
export function useDeleteDraft() {
const agent = useAgent()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (draftId: string) => {
// 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)
}
}
}
}
// Delete from server
await agent.app.bsky.draft.deleteDraft({id: draftId})
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: DRAFTS_QUERY_KEY})
},
})
}
// Re-export utilities for use in composer
export {draftToComposerPosts, threadgateToUISettings}
@@ -0,0 +1,3 @@
import {Logger} from '#/logger'
export const logger = Logger.create(Logger.Context.Drafts)
@@ -0,0 +1,66 @@
/**
* Types for draft display and local media tracking.
* Server draft types come from @atproto/api.
*/
/**
* Reference to locally cached media file for display
*/
export type LocalMediaDisplay = {
/** Path stored in server draft (used as key for local lookup) */
localPath: string
/** Alt text */
altText: string
/** Whether the local file exists on this device */
exists: boolean
}
/**
* GIF display data (parsed from external embed URL)
*/
export type GifDisplay = {
/** Full URL with dimensions */
url: string
/** Width */
width: number
/** Height */
height: number
}
/**
* Post content for display in draft list
*/
export type DraftPostDisplay = {
id: string
/** Full text content */
text: string
/** Image references for display */
images?: LocalMediaDisplay[]
/** Video reference */
video?: LocalMediaDisplay
/** GIF data (from URL) */
gif?: GifDisplay
}
/**
* Draft summary for list display
*/
export type DraftSummary = {
id: string
/** First ~100 chars of first post */
previewText: string
/** Whether the draft has media */
hasMedia: boolean
/** Whether some media is missing (saved on another device) */
hasMissingMedia?: boolean
/** Number of media items */
mediaCount: number
/** Number of posts in thread */
postCount: number
/** Whether this is a reply (always false - replies not supported) */
isReply: boolean
/** ISO timestamp of last update */
updatedAt: string
/** All posts in the draft for full display */
posts: DraftPostDisplay[]
}
@@ -0,0 +1,157 @@
/**
* Native file system storage for draft media.
* Media is stored by localRefPath key (unique identifier stored in server draft).
*/
import {Directory, File, Paths} from 'expo-file-system'
import {logger} from './logger'
const MEDIA_DIR = 'bsky-draft-media'
function getMediaDirectory(): Directory {
return new Directory(Paths.document, MEDIA_DIR)
}
function getMediaFile(localRefPath: string): File {
// Use localRefPath as filename (replace unsafe chars)
const safeFilename = localRefPath.replace(/[/:]/g, '_')
return new File(getMediaDirectory(), safeFilename)
}
let dirCreated = false
/**
* Ensure the media directory exists
*/
function ensureDirectory(): void {
if (dirCreated) return
const dir = getMediaDirectory()
if (!dir.exists) {
dir.create()
}
dirCreated = true
}
/**
* Save a media file to local storage by localRefPath key
*/
export async function saveMediaToLocal(
localRefPath: string,
sourcePath: string,
): Promise<void> {
ensureDirectory()
const destFile = getMediaFile(localRefPath)
// Ensure source path has file:// prefix for expo-file-system
let normalizedSource = sourcePath
if (!sourcePath.startsWith('file://') && sourcePath.startsWith('/')) {
normalizedSource = `file://${sourcePath}`
}
try {
const sourceFile = new File(normalizedSource)
sourceFile.copy(destFile)
// Update cache after successful save
mediaExistsCache.set(localRefPath, true)
} catch (error) {
logger.error('Failed to save media to drafts storage', {
error,
localRefPath,
sourcePath: normalizedSource,
destPath: destFile.uri,
})
throw error
}
}
/**
* Load a media file path from local storage
* @returns The file URI for the saved media
*/
export async function loadMediaFromLocal(
localRefPath: string,
): Promise<string> {
const file = getMediaFile(localRefPath)
if (!file.exists) {
throw new Error(`Media file not found: ${localRefPath}`)
}
return file.uri
}
/**
* Delete a media file from local storage
*/
export async function deleteMediaFromLocal(
localRefPath: string,
): Promise<void> {
const file = getMediaFile(localRefPath)
// Idempotent: only delete if file exists
if (file.exists) {
file.delete()
}
}
/**
* Check if a media file exists in local storage (synchronous check using cache)
* Note: This uses a cached directory listing for performance
*/
const mediaExistsCache = new Map<string, boolean>()
let cachePopulated = false
export function mediaExists(localRefPath: string): boolean {
// For native, we need an async check but the API requires sync
// Use cached result if available, otherwise assume doesn't exist
if (mediaExistsCache.has(localRefPath)) {
return mediaExistsCache.get(localRefPath)!
}
// If cache not populated yet, trigger async population
if (!cachePopulated && !populateCachePromise) {
populateCachePromise = populateCacheInternal()
}
return false // Conservative: assume doesn't exist if not in cache
}
let populateCachePromise: Promise<void> | null = null
function populateCacheInternal(): Promise<void> {
return new Promise(resolve => {
try {
const dir = getMediaDirectory()
if (dir.exists) {
const items = dir.list()
for (const item of items) {
// Reverse the safe filename transformation
const localRefPath = item.name.replace(/_/g, ':').replace(/_/g, '/')
mediaExistsCache.set(localRefPath, true)
}
}
cachePopulated = true
} catch (e) {
logger.warn('Failed to populate media cache', {error: e})
}
resolve()
})
}
/**
* Ensure the media cache is populated. Call this before checking mediaExists.
*/
export async function ensureMediaCachePopulated(): Promise<void> {
if (cachePopulated) return
if (!populateCachePromise) {
populateCachePromise = populateCacheInternal()
}
await populateCachePromise
}
/**
* Clear the media exists cache (call when media is added/deleted)
*/
export function clearMediaCache(): void {
mediaExistsCache.clear()
cachePopulated = false
populateCachePromise = null
}
@@ -0,0 +1,193 @@
/**
* 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 {logger} from './logger'
const DB_NAME = 'bsky-draft-media'
const DB_VERSION = 1
interface DraftMediaDB extends DBSchema {
media: {
key: string // localRefPath
value: {
blob: Blob
createdAt: string
}
}
}
let dbPromise: Promise<IDBPDatabase<DraftMediaDB>> | null = null
async function getDB(): Promise<IDBPDatabase<DraftMediaDB>> {
if (!dbPromise) {
dbPromise = openDB<DraftMediaDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains('media')) {
db.createObjectStore('media')
}
},
})
}
return dbPromise
}
/**
* Convert a path/URL to a Blob
*/
async function toBlob(sourcePath: string): Promise<Blob> {
// Handle data URIs directly
if (sourcePath.startsWith('data:')) {
const response = await fetch(sourcePath)
return response.blob()
}
// Handle blob URLs
if (sourcePath.startsWith('blob:')) {
try {
const response = await fetch(sourcePath)
return response.blob()
} catch (e) {
logger.error('Failed to fetch blob URL - it may have been revoked', {
error: e,
sourcePath,
})
throw e
}
}
// Handle regular URLs
const response = await fetch(sourcePath)
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status}`)
}
return response.blob()
}
/**
* Save a media file to IndexedDB by localRefPath key
*/
export async function saveMediaToLocal(
localRefPath: string,
sourcePath: string,
): Promise<void> {
const db = await getDB()
let blob: Blob
try {
blob = await toBlob(sourcePath)
} catch (error) {
logger.error('Failed to convert source to blob', {
error,
localRefPath,
sourcePath,
})
throw error
}
try {
await db.put(
'media',
{
blob,
createdAt: new Date().toISOString(),
},
localRefPath,
)
// Update cache
mediaExistsCache.set(localRefPath, true)
} catch (error) {
logger.error('Failed to save media to IndexedDB', {error, localRefPath})
throw error
}
}
/**
* Load a media file from IndexedDB
* @returns A blob URL for the saved media
*/
export async function loadMediaFromLocal(
localRefPath: string,
): Promise<string> {
const db = await getDB()
const record = await db.get('media', localRefPath)
if (!record) {
throw new Error(`Media file not found: ${localRefPath}`)
}
return URL.createObjectURL(record.blob)
}
/**
* Delete a media file from IndexedDB
*/
export async function deleteMediaFromLocal(
localRefPath: string,
): Promise<void> {
const db = await getDB()
await db.delete('media', localRefPath)
mediaExistsCache.delete(localRefPath)
}
/**
* Check if a media file exists in IndexedDB (synchronous check using cache)
*/
const mediaExistsCache = new Map<string, boolean>()
let cachePopulated = false
let populateCachePromise: Promise<void> | null = null
export function mediaExists(localRefPath: string): boolean {
if (mediaExistsCache.has(localRefPath)) {
return mediaExistsCache.get(localRefPath)!
}
// If cache not populated yet, trigger async population
if (!cachePopulated && !populateCachePromise) {
populateCachePromise = populateCacheInternal()
}
return false // Conservative: assume doesn't exist if not in cache
}
async function populateCacheInternal(): Promise<void> {
try {
const db = await getDB()
const keys = await db.getAllKeys('media')
for (const key of keys) {
mediaExistsCache.set(key, true)
}
cachePopulated = true
} catch (e) {
logger.warn('Failed to populate media cache', {error: e})
}
}
/**
* Ensure the media cache is populated. Call this before checking mediaExists.
*/
export async function ensureMediaCachePopulated(): Promise<void> {
if (cachePopulated) return
if (!populateCachePromise) {
populateCachePromise = populateCacheInternal()
}
await populateCachePromise
}
/**
* Clear the media exists cache (call when media is added/deleted)
*/
export function clearMediaCache(): void {
mediaExistsCache.clear()
cachePopulated = false
populateCachePromise = null
}
/**
* Revoke a blob URL when done with it (to prevent memory leaks)
*/
export function revokeMediaUrl(url: string): void {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url)
}
}