Add client events
This commit is contained in:
committed by
Samuel Newman
parent
80bae9da21
commit
9df81cecd7
@@ -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': {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user