[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>
This commit is contained in:
@@ -233,6 +233,53 @@ export type Events = {
|
|||||||
persist: boolean
|
persist: boolean
|
||||||
hasChanged: 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
|
// Data events
|
||||||
'account:create:begin': {}
|
'account:create:begin': {}
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ let PostControls = ({
|
|||||||
openComposer({
|
openComposer({
|
||||||
quote: post,
|
quote: post,
|
||||||
onPost: onPostReply,
|
onPost: onPostReply,
|
||||||
|
logContext: 'QuotePost',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export function useComposeIntent() {
|
|||||||
openComposer({
|
openComposer({
|
||||||
text: text ?? undefined,
|
text: text ?? undefined,
|
||||||
videoUri: {uri, width: Number(width), height: Number(height)},
|
videoUri: {uri, width: Number(width), height: Number(height)},
|
||||||
|
logContext: 'Deeplink',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,6 +154,7 @@ export function useComposeIntent() {
|
|||||||
openComposer({
|
openComposer({
|
||||||
text: text ?? undefined,
|
text: text ?? undefined,
|
||||||
imageUris: IS_NATIVE ? imageUris : undefined,
|
imageUris: IS_NATIVE ? imageUris : undefined,
|
||||||
|
logContext: 'Deeplink',
|
||||||
})
|
})
|
||||||
}, 500)
|
}, 500)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
langs: record.langs,
|
langs: record.langs,
|
||||||
},
|
},
|
||||||
onPostSuccess: onPostSuccess,
|
onPostSuccess: onPostSuccess,
|
||||||
|
logContext: 'PostReply',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (postSource) {
|
if (postSource) {
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
|
|||||||
langs: post.record.langs,
|
langs: post.record.langs,
|
||||||
},
|
},
|
||||||
onPostSuccess: onPostSuccess,
|
onPostSuccess: onPostSuccess,
|
||||||
|
logContext: 'PostReply',
|
||||||
})
|
})
|
||||||
}, [openComposer, post, record, onPostSuccess, moderation])
|
}, [openComposer, post, record, onPostSuccess, moderation])
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
|
|||||||
langs: post.record.langs,
|
langs: post.record.langs,
|
||||||
},
|
},
|
||||||
onPostSuccess: onPostSuccess,
|
onPostSuccess: onPostSuccess,
|
||||||
|
logContext: 'PostReply',
|
||||||
})
|
})
|
||||||
}, [openComposer, post, record, onPostSuccess, moderation])
|
}, [openComposer, post, record, onPostSuccess, moderation])
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export function PostThread({uri}: {uri: string}) {
|
|||||||
langs: post.record.langs,
|
langs: post.record.langs,
|
||||||
},
|
},
|
||||||
onPostSuccess: optimisticOnPostReply,
|
onPostSuccess: optimisticOnPostReply,
|
||||||
|
logContext: 'PostReply',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (anchorPostSource) {
|
if (anchorPostSource) {
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
|||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||||
import {ComposeIcon2} from '#/lib/icons'
|
import {ComposeIcon2} from '#/lib/icons'
|
||||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
import {
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
type CommonNavigatorParams,
|
||||||
|
type NavigationProp,
|
||||||
|
} from '#/lib/routes/types'
|
||||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
@@ -236,7 +238,7 @@ export function ProfileFeedScreenInner({
|
|||||||
{hasSession && (
|
{hasSession && (
|
||||||
<FAB
|
<FAB
|
||||||
testID="composeFAB"
|
testID="composeFAB"
|
||||||
onPress={() => openComposer({})}
|
onPress={() => openComposer({logContext: 'Fab'})}
|
||||||
icon={
|
icon={
|
||||||
<ComposeIcon2
|
<ComposeIcon2
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ function ProfileListScreenLoaded({
|
|||||||
</PagerWithHeader>
|
</PagerWithHeader>
|
||||||
<FAB
|
<FAB
|
||||||
testID="composeFAB"
|
testID="composeFAB"
|
||||||
onPress={() => openComposer({})}
|
onPress={() => openComposer({logContext: 'Fab'})}
|
||||||
icon={
|
icon={
|
||||||
<ComposeIcon2
|
<ComposeIcon2
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
@@ -272,7 +272,7 @@ function ProfileListScreenLoaded({
|
|||||||
/>
|
/>
|
||||||
<FAB
|
<FAB
|
||||||
testID="composeFAB"
|
testID="composeFAB"
|
||||||
onPress={() => openComposer({})}
|
onPress={() => openComposer({logContext: 'Fab'})}
|
||||||
icon={
|
icon={
|
||||||
<ComposeIcon2
|
<ComposeIcon2
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
|
|||||||
@@ -777,6 +777,7 @@ function Overlay({
|
|||||||
embed: post.embed,
|
embed: post.embed,
|
||||||
langs: record?.langs,
|
langs: record?.langs,
|
||||||
},
|
},
|
||||||
|
logContext: 'PostReply',
|
||||||
})
|
})
|
||||||
}, [openComposer, post, record])
|
}, [openComposer, post, record])
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export type OnPostSuccessData =
|
|||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
|
export type ComposerLogContext =
|
||||||
|
| 'Fab'
|
||||||
|
| 'PostReply'
|
||||||
|
| 'QuotePost'
|
||||||
|
| 'ProfileFeed'
|
||||||
|
| 'Deeplink'
|
||||||
|
| 'Other'
|
||||||
|
|
||||||
export interface ComposerOpts {
|
export interface ComposerOpts {
|
||||||
replyTo?: ComposerOptsPostRef
|
replyTo?: ComposerOptsPostRef
|
||||||
onPost?: (postUri: string | undefined) => void
|
onPost?: (postUri: string | undefined) => void
|
||||||
@@ -44,6 +52,7 @@ export interface ComposerOpts {
|
|||||||
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
|
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
|
||||||
videoUri?: {uri: string; width: number; height: number}
|
videoUri?: {uri: string; width: number; height: number}
|
||||||
openGallery?: boolean
|
openGallery?: boolean
|
||||||
|
logContext?: ComposerLogContext
|
||||||
}
|
}
|
||||||
|
|
||||||
type StateContext = ComposerOpts | undefined
|
type StateContext = ComposerOpts | undefined
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function useComposerKeyboardShortcut() {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
if (event.key === 'n' || event.key === 'N') {
|
if (event.key === 'n' || event.key === 'N') {
|
||||||
openComposer({})
|
openComposer({logContext: 'Other'})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handler)
|
document.addEventListener('keydown', handler)
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
|||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {mimeToExt} from '#/lib/media/video/util'
|
import {mimeToExt} from '#/lib/media/video/util'
|
||||||
|
import {useCallOnce} from '#/lib/once'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {colors} from '#/lib/styles'
|
import {colors} from '#/lib/styles'
|
||||||
@@ -142,6 +143,7 @@ import {
|
|||||||
useSaveDraftMutation,
|
useSaveDraftMutation,
|
||||||
} from './drafts/state/queries'
|
} from './drafts/state/queries'
|
||||||
import {type DraftSummary} from './drafts/state/schema'
|
import {type DraftSummary} from './drafts/state/schema'
|
||||||
|
import {revokeAllMediaUrls} from './drafts/state/storage'
|
||||||
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
|
import {PostLanguageSelect} from './select-language/PostLanguageSelect'
|
||||||
import {
|
import {
|
||||||
type AssetType,
|
type AssetType,
|
||||||
@@ -184,6 +186,7 @@ export const ComposePost = ({
|
|||||||
imageUris: initImageUris,
|
imageUris: initImageUris,
|
||||||
videoUri: initVideoUri,
|
videoUri: initVideoUri,
|
||||||
openGallery,
|
openGallery,
|
||||||
|
logContext,
|
||||||
cancelRef,
|
cancelRef,
|
||||||
}: Props & {
|
}: Props & {
|
||||||
cancelRef?: React.RefObject<CancelRef | null>
|
cancelRef?: React.RefObject<CancelRef | null>
|
||||||
@@ -213,6 +216,14 @@ export const ComposePost = ({
|
|||||||
const [publishingStage, setPublishingStage] = useState('')
|
const [publishingStage, setPublishingStage] = useState('')
|
||||||
const [error, setError] = 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
|
* A temporary local reference to a language suggestion that the user has
|
||||||
* accepted. This overrides the global post language preference, but is not
|
* accepted. This overrides the global post language preference, but is not
|
||||||
@@ -321,6 +332,16 @@ export const ComposePost = ({
|
|||||||
onInitVideo()
|
onInitVideo()
|
||||||
}, [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(
|
const clearVideo = useCallback(
|
||||||
(postId: string) => {
|
(postId: string) => {
|
||||||
composerDispatch({
|
composerDispatch({
|
||||||
@@ -489,6 +510,21 @@ export const ComposePost = ({
|
|||||||
originalLocalRefs,
|
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
|
// Initiate video processing for any restored videos
|
||||||
// This is async but we don't await - videos process in the background
|
// This is async but we don't await - videos process in the background
|
||||||
for (const [postIndex, videoInfo] of restoredVideos) {
|
for (const [postIndex, videoInfo] of restoredVideos) {
|
||||||
@@ -496,7 +532,7 @@ export const ComposePost = ({
|
|||||||
restoreVideo(postId, videoInfo)
|
restoreVideo(postId, videoInfo)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[composerDispatch, restoreVideo],
|
[composerDispatch, restoreVideo, ax],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
@@ -504,21 +540,38 @@ export const ComposePost = ({
|
|||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
closeComposer()
|
closeComposer()
|
||||||
clearThumbnailCache(queryClient)
|
clearThumbnailCache(queryClient)
|
||||||
|
revokeAllMediaUrls()
|
||||||
}, [closeComposer, queryClient])
|
}, [closeComposer, queryClient])
|
||||||
|
|
||||||
const handleSaveDraft = React.useCallback(async () => {
|
const handleSaveDraft = React.useCallback(async () => {
|
||||||
|
const isNewDraft = !composerState.draftId
|
||||||
try {
|
try {
|
||||||
const result = await saveDraft({
|
const result = await saveDraft({
|
||||||
composerState,
|
composerState,
|
||||||
existingDraftId: composerState.draftId,
|
existingDraftId: composerState.draftId,
|
||||||
})
|
})
|
||||||
composerDispatch({type: 'mark_saved', draftId: result.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()
|
onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Failed to save draft', {error: e})
|
logger.error('Failed to save draft', {error: e})
|
||||||
setError(_(msg`Failed to save draft`))
|
setError(_(msg`Failed to save draft`))
|
||||||
}
|
}
|
||||||
}, [saveDraft, composerState, composerDispatch, onClose, _])
|
}, [saveDraft, composerState, composerDispatch, onClose, _, ax])
|
||||||
|
|
||||||
// Save without closing - for use by DraftsButton
|
// Save without closing - for use by DraftsButton
|
||||||
const saveCurrentDraft = React.useCallback(async () => {
|
const saveCurrentDraft = React.useCallback(async () => {
|
||||||
@@ -529,6 +582,23 @@ export const ComposePost = ({
|
|||||||
composerDispatch({type: 'mark_saved', draftId: result.draftId})
|
composerDispatch({type: 'mark_saved', draftId: result.draftId})
|
||||||
}, [saveDraft, composerState, composerDispatch])
|
}, [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)
|
// Check if composer is empty (no content to save)
|
||||||
const isComposerEmpty = React.useMemo(() => {
|
const isComposerEmpty = React.useMemo(() => {
|
||||||
// Has multiple posts means it's not empty
|
// Has multiple posts means it's not empty
|
||||||
@@ -786,6 +856,15 @@ export const ComposePost = ({
|
|||||||
}
|
}
|
||||||
// Clean up draft and its media after successful publish
|
// Clean up draft and its media after successful publish
|
||||||
if (composerState.draftId && composerState.originalLocalRefs) {
|
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', {
|
logger.debug('post published, cleaning up draft', {
|
||||||
draftId: composerState.draftId,
|
draftId: composerState.draftId,
|
||||||
mediaFileCount: composerState.originalLocalRefs.size,
|
mediaFileCount: composerState.originalLocalRefs.size,
|
||||||
@@ -860,7 +939,9 @@ export const ComposePost = ({
|
|||||||
navigation,
|
navigation,
|
||||||
composerState.draftId,
|
composerState.draftId,
|
||||||
composerState.originalLocalRefs,
|
composerState.originalLocalRefs,
|
||||||
|
composerState.isDirty,
|
||||||
cleanupPublishedDraft,
|
cleanupPublishedDraft,
|
||||||
|
loadedDraftCreatedAt,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Preserves the referential identity passed to each post item.
|
// Preserves the referential identity passed to each post item.
|
||||||
@@ -1008,7 +1089,8 @@ export const ComposePost = ({
|
|||||||
onDiscard={handleClearComposer}
|
onDiscard={handleClearComposer}
|
||||||
isEmpty={isComposerEmpty}
|
isEmpty={isComposerEmpty}
|
||||||
isDirty={composerState.isDirty}
|
isDirty={composerState.isDirty}
|
||||||
isEditingDraft={!!composerState.draftId}>
|
isEditingDraft={!!composerState.draftId}
|
||||||
|
textLength={thread.posts[0].richtext.text.length}>
|
||||||
{missingAltError && <AltTextReminder error={missingAltError} />}
|
{missingAltError && <AltTextReminder error={missingAltError} />}
|
||||||
<ErrorBanner
|
<ErrorBanner
|
||||||
error={error}
|
error={error}
|
||||||
@@ -1088,7 +1170,7 @@ export const ComposePost = ({
|
|||||||
/>
|
/>
|
||||||
<Prompt.Action
|
<Prompt.Action
|
||||||
cta={_(msg`Discard`)}
|
cta={_(msg`Discard`)}
|
||||||
onPress={onClose}
|
onPress={handleDiscard}
|
||||||
color="negative_subtle"
|
color="negative_subtle"
|
||||||
/>
|
/>
|
||||||
<Prompt.Cancel />
|
<Prompt.Cancel />
|
||||||
@@ -1318,6 +1400,7 @@ function ComposerTopBar({
|
|||||||
isEmpty,
|
isEmpty,
|
||||||
isDirty,
|
isDirty,
|
||||||
isEditingDraft,
|
isEditingDraft,
|
||||||
|
textLength,
|
||||||
topBarAnimatedStyle,
|
topBarAnimatedStyle,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -1335,6 +1418,7 @@ function ComposerTopBar({
|
|||||||
isEmpty: boolean
|
isEmpty: boolean
|
||||||
isDirty: boolean
|
isDirty: boolean
|
||||||
isEditingDraft: boolean
|
isEditingDraft: boolean
|
||||||
|
textLength: number
|
||||||
topBarAnimatedStyle: StyleProp<ViewStyle>
|
topBarAnimatedStyle: StyleProp<ViewStyle>
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
@@ -1381,6 +1465,7 @@ function ComposerTopBar({
|
|||||||
isEmpty={isEmpty}
|
isEmpty={isEmpty}
|
||||||
isDirty={isDirty}
|
isDirty={isDirty}
|
||||||
isEditingDraft={isEditingDraft}
|
isEditingDraft={isEditingDraft}
|
||||||
|
textLength={textLength}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {atoms as a} from '#/alf'
|
|||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
import {DraftsListDialog} from './DraftsListDialog'
|
import {DraftsListDialog} from './DraftsListDialog'
|
||||||
import {useSaveDraftMutation} from './state/queries'
|
import {useSaveDraftMutation} from './state/queries'
|
||||||
import {type DraftSummary} from './state/schema'
|
import {type DraftSummary} from './state/schema'
|
||||||
@@ -16,6 +17,7 @@ export function DraftsButton({
|
|||||||
isEmpty,
|
isEmpty,
|
||||||
isDirty,
|
isDirty,
|
||||||
isEditingDraft,
|
isEditingDraft,
|
||||||
|
textLength,
|
||||||
}: {
|
}: {
|
||||||
onSelectDraft: (draft: DraftSummary) => void
|
onSelectDraft: (draft: DraftSummary) => void
|
||||||
onSaveDraft: () => Promise<void>
|
onSaveDraft: () => Promise<void>
|
||||||
@@ -23,8 +25,10 @@ export function DraftsButton({
|
|||||||
isEmpty: boolean
|
isEmpty: boolean
|
||||||
isDirty: boolean
|
isDirty: boolean
|
||||||
isEditingDraft: boolean
|
isEditingDraft: boolean
|
||||||
|
textLength: number
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const ax = useAnalytics()
|
||||||
const draftsDialogControl = Dialog.useDialogControl()
|
const draftsDialogControl = Dialog.useDialogControl()
|
||||||
const savePromptControl = Prompt.usePromptControl()
|
const savePromptControl = Prompt.usePromptControl()
|
||||||
const {isPending: isSaving} = useSaveDraftMutation()
|
const {isPending: isSaving} = useSaveDraftMutation()
|
||||||
@@ -45,6 +49,12 @@ export function DraftsButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDiscardAndOpen = () => {
|
const handleDiscardAndOpen = () => {
|
||||||
|
// Fire draft:discard metric before discarding
|
||||||
|
ax.metric('draft:discard', {
|
||||||
|
logContext: 'BeforeDraftsList',
|
||||||
|
hadContent: !isEmpty,
|
||||||
|
textLength,
|
||||||
|
})
|
||||||
onDiscard()
|
onDiscard()
|
||||||
draftsDialogControl.open()
|
draftsDialogControl.open()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {useCallback, useMemo} from 'react'
|
import {useCallback, useEffect, useMemo} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useCallOnce} from '#/lib/once'
|
||||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
@@ -10,6 +11,7 @@ import * as Dialog from '#/components/Dialog'
|
|||||||
import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
|
import {PageX_Stroke2_Corner0_Rounded_Large as PageXIcon} from '#/components/icons/PageX'
|
||||||
import {ListFooter} from '#/components/Lists'
|
import {ListFooter} from '#/components/Lists'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
import {DraftItem} from './DraftItem'
|
import {DraftItem} from './DraftItem'
|
||||||
import {useDeleteDraftMutation, useDraftsQuery} from './state/queries'
|
import {useDeleteDraftMutation, useDraftsQuery} from './state/queries'
|
||||||
@@ -24,6 +26,7 @@ export function DraftsListDialog({
|
|||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const ax = useAnalytics()
|
||||||
const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} =
|
const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} =
|
||||||
useDraftsQuery()
|
useDraftsQuery()
|
||||||
const {mutate: deleteDraft} = useDeleteDraftMutation()
|
const {mutate: deleteDraft} = useDeleteDraftMutation()
|
||||||
@@ -33,6 +36,20 @@ export function DraftsListDialog({
|
|||||||
[data],
|
[data],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Fire draft:listOpen metric when dialog opens and data is loaded
|
||||||
|
const draftCount = drafts.length
|
||||||
|
const isDataReady = !isLoading && data !== undefined
|
||||||
|
const onDraftListOpen = useCallOnce()
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDataReady) {
|
||||||
|
onDraftListOpen(() => {
|
||||||
|
ax.metric('draft:listOpen', {
|
||||||
|
draftCount,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [onDraftListOpen, isDataReady, draftCount, ax])
|
||||||
|
|
||||||
const handleSelectDraft = useCallback(
|
const handleSelectDraft = useCallback(
|
||||||
(summary: DraftSummary) => {
|
(summary: DraftSummary) => {
|
||||||
control.close(() => {
|
control.close(() => {
|
||||||
@@ -44,9 +61,15 @@ export function DraftsListDialog({
|
|||||||
|
|
||||||
const handleDeleteDraft = useCallback(
|
const handleDeleteDraft = useCallback(
|
||||||
(draftSummary: DraftSummary) => {
|
(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({draftId: draftSummary.id, draft: draftSummary.draft})
|
||||||
},
|
},
|
||||||
[deleteDraft],
|
[deleteDraft, ax],
|
||||||
)
|
)
|
||||||
|
|
||||||
const backButton = useCallback(
|
const backButton = useCallback(
|
||||||
@@ -93,7 +116,7 @@ export function DraftsListDialog({
|
|||||||
|
|
||||||
const onEndReached = useCallback(() => {
|
const onEndReached = useCallback(() => {
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
if (hasNextPage && !isFetchingNextPage) {
|
||||||
fetchNextPage()
|
void fetchNextPage()
|
||||||
}
|
}
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||||
|
|
||||||
@@ -132,7 +155,7 @@ export function DraftsListDialog({
|
|||||||
<Dialog.InnerFlatList
|
<Dialog.InnerFlatList
|
||||||
data={drafts}
|
data={drafts}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={item => item.id}
|
keyExtractor={(item: DraftSummary) => item.id}
|
||||||
ListHeaderComponent={web(header)}
|
ListHeaderComponent={web(header)}
|
||||||
stickyHeaderIndices={web([0])}
|
stickyHeaderIndices={web([0])}
|
||||||
ListEmptyComponent={emptyComponent}
|
ListEmptyComponent={emptyComponent}
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export function draftViewToSummary(
|
|||||||
hasMissingMedia,
|
hasMissingMedia,
|
||||||
mediaCount,
|
mediaCount,
|
||||||
postCount: view.draft.posts.length,
|
postCount: view.draft.posts.length,
|
||||||
|
createdAt: view.createdAt,
|
||||||
updatedAt: view.updatedAt,
|
updatedAt: view.updatedAt,
|
||||||
posts,
|
posts,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export type DraftSummary = {
|
|||||||
mediaCount: number
|
mediaCount: number
|
||||||
/** Number of posts in thread */
|
/** Number of posts in thread */
|
||||||
postCount: number
|
postCount: number
|
||||||
|
/** ISO timestamp of creation */
|
||||||
|
createdAt: string
|
||||||
/** ISO timestamp of last update */
|
/** ISO timestamp of last update */
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
/** All posts in the draft for full display */
|
/** All posts in the draft for full display */
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export async function deleteMediaFromLocal(
|
|||||||
if (file.exists) {
|
if (file.exists) {
|
||||||
file.delete()
|
file.delete()
|
||||||
}
|
}
|
||||||
|
mediaExistsCache.delete(localRefPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,3 +155,17 @@ export function clearMediaCache(): void {
|
|||||||
cachePopulated = false
|
cachePopulated = false
|
||||||
populateCachePromise = null
|
populateCachePromise = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a media URL (no-op on native - only needed for web blob URLs)
|
||||||
|
*/
|
||||||
|
export function revokeMediaUrl(_url: string): void {
|
||||||
|
// No-op on native - file URIs don't need revocation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all media URLs (no-op on native - only needed for web blob URLs)
|
||||||
|
*/
|
||||||
|
export function revokeAllMediaUrls(): void {
|
||||||
|
// No-op on native - file URIs don't need revocation
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ export async function saveMediaToLocal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track blob URLs created by loadMediaFromLocal for cleanup
|
||||||
|
*/
|
||||||
|
const createdBlobUrls = new Set<string>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a media file from IndexedDB
|
* Load a media file from IndexedDB
|
||||||
* @returns A blob URL for the saved media
|
* @returns A blob URL for the saved media
|
||||||
@@ -97,7 +102,10 @@ export async function loadMediaFromLocal(
|
|||||||
throw new Error(`Media file not found: ${localRefPath}`)
|
throw new Error(`Media file not found: ${localRefPath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return URL.createObjectURL(record.blob)
|
const url = URL.createObjectURL(record.blob)
|
||||||
|
logger.debug('Created blob URL', {url})
|
||||||
|
createdBlobUrls.add(url)
|
||||||
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,6 +173,20 @@ export function clearMediaCache(): void {
|
|||||||
*/
|
*/
|
||||||
export function revokeMediaUrl(url: string): void {
|
export function revokeMediaUrl(url: string): void {
|
||||||
if (url.startsWith('blob:')) {
|
if (url.startsWith('blob:')) {
|
||||||
|
logger.debug('Revoking blob URL', {url})
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
|
createdBlobUrls.delete(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all blob URLs created by loadMediaFromLocal.
|
||||||
|
* Call this when closing the drafts list dialog to prevent memory leaks.
|
||||||
|
*/
|
||||||
|
export function revokeAllMediaUrls(): void {
|
||||||
|
logger.debug(`Revoking ${createdBlobUrls.size} blob URLs`)
|
||||||
|
for (const url of createdBlobUrls) {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
createdBlobUrls.clear()
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const RQKEY = 'video-thumbnail'
|
|||||||
|
|
||||||
export function clearThumbnailCache(queryClient: QueryClient) {
|
export function clearThumbnailCache(queryClient: QueryClient) {
|
||||||
clearCache().catch(() => {})
|
clearCache().catch(() => {})
|
||||||
queryClient.resetQueries({queryKey: [RQKEY]})
|
void queryClient.resetQueries({queryKey: [RQKEY]})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function ComposerPrompt() {
|
|||||||
|
|
||||||
const onPress = useCallback(() => {
|
const onPress = useCallback(() => {
|
||||||
ax.metric('composerPrompt:press', {})
|
ax.metric('composerPrompt:press', {})
|
||||||
openComposer({})
|
openComposer({logContext: 'Fab'})
|
||||||
}, [ax, openComposer])
|
}, [ax, openComposer])
|
||||||
|
|
||||||
const onPressImage = useCallback(async () => {
|
const onPressImage = useCallback(async () => {
|
||||||
@@ -45,7 +45,7 @@ export function ComposerPrompt() {
|
|||||||
|
|
||||||
// On web, open the composer with the gallery picker auto-opening
|
// On web, open the composer with the gallery picker auto-opening
|
||||||
if (!IS_NATIVE) {
|
if (!IS_NATIVE) {
|
||||||
openComposer({openGallery: true})
|
openComposer({openGallery: true, logContext: 'Fab'})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export function ComposerPrompt() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
if (imageUris.length > 0) {
|
if (imageUris.length > 0) {
|
||||||
openComposer({imageUris})
|
openComposer({imageUris, logContext: 'Fab'})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -125,6 +125,7 @@ export function ComposerPrompt() {
|
|||||||
|
|
||||||
openComposer({
|
openComposer({
|
||||||
imageUris: IS_NATIVE ? imageUris : undefined,
|
imageUris: IS_NATIVE ? imageUris : undefined,
|
||||||
|
logContext: 'Fab',
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (!String(err).toLowerCase().includes('cancel')) {
|
if (!String(err).toLowerCase().includes('cancel')) {
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function FeedPage({
|
|||||||
}, [onSoftReset, isPageFocused])
|
}, [onSoftReset, isPageFocused])
|
||||||
|
|
||||||
const onPressCompose = useCallback(() => {
|
const onPressCompose = useCallback(() => {
|
||||||
openComposer({})
|
openComposer({logContext: 'Fab'})
|
||||||
}, [openComposer])
|
}, [openComposer])
|
||||||
|
|
||||||
const onPressLoadLatest = useCallback(() => {
|
const onPressLoadLatest = useCallback(() => {
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ function PostInner({
|
|||||||
moderation,
|
moderation,
|
||||||
langs: record.langs,
|
langs: record.langs,
|
||||||
},
|
},
|
||||||
|
logContext: 'PostReply',
|
||||||
})
|
})
|
||||||
}, [openComposer, post, record, moderation])
|
}, [openComposer, post, record, moderation])
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ let FeedItemInner = ({
|
|||||||
moderation,
|
moderation,
|
||||||
langs: record.langs,
|
langs: record.langs,
|
||||||
},
|
},
|
||||||
|
logContext: 'PostReply',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function FeedsScreen(_props: Props) {
|
|||||||
[search],
|
[search],
|
||||||
)
|
)
|
||||||
const onPressCompose = React.useCallback(() => {
|
const onPressCompose = React.useCallback(() => {
|
||||||
openComposer({})
|
openComposer({logContext: 'Fab'})
|
||||||
}, [openComposer])
|
}, [openComposer])
|
||||||
const onChangeQuery = React.useCallback(
|
const onChangeQuery = React.useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ import {FAB} from '#/view/com/util/fab/FAB'
|
|||||||
import {type ListMethods} from '#/view/com/util/List'
|
import {type ListMethods} from '#/view/com/util/List'
|
||||||
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
|
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
|
||||||
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
|
import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {web} from '#/alf'
|
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {ButtonIcon} from '#/components/Button'
|
import {ButtonIcon} from '#/components/Button'
|
||||||
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
|
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
|
||||||
@@ -161,7 +160,7 @@ export function NotificationsScreen({}: Props) {
|
|||||||
</Pager>
|
</Pager>
|
||||||
<FAB
|
<FAB
|
||||||
testID="composeFAB"
|
testID="composeFAB"
|
||||||
onPress={() => openComposer({})}
|
onPress={() => openComposer({logContext: 'Fab'})}
|
||||||
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
|
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={_(msg`New post`)}
|
accessibilityLabel={_(msg`New post`)}
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ function ProfileScreenLoaded({
|
|||||||
isInvalidHandle(profile.handle)
|
isInvalidHandle(profile.handle)
|
||||||
? undefined
|
? undefined
|
||||||
: profile.handle
|
: profile.handle
|
||||||
openComposer({mention})
|
openComposer({mention, logContext: 'ProfileFeed'})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPageSelected = (i: number) => {
|
const onPageSelected = (i: number) => {
|
||||||
@@ -434,7 +434,8 @@ function ProfileScreenLoaded({
|
|||||||
? {
|
? {
|
||||||
label: _(msg`Write a post`),
|
label: _(msg`Write a post`),
|
||||||
text: _(msg`Write a post`),
|
text: _(msg`Write a post`),
|
||||||
onPress: () => openComposer({}),
|
onPress: () =>
|
||||||
|
openComposer({logContext: 'ProfileFeed'}),
|
||||||
size: 'small',
|
size: 'small',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
}
|
}
|
||||||
@@ -474,7 +475,8 @@ function ProfileScreenLoaded({
|
|||||||
? {
|
? {
|
||||||
label: _(msg`Post a photo`),
|
label: _(msg`Post a photo`),
|
||||||
text: _(msg`Post a photo`),
|
text: _(msg`Post a photo`),
|
||||||
onPress: () => openComposer({}),
|
onPress: () =>
|
||||||
|
openComposer({logContext: 'ProfileFeed'}),
|
||||||
size: 'small',
|
size: 'small',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
}
|
}
|
||||||
@@ -500,7 +502,8 @@ function ProfileScreenLoaded({
|
|||||||
? {
|
? {
|
||||||
label: _(msg`Post a video`),
|
label: _(msg`Post a video`),
|
||||||
text: _(msg`Post a video`),
|
text: _(msg`Post a video`),
|
||||||
onPress: () => openComposer({}),
|
onPress: () =>
|
||||||
|
openComposer({logContext: 'ProfileFeed'}),
|
||||||
size: 'small',
|
size: 'small',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ function ComposeBtn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onPressCompose = async () =>
|
const onPressCompose = async () =>
|
||||||
openComposer({mention: await getProfileHandle()})
|
openComposer({mention: await getProfileHandle(), logContext: 'Fab'})
|
||||||
|
|
||||||
if (leftNavMinimal) {
|
if (leftNavMinimal) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
Reference in New Issue
Block a user