Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a877fede5b | |||
| 9df81cecd7 | |||
| 80bae9da21 | |||
| d1e62af654 | |||
| 7da77a3107 | |||
| 378c4bc0a2 | |||
| bb9d7f48a7 | |||
| 22e0edbdaa | |||
| 649e1fe1f0 | |||
| 2a01065a31 | |||
| 99f60bfcc2 |
@@ -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': {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export {serializeDraft} from './serialization'
|
||||
export {type DraftItem, draftsStorage} from './storage'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'}}
|
||||
|
||||
Reference in New Issue
Block a user