Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

[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>

authored by samuel.fm

Eric Bailey and committed by
GitHub
58f532a4 0babd0f4

+258 -29
+47
src/analytics/metrics/types.ts
··· 233 233 persist: boolean 234 234 hasChanged: boolean 235 235 } 236 + 'composer:open': { 237 + logContext: 238 + | 'Fab' 239 + | 'PostReply' 240 + | 'QuotePost' 241 + | 'ProfileFeed' 242 + | 'Deeplink' 243 + | 'Other' 244 + isReply: boolean 245 + hasQuote: boolean 246 + hasDraft: boolean 247 + } 248 + 'draft:save': { 249 + isNewDraft: boolean 250 + hasText: boolean 251 + hasImages: boolean 252 + hasVideo: boolean 253 + hasGif: boolean 254 + hasQuote: boolean 255 + hasLink: boolean 256 + postCount: number 257 + textLength: number 258 + } 259 + 'draft:load': { 260 + draftAgeMs: number 261 + hasText: boolean 262 + hasImages: boolean 263 + hasVideo: boolean 264 + hasGif: boolean 265 + postCount: number 266 + } 267 + 'draft:delete': { 268 + logContext: 'DraftsList' 269 + draftAgeMs: number 270 + } 271 + 'draft:listOpen': { 272 + draftCount: number 273 + } 274 + 'draft:post': { 275 + draftAgeMs: number 276 + wasEdited: boolean 277 + } 278 + 'draft:discard': { 279 + logContext: 'ComposerClose' | 'BeforeDraftsList' 280 + hadContent: boolean 281 + textLength: number 282 + } 236 283 237 284 // Data events 238 285 'account:create:begin': {}
+1
src/components/PostControls/index.tsx
··· 185 185 openComposer({ 186 186 quote: post, 187 187 onPost: onPostReply, 188 + logContext: 'QuotePost', 188 189 }) 189 190 } 190 191
+2
src/lib/hooks/useIntentHandler.ts
··· 128 128 openComposer({ 129 129 text: text ?? undefined, 130 130 videoUri: {uri, width: Number(width), height: Number(height)}, 131 + logContext: 'Deeplink', 131 132 }) 132 133 return 133 134 } ··· 153 154 openComposer({ 154 155 text: text ?? undefined, 155 156 imageUris: IS_NATIVE ? imageUris : undefined, 157 + logContext: 'Deeplink', 156 158 }) 157 159 }, 500) 158 160 },
+1
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 261 261 langs: record.langs, 262 262 }, 263 263 onPostSuccess: onPostSuccess, 264 + logContext: 'PostReply', 264 265 }) 265 266 266 267 if (postSource) {
+1
src/screens/PostThread/components/ThreadItemPost.tsx
··· 237 237 langs: post.record.langs, 238 238 }, 239 239 onPostSuccess: onPostSuccess, 240 + logContext: 'PostReply', 240 241 }) 241 242 }, [openComposer, post, record, onPostSuccess, moderation]) 242 243
+1
src/screens/PostThread/components/ThreadItemTreePost.tsx
··· 302 302 langs: post.record.langs, 303 303 }, 304 304 onPostSuccess: onPostSuccess, 305 + logContext: 'PostReply', 305 306 }) 306 307 }, [openComposer, post, record, onPostSuccess, moderation]) 307 308
+1
src/screens/PostThread/index.tsx
··· 125 125 langs: post.record.langs, 126 126 }, 127 127 onPostSuccess: optimisticOnPostReply, 128 + logContext: 'PostReply', 128 129 }) 129 130 130 131 if (anchorPostSource) {
+5 -3
src/screens/Profile/ProfileFeed/index.tsx
··· 13 13 import {usePalette} from '#/lib/hooks/usePalette' 14 14 import {useSetTitle} from '#/lib/hooks/useSetTitle' 15 15 import {ComposeIcon2} from '#/lib/icons' 16 - import {type CommonNavigatorParams} from '#/lib/routes/types' 17 - import {type NavigationProp} from '#/lib/routes/types' 16 + import { 17 + type CommonNavigatorParams, 18 + type NavigationProp, 19 + } from '#/lib/routes/types' 18 20 import {makeRecordUri} from '#/lib/strings/url-helpers' 19 21 import {s} from '#/lib/styles' 20 22 import {listenSoftReset} from '#/state/events' ··· 236 238 {hasSession && ( 237 239 <FAB 238 240 testID="composeFAB" 239 - onPress={() => openComposer({})} 241 + onPress={() => openComposer({logContext: 'Fab'})} 240 242 icon={ 241 243 <ComposeIcon2 242 244 strokeWidth={1.5}
+2 -2
src/screens/ProfileList/index.tsx
··· 234 234 </PagerWithHeader> 235 235 <FAB 236 236 testID="composeFAB" 237 - onPress={() => openComposer({})} 237 + onPress={() => openComposer({logContext: 'Fab'})} 238 238 icon={ 239 239 <ComposeIcon2 240 240 strokeWidth={1.5} ··· 272 272 /> 273 273 <FAB 274 274 testID="composeFAB" 275 - onPress={() => openComposer({})} 275 + onPress={() => openComposer({logContext: 'Fab'})} 276 276 icon={ 277 277 <ComposeIcon2 278 278 strokeWidth={1.5}
+1
src/screens/VideoFeed/index.tsx
··· 777 777 embed: post.embed, 778 778 langs: record?.langs, 779 779 }, 780 + logContext: 'PostReply', 780 781 }) 781 782 }, [openComposer, post, record]) 782 783
+9
src/state/shell/composer/index.tsx
··· 33 33 } 34 34 | undefined 35 35 36 + export type ComposerLogContext = 37 + | 'Fab' 38 + | 'PostReply' 39 + | 'QuotePost' 40 + | 'ProfileFeed' 41 + | 'Deeplink' 42 + | 'Other' 43 + 36 44 export interface ComposerOpts { 37 45 replyTo?: ComposerOptsPostRef 38 46 onPost?: (postUri: string | undefined) => void ··· 44 52 imageUris?: {uri: string; width: number; height: number; altText?: string}[] 45 53 videoUri?: {uri: string; width: number; height: number} 46 54 openGallery?: boolean 55 + logContext?: ComposerLogContext 47 56 } 48 57 49 58 type StateContext = ComposerOpts | undefined
+1 -1
src/state/shell/composer/useComposerKeyboardShortcut.tsx
··· 61 61 ) 62 62 return 63 63 if (event.key === 'n' || event.key === 'N') { 64 - openComposer({}) 64 + openComposer({logContext: 'Other'}) 65 65 } 66 66 } 67 67 document.addEventListener('keydown', handler)
+89 -4
src/view/com/composer/Composer.tsx
··· 70 70 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 71 71 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 72 72 import {mimeToExt} from '#/lib/media/video/util' 73 + import {useCallOnce} from '#/lib/once' 73 74 import {type NavigationProp} from '#/lib/routes/types' 74 75 import {cleanError} from '#/lib/strings/errors' 75 76 import {colors} from '#/lib/styles' ··· 142 143 useSaveDraftMutation, 143 144 } from './drafts/state/queries' 144 145 import {type DraftSummary} from './drafts/state/schema' 146 + import {revokeAllMediaUrls} from './drafts/state/storage' 145 147 import {PostLanguageSelect} from './select-language/PostLanguageSelect' 146 148 import { 147 149 type AssetType, ··· 184 186 imageUris: initImageUris, 185 187 videoUri: initVideoUri, 186 188 openGallery, 189 + logContext, 187 190 cancelRef, 188 191 }: Props & { 189 192 cancelRef?: React.RefObject<CancelRef | null> ··· 214 217 const [error, setError] = useState('') 215 218 216 219 /** 220 + * Track when a draft was created so we can measure draft age in metrics. 221 + * Set when a draft is loaded via handleSelectDraft. 222 + */ 223 + const [loadedDraftCreatedAt, setLoadedDraftCreatedAt] = useState< 224 + string | null 225 + >(null) 226 + 227 + /** 217 228 * A temporary local reference to a language suggestion that the user has 218 229 * accepted. This overrides the global post language preference, but is not 219 230 * stored permanently. ··· 321 332 onInitVideo() 322 333 }, [onInitVideo]) 323 334 335 + // Fire composer:open metric on mount 336 + useCallOnce(() => { 337 + ax.metric('composer:open', { 338 + logContext: logContext ?? 'Other', 339 + isReply: !!replyTo, 340 + hasQuote: !!initQuote, 341 + hasDraft: false, 342 + }) 343 + })() 344 + 324 345 const clearVideo = useCallback( 325 346 (postId: string) => { 326 347 composerDispatch({ ··· 489 510 originalLocalRefs, 490 511 }) 491 512 513 + // Track when the draft was created for metrics 514 + setLoadedDraftCreatedAt(draftSummary.createdAt) 515 + 516 + // Fire draft:load metric 517 + const draftPosts = draftSummary.posts 518 + const draftAgeMs = Date.now() - new Date(draftSummary.createdAt).getTime() 519 + ax.metric('draft:load', { 520 + draftAgeMs, 521 + hasText: draftPosts.some(p => p.text.trim().length > 0), 522 + hasImages: draftPosts.some(p => p.images && p.images.length > 0), 523 + hasVideo: draftPosts.some(p => !!p.video), 524 + hasGif: draftPosts.some(p => !!p.gif), 525 + postCount: draftPosts.length, 526 + }) 527 + 492 528 // Initiate video processing for any restored videos 493 529 // This is async but we don't await - videos process in the background 494 530 for (const [postIndex, videoInfo] of restoredVideos) { ··· 496 532 restoreVideo(postId, videoInfo) 497 533 } 498 534 }, 499 - [composerDispatch, restoreVideo], 535 + [composerDispatch, restoreVideo, ax], 500 536 ) 501 537 502 538 const [publishOnUpload, setPublishOnUpload] = useState(false) ··· 504 540 const onClose = useCallback(() => { 505 541 closeComposer() 506 542 clearThumbnailCache(queryClient) 543 + revokeAllMediaUrls() 507 544 }, [closeComposer, queryClient]) 508 545 509 546 const handleSaveDraft = React.useCallback(async () => { 547 + const isNewDraft = !composerState.draftId 510 548 try { 511 549 const result = await saveDraft({ 512 550 composerState, 513 551 existingDraftId: composerState.draftId, 514 552 }) 515 553 composerDispatch({type: 'mark_saved', draftId: result.draftId}) 554 + 555 + // Fire draft:save metric 556 + const posts = composerState.thread.posts 557 + ax.metric('draft:save', { 558 + isNewDraft, 559 + hasText: posts.some(p => p.richtext.text.trim().length > 0), 560 + hasImages: posts.some(p => p.embed.media?.type === 'images'), 561 + hasVideo: posts.some(p => p.embed.media?.type === 'video'), 562 + hasGif: posts.some(p => p.embed.media?.type === 'gif'), 563 + hasQuote: posts.some(p => !!p.embed.quote), 564 + hasLink: posts.some(p => !!p.embed.link), 565 + postCount: posts.length, 566 + textLength: posts[0].richtext.text.length, 567 + }) 568 + 516 569 onClose() 517 570 } catch (e) { 518 571 logger.error('Failed to save draft', {error: e}) 519 572 setError(_(msg`Failed to save draft`)) 520 573 } 521 - }, [saveDraft, composerState, composerDispatch, onClose, _]) 574 + }, [saveDraft, composerState, composerDispatch, onClose, _, ax]) 522 575 523 576 // Save without closing - for use by DraftsButton 524 577 const saveCurrentDraft = React.useCallback(async () => { ··· 529 582 composerDispatch({type: 'mark_saved', draftId: result.draftId}) 530 583 }, [saveDraft, composerState, composerDispatch]) 531 584 585 + // Handle discard action - fires metric and closes composer 586 + const handleDiscard = React.useCallback(() => { 587 + const posts = thread.posts 588 + const hasContent = posts.some( 589 + post => 590 + post.richtext.text.trim().length > 0 || 591 + post.embed.media || 592 + post.embed.link, 593 + ) 594 + ax.metric('draft:discard', { 595 + logContext: 'ComposerClose', 596 + hadContent: hasContent, 597 + textLength: posts[0].richtext.text.length, 598 + }) 599 + onClose() 600 + }, [thread.posts, ax, onClose]) 601 + 532 602 // Check if composer is empty (no content to save) 533 603 const isComposerEmpty = React.useMemo(() => { 534 604 // Has multiple posts means it's not empty ··· 786 856 } 787 857 // Clean up draft and its media after successful publish 788 858 if (composerState.draftId && composerState.originalLocalRefs) { 859 + // Fire draft:post metric 860 + if (loadedDraftCreatedAt) { 861 + const draftAgeMs = Date.now() - new Date(loadedDraftCreatedAt).getTime() 862 + ax.metric('draft:post', { 863 + draftAgeMs, 864 + wasEdited: composerState.isDirty, 865 + }) 866 + } 867 + 789 868 logger.debug('post published, cleaning up draft', { 790 869 draftId: composerState.draftId, 791 870 mediaFileCount: composerState.originalLocalRefs.size, ··· 860 939 navigation, 861 940 composerState.draftId, 862 941 composerState.originalLocalRefs, 942 + composerState.isDirty, 863 943 cleanupPublishedDraft, 944 + loadedDraftCreatedAt, 864 945 ]) 865 946 866 947 // Preserves the referential identity passed to each post item. ··· 1008 1089 onDiscard={handleClearComposer} 1009 1090 isEmpty={isComposerEmpty} 1010 1091 isDirty={composerState.isDirty} 1011 - isEditingDraft={!!composerState.draftId}> 1092 + isEditingDraft={!!composerState.draftId} 1093 + textLength={thread.posts[0].richtext.text.length}> 1012 1094 {missingAltError && <AltTextReminder error={missingAltError} />} 1013 1095 <ErrorBanner 1014 1096 error={error} ··· 1088 1170 /> 1089 1171 <Prompt.Action 1090 1172 cta={_(msg`Discard`)} 1091 - onPress={onClose} 1173 + onPress={handleDiscard} 1092 1174 color="negative_subtle" 1093 1175 /> 1094 1176 <Prompt.Cancel /> ··· 1318 1400 isEmpty, 1319 1401 isDirty, 1320 1402 isEditingDraft, 1403 + textLength, 1321 1404 topBarAnimatedStyle, 1322 1405 children, 1323 1406 }: { ··· 1335 1418 isEmpty: boolean 1336 1419 isDirty: boolean 1337 1420 isEditingDraft: boolean 1421 + textLength: number 1338 1422 topBarAnimatedStyle: StyleProp<ViewStyle> 1339 1423 children?: React.ReactNode 1340 1424 }) { ··· 1381 1465 isEmpty={isEmpty} 1382 1466 isDirty={isDirty} 1383 1467 isEditingDraft={isEditingDraft} 1468 + textLength={textLength} 1384 1469 /> 1385 1470 )} 1386 1471 <Button
+10
src/view/com/composer/drafts/DraftsButton.tsx
··· 5 5 import {Button, ButtonText} from '#/components/Button' 6 6 import * as Dialog from '#/components/Dialog' 7 7 import * as Prompt from '#/components/Prompt' 8 + import {useAnalytics} from '#/analytics' 8 9 import {DraftsListDialog} from './DraftsListDialog' 9 10 import {useSaveDraftMutation} from './state/queries' 10 11 import {type DraftSummary} from './state/schema' ··· 16 17 isEmpty, 17 18 isDirty, 18 19 isEditingDraft, 20 + textLength, 19 21 }: { 20 22 onSelectDraft: (draft: DraftSummary) => void 21 23 onSaveDraft: () => Promise<void> ··· 23 25 isEmpty: boolean 24 26 isDirty: boolean 25 27 isEditingDraft: boolean 28 + textLength: number 26 29 }) { 27 30 const {_} = useLingui() 31 + const ax = useAnalytics() 28 32 const draftsDialogControl = Dialog.useDialogControl() 29 33 const savePromptControl = Prompt.usePromptControl() 30 34 const {isPending: isSaving} = useSaveDraftMutation() ··· 45 49 } 46 50 47 51 const handleDiscardAndOpen = () => { 52 + // Fire draft:discard metric before discarding 53 + ax.metric('draft:discard', { 54 + logContext: 'BeforeDraftsList', 55 + hadContent: !isEmpty, 56 + textLength, 57 + }) 48 58 onDiscard() 49 59 draftsDialogControl.open() 50 60 }
+27 -4
src/view/com/composer/drafts/DraftsListDialog.tsx
··· 1 - import {useCallback, useMemo} from 'react' 1 + import {useCallback, useEffect, useMemo} from 'react' 2 2 import {View} from 'react-native' 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 + import {useCallOnce} from '#/lib/once' 6 7 import {EmptyState} from '#/view/com/util/EmptyState' 7 8 import {atoms as a, useTheme, web} from '#/alf' 8 9 import {Button, ButtonText} from '#/components/Button' ··· 10 11 import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX' 11 12 import {ListFooter} from '#/components/Lists' 12 13 import {Loader} from '#/components/Loader' 14 + import {useAnalytics} from '#/analytics' 13 15 import {IS_NATIVE} from '#/env' 14 16 import {DraftItem} from './DraftItem' 15 17 import {useDeleteDraftMutation, useDraftsQuery} from './state/queries' ··· 24 26 }) { 25 27 const {_} = useLingui() 26 28 const t = useTheme() 29 + const ax = useAnalytics() 27 30 const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} = 28 31 useDraftsQuery() 29 32 const {mutate: deleteDraft} = useDeleteDraftMutation() ··· 33 36 [data], 34 37 ) 35 38 39 + // Fire draft:listOpen metric when dialog opens and data is loaded 40 + const draftCount = drafts.length 41 + const isDataReady = !isLoading && data !== undefined 42 + const onDraftListOpen = useCallOnce() 43 + useEffect(() => { 44 + if (isDataReady) { 45 + onDraftListOpen(() => { 46 + ax.metric('draft:listOpen', { 47 + draftCount, 48 + }) 49 + }) 50 + } 51 + }, [onDraftListOpen, isDataReady, draftCount, ax]) 52 + 36 53 const handleSelectDraft = useCallback( 37 54 (summary: DraftSummary) => { 38 55 control.close(() => { ··· 44 61 45 62 const handleDeleteDraft = useCallback( 46 63 (draftSummary: DraftSummary) => { 64 + // Fire draft:delete metric 65 + const draftAgeMs = Date.now() - new Date(draftSummary.createdAt).getTime() 66 + ax.metric('draft:delete', { 67 + logContext: 'DraftsList', 68 + draftAgeMs, 69 + }) 47 70 deleteDraft({draftId: draftSummary.id, draft: draftSummary.draft}) 48 71 }, 49 - [deleteDraft], 72 + [deleteDraft, ax], 50 73 ) 51 74 52 75 const backButton = useCallback( ··· 93 116 94 117 const onEndReached = useCallback(() => { 95 118 if (hasNextPage && !isFetchingNextPage) { 96 - fetchNextPage() 119 + void fetchNextPage() 97 120 } 98 121 }, [hasNextPage, isFetchingNextPage, fetchNextPage]) 99 122 ··· 132 155 <Dialog.InnerFlatList 133 156 data={drafts} 134 157 renderItem={renderItem} 135 - keyExtractor={item => item.id} 158 + keyExtractor={(item: DraftSummary) => item.id} 136 159 ListHeaderComponent={web(header)} 137 160 stickyHeaderIndices={web([0])} 138 161 ListEmptyComponent={emptyComponent}
+1
src/view/com/composer/drafts/state/api.ts
··· 356 356 hasMissingMedia, 357 357 mediaCount, 358 358 postCount: view.draft.posts.length, 359 + createdAt: view.createdAt, 359 360 updatedAt: view.updatedAt, 360 361 posts, 361 362 }
+2
src/view/com/composer/drafts/state/schema.ts
··· 62 62 mediaCount: number 63 63 /** Number of posts in thread */ 64 64 postCount: number 65 + /** ISO timestamp of creation */ 66 + createdAt: string 65 67 /** ISO timestamp of last update */ 66 68 updatedAt: string 67 69 /** All posts in the draft for full display */
+15
src/view/com/composer/drafts/state/storage.ts
··· 91 91 if (file.exists) { 92 92 file.delete() 93 93 } 94 + mediaExistsCache.delete(localRefPath) 94 95 } 95 96 96 97 /** ··· 154 155 cachePopulated = false 155 156 populateCachePromise = null 156 157 } 158 + 159 + /** 160 + * Revoke a media URL (no-op on native - only needed for web blob URLs) 161 + */ 162 + export function revokeMediaUrl(_url: string): void { 163 + // No-op on native - file URIs don't need revocation 164 + } 165 + 166 + /** 167 + * Revoke all media URLs (no-op on native - only needed for web blob URLs) 168 + */ 169 + export function revokeAllMediaUrls(): void { 170 + // No-op on native - file URIs don't need revocation 171 + }
+23 -1
src/view/com/composer/drafts/state/storage.web.ts
··· 85 85 } 86 86 87 87 /** 88 + * Track blob URLs created by loadMediaFromLocal for cleanup 89 + */ 90 + const createdBlobUrls = new Set<string>() 91 + 92 + /** 88 93 * Load a media file from IndexedDB 89 94 * @returns A blob URL for the saved media 90 95 */ ··· 97 102 throw new Error(`Media file not found: ${localRefPath}`) 98 103 } 99 104 100 - return URL.createObjectURL(record.blob) 105 + const url = URL.createObjectURL(record.blob) 106 + logger.debug('Created blob URL', {url}) 107 + createdBlobUrls.add(url) 108 + return url 101 109 } 102 110 103 111 /** ··· 165 173 */ 166 174 export function revokeMediaUrl(url: string): void { 167 175 if (url.startsWith('blob:')) { 176 + logger.debug('Revoking blob URL', {url}) 177 + URL.revokeObjectURL(url) 178 + createdBlobUrls.delete(url) 179 + } 180 + } 181 + 182 + /** 183 + * Revoke all blob URLs created by loadMediaFromLocal. 184 + * Call this when closing the drafts list dialog to prevent memory leaks. 185 + */ 186 + export function revokeAllMediaUrls(): void { 187 + logger.debug(`Revoking ${createdBlobUrls.size} blob URLs`) 188 + for (const url of createdBlobUrls) { 168 189 URL.revokeObjectURL(url) 169 190 } 191 + createdBlobUrls.clear() 170 192 }
+1 -1
src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
··· 9 9 10 10 export function clearThumbnailCache(queryClient: QueryClient) { 11 11 clearCache().catch(() => {}) 12 - queryClient.resetQueries({queryKey: [RQKEY]}) 12 + void queryClient.resetQueries({queryKey: [RQKEY]}) 13 13 } 14 14 15 15 export function VideoTranscodeBackdrop({uri}: {uri: string}) {
+4 -3
src/view/com/feeds/ComposerPrompt.tsx
··· 37 37 38 38 const onPress = useCallback(() => { 39 39 ax.metric('composerPrompt:press', {}) 40 - openComposer({}) 40 + openComposer({logContext: 'Fab'}) 41 41 }, [ax, openComposer]) 42 42 43 43 const onPressImage = useCallback(async () => { ··· 45 45 46 46 // On web, open the composer with the gallery picker auto-opening 47 47 if (!IS_NATIVE) { 48 - openComposer({openGallery: true}) 48 + openComposer({openGallery: true, logContext: 'Fab'}) 49 49 return 50 50 } 51 51 ··· 83 83 })) 84 84 85 85 if (imageUris.length > 0) { 86 - openComposer({imageUris}) 86 + openComposer({imageUris, logContext: 'Fab'}) 87 87 } 88 88 } 89 89 } catch (err: any) { ··· 125 125 126 126 openComposer({ 127 127 imageUris: IS_NATIVE ? imageUris : undefined, 128 + logContext: 'Fab', 128 129 }) 129 130 } catch (err: any) { 130 131 if (!String(err).toLowerCase().includes('cancel')) {
+1 -1
src/view/com/feeds/FeedPage.tsx
··· 123 123 }, [onSoftReset, isPageFocused]) 124 124 125 125 const onPressCompose = useCallback(() => { 126 - openComposer({}) 126 + openComposer({logContext: 'Fab'}) 127 127 }, [openComposer]) 128 128 129 129 const onPressLoadLatest = useCallback(() => {
+1
src/view/com/post/Post.tsx
··· 138 138 moderation, 139 139 langs: record.langs, 140 140 }, 141 + logContext: 'PostReply', 141 142 }) 142 143 }, [openComposer, post, record, moderation]) 143 144
+1
src/view/com/posts/PostFeedItem.tsx
··· 189 189 moderation, 190 190 langs: record.langs, 191 191 }, 192 + logContext: 'PostReply', 192 193 }) 193 194 } 194 195
+1 -1
src/view/screens/Feeds.tsx
··· 145 145 [search], 146 146 ) 147 147 const onPressCompose = React.useCallback(() => { 148 - openComposer({}) 148 + openComposer({logContext: 'Fab'}) 149 149 }, [openComposer]) 150 150 const onChangeQuery = React.useCallback( 151 151 (text: string) => {
+2 -3
src/view/screens/Notifications.tsx
··· 30 30 import {type ListMethods} from '#/view/com/util/List' 31 31 import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' 32 32 import {MainScrollProvider} from '#/view/com/util/MainScrollProvider' 33 - import {atoms as a, useTheme} from '#/alf' 34 - import {web} from '#/alf' 33 + import {atoms as a, useTheme, web} from '#/alf' 35 34 import {Admonition} from '#/components/Admonition' 36 35 import {ButtonIcon} from '#/components/Button' 37 36 import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2' ··· 161 160 </Pager> 162 161 <FAB 163 162 testID="composeFAB" 164 - onPress={() => openComposer({})} 163 + onPress={() => openComposer({logContext: 'Fab'})} 165 164 icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} 166 165 accessibilityRole="button" 167 166 accessibilityLabel={_(msg`New post`)}
+7 -4
src/view/screens/Profile.tsx
··· 332 332 isInvalidHandle(profile.handle) 333 333 ? undefined 334 334 : profile.handle 335 - openComposer({mention}) 335 + openComposer({mention, logContext: 'ProfileFeed'}) 336 336 } 337 337 338 338 const onPageSelected = (i: number) => { ··· 434 434 ? { 435 435 label: _(msg`Write a post`), 436 436 text: _(msg`Write a post`), 437 - onPress: () => openComposer({}), 437 + onPress: () => 438 + openComposer({logContext: 'ProfileFeed'}), 438 439 size: 'small', 439 440 color: 'primary', 440 441 } ··· 474 475 ? { 475 476 label: _(msg`Post a photo`), 476 477 text: _(msg`Post a photo`), 477 - onPress: () => openComposer({}), 478 + onPress: () => 479 + openComposer({logContext: 'ProfileFeed'}), 478 480 size: 'small', 479 481 color: 'primary', 480 482 } ··· 500 502 ? { 501 503 label: _(msg`Post a video`), 502 504 text: _(msg`Post a video`), 503 - onPress: () => openComposer({}), 505 + onPress: () => 506 + openComposer({logContext: 'ProfileFeed'}), 504 507 size: 'small', 505 508 color: 'primary', 506 509 }
+1 -1
src/view/shell/desktop/LeftNav.tsx
··· 560 560 } 561 561 562 562 const onPressCompose = async () => 563 - openComposer({mention: await getProfileHandle()}) 563 + openComposer({mention: await getProfileHandle(), logContext: 'Fab'}) 564 564 565 565 if (leftNavMinimal) { 566 566 return null