[Drafts] Storage fixes (#9790)

* delete media from existsCache when deleting

* revoke media URLs

* skip revoking objecturls until the composer is completely closed

* [Drafts] Metrics (#9794)

* metrics for drafts

* Nit: format

* nit: use new util for clarity

* nit: use new util for clarity

---------

Co-authored-by: Eric Bailey <git@esb.lol>

---------

Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Samuel Newman
2026-01-29 23:21:38 +02:00
committed by GitHub
parent 0babd0f475
commit 58f532a495
28 changed files with 258 additions and 29 deletions
@@ -5,6 +5,7 @@ import {atoms as a} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import {useAnalytics} from '#/analytics'
import {DraftsListDialog} from './DraftsListDialog'
import {useSaveDraftMutation} from './state/queries'
import {type DraftSummary} from './state/schema'
@@ -16,6 +17,7 @@ export function DraftsButton({
isEmpty,
isDirty,
isEditingDraft,
textLength,
}: {
onSelectDraft: (draft: DraftSummary) => void
onSaveDraft: () => Promise<void>
@@ -23,8 +25,10 @@ export function DraftsButton({
isEmpty: boolean
isDirty: boolean
isEditingDraft: boolean
textLength: number
}) {
const {_} = useLingui()
const ax = useAnalytics()
const draftsDialogControl = Dialog.useDialogControl()
const savePromptControl = Prompt.usePromptControl()
const {isPending: isSaving} = useSaveDraftMutation()
@@ -45,6 +49,12 @@ export function DraftsButton({
}
const handleDiscardAndOpen = () => {
// Fire draft:discard metric before discarding
ax.metric('draft:discard', {
logContext: 'BeforeDraftsList',
hadContent: !isEmpty,
textLength,
})
onDiscard()
draftsDialogControl.open()
}
@@ -1,8 +1,9 @@
import {useCallback, useMemo} from 'react'
import {useCallback, useEffect, useMemo} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useCallOnce} from '#/lib/once'
import {EmptyState} from '#/view/com/util/EmptyState'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -10,6 +11,7 @@ import * as Dialog from '#/components/Dialog'
import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import {DraftItem} from './DraftItem'
import {useDeleteDraftMutation, useDraftsQuery} from './state/queries'
@@ -24,6 +26,7 @@ export function DraftsListDialog({
}) {
const {_} = useLingui()
const t = useTheme()
const ax = useAnalytics()
const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} =
useDraftsQuery()
const {mutate: deleteDraft} = useDeleteDraftMutation()
@@ -33,6 +36,20 @@ export function DraftsListDialog({
[data],
)
// Fire draft:listOpen metric when dialog opens and data is loaded
const draftCount = drafts.length
const isDataReady = !isLoading && data !== undefined
const onDraftListOpen = useCallOnce()
useEffect(() => {
if (isDataReady) {
onDraftListOpen(() => {
ax.metric('draft:listOpen', {
draftCount,
})
})
}
}, [onDraftListOpen, isDataReady, draftCount, ax])
const handleSelectDraft = useCallback(
(summary: DraftSummary) => {
control.close(() => {
@@ -44,9 +61,15 @@ export function DraftsListDialog({
const handleDeleteDraft = useCallback(
(draftSummary: DraftSummary) => {
// Fire draft:delete metric
const draftAgeMs = Date.now() - new Date(draftSummary.createdAt).getTime()
ax.metric('draft:delete', {
logContext: 'DraftsList',
draftAgeMs,
})
deleteDraft({draftId: draftSummary.id, draft: draftSummary.draft})
},
[deleteDraft],
[deleteDraft, ax],
)
const backButton = useCallback(
@@ -93,7 +116,7 @@ export function DraftsListDialog({
const onEndReached = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage()
void fetchNextPage()
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
@@ -132,7 +155,7 @@ export function DraftsListDialog({
<Dialog.InnerFlatList
data={drafts}
renderItem={renderItem}
keyExtractor={item => item.id}
keyExtractor={(item: DraftSummary) => item.id}
ListHeaderComponent={web(header)}
stickyHeaderIndices={web([0])}
ListEmptyComponent={emptyComponent}
@@ -356,6 +356,7 @@ export function draftViewToSummary(
hasMissingMedia,
mediaCount,
postCount: view.draft.posts.length,
createdAt: view.createdAt,
updatedAt: view.updatedAt,
posts,
}
@@ -62,6 +62,8 @@ export type DraftSummary = {
mediaCount: number
/** Number of posts in thread */
postCount: number
/** ISO timestamp of creation */
createdAt: string
/** ISO timestamp of last update */
updatedAt: string
/** All posts in the draft for full display */
@@ -91,6 +91,7 @@ export async function deleteMediaFromLocal(
if (file.exists) {
file.delete()
}
mediaExistsCache.delete(localRefPath)
}
/**
@@ -154,3 +155,17 @@ export function clearMediaCache(): void {
cachePopulated = false
populateCachePromise = null
}
/**
* Revoke a media URL (no-op on native - only needed for web blob URLs)
*/
export function revokeMediaUrl(_url: string): void {
// No-op on native - file URIs don't need revocation
}
/**
* Revoke all media URLs (no-op on native - only needed for web blob URLs)
*/
export function revokeAllMediaUrls(): void {
// No-op on native - file URIs don't need revocation
}
@@ -84,6 +84,11 @@ export async function saveMediaToLocal(
}
}
/**
* Track blob URLs created by loadMediaFromLocal for cleanup
*/
const createdBlobUrls = new Set<string>()
/**
* Load a media file from IndexedDB
* @returns A blob URL for the saved media
@@ -97,7 +102,10 @@ export async function loadMediaFromLocal(
throw new Error(`Media file not found: ${localRefPath}`)
}
return URL.createObjectURL(record.blob)
const url = URL.createObjectURL(record.blob)
logger.debug('Created blob URL', {url})
createdBlobUrls.add(url)
return url
}
/**
@@ -165,6 +173,20 @@ export function clearMediaCache(): void {
*/
export function revokeMediaUrl(url: string): void {
if (url.startsWith('blob:')) {
logger.debug('Revoking blob URL', {url})
URL.revokeObjectURL(url)
createdBlobUrls.delete(url)
}
}
/**
* Revoke all blob URLs created by loadMediaFromLocal.
* Call this when closing the drafts list dialog to prevent memory leaks.
*/
export function revokeAllMediaUrls(): void {
logger.debug(`Revoking ${createdBlobUrls.size} blob URLs`)
for (const url of createdBlobUrls) {
URL.revokeObjectURL(url)
}
createdBlobUrls.clear()
}