metrics for drafts

This commit is contained in:
Samuel Newman
2026-01-29 22:20:06 +02:00
parent 7225b0ba3d
commit 532ad39394
25 changed files with 218 additions and 25 deletions
+54
View File
@@ -234,6 +234,60 @@ export type Events = {
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': {}
'account:create:success': {
+1
View File
@@ -185,6 +185,7 @@ let PostControls = ({
openComposer({
quote: post,
onPost: onPostReply,
logContext: 'QuotePost',
})
}
+2
View File
@@ -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)
},
@@ -261,6 +261,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
langs: record.langs,
},
onPostSuccess: onPostSuccess,
logContext: 'PostReply',
})
if (postSource) {
@@ -237,6 +237,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
langs: post.record.langs,
},
onPostSuccess: onPostSuccess,
logContext: 'PostReply',
})
}, [openComposer, post, record, onPostSuccess, moderation])
@@ -302,6 +302,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
langs: post.record.langs,
},
onPostSuccess: onPostSuccess,
logContext: 'PostReply',
})
}, [openComposer, post, record, onPostSuccess, moderation])
+1
View File
@@ -125,6 +125,7 @@ export function PostThread({uri}: {uri: string}) {
langs: post.record.langs,
},
onPostSuccess: optimisticOnPostReply,
logContext: 'PostReply',
})
if (anchorPostSource) {
+5 -3
View File
@@ -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 && (
<FAB
testID="composeFAB"
onPress={() => openComposer({})}
onPress={() => openComposer({logContext: 'Fab'})}
icon={
<ComposeIcon2
strokeWidth={1.5}
+2 -2
View File
@@ -234,7 +234,7 @@ function ProfileListScreenLoaded({
</PagerWithHeader>
<FAB
testID="composeFAB"
onPress={() => openComposer({})}
onPress={() => openComposer({logContext: 'Fab'})}
icon={
<ComposeIcon2
strokeWidth={1.5}
@@ -272,7 +272,7 @@ function ProfileListScreenLoaded({
/>
<FAB
testID="composeFAB"
onPress={() => openComposer({})}
onPress={() => openComposer({logContext: 'Fab'})}
icon={
<ComposeIcon2
strokeWidth={1.5}
+1
View File
@@ -777,6 +777,7 @@ function Overlay({
embed: post.embed,
langs: record?.langs,
},
logContext: 'PostReply',
})
}, [openComposer, post, record])
+9
View File
@@ -33,6 +33,14 @@ export type OnPostSuccessData =
}
| undefined
export type ComposerLogContext =
| 'Fab'
| 'PostReply'
| 'QuotePost'
| 'ProfileFeed'
| 'Deeplink'
| 'Other'
export interface ComposerOpts {
replyTo?: ComposerOptsPostRef
onPost?: (postUri: string | undefined) => 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
@@ -61,7 +61,7 @@ export function useComposerKeyboardShortcut() {
)
return
if (event.key === 'n' || event.key === 'N') {
openComposer({})
openComposer({logContext: 'Other'})
}
}
document.addEventListener('keydown', handler)
+87 -4
View File
@@ -185,6 +185,7 @@ export const ComposePost = ({
imageUris: initImageUris,
videoUri: initVideoUri,
openGallery,
logContext,
cancelRef,
}: Props & {
cancelRef?: React.RefObject<CancelRef | null>
@@ -214,6 +215,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
@@ -322,6 +331,17 @@ export const ComposePost = ({
onInitVideo()
}, [onInitVideo])
// Fire composer:open metric on mount
useEffect(() => {
ax.metric('composer:open', {
logContext: logContext ?? 'Other',
isReply: !!replyTo,
hasQuote: !!initQuote,
hasDraft: false,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const clearVideo = useCallback(
(postId: string) => {
composerDispatch({
@@ -490,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) {
@@ -497,7 +532,7 @@ export const ComposePost = ({
restoreVideo(postId, videoInfo)
}
},
[composerDispatch, restoreVideo],
[composerDispatch, restoreVideo, ax],
)
const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -509,18 +544,34 @@ export const ComposePost = ({
}, [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 () => {
@@ -531,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
@@ -788,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,
@@ -862,7 +939,9 @@ export const ComposePost = ({
navigation,
composerState.draftId,
composerState.originalLocalRefs,
composerState.isDirty,
cleanupPublishedDraft,
loadedDraftCreatedAt,
])
// Preserves the referential identity passed to each post item.
@@ -1010,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 && <AltTextReminder error={missingAltError} />}
<ErrorBanner
error={error}
@@ -1090,7 +1170,7 @@ export const ComposePost = ({
/>
<Prompt.Action
cta={_(msg`Discard`)}
onPress={onClose}
onPress={handleDiscard}
color="negative_subtle"
/>
<Prompt.Cancel />
@@ -1320,6 +1400,7 @@ function ComposerTopBar({
isEmpty,
isDirty,
isEditingDraft,
textLength,
topBarAnimatedStyle,
children,
}: {
@@ -1337,6 +1418,7 @@ function ComposerTopBar({
isEmpty: boolean
isDirty: boolean
isEditingDraft: boolean
textLength: number
topBarAnimatedStyle: StyleProp<ViewStyle>
children?: React.ReactNode
}) {
@@ -1383,6 +1465,7 @@ function ComposerTopBar({
isEmpty={isEmpty}
isDirty={isDirty}
isEditingDraft={isEditingDraft}
textLength={textLength}
/>
)}
<Button
@@ -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,4 +1,4 @@
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'
@@ -10,6 +10,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 +25,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 +35,17 @@ export function DraftsListDialog({
[data],
)
// Fire draft:listOpen metric when dialog opens and data is loaded
const draftCount = drafts.length
const isDataReady = !isLoading && data !== undefined
useEffect(() => {
if (isDataReady) {
ax.metric('draft:listOpen', {
draftCount,
})
}
}, [isDataReady, draftCount, ax])
const handleSelectDraft = useCallback(
(summary: DraftSummary) => {
control.close(() => {
@@ -44,9 +57,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(
@@ -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 */
+4 -3
View File
@@ -37,7 +37,7 @@ export function ComposerPrompt() {
const onPress = useCallback(() => {
ax.metric('composerPrompt:press', {})
openComposer({})
openComposer({logContext: 'Fab'})
}, [ax, openComposer])
const onPressImage = useCallback(async () => {
@@ -45,7 +45,7 @@ export function ComposerPrompt() {
// On web, open the composer with the gallery picker auto-opening
if (!IS_NATIVE) {
openComposer({openGallery: true})
openComposer({openGallery: true, logContext: 'Fab'})
return
}
@@ -83,7 +83,7 @@ export function ComposerPrompt() {
}))
if (imageUris.length > 0) {
openComposer({imageUris})
openComposer({imageUris, logContext: 'Fab'})
}
}
} catch (err: any) {
@@ -125,6 +125,7 @@ export function ComposerPrompt() {
openComposer({
imageUris: IS_NATIVE ? imageUris : undefined,
logContext: 'Fab',
})
} catch (err: any) {
if (!String(err).toLowerCase().includes('cancel')) {
+1 -1
View File
@@ -123,7 +123,7 @@ export function FeedPage({
}, [onSoftReset, isPageFocused])
const onPressCompose = useCallback(() => {
openComposer({})
openComposer({logContext: 'Fab'})
}, [openComposer])
const onPressLoadLatest = useCallback(() => {
+1
View File
@@ -138,6 +138,7 @@ function PostInner({
moderation,
langs: record.langs,
},
logContext: 'PostReply',
})
}, [openComposer, post, record, moderation])
+1
View File
@@ -189,6 +189,7 @@ let FeedItemInner = ({
moderation,
langs: record.langs,
},
logContext: 'PostReply',
})
}
+1 -1
View File
@@ -145,7 +145,7 @@ export function FeedsScreen(_props: Props) {
[search],
)
const onPressCompose = React.useCallback(() => {
openComposer({})
openComposer({logContext: 'Fab'})
}, [openComposer])
const onChangeQuery = React.useCallback(
(text: string) => {
+2 -3
View File
@@ -30,8 +30,7 @@ import {FAB} from '#/view/com/util/fab/FAB'
import {type ListMethods} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
import {atoms as a, useTheme} from '#/alf'
import {web} from '#/alf'
import {atoms as a, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {ButtonIcon} from '#/components/Button'
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
@@ -161,7 +160,7 @@ export function NotificationsScreen({}: Props) {
</Pager>
<FAB
testID="composeFAB"
onPress={() => openComposer({})}
onPress={() => openComposer({logContext: 'Fab'})}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
+7 -4
View File
@@ -332,7 +332,7 @@ function ProfileScreenLoaded({
isInvalidHandle(profile.handle)
? undefined
: profile.handle
openComposer({mention})
openComposer({mention, logContext: 'ProfileFeed'})
}
const onPageSelected = (i: number) => {
@@ -434,7 +434,8 @@ function ProfileScreenLoaded({
? {
label: _(msg`Write a post`),
text: _(msg`Write a post`),
onPress: () => openComposer({}),
onPress: () =>
openComposer({logContext: 'ProfileFeed'}),
size: 'small',
color: 'primary',
}
@@ -474,7 +475,8 @@ function ProfileScreenLoaded({
? {
label: _(msg`Post a photo`),
text: _(msg`Post a photo`),
onPress: () => openComposer({}),
onPress: () =>
openComposer({logContext: 'ProfileFeed'}),
size: 'small',
color: 'primary',
}
@@ -500,7 +502,8 @@ function ProfileScreenLoaded({
? {
label: _(msg`Post a video`),
text: _(msg`Post a video`),
onPress: () => openComposer({}),
onPress: () =>
openComposer({logContext: 'ProfileFeed'}),
size: 'small',
color: 'primary',
}
+1 -1
View File
@@ -560,7 +560,7 @@ function ComposeBtn() {
}
const onPressCompose = async () =>
openComposer({mention: await getProfileHandle()})
openComposer({mention: await getProfileHandle(), logContext: 'Fab'})
if (leftNavMinimal) {
return null