Add client events

This commit is contained in:
Alex Benzer
2026-01-02 21:55:34 -08:00
committed by Samuel Newman
parent 80bae9da21
commit 9df81cecd7
3 changed files with 186 additions and 17 deletions
+42
View File
@@ -217,6 +217,48 @@ export type MetricEvents = {
hasChanged: boolean 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 // Data events
'account:create:begin': {} 'account:create:begin': {}
'account:create:success': { 'account:create:success': {
+138 -16
View File
@@ -375,6 +375,17 @@ export const ComposePost = ({
}, },
) )
// Track the timestamp of the loaded draft (for analytics)
const [loadedDraftTimestamp, setLoadedDraftTimestamp] = useState<
number | null
>(() => {
if (initDraftId && currentAccount) {
const draft = draftsStorage.getDraftSync(currentAccount.did, initDraftId)
return draft?.timestamp ?? null
}
return null
})
// Drafts list for the drafts dialog // Drafts list for the drafts dialog
const {draftsCount} = useDraftsList() const {draftsCount} = useDraftsList()
@@ -400,6 +411,22 @@ export const ComposePost = ({
return currentSnapshot !== loadedDraftSnapshot return currentSnapshot !== loadedDraftSnapshot
}, [isEditingExistingDraft, loadedDraftSnapshot, composerState]) }, [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 // Handler for selecting a draft from the list
const onSelectDraft = useCallback( const onSelectDraft = useCallback(
(selectedDraftId: string) => { (selectedDraftId: string) => {
@@ -437,14 +464,26 @@ export const ComposePost = ({
setCurrentDraftId(selectedDraftId) setCurrentDraftId(selectedDraftId)
setIsEditingExistingDraft(true) setIsEditingExistingDraft(true)
setLoadedDraftSnapshot(serializeStateForComparison(newState)) setLoadedDraftSnapshot(serializeStateForComparison(newState))
setLoadedDraftTimestamp(draft.timestamp)
// Load the draft and switch back to compose mode // Load the draft and switch back to compose mode
composerDispatch({type: 'load_draft', draft: newState}) composerDispatch({type: 'load_draft', draft: newState})
setViewMode('compose') setViewMode('compose')
// Log draft:load event
const metadata = getDraftMetadata(newState)
logEvent('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}) logger.info('Loaded draft into composer', {draftId: selectedDraftId})
}, },
[currentAccount, preferences?.postInteractionSettings], [currentAccount, preferences?.postInteractionSettings, getDraftMetadata],
) )
// Handler for opening drafts view // Handler for opening drafts view
@@ -458,6 +497,9 @@ export const ComposePost = ({
} else { } else {
// No content or no changes - just show drafts // No content or no changes - just show drafts
setViewMode('drafts') setViewMode('drafts')
logEvent('draft:listOpen', {
draftCount: draftsCount ?? 0,
})
} }
}, [ }, [
hasContent, hasContent,
@@ -465,27 +507,106 @@ export const ComposePost = ({
hasUnsavedChanges, hasUnsavedChanges,
saveBeforeDraftsPromptControl, saveBeforeDraftsPromptControl,
updateBeforeDraftsPromptControl, updateBeforeDraftsPromptControl,
draftsCount,
]) ])
// Handler for "Save" in the save-before-drafts prompt // Handler for "Save" in the save-before-drafts prompt
const onSaveBeforeDrafts = useCallback(() => { const onSaveBeforeDrafts = useCallback(() => {
saveImmediate(composerState) saveImmediate(composerState)
const metadata = getDraftMetadata(composerState)
logEvent('draft:save', {
isNewDraft: !isEditingExistingDraft,
...metadata,
})
setViewMode('drafts') setViewMode('drafts')
}, [saveImmediate, composerState]) logEvent('draft:listOpen', {
draftCount: (draftsCount ?? 0) + (isEditingExistingDraft ? 0 : 1),
})
}, [
saveImmediate,
composerState,
getDraftMetadata,
isEditingExistingDraft,
draftsCount,
])
// Handler for "Don't save" in the save-before-drafts prompt // Handler for "Don't save" in the save-before-drafts prompt
const onDiscardBeforeDrafts = useCallback(() => { const onDiscardBeforeDrafts = useCallback(() => {
const metadata = getDraftMetadata(composerState)
logEvent('draft:discard', {
logContext: 'BeforeDraftsList',
hadContent: hasContent,
textLength: metadata.textLength,
})
clearDraft() clearDraft()
setViewMode('drafts') setViewMode('drafts')
}, [clearDraft]) logEvent('draft:listOpen', {
draftCount: draftsCount ?? 0,
})
}, [clearDraft, getDraftMetadata, composerState, hasContent, draftsCount])
// Handler for saving draft and closing with toast // Handler for saving draft and closing with toast
const onSaveDraftAndClose = useCallback(() => { const onSaveDraftAndClose = useCallback(() => {
saveImmediate(composerState) saveImmediate(composerState)
const metadata = getDraftMetadata(composerState)
logEvent('draft:save', {
isNewDraft: !isEditingExistingDraft,
...metadata,
})
closeComposer() closeComposer()
clearThumbnailCache(queryClient) clearThumbnailCache(queryClient)
Toast.show(_(msg`Saved to drafts`)) Toast.show(_(msg`Saved to drafts`))
}, [_, saveImmediate, composerState, closeComposer, queryClient]) }, [
_,
saveImmediate,
composerState,
getDraftMetadata,
isEditingExistingDraft,
closeComposer,
queryClient,
])
// Handler for "Don't save" when closing composer with unsaved content
const onDiscardAndClose = useCallback(() => {
const metadata = getDraftMetadata(composerState)
logEvent('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(() => {
saveImmediate(composerState)
const metadata = getDraftMetadata(composerState)
logEvent('draft:save', {
isNewDraft: false,
...metadata,
})
setViewMode('drafts')
logEvent('draft:listOpen', {
draftCount: draftsCount ?? 0,
})
}, [saveImmediate, composerState, getDraftMetadata, draftsCount])
// Handler for "Don't update" in the update-before-drafts prompt
const onSkipUpdateBeforeDrafts = useCallback(() => {
setViewMode('drafts')
logEvent('draft:listOpen', {
draftCount: draftsCount ?? 0,
})
}, [draftsCount])
// Handler for when a draft is deleted from the drafts list // Handler for when a draft is deleted from the drafts list
const onDraftDeleted = useCallback( const onDraftDeleted = useCallback(
@@ -839,6 +960,13 @@ export const ComposePost = ({
onPost?.(postUri) onPost?.(postUri)
onPostSuccess?.(postSuccessData) onPostSuccess?.(postSuccessData)
} }
// Log draft:post event if we posted from an existing draft
if (isEditingExistingDraft && loadedDraftTimestamp) {
logEvent('draft:post', {
draftAgeMs: Date.now() - loadedDraftTimestamp,
wasEdited: hasUnsavedChanges,
})
}
clearDraft() clearDraft()
onClose() onClose()
setTimeout(() => { setTimeout(() => {
@@ -884,6 +1012,9 @@ export const ComposePost = ({
setLangPrefs, setLangPrefs,
queryClient, queryClient,
navigation, navigation,
isEditingExistingDraft,
loadedDraftTimestamp,
hasUnsavedChanges,
]) ])
// Preserves the referential identity passed to each post item. // Preserves the referential identity passed to each post item.
@@ -1129,11 +1260,7 @@ export const ComposePost = ({
<Prompt.Action <Prompt.Action
cta={_(msg`Don't save`)} cta={_(msg`Don't save`)}
color="negative_subtle" color="negative_subtle"
onPress={() => { onPress={onDiscardAndClose}
clearDraft()
closeComposer()
clearThumbnailCache(queryClient)
}}
/> />
<Prompt.Cancel /> <Prompt.Cancel />
</Prompt.Actions> </Prompt.Actions>
@@ -1171,17 +1298,12 @@ export const ComposePost = ({
<Prompt.Actions> <Prompt.Actions>
<Prompt.Action <Prompt.Action
cta={_(msg`Update draft`)} cta={_(msg`Update draft`)}
onPress={() => { onPress={onUpdateBeforeDrafts}
saveImmediate(composerState)
setViewMode('drafts')
}}
/> />
<Prompt.Action <Prompt.Action
cta={_(msg`Don't update`)} cta={_(msg`Don't update`)}
color="negative_subtle" color="negative_subtle"
onPress={() => { onPress={onSkipUpdateBeforeDrafts}
setViewMode('drafts')
}}
/> />
<Prompt.Cancel /> <Prompt.Cancel />
</Prompt.Actions> </Prompt.Actions>
+6 -1
View File
@@ -4,6 +4,7 @@ import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {logEvent} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -131,8 +132,12 @@ function DraftListItem({
}, [item.id, onSelect]) }, [item.id, onSelect])
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
logEvent('draft:delete', {
logContext: 'DraftsList',
draftAgeMs: Date.now() - item.draft.timestamp,
})
onDelete(item.id) onDelete(item.id)
}, [item.id, onDelete]) }, [item.id, item.draft.timestamp, onDelete])
const previewText = getPreviewText(item.draft, _) const previewText = getPreviewText(item.draft, _)
const timeAgo = getTimeAgo(item.draft.timestamp, Date.now()) const timeAgo = getTimeAgo(item.draft.timestamp, Date.now())