From 58f532a495c00afe1fce8a57917a926f789aee37 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 29 Jan 2026 23:21:38 +0200 Subject: [PATCH] [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 --------- Co-authored-by: Eric Bailey --- src/analytics/metrics/types.ts | 47 ++++++++++ src/components/PostControls/index.tsx | 1 + src/lib/hooks/useIntentHandler.ts | 2 + .../components/ThreadItemAnchor.tsx | 1 + .../PostThread/components/ThreadItemPost.tsx | 1 + .../components/ThreadItemTreePost.tsx | 1 + src/screens/PostThread/index.tsx | 1 + src/screens/Profile/ProfileFeed/index.tsx | 8 +- src/screens/ProfileList/index.tsx | 4 +- src/screens/VideoFeed/index.tsx | 1 + src/state/shell/composer/index.tsx | 9 ++ .../composer/useComposerKeyboardShortcut.tsx | 2 +- src/view/com/composer/Composer.tsx | 93 ++++++++++++++++++- src/view/com/composer/drafts/DraftsButton.tsx | 10 ++ .../com/composer/drafts/DraftsListDialog.tsx | 31 ++++++- src/view/com/composer/drafts/state/api.ts | 1 + src/view/com/composer/drafts/state/schema.ts | 2 + src/view/com/composer/drafts/state/storage.ts | 15 +++ .../com/composer/drafts/state/storage.web.ts | 24 ++++- .../videos/VideoTranscodeBackdrop.tsx | 2 +- src/view/com/feeds/ComposerPrompt.tsx | 7 +- src/view/com/feeds/FeedPage.tsx | 2 +- src/view/com/post/Post.tsx | 1 + src/view/com/posts/PostFeedItem.tsx | 1 + src/view/screens/Feeds.tsx | 2 +- src/view/screens/Notifications.tsx | 5 +- src/view/screens/Profile.tsx | 11 ++- src/view/shell/desktop/LeftNav.tsx | 2 +- 28 files changed, 258 insertions(+), 29 deletions(-) diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 0fa5279edf..8d6b64c54d 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -233,6 +233,53 @@ export type Events = { persist: boolean hasChanged: boolean } + 'composer:open': { + logContext: + | 'Fab' + | 'PostReply' + | 'QuotePost' + | 'ProfileFeed' + | 'Deeplink' + | 'Other' + isReply: boolean + hasQuote: boolean + hasDraft: boolean + } + 'draft:save': { + isNewDraft: boolean + hasText: boolean + hasImages: boolean + hasVideo: boolean + hasGif: boolean + hasQuote: boolean + hasLink: boolean + postCount: number + textLength: number + } + 'draft:load': { + draftAgeMs: number + hasText: boolean + hasImages: boolean + hasVideo: boolean + hasGif: boolean + postCount: number + } + 'draft:delete': { + logContext: 'DraftsList' + draftAgeMs: number + } + 'draft:listOpen': { + draftCount: number + } + 'draft:post': { + draftAgeMs: number + wasEdited: boolean + } + 'draft:discard': { + logContext: 'ComposerClose' | 'BeforeDraftsList' + hadContent: boolean + textLength: number + } // Data events 'account:create:begin': {} diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx index 4eb67879ab..c15395da65 100644 --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -185,6 +185,7 @@ let PostControls = ({ openComposer({ quote: post, onPost: onPostReply, + logContext: 'QuotePost', }) } diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 347aa42122..4d4d394bcb 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -128,6 +128,7 @@ export function useComposeIntent() { openComposer({ text: text ?? undefined, videoUri: {uri, width: Number(width), height: Number(height)}, + logContext: 'Deeplink', }) return } @@ -153,6 +154,7 @@ export function useComposeIntent() { openComposer({ text: text ?? undefined, imageUris: IS_NATIVE ? imageUris : undefined, + logContext: 'Deeplink', }) }, 500) }, diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 888c05271e..e475b42de1 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -261,6 +261,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ langs: record.langs, }, onPostSuccess: onPostSuccess, + logContext: 'PostReply', }) if (postSource) { diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index eccc7be2cb..64ccd36145 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -237,6 +237,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ langs: post.record.langs, }, onPostSuccess: onPostSuccess, + logContext: 'PostReply', }) }, [openComposer, post, record, onPostSuccess, moderation]) diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx index d77ef469c0..455fde6923 100644 --- a/src/screens/PostThread/components/ThreadItemTreePost.tsx +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -302,6 +302,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({ langs: post.record.langs, }, onPostSuccess: onPostSuccess, + logContext: 'PostReply', }) }, [openComposer, post, record, onPostSuccess, moderation]) diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 3718c11129..77f79b8fb8 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -125,6 +125,7 @@ export function PostThread({uri}: {uri: string}) { langs: post.record.langs, }, onPostSuccess: optimisticOnPostReply, + logContext: 'PostReply', }) if (anchorPostSource) { diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx index 7d98c12aa1..27a17b1224 100644 --- a/src/screens/Profile/ProfileFeed/index.tsx +++ b/src/screens/Profile/ProfileFeed/index.tsx @@ -13,8 +13,10 @@ import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {usePalette} from '#/lib/hooks/usePalette' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {ComposeIcon2} from '#/lib/icons' -import {type CommonNavigatorParams} from '#/lib/routes/types' -import {type NavigationProp} from '#/lib/routes/types' +import { + type CommonNavigatorParams, + type NavigationProp, +} from '#/lib/routes/types' import {makeRecordUri} from '#/lib/strings/url-helpers' import {s} from '#/lib/styles' import {listenSoftReset} from '#/state/events' @@ -236,7 +238,7 @@ export function ProfileFeedScreenInner({ {hasSession && ( openComposer({})} + onPress={() => openComposer({logContext: 'Fab'})} icon={ openComposer({})} + onPress={() => openComposer({logContext: 'Fab'})} icon={ openComposer({})} + onPress={() => openComposer({logContext: 'Fab'})} icon={ void @@ -44,6 +52,7 @@ export interface ComposerOpts { imageUris?: {uri: string; width: number; height: number; altText?: string}[] videoUri?: {uri: string; width: number; height: number} openGallery?: boolean + logContext?: ComposerLogContext } type StateContext = ComposerOpts | undefined diff --git a/src/state/shell/composer/useComposerKeyboardShortcut.tsx b/src/state/shell/composer/useComposerKeyboardShortcut.tsx index 4a48f5e458..397b5b6f7e 100644 --- a/src/state/shell/composer/useComposerKeyboardShortcut.tsx +++ b/src/state/shell/composer/useComposerKeyboardShortcut.tsx @@ -61,7 +61,7 @@ export function useComposerKeyboardShortcut() { ) return if (event.key === 'n' || event.key === 'N') { - openComposer({}) + openComposer({logContext: 'Other'}) } } document.addEventListener('keydown', handler) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e42dce8cd7..148811bc15 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -70,6 +70,7 @@ import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {mimeToExt} from '#/lib/media/video/util' +import {useCallOnce} from '#/lib/once' import {type NavigationProp} from '#/lib/routes/types' import {cleanError} from '#/lib/strings/errors' import {colors} from '#/lib/styles' @@ -142,6 +143,7 @@ import { useSaveDraftMutation, } from './drafts/state/queries' import {type DraftSummary} from './drafts/state/schema' +import {revokeAllMediaUrls} from './drafts/state/storage' import {PostLanguageSelect} from './select-language/PostLanguageSelect' import { type AssetType, @@ -184,6 +186,7 @@ export const ComposePost = ({ imageUris: initImageUris, videoUri: initVideoUri, openGallery, + logContext, cancelRef, }: Props & { cancelRef?: React.RefObject @@ -213,6 +216,14 @@ export const ComposePost = ({ const [publishingStage, setPublishingStage] = useState('') const [error, setError] = useState('') + /** + * Track when a draft was created so we can measure draft age in metrics. + * Set when a draft is loaded via handleSelectDraft. + */ + const [loadedDraftCreatedAt, setLoadedDraftCreatedAt] = useState< + string | null + >(null) + /** * A temporary local reference to a language suggestion that the user has * accepted. This overrides the global post language preference, but is not @@ -321,6 +332,16 @@ export const ComposePost = ({ onInitVideo() }, [onInitVideo]) + // Fire composer:open metric on mount + useCallOnce(() => { + ax.metric('composer:open', { + logContext: logContext ?? 'Other', + isReply: !!replyTo, + hasQuote: !!initQuote, + hasDraft: false, + }) + })() + const clearVideo = useCallback( (postId: string) => { composerDispatch({ @@ -489,6 +510,21 @@ export const ComposePost = ({ originalLocalRefs, }) + // Track when the draft was created for metrics + setLoadedDraftCreatedAt(draftSummary.createdAt) + + // Fire draft:load metric + const draftPosts = draftSummary.posts + const draftAgeMs = Date.now() - new Date(draftSummary.createdAt).getTime() + ax.metric('draft:load', { + draftAgeMs, + hasText: draftPosts.some(p => p.text.trim().length > 0), + hasImages: draftPosts.some(p => p.images && p.images.length > 0), + hasVideo: draftPosts.some(p => !!p.video), + hasGif: draftPosts.some(p => !!p.gif), + postCount: draftPosts.length, + }) + // Initiate video processing for any restored videos // This is async but we don't await - videos process in the background for (const [postIndex, videoInfo] of restoredVideos) { @@ -496,7 +532,7 @@ export const ComposePost = ({ restoreVideo(postId, videoInfo) } }, - [composerDispatch, restoreVideo], + [composerDispatch, restoreVideo, ax], ) const [publishOnUpload, setPublishOnUpload] = useState(false) @@ -504,21 +540,38 @@ export const ComposePost = ({ const onClose = useCallback(() => { closeComposer() clearThumbnailCache(queryClient) + revokeAllMediaUrls() }, [closeComposer, queryClient]) const handleSaveDraft = React.useCallback(async () => { + const isNewDraft = !composerState.draftId try { const result = await saveDraft({ composerState, existingDraftId: composerState.draftId, }) composerDispatch({type: 'mark_saved', draftId: result.draftId}) + + // Fire draft:save metric + const posts = composerState.thread.posts + ax.metric('draft:save', { + isNewDraft, + 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: posts[0].richtext.text.length, + }) + onClose() } catch (e) { logger.error('Failed to save draft', {error: e}) setError(_(msg`Failed to save draft`)) } - }, [saveDraft, composerState, composerDispatch, onClose, _]) + }, [saveDraft, composerState, composerDispatch, onClose, _, ax]) // Save without closing - for use by DraftsButton const saveCurrentDraft = React.useCallback(async () => { @@ -529,6 +582,23 @@ export const ComposePost = ({ composerDispatch({type: 'mark_saved', draftId: result.draftId}) }, [saveDraft, composerState, composerDispatch]) + // Handle discard action - fires metric and closes composer + const handleDiscard = React.useCallback(() => { + const posts = thread.posts + const hasContent = posts.some( + post => + post.richtext.text.trim().length > 0 || + post.embed.media || + post.embed.link, + ) + ax.metric('draft:discard', { + logContext: 'ComposerClose', + hadContent: hasContent, + textLength: posts[0].richtext.text.length, + }) + onClose() + }, [thread.posts, ax, onClose]) + // Check if composer is empty (no content to save) const isComposerEmpty = React.useMemo(() => { // Has multiple posts means it's not empty @@ -786,6 +856,15 @@ export const ComposePost = ({ } // Clean up draft and its media after successful publish if (composerState.draftId && composerState.originalLocalRefs) { + // Fire draft:post metric + if (loadedDraftCreatedAt) { + const draftAgeMs = Date.now() - new Date(loadedDraftCreatedAt).getTime() + ax.metric('draft:post', { + draftAgeMs, + wasEdited: composerState.isDirty, + }) + } + logger.debug('post published, cleaning up draft', { draftId: composerState.draftId, mediaFileCount: composerState.originalLocalRefs.size, @@ -860,7 +939,9 @@ export const ComposePost = ({ navigation, composerState.draftId, composerState.originalLocalRefs, + composerState.isDirty, cleanupPublishedDraft, + loadedDraftCreatedAt, ]) // Preserves the referential identity passed to each post item. @@ -1008,7 +1089,8 @@ export const ComposePost = ({ onDiscard={handleClearComposer} isEmpty={isComposerEmpty} isDirty={composerState.isDirty} - isEditingDraft={!!composerState.draftId}> + isEditingDraft={!!composerState.draftId} + textLength={thread.posts[0].richtext.text.length}> {missingAltError && } @@ -1318,6 +1400,7 @@ function ComposerTopBar({ isEmpty, isDirty, isEditingDraft, + textLength, topBarAnimatedStyle, children, }: { @@ -1335,6 +1418,7 @@ function ComposerTopBar({ isEmpty: boolean isDirty: boolean isEditingDraft: boolean + textLength: number topBarAnimatedStyle: StyleProp children?: React.ReactNode }) { @@ -1381,6 +1465,7 @@ function ComposerTopBar({ isEmpty={isEmpty} isDirty={isDirty} isEditingDraft={isEditingDraft} + textLength={textLength} /> )}