[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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user