Compare commits

...

11 Commits

Author SHA1 Message Date
Alex Benzer a877fede5b Refactor to simplify code for Drafts + add client events 2026-01-06 20:25:25 +02:00
Alex Benzer 9df81cecd7 Add client events 2026-01-06 20:25:25 +02:00
Alex Benzer 80bae9da21 Adjust padding 2026-01-06 20:25:25 +02:00
Alex Benzer d1e62af654 Drafts toast 2026-01-06 20:25:25 +02:00
Alex Benzer 7da77a3107 Draft styles 2026-01-06 20:25:25 +02:00
Alex Benzer 378c4bc0a2 Add empty state, delete draft after posting 2026-01-06 20:25:25 +02:00
Alex Benzer bb9d7f48a7 Draft storage abstraction layer 2026-01-06 20:25:25 +02:00
Alex Benzer 22e0edbdaa Drafts UI 2026-01-06 20:25:25 +02:00
Alex Benzer 649e1fe1f0 Switch to using MMKV instead of localstorage 2026-01-06 20:25:25 +02:00
Alex Benzer 2a01065a31 Adds video support to composer draft 2026-01-06 20:25:25 +02:00
Alex Benzer 99f60bfcc2 Save composer's contents to localstorage 2026-01-06 20:25:25 +02:00
13 changed files with 1534 additions and 96 deletions
+55
View File
@@ -201,6 +201,19 @@ export type MetricEvents = {
userDid: string
}
// Fired when the composer is opened
'composer:open': {
logContext:
| 'Fab'
| 'PostReply'
| 'QuotePost'
| 'ProfileFeed'
| 'Deeplink'
| 'Other'
isReply: boolean
hasQuote: boolean
hasDraft: boolean
}
'composer:gif:open': {}
'composer:gif:select': {}
'composerPrompt:press': {}
@@ -217,6 +230,48 @@ export type MetricEvents = {
hasChanged: boolean
}
// Fired when user explicitly saves a draft (via "Save draft" button or prompt)
'draft:save': {
isNewDraft: boolean
hasText: boolean
hasImages: boolean
hasVideo: boolean
hasGif: boolean
hasQuote: boolean
hasLink: boolean
postCount: number
textLength: number
}
// Fired when user selects a draft from the drafts list to load it into the composer
'draft:load': {
draftAgeMs: number
hasText: boolean
hasImages: boolean
hasVideo: boolean
hasGif: boolean
postCount: number
}
// Fired when user explicitly deletes a draft
'draft:delete': {
logContext: 'DraftsList' | 'ComposerClose'
draftAgeMs: number
}
// Fired when user opens the drafts list view
'draft:listOpen': {
draftCount: number
}
// Fired when a draft is successfully posted
'draft:post': {
draftAgeMs: number
wasEdited: boolean
}
// Fired when user chooses "Don't save" and discards unsaved content
'draft:discard': {
logContext: 'ComposerClose' | 'BeforeDraftsList'
hadContent: boolean
textLength: number
}
// Data events
'account:create:begin': {}
'account:create:success': {
+2
View File
@@ -0,0 +1,2 @@
export {serializeDraft} from './serialization'
export {type DraftItem, draftsStorage} from './storage'
+78
View File
@@ -0,0 +1,78 @@
import {type ComposerState} from '#/view/com/composer/state/composer'
import {type ComposerDraft} from '#/storage'
type SerializedImage = {
alt: string
path: string
width: number
height: number
mime: string
}
type SerializedVideo = {
blobRef: unknown
width: number
height: number
mimeType: string
altText: string
}
export function serializeDraft(state: ComposerState): ComposerDraft {
return {
version: 1,
timestamp: Date.now(),
thread: {
posts: state.thread.posts.map(post => {
const media = post.embed.media
let images: SerializedImage[] | undefined
let gif:
| {id: string; media_formats: unknown; title: string; alt: string}
| undefined
let video: SerializedVideo | undefined
if (media?.type === 'images') {
images = media.images.map(img => ({
alt: img.alt,
path: img.source.path,
width: img.source.width,
height: img.source.height,
mime: img.source.mime,
}))
} else if (media?.type === 'gif') {
gif = {
id: media.gif.id,
media_formats: media.gif.media_formats,
title: media.gif.title,
alt: media.alt,
}
} else if (media?.type === 'video') {
if (media.video.status === 'done') {
video = {
blobRef: media.video.pendingPublish.blobRef,
width: media.video.asset.width,
height: media.video.asset.height,
mimeType: media.video.asset.mimeType || 'video/mp4',
altText: media.video.altText,
}
}
}
return {
id: post.id,
text: post.richtext.text,
labels: post.labels,
embed: {
quoteUri: post.embed.quote?.uri,
linkUri: post.embed.link?.uri,
images,
gif,
video,
},
}
}),
postgate: state.thread.postgate,
threadgate: state.thread.threadgate,
},
activePostIndex: state.activePostIndex,
}
}
+148
View File
@@ -0,0 +1,148 @@
import {logger} from '#/logger'
import {account, type ComposerDraft} from '#/storage'
const MAX_DRAFT_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export type DraftItem = {
id: string
draft: ComposerDraft
}
/**
* Storage layer for composer drafts.
*
* Currently backed by MMKV (local storage), but uses an async API
* to support future migration to a server-side KV store.
*
* All operations are scoped to a specific account (DID).
*/
export const draftsStorage = {
/**
* Get a single draft by ID
*/
async getDraft(did: string, draftId: string): Promise<ComposerDraft | null> {
try {
const allDrafts = account.get([did, 'composerDrafts'])
if (!allDrafts) return null
const draft = allDrafts[draftId]
if (!draft) return null
if (draft.version !== 1) {
logger.warn('Incompatible draft version', {
draftId,
version: draft.version,
})
return null
}
if (Date.now() - draft.timestamp > MAX_DRAFT_AGE_MS) {
return null
}
return draft
} catch (e) {
logger.error('Failed to get draft', {error: e, draftId})
return null
}
},
/**
* Get all drafts for an account, sorted by timestamp (newest first)
*/
async getAllDrafts(did: string): Promise<DraftItem[]> {
try {
const allDrafts = account.get([did, 'composerDrafts'])
if (!allDrafts) return []
const now = Date.now()
const items: DraftItem[] = []
for (const [id, draft] of Object.entries(allDrafts)) {
if (draft.version !== 1) continue
if (now - draft.timestamp > MAX_DRAFT_AGE_MS) continue
items.push({id, draft})
}
items.sort((a, b) => b.draft.timestamp - a.draft.timestamp)
return items
} catch (e) {
logger.error('Failed to get drafts list', {error: e})
return []
}
},
/**
* Save a draft (create or update)
*/
async saveDraft(
did: string,
draftId: string,
draft: ComposerDraft,
): Promise<void> {
try {
const allDrafts = account.get([did, 'composerDrafts']) ?? {}
account.set([did, 'composerDrafts'], {
...allDrafts,
[draftId]: draft,
})
} catch (e) {
logger.error('Failed to save draft', {error: e, draftId})
}
},
/**
* Delete a single draft
*/
async deleteDraft(did: string, draftId: string): Promise<void> {
try {
const allDrafts = account.get([did, 'composerDrafts'])
if (!allDrafts || !allDrafts[draftId]) return
const {[draftId]: _deleted, ...remainingDrafts} = allDrafts
if (Object.keys(remainingDrafts).length > 0) {
account.set([did, 'composerDrafts'], remainingDrafts)
} else {
account.remove([did, 'composerDrafts'])
}
} catch (e) {
logger.error('Failed to delete draft', {error: e, draftId})
}
},
/**
* Clean up old/invalid drafts, returns count of removed drafts
*/
async cleanupOldDrafts(did: string): Promise<number> {
try {
const allDrafts = account.get([did, 'composerDrafts'])
if (!allDrafts) return 0
const now = Date.now()
const remainingDrafts: Record<string, ComposerDraft> = {}
let removedCount = 0
for (const [id, draft] of Object.entries(allDrafts)) {
if (draft.version === 1 && now - draft.timestamp <= MAX_DRAFT_AGE_MS) {
remainingDrafts[id] = draft
} else {
removedCount++
}
}
if (removedCount > 0) {
if (Object.keys(remainingDrafts).length > 0) {
account.set([did, 'composerDrafts'], remainingDrafts)
} else {
account.remove([did, 'composerDrafts'])
}
}
return removedCount
} catch (e) {
logger.error('Failed to cleanup old drafts', {error: e})
return 0
}
},
}
+2
View File
@@ -44,6 +44,8 @@ export interface ComposerOpts {
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
videoUri?: {uri: string; width: number; height: number}
openGallery?: boolean
/** ID of a saved draft to load */
draftId?: string
}
type StateContext = ComposerOpts | undefined
+56
View File
@@ -66,4 +66,60 @@ export type Account = {
* this device.
*/
birthdateLastUpdatedAt?: string
/**
* Composer draft, saved when the user has unsent content in the post composer.
* Keyed by context (e.g., 'default', reply URI, etc.)
* @deprecated Use composerDrafts instead for multi-draft support
*/
composerDraft?: {
[context: string]: ComposerDraft
}
/**
* Composer drafts library, allowing users to save multiple drafts.
* Keyed by unique draft ID (UUID).
*/
composerDrafts?: {
[draftId: string]: ComposerDraft
}
}
export type ComposerDraft = {
version: 1
timestamp: number
thread: {
posts: Array<{
id: string
text: string
labels: string[]
embed: {
quoteUri?: string
linkUri?: string
images?: Array<{
alt: string
path: string
width: number
height: number
mime: string
}>
gif?: {
id: string
media_formats: unknown
title: string
alt: string
}
video?: {
blobRef: unknown
width: number
height: number
mimeType: string
altText: string
}
}
}>
postgate: unknown
threadgate: unknown
}
activePostIndex: number
}
+617 -69
View File
@@ -78,6 +78,7 @@ import {colors} from '#/lib/styles'
import {logger} from '#/logger'
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
import {useDialogStateControlContext} from '#/state/dialogs'
import {draftsStorage} from '#/state/drafts'
import {emitPostCreated} from '#/state/events'
import {
type ComposerImage,
@@ -130,7 +131,9 @@ import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {Text as NewText} from '#/components/Typography'
import {type ComposerDraft} from '#/storage'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import {DraftsView} from './DraftsDialog'
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
import {
type AssetType,
@@ -140,6 +143,7 @@ import {
import {
type ComposerAction,
composerReducer,
type ComposerState,
createComposerState,
type EmbedDraft,
MAX_IMAGES,
@@ -154,9 +158,74 @@ import {
type VideoState,
} from './state/video'
import {type TextInputRef} from './text-input/TextInput.types'
import {useComposerDraft} from './useComposerDraft'
import {useDraftsList} from './useDraftsList'
import {getVideoMetadata} from './videos/pickVideo'
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
/**
* Serializes the relevant parts of composer state for comparison.
* Used to detect if a loaded draft has been modified.
*/
function serializeStateForComparison(state: ComposerState): string {
return JSON.stringify({
posts: state.thread.posts.map(post => ({
text: post.richtext.text,
labels: post.labels,
hasQuote: !!post.embed.quote,
quoteUri: post.embed.quote?.uri,
hasLink: !!post.embed.link,
linkUri: post.embed.link?.uri,
mediaType: post.embed.media?.type,
// For images, compare paths and alts
images:
post.embed.media?.type === 'images'
? post.embed.media.images.map(img => ({
path: img.source.path,
alt: img.alt,
}))
: undefined,
// For gif, compare id and alt
gif:
post.embed.media?.type === 'gif'
? {id: post.embed.media.gif.id, alt: post.embed.media.alt}
: undefined,
// For video, compare blobRef (if done)
video:
post.embed.media?.type === 'video' &&
post.embed.media.video.status === 'done'
? {
blobRef: (post.embed.media.video as any).pendingPublish?.blobRef,
alt: post.embed.media.video.altText,
}
: undefined,
})),
postgate: state.thread.postgate,
threadgate: state.thread.threadgate,
})
}
/**
* Prepares a draft from storage for loading into the composer.
* Constructs video URLs from blobRefs since we only store the ref, not the URL.
*/
function prepareDraftForLoading(
draft: ComposerDraft,
did: string,
): ComposerDraft {
const prepared = JSON.parse(JSON.stringify(draft)) // Deep clone
if (prepared.thread?.posts) {
prepared.thread.posts = prepared.thread.posts.map((post: any) => {
if (post.embed?.video?.blobRef?.ref?.$link) {
const cid = post.embed.video.blobRef.ref.$link
post.embed.video.uri = `https://video.bsky.app/watch/${encodeURIComponent(did)}/${cid}/playlist.m3u8`
}
return post
})
}
return prepared
}
type CancelRef = {
onPressCancel: () => void
}
@@ -173,6 +242,7 @@ export const ComposePost = ({
imageUris: initImageUris,
videoUri: initVideoUri,
openGallery,
draftId: initDraftId,
cancelRef,
}: Props & {
cancelRef?: React.RefObject<CancelRef | null>
@@ -188,6 +258,8 @@ export const ComposePost = ({
const setLangPrefs = useLanguagePrefsApi()
const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const saveBeforeDraftsPromptControl = Prompt.usePromptControl()
const updateBeforeDraftsPromptControl = Prompt.usePromptControl()
const {closeAllDialogs} = useDialogStateControlContext()
const {closeAllModals} = useModalControls()
const {data: preferences} = usePreferencesQuery()
@@ -197,6 +269,7 @@ export const ComposePost = ({
const [isPublishing, setIsPublishing] = useState(false)
const [publishingStage, setPublishingStage] = useState('')
const [error, setError] = useState('')
const [viewMode, setViewMode] = useState<'compose' | 'drafts'>('compose')
/**
* A temporary local reference to a language suggestion that the user has
@@ -236,6 +309,15 @@ export const ComposePost = ({
setReplyToLanguages([])
}
// Check if we should load a draft (no other initial content provided)
const shouldLoadDraft =
initDraftId &&
!initText &&
!initMention &&
!initImageUris?.length &&
!initQuote &&
!initVideoUri
const [composerState, composerDispatch] = useReducer(
composerReducer,
{
@@ -244,11 +326,324 @@ export const ComposePost = ({
initText,
initMention,
initInteractionSettings: preferences?.postInteractionSettings,
initDraft: undefined, // Draft loaded async below
},
createComposerState,
)
// Track current draft ID - can be updated when user selects a draft from the list
const [currentDraftId, setCurrentDraftId] = useState<string | undefined>(
!replyTo ? initDraftId : undefined,
)
// Draft persistence - only for top-level posts (not replies)
const {clearDraft, saveDraft} = useComposerDraft(currentDraftId)
// Track if we're editing an existing draft (either from initial load or from list selection)
const [isEditingExistingDraft, setIsEditingExistingDraft] =
useState(!!initDraftId)
// Snapshot of the draft content when loaded, for detecting changes
const [loadedDraftSnapshot, setLoadedDraftSnapshot] = useState<string | null>(
null,
)
// Track the timestamp of the loaded draft (for analytics)
const [loadedDraftTimestamp, setLoadedDraftTimestamp] = useState<
number | null
>(null)
// Loading state for initial draft
const [isLoadingDraft, setIsLoadingDraft] = useState(!!shouldLoadDraft)
// Load initial draft asynchronously
useEffect(() => {
if (!shouldLoadDraft || !currentAccount) {
setIsLoadingDraft(false)
return
}
let cancelled = false
async function loadDraft() {
const draft = await draftsStorage.getDraft(
currentAccount!.did,
initDraftId!,
)
if (cancelled || !draft) {
setIsLoadingDraft(false)
return
}
const prepared = prepareDraftForLoading(draft, currentAccount!.did)
const newState = createComposerState({
initText: undefined,
initMention: undefined,
initImageUris: undefined,
initQuoteUri: undefined,
initInteractionSettings: preferences?.postInteractionSettings,
initDraft: prepared,
})
composerDispatch({type: 'load_draft', draft: newState})
setLoadedDraftSnapshot(serializeStateForComparison(newState))
setLoadedDraftTimestamp(draft.timestamp)
setIsLoadingDraft(false)
logger.info('Composer: loaded draft', {
draftId: initDraftId,
textLength: draft.thread.posts[0]?.text.length || 0,
})
}
loadDraft()
return () => {
cancelled = true
}
}, [
shouldLoadDraft,
currentAccount,
initDraftId,
preferences?.postInteractionSettings,
])
// Log composer:open event on mount
const onceRef = useRef(false)
useEffect(() => {
if (onceRef.current) return
onceRef.current = true
const logContext = replyTo ? 'PostReply' : initQuote ? 'QuotePost' : 'Fab'
logger.metric('composer:open', {
logContext,
isReply: !!replyTo,
hasQuote: !!initQuote,
hasDraft: !!initDraftId,
})
}, [replyTo, initQuote, initDraftId])
// Drafts list for the drafts dialog
const {draftsCount} = useDraftsList()
const thread = composerState.thread
// Check if composer has content
const hasContent = useMemo(() => {
return thread.posts.some(
post =>
post.richtext.text.trim().length > 0 ||
post.embed.quote ||
post.embed.link ||
post.embed.media,
)
}, [thread.posts])
// Check if an existing draft has been modified since loading
const hasUnsavedChanges = useMemo(() => {
if (!isEditingExistingDraft || !loadedDraftSnapshot) {
return false
}
const currentSnapshot = serializeStateForComparison(composerState)
return currentSnapshot !== loadedDraftSnapshot
}, [isEditingExistingDraft, loadedDraftSnapshot, composerState])
// Helper to get draft metadata for analytics events
const getDraftMetadata = useCallback((state: ComposerState) => {
const posts = state.thread.posts
const firstPost = posts[0]
return {
hasText: posts.some(p => p.richtext.text.trim().length > 0),
hasImages: posts.some(p => p.embed.media?.type === 'images'),
hasVideo: posts.some(p => p.embed.media?.type === 'video'),
hasGif: posts.some(p => p.embed.media?.type === 'gif'),
hasQuote: posts.some(p => !!p.embed.quote),
hasLink: posts.some(p => !!p.embed.link),
postCount: posts.length,
textLength: firstPost?.richtext.text.length ?? 0,
}
}, [])
// Handler for selecting a draft from the list
const onSelectDraft = useCallback(
async (selectedDraftId: string) => {
if (!currentAccount) return
const draft = await draftsStorage.getDraft(
currentAccount.did,
selectedDraftId,
)
if (!draft) return
const prepared = prepareDraftForLoading(draft, currentAccount.did)
const newState = createComposerState({
initText: undefined,
initMention: undefined,
initImageUris: undefined,
initQuoteUri: undefined,
initInteractionSettings: preferences?.postInteractionSettings,
initDraft: prepared,
})
// Update draft tracking state
setCurrentDraftId(selectedDraftId)
setIsEditingExistingDraft(true)
setLoadedDraftSnapshot(serializeStateForComparison(newState))
setLoadedDraftTimestamp(draft.timestamp)
// Load the draft and switch back to compose mode
composerDispatch({type: 'load_draft', draft: newState})
setViewMode('compose')
// Log draft:load event
const metadata = getDraftMetadata(newState)
logger.metric('draft:load', {
draftAgeMs: Date.now() - draft.timestamp,
hasText: metadata.hasText,
hasImages: metadata.hasImages,
hasVideo: metadata.hasVideo,
hasGif: metadata.hasGif,
postCount: metadata.postCount,
})
logger.info('Loaded draft into composer', {draftId: selectedDraftId})
},
[currentAccount, preferences?.postInteractionSettings, getDraftMetadata],
)
// Handler for opening drafts view
const onPressDrafts = useCallback(() => {
if (hasContent && !isEditingExistingDraft) {
// New unsaved content - prompt to save first
saveBeforeDraftsPromptControl.open()
} else if (isEditingExistingDraft && hasUnsavedChanges) {
// Editing existing draft with changes - prompt to update first
updateBeforeDraftsPromptControl.open()
} else {
// No content or no changes - just show drafts
setViewMode('drafts')
logger.metric('draft:listOpen', {
draftCount: draftsCount ?? 0,
})
}
}, [
hasContent,
isEditingExistingDraft,
hasUnsavedChanges,
saveBeforeDraftsPromptControl,
updateBeforeDraftsPromptControl,
draftsCount,
])
// Handler for "Save" in the save-before-drafts prompt
const onSaveBeforeDrafts = useCallback(() => {
saveDraft(composerState)
const metadata = getDraftMetadata(composerState)
logger.metric('draft:save', {
isNewDraft: !isEditingExistingDraft,
...metadata,
})
setViewMode('drafts')
logger.metric('draft:listOpen', {
draftCount: (draftsCount ?? 0) + (isEditingExistingDraft ? 0 : 1),
})
}, [
saveDraft,
composerState,
getDraftMetadata,
isEditingExistingDraft,
draftsCount,
])
// Handler for "Don't save" in the save-before-drafts prompt
const onDiscardBeforeDrafts = useCallback(() => {
const metadata = getDraftMetadata(composerState)
logger.metric('draft:discard', {
logContext: 'BeforeDraftsList',
hadContent: hasContent,
textLength: metadata.textLength,
})
clearDraft()
setViewMode('drafts')
logger.metric('draft:listOpen', {
draftCount: draftsCount ?? 0,
})
}, [clearDraft, getDraftMetadata, composerState, hasContent, draftsCount])
// Handler for saving draft and closing with toast
const onSaveDraftAndClose = useCallback(() => {
saveDraft(composerState)
const metadata = getDraftMetadata(composerState)
logger.metric('draft:save', {
isNewDraft: !isEditingExistingDraft,
...metadata,
})
closeComposer()
clearThumbnailCache(queryClient)
Toast.show(_(msg`Saved to drafts`))
}, [
_,
saveDraft,
composerState,
getDraftMetadata,
isEditingExistingDraft,
closeComposer,
queryClient,
])
// Handler for "Don't save" when closing composer with unsaved content
const onDiscardAndClose = useCallback(() => {
const metadata = getDraftMetadata(composerState)
logger.metric('draft:discard', {
logContext: 'ComposerClose',
hadContent: hasContent,
textLength: metadata.textLength,
})
clearDraft()
closeComposer()
clearThumbnailCache(queryClient)
}, [
getDraftMetadata,
composerState,
hasContent,
clearDraft,
closeComposer,
queryClient,
])
// Handler for "Update draft" in the update-before-drafts prompt
const onUpdateBeforeDrafts = useCallback(() => {
saveDraft(composerState)
const metadata = getDraftMetadata(composerState)
logger.metric('draft:save', {
isNewDraft: false,
...metadata,
})
setViewMode('drafts')
logger.metric('draft:listOpen', {
draftCount: draftsCount ?? 0,
})
}, [saveDraft, composerState, getDraftMetadata, draftsCount])
// Handler for "Don't update" in the update-before-drafts prompt
const onSkipUpdateBeforeDrafts = useCallback(() => {
setViewMode('drafts')
logger.metric('draft:listOpen', {
draftCount: draftsCount ?? 0,
})
}, [draftsCount])
// Handler for when a draft is deleted from the drafts list
const onDraftDeleted = useCallback(
(deletedDraftId: string) => {
// If the deleted draft is the one we're currently editing, reset to new draft state
if (currentDraftId === deletedDraftId) {
setCurrentDraftId(undefined)
setIsEditingExistingDraft(false)
setLoadedDraftSnapshot(null)
}
},
[currentDraftId],
)
const activePost = thread.posts[composerState.activePostIndex]
const nextPost: PostDraft | undefined =
thread.posts[composerState.activePostIndex + 1]
@@ -322,6 +717,7 @@ export const ComposePost = ({
const [publishOnUpload, setPublishOnUpload] = useState(false)
const onClose = useCallback(() => {
// Don't clear draft - it's auto-saved and will be kept
closeComposer()
clearThumbnailCache(queryClient)
}, [closeComposer, queryClient])
@@ -346,7 +742,23 @@ export const ComposePost = ({
const onPressCancel = useCallback(() => {
if (textInput.current?.maybeClosePopup()) {
return
} else if (
}
// For existing drafts, only prompt if there are unsaved changes
if (isEditingExistingDraft) {
if (hasUnsavedChanges) {
closeAllDialogs()
Keyboard.dismiss()
discardPromptControl.open()
} else {
// No changes made, just close without prompt
onClose()
}
return
}
// For new content, prompt if there's any content
if (
thread.posts.some(
post =>
post.shortenedGraphemeLength > 0 ||
@@ -360,7 +772,14 @@ export const ComposePost = ({
} else {
onClose()
}
}, [thread, closeAllDialogs, discardPromptControl, onClose])
}, [
thread,
closeAllDialogs,
discardPromptControl,
onClose,
isEditingExistingDraft,
hasUnsavedChanges,
])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -564,6 +983,14 @@ export const ComposePost = ({
onPost?.(postUri)
onPostSuccess?.(postSuccessData)
}
// Log draft:post event if we posted from an existing draft
if (isEditingExistingDraft && loadedDraftTimestamp) {
logger.metric('draft:post', {
draftAgeMs: Date.now() - loadedDraftTimestamp,
wasEdited: hasUnsavedChanges,
})
}
clearDraft()
onClose()
setTimeout(() => {
Toast.show(
@@ -599,6 +1026,7 @@ export const ComposePost = ({
canPost,
isPublishing,
currentLanguages,
clearDraft,
onClose,
onPost,
onPostSuccess,
@@ -607,6 +1035,9 @@ export const ComposePost = ({
setLangPrefs,
queryClient,
navigation,
isEditingExistingDraft,
loadedDraftTimestamp,
hasUnsavedChanges,
])
// Preserves the referential identity passed to each post item.
@@ -739,74 +1170,171 @@ export const ComposePost = ({
style={[a.flex_1, viewStyles]}
aria-modal
accessibilityViewIsModal>
<ComposerTopBar
canPost={canPost}
isReply={!!replyTo}
isPublishQueued={publishOnUpload}
isPublishing={isPublishing}
isThread={thread.posts.length > 1}
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={onPressPublish}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
videoState={erroredVideo}
clearError={() => setError('')}
clearVideo={
erroredVideoPostId
? () => clearVideo(erroredVideoPostId)
: () => {}
}
{isLoadingDraft ? (
<View style={[a.flex_1, a.justify_center, a.align_center]}>
<ActivityIndicator size="large" />
</View>
) : viewMode === 'drafts' ? (
<DraftsView
onSelectDraft={onSelectDraft}
onBack={() => setViewMode('compose')}
onDeleteDraft={onDraftDeleted}
/>
</ComposerTopBar>
<Animated.ScrollView
ref={scrollViewRef}
layout={native(LinearTransition)}
onScroll={scrollHandler}
contentContainerStyle={a.flex_grow}
style={a.flex_1}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{thread.posts.map((post, index) => (
<React.Fragment key={post.id}>
<ComposerPost
post={post}
dispatch={composerDispatch}
textInput={post.id === activePost.id ? textInput : null}
isFirstPost={index === 0}
isLastPost={index === thread.posts.length - 1}
isPartOfThread={thread.posts.length > 1}
isReply={index > 0 || !!replyTo}
isActive={post.id === activePost.id}
canRemovePost={thread.posts.length > 1}
canRemoveQuote={index > 0 || !initQuote}
onSelectVideo={selectVideo}
onClearVideo={clearVideo}
onPublish={onComposerPostPublish}
onError={setError}
) : (
<>
<ComposerTopBar
canPost={canPost}
isReply={!!replyTo}
isPublishQueued={publishOnUpload}
isPublishing={isPublishing}
isThread={thread.posts.length > 1}
publishingStage={publishingStage}
topBarAnimatedStyle={topBarAnimatedStyle}
onCancel={onPressCancel}
onPublish={onPressPublish}
onPressDrafts={onPressDrafts}
draftsCount={draftsCount}>
{missingAltError && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
videoState={erroredVideo}
clearError={() => setError('')}
clearVideo={
erroredVideoPostId
? () => clearVideo(erroredVideoPostId)
: () => {}
}
/>
{isWebFooterSticky && post.id === activePost.id && (
<View style={styles.stickyFooterWeb}>{footer}</View>
)}
</React.Fragment>
))}
</Animated.ScrollView>
{!isWebFooterSticky && footer}
</ComposerTopBar>
<Animated.ScrollView
ref={scrollViewRef}
layout={native(LinearTransition)}
onScroll={scrollHandler}
contentContainerStyle={a.flex_grow}
style={a.flex_1}
keyboardShouldPersistTaps="always"
onContentSizeChange={onScrollViewContentSizeChange}
onLayout={onScrollViewLayout}>
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
{thread.posts.map((post, index) => (
<React.Fragment key={post.id}>
<ComposerPost
post={post}
dispatch={composerDispatch}
textInput={post.id === activePost.id ? textInput : null}
isFirstPost={index === 0}
isLastPost={index === thread.posts.length - 1}
isPartOfThread={thread.posts.length > 1}
isReply={index > 0 || !!replyTo}
isActive={post.id === activePost.id}
canRemovePost={thread.posts.length > 1}
canRemoveQuote={index > 0 || !initQuote}
onSelectVideo={selectVideo}
onClearVideo={clearVideo}
onPublish={onComposerPostPublish}
onError={setError}
/>
{isWebFooterSticky && post.id === activePost.id && (
<View style={styles.stickyFooterWeb}>{footer}</View>
)}
</React.Fragment>
))}
</Animated.ScrollView>
{!isWebFooterSticky && footer}
</>
)}
</View>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
/>
{isEditingExistingDraft ? (
<Prompt.Outer control={discardPromptControl}>
<Prompt.TitleText>
<Trans>Update draft?</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>Update draft and save it to post later.</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Update draft`)}
onPress={onSaveDraftAndClose}
/>
<Prompt.Action
cta={_(msg`Don't update`)}
color="negative_subtle"
onPress={() => {
// Just close without saving - preserve the original draft
closeComposer()
clearThumbnailCache(queryClient)
}}
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
) : (
<Prompt.Outer control={discardPromptControl}>
<Prompt.TitleText>
<Trans>Save to drafts?</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>Save to drafts to edit and post later.</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Save draft`)}
onPress={onSaveDraftAndClose}
/>
<Prompt.Action
cta={_(msg`Don't save`)}
color="negative_subtle"
onPress={onDiscardAndClose}
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
)}
<Prompt.Outer control={saveBeforeDraftsPromptControl}>
<Prompt.TitleText>
<Trans>Save to drafts?</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>Save to drafts to edit and post later.</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Save draft`)}
onPress={onSaveBeforeDrafts}
/>
<Prompt.Action
cta={_(msg`Don't save`)}
color="negative_subtle"
onPress={onDiscardBeforeDrafts}
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
<Prompt.Outer control={updateBeforeDraftsPromptControl}>
<Prompt.TitleText>
<Trans>Update draft?</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>Update draft and save it to post later.</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Update draft`)}
onPress={onUpdateBeforeDrafts}
/>
<Prompt.Action
cta={_(msg`Don't update`)}
color="negative_subtle"
onPress={onSkipUpdateBeforeDrafts}
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
</KeyboardAvoidingView>
</BottomSheetPortalProvider>
)
@@ -1025,6 +1553,8 @@ function ComposerTopBar({
publishingStage,
onCancel,
onPublish,
onPressDrafts,
draftsCount,
topBarAnimatedStyle,
children,
}: {
@@ -1036,11 +1566,14 @@ function ComposerTopBar({
isThread: boolean
onCancel: () => void
onPublish: () => void
onPressDrafts?: () => void
draftsCount?: number
topBarAnimatedStyle: StyleProp<ViewStyle>
children?: React.ReactNode
}) {
const pal = usePalette('default')
const {_} = useLingui()
const showDraftsButton = !isReply && draftsCount !== undefined
return (
<Animated.View
style={topBarAnimatedStyle}
@@ -1062,6 +1595,21 @@ function ComposerTopBar({
</ButtonText>
</Button>
<View style={a.flex_1} />
{showDraftsButton && (
<Button
testID="composerDraftsBtn"
label={_(msg`Drafts`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm, a.mr_sm]}
onPress={onPressDrafts}>
<ButtonText style={[a.text_md]}>
<Trans>Drafts</Trans>
</ButtonText>
</Button>
)}
{isPublishing ? (
<>
<Text style={pal.textLight}>{publishingStage}</Text>
@@ -1409,7 +1957,7 @@ function ComposerFooter({
if (assets.length) {
if (type === 'image') {
const images: ComposerImage[] = []
const composerImages: ComposerImage[] = []
await Promise.all(
assets.map(async image => {
@@ -1419,7 +1967,7 @@ function ComposerFooter({
height: image.height,
mime: image.mimeType!,
})
images.push(composerImage)
composerImages.push(composerImage)
}),
).catch(e => {
logger.error(`createComposerImage failed`, {
@@ -1427,7 +1975,7 @@ function ComposerFooter({
})
})
onImageAdd(images)
onImageAdd(composerImages)
} else if (type === 'video') {
onSelectVideo(post.id, assets[0])
} else if (type === 'gif') {
+286
View File
@@ -0,0 +1,286 @@
import React, {useCallback} from 'react'
import {ScrollView, View} from 'react-native'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLineIcon} from '#/components/icons/Pencil'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography'
import {type ComposerDraft} from '#/storage'
import {type DraftItem, useDraftsList} from './useDraftsList'
/**
* Inline drafts view - renders directly in the composer
*/
export function DraftsView({
onSelectDraft,
onBack,
onDeleteDraft,
}: {
onSelectDraft: (draftId: string) => void
onBack: () => void
onDeleteDraft?: (draftId: string) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {drafts, deleteDraft} = useDraftsList()
const handleDeleteDraft = useCallback(
(draftId: string) => {
deleteDraft(draftId)
onDeleteDraft?.(draftId)
},
[deleteDraft, onDeleteDraft],
)
return (
<View style={[a.flex_1]}>
<View
style={[a.flex_row, a.align_center, a.px_sm, a.gap_xs, {height: 54}]}>
<View style={[a.flex_1, a.flex_row, a.justify_start]}>
<Button
label={_(msg`Back`)}
variant="ghost"
color="primary"
shape="default"
size="small"
style={[a.rounded_full, a.py_sm, {paddingLeft: 7, paddingRight: 7}]}
onPress={onBack}>
<ButtonText style={[a.text_md]}>
<Trans>Back</Trans>
</ButtonText>
</Button>
</View>
<Text style={[a.text_lg, a.font_bold]}>
<Trans>Drafts</Trans>
</Text>
<View style={[a.flex_1]} />
</View>
<ScrollView
style={[a.flex_1]}
contentContainerStyle={[
a.gap_md,
drafts.length === 0 && {flexGrow: 1},
]}>
{drafts.length === 0 ? (
<View
style={[
a.align_center,
a.gap_lg,
isNative ? [a.flex_1, a.justify_center] : [a.pt_xl, a.pb_4xl],
]}>
<PencilLineIcon width={48} style={[t.atoms.text_contrast_low]} />
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>No drafts yet</Trans>
</Text>
</View>
) : (
<View
style={[
a.overflow_hidden,
a.border_t,
isNative && a.border_b,
t.atoms.border_contrast_low,
]}>
{drafts.map((item, index) => (
<React.Fragment key={item.id}>
<DraftListItem
item={item}
onSelect={onSelectDraft}
onDelete={handleDeleteDraft}
/>
{index < drafts.length - 1 && (
<View style={[a.border_b, t.atoms.border_contrast_low]} />
)}
</React.Fragment>
))}
</View>
)}
</ScrollView>
</View>
)
}
function DraftListItem({
item,
onSelect,
onDelete,
}: {
item: DraftItem
onSelect: (draftId: string) => void
onDelete: (draftId: string) => void
}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const getTimeAgo = useGetTimeAgo()
const handleSelect = useCallback(() => {
onSelect(item.id)
}, [item.id, onSelect])
const handleDelete = useCallback(() => {
logger.metric('draft:delete', {
logContext: 'DraftsList',
draftAgeMs: Date.now() - item.draft.timestamp,
})
onDelete(item.id)
}, [item.id, item.draft.timestamp, onDelete])
const previewText = getPreviewText(item.draft, _)
const timeAgo = getTimeAgo(item.draft.timestamp, Date.now())
return (
<Button
testID={`draftItem-${item.id}`}
style={[a.w_full]}
onPress={handleSelect}
label={_(msg`Load draft: ${previewText}`)}>
{({hovered, pressed}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_start,
a.p_lg,
a.gap_md,
(hovered || pressed) && t.atoms.bg_contrast_25,
]}>
<UserAvatar avatar={profile?.avatar} size={42} type="user" />
<View style={[a.flex_1, a.gap_xs, a.pr_md]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text
style={[
a.font_semi_bold,
a.leading_tight,
a.text_md,
a.flex_shrink_0,
]}
numberOfLines={1}>
{profile?.displayName || currentAccount?.handle}
</Text>
<Text
style={[
t.atoms.text_contrast_medium,
a.text_md,
a.leading_tight,
{flexShrink: 10},
]}
numberOfLines={1}
ellipsizeMode="tail">
@{currentAccount?.handle}
</Text>
<Text
style={[
t.atoms.text_contrast_medium,
a.text_md,
a.flex_shrink_0,
]}>
· {timeAgo}
</Text>
</View>
<Text
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
{previewText}
</Text>
</View>
<Menu.Root>
<Menu.Trigger label={_(msg`Draft options`)}>
{({props, state}) => (
<Button
{...props}
label={_(msg`Draft options`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
style={[
a.justify_center,
a.align_center,
{width: 34, height: 34, marginRight: -8, marginTop: -8},
(state.hovered || state.pressed) && t.atoms.bg_contrast_50,
]}>
<ButtonIcon icon={DotsIcon} size="md" />
</Button>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.Item
label={_(msg`Delete draft`)}
onPress={handleDelete}
testID="deleteDraftBtn"
style={[a.gap_sm]}>
<Menu.ItemIcon icon={TrashIcon} />
<Menu.ItemText>
<Trans>Delete</Trans>
</Menu.ItemText>
</Menu.Item>
</Menu.Outer>
</Menu.Root>
</View>
)}
</Button>
)
}
function getPreviewText(
draft: ComposerDraft,
_: ReturnType<typeof useLingui>['_'],
): string {
// Get text from first post
const firstPost = draft.thread.posts[0]
if (!firstPost) return ''
const text = firstPost.text.trim()
if (text) {
// Truncate to ~100 chars
if (text.length > 100) {
return text.substring(0, 100) + '...'
}
return text
}
// If no text, describe the media
if (firstPost.embed.images && firstPost.embed.images.length > 0) {
const count = firstPost.embed.images.length
return _(
plural(count, {
one: '1 image',
other: `${count} images`,
}),
)
}
if (firstPost.embed.gif) {
return _(msg`GIF`)
}
if (firstPost.embed.video) {
return _(msg`Video`)
}
if (firstPost.embed.quoteUri) {
return _(msg`Quote post`)
}
if (firstPost.embed.linkUri) {
return _(msg`Link`)
}
return ''
}
+107
View File
@@ -122,6 +122,10 @@ export type ComposerAction =
type: 'focus_post'
postId: string
}
| {
type: 'load_draft'
draft: ComposerState
}
export const MAX_IMAGES = 4
@@ -229,6 +233,9 @@ export function composerReducer(
activePostIndex: nextActivePostIndex,
}
}
case 'load_draft': {
return action.draft
}
}
}
@@ -488,6 +495,7 @@ export function createComposerState({
initImageUris,
initQuoteUri,
initInteractionSettings,
initDraft,
}: {
initText: string | undefined
initMention: string | undefined
@@ -496,7 +504,106 @@ export function createComposerState({
initInteractionSettings:
| BskyPreferences['postInteractionSettings']
| undefined
initDraft?: any
}): ComposerState {
// If we have a draft, use it instead of init values
if (initDraft?.thread?.posts?.[0]) {
return {
activePostIndex: initDraft.activePostIndex || 0,
mutableNeedsFocusActive: false,
thread: {
posts: initDraft.thread.posts.map((post: any) => {
let media: ImagesMedia | GifMedia | VideoMedia | undefined
if (post.embed?.images?.length) {
media = {
type: 'images',
images: post.embed.images.map((img: any) => ({
alt: img.alt,
source: {
id: `restored-${Date.now()}-${Math.random()}`,
path: img.path,
width: img.width,
height: img.height,
mime: img.mime,
},
})),
}
} else if (post.embed?.gif) {
media = {
type: 'gif',
gif: post.embed.gif,
alt: post.embed.gif.alt || '',
}
} else if (post.embed?.video) {
// Restore video from draft (already uploaded to server)
const abortController = new AbortController()
abortController.abort() // Can't resume, already uploaded
const videoUri = post.embed.video.uri || '' // URL constructed from blobRef in Composer.tsx
media = {
type: 'video',
video: {
status: 'done',
progress: 100,
abortController,
asset: {
uri: videoUri,
width: post.embed.video.width,
height: post.embed.video.height,
mimeType: post.embed.video.mimeType,
},
video: {
uri: videoUri,
mimeType: post.embed.video.mimeType,
size: 0,
},
pendingPublish: {
blobRef: post.embed.video.blobRef,
},
altText: post.embed.video.altText || '',
captions: [],
},
}
}
return {
id: nanoid(),
richtext: new RichText({text: post.text || ''}),
shortenedGraphemeLength: getShortenedLength(
new RichText({text: post.text || ''}),
),
labels: post.labels || [],
embed: {
quote: post.embed?.quoteUri
? {type: 'link', uri: post.embed.quoteUri}
: undefined,
media,
link: post.embed?.linkUri
? {type: 'link', uri: post.embed.linkUri}
: undefined,
},
}
}),
postgate:
initDraft.thread.postgate ||
createPostgateRecord({
post: '',
embeddingRules:
initInteractionSettings?.postgateEmbeddingRules || [],
}),
threadgate:
initDraft.thread.threadgate ||
threadgateRecordToAllowUISetting({
$type: 'app.bsky.feed.threadgate',
post: '',
createdAt: new Date().toString(),
allow: initInteractionSettings?.threadgateAllowRules,
}),
},
}
}
// Otherwise use normal initialization
let media: ImagesMedia | undefined
if (initImageUris?.length) {
media = {
+59
View File
@@ -0,0 +1,59 @@
import {useCallback, useMemo} from 'react'
import {draftsStorage, serializeDraft} from '#/state/drafts'
import {useSession} from '#/state/session'
import {type ComposerState} from './state/composer'
function generateDraftId(): string {
return `draft-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
/**
* Hook for managing a single draft in the composer.
* Provides methods to save and clear drafts.
*
* @param draftId Optional draft ID for an existing draft. If not provided, a new ID is generated.
*/
export function useComposerDraft(draftId?: string) {
const {currentAccount} = useSession()
const accountDid = currentAccount?.did
const currentDraftId = useMemo(() => draftId || generateDraftId(), [draftId])
const saveDraft = useCallback(
(state: ComposerState) => {
if (!accountDid) return
const hasContent = state.thread.posts.some(
post =>
post.richtext.text.trim().length > 0 ||
post.embed.quote ||
post.embed.link ||
post.embed.media,
)
if (hasContent) {
draftsStorage.saveDraft(
accountDid,
currentDraftId,
serializeDraft(state),
)
} else {
draftsStorage.deleteDraft(accountDid, currentDraftId)
}
},
[accountDid, currentDraftId],
)
const clearDraft = useCallback(() => {
if (!accountDid) return
draftsStorage.deleteDraft(accountDid, currentDraftId)
}, [accountDid, currentDraftId])
return {
draftId: currentDraftId,
saveDraft,
clearDraft,
isExistingDraft: !!draftId,
}
}
+58
View File
@@ -0,0 +1,58 @@
import {useCallback, useEffect, useState} from 'react'
import {type DraftItem, draftsStorage} from '#/state/drafts'
import {useSession} from '#/state/session'
export type {DraftItem}
/**
* Hook for managing the drafts list.
* Provides access to all saved drafts and methods to manage them.
*/
export function useDraftsList() {
const {currentAccount} = useSession()
const accountDid = currentAccount?.did
const [drafts, setDrafts] = useState<DraftItem[]>([])
useEffect(() => {
if (!accountDid) {
setDrafts([])
return
}
draftsStorage.getAllDrafts(accountDid).then(setDrafts)
}, [accountDid])
const deleteDraft = useCallback(
async (draftId: string) => {
if (!accountDid) return
await draftsStorage.deleteDraft(accountDid, draftId)
const updated = await draftsStorage.getAllDrafts(accountDid)
setDrafts(updated)
},
[accountDid],
)
const cleanupOldDrafts = useCallback(async () => {
if (!accountDid) return
const removedCount = await draftsStorage.cleanupOldDrafts(accountDid)
if (removedCount > 0) {
const updated = await draftsStorage.getAllDrafts(accountDid)
setDrafts(updated)
}
}, [accountDid])
const refreshDrafts = useCallback(async () => {
if (!accountDid) return
const updated = await draftsStorage.getAllDrafts(accountDid)
setDrafts(updated)
}, [accountDid])
return {
drafts,
draftsCount: drafts.length,
deleteDraft,
cleanupOldDrafts,
refreshDrafts,
}
}
+46 -24
View File
@@ -3,12 +3,14 @@ import {View} from 'react-native'
import {Image} from 'expo-image'
import {type ImagePickerAsset} from 'expo-image-picker'
import {BlueskyVideoView} from '@haileyok/bluesky-video'
import {Trans} from '@lingui/macro'
import {type CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers'
import {useAutoplayDisabled} from '#/state/preferences'
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
@@ -26,6 +28,8 @@ export function VideoPreview({
const t = useTheme()
const playerRef = React.useRef<BlueskyVideoView>(null)
const autoplayDisabled = useAutoplayDisabled()
const isRestoredFromDraft = !video.uri
let aspectRatio = asset.width / asset.height
if (isNaN(aspectRatio)) {
@@ -45,32 +49,50 @@ export function VideoPreview({
t.atoms.border_contrast_low,
{backgroundColor: 'black'},
]}>
<View style={[a.absolute, a.inset_0]}>
<VideoTranscodeBackdrop uri={asset.uri} />
</View>
{isActivePost && (
<>
{video.mimeType === 'image/gif' ? (
<Image
style={[a.flex_1]}
autoplay={!autoplayDisabled}
source={{uri: video.uri}}
accessibilityIgnoresInvertColors
cachePolicy="none"
/>
) : (
<BlueskyVideoView
url={video.uri}
autoplay={!autoplayDisabled}
beginMuted={true}
forceTakeover={true}
ref={playerRef}
/>
)}
</>
{!isRestoredFromDraft && (
<View style={[a.absolute, a.inset_0]}>
<VideoTranscodeBackdrop uri={asset.uri} />
</View>
)}
{isRestoredFromDraft ? (
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
a.gap_md,
]}>
<PlayButtonIcon />
<Text style={[a.text_center, {color: t.palette.white}]}>
<Trans>Video uploaded and ready to post</Trans>
</Text>
</View>
) : (
isActivePost && (
<>
{video.mimeType === 'image/gif' ? (
<Image
style={[a.flex_1]}
autoplay={!autoplayDisabled}
source={{uri: video.uri}}
accessibilityIgnoresInvertColors
cachePolicy="none"
/>
) : (
<BlueskyVideoView
url={video.uri}
autoplay={!autoplayDisabled}
beginMuted={true}
forceTakeover={true}
ref={playerRef}
/>
)}
</>
)
)}
<ExternalEmbedRemoveBtn onRemove={clear} />
{autoplayDisabled && (
{!isRestoredFromDraft && autoplayDisabled && (
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon />
</View>
@@ -1,6 +1,6 @@
import {View} from 'react-native'
import {type ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type CompressedVideo} from '#/lib/media/video/types'
@@ -8,7 +8,8 @@ import {clamp} from '#/lib/numbers'
import {useAutoplayDisabled} from '#/state/preferences'
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function VideoPreview({
@@ -23,9 +24,11 @@ export function VideoPreview({
clear: () => void
}) {
const {_} = useLingui()
const t = useTheme()
// TODO: figure out how to pause a GIF for reduced motion
// it's not possible using an img tag -sfn
const autoplayDisabled = useAutoplayDisabled()
const isRestoredFromDraft = !video.uri
let aspectRatio = asset.width / asset.height
@@ -46,7 +49,21 @@ export function VideoPreview({
a.relative,
]}>
<ExternalEmbedRemoveBtn onRemove={clear} />
{video.mimeType === 'image/gif' ? (
{isRestoredFromDraft ? (
<View
style={[
a.absolute,
a.inset_0,
a.justify_center,
a.align_center,
a.gap_md,
]}>
<PlayButtonIcon />
<Text style={[a.text_center, {color: t.palette.white}]}>
<Trans>Video uploaded and ready to post</Trans>
</Text>
</View>
) : video.mimeType === 'image/gif' ? (
<img
src={video.uri}
style={{width: '100%', height: '100%', objectFit: 'cover'}}