diff --git a/package.json b/package.json index 6e17fcb0..89620182 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.72.0", + "version": "1.73.0", "private": true, "engines": { "node": ">=18" diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 8a9f69b5..77706ce3 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -78,7 +78,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack import {msg} from '@lingui/macro' import {i18n, MessageDescriptor} from '@lingui/core' import HashtagScreen from '#/screens/Hashtag' -import {logEvent} from './lib/statsig/statsig' +import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig' const navigationRef = createNavigationContainerRef() @@ -543,6 +543,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { linking={LINKING} theme={theme} onReady={() => { + attachRouteToLogEvents(getCurrentRouteName) logModuleInitTime() onReady() }}> @@ -551,6 +552,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { ) } +function getCurrentRouteName() { + return navigationRef.getCurrentRoute()?.name +} + /** * These helpers can be used from outside of the RoutesContainer * (eg in the state models). @@ -656,7 +661,9 @@ function logModuleInitTime() { performance.now() - global.__BUNDLE_START_TIME__, ) console.log(`Time to first paint: ${initMs} ms`) - logEvent('init', initMs) + logEvent('init', { + initMs, + }) if (__DEV__) { // This log is noisy, so keep false committed diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index 8bade34e..96e82e4c 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -18,11 +18,18 @@ export async function openPicker(opts?: ImagePickerOptions) { Toast.show('You may only select up to 4 images') } - return (response.assets ?? []).slice(0, 4).map(image => ({ - mime: 'image/jpeg', - height: image.height, - width: image.width, - path: image.uri, - size: getDataUriSize(image.uri), - })) + return (response.assets ?? []) + .slice(0, 4) + .filter(asset => { + if (asset.mimeType?.startsWith('image/')) return true + Toast.show('Only image files are supported') + return false + }) + .map(image => ({ + mime: 'image/jpeg', + height: image.height, + width: image.width, + path: image.uri, + size: getDataUriSize(image.uri), + })) } diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts new file mode 100644 index 00000000..fa7e597f --- /dev/null +++ b/src/lib/statsig/events.ts @@ -0,0 +1,47 @@ +export type LogEvents = { + init: { + initMs: number + } + 'feed:endReached': { + feedType: string + itemCount: number + } + 'post:create': { + imageCount: number + isReply: boolean + hasLink: boolean + hasQuote: boolean + langs: string + logContext: 'Composer' + } + 'post:like': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + } + 'post:repost': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + } + 'post:unlike': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + } + 'post:unrepost': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' + } + 'profile:follow': { + logContext: + | 'RecommendedFollowsItem' + | 'PostThreadItem' + | 'ProfileCard' + | 'ProfileHeader' + | 'ProfileHeaderSuggestedFollows' + | 'ProfileMenu' + } + 'profile:unfollow': { + logContext: + | 'RecommendedFollowsItem' + | 'PostThreadItem' + | 'ProfileCard' + | 'ProfileHeader' + | 'ProfileHeaderSuggestedFollows' + | 'ProfileMenu' + } +} diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 6d9ebeb0..5745d204 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -6,6 +6,9 @@ import { } from 'statsig-react-native-expo' import {useSession} from '../../state/session' import {sha256} from 'js-sha256' +import {LogEvents} from './events' + +export type {LogEvents} const statsigOptions = { environment: { @@ -17,12 +20,28 @@ const statsigOptions = { initTimeoutMs: 1, } -export function logEvent( - eventName: string, - value?: string | number | null, - metadata?: Record | null, +type FlatJSONRecord = Record< + string, + string | number | boolean | null | undefined +> + +let getCurrentRouteName: () => string | null | undefined = () => null + +export function attachRouteToLogEvents( + getRouteName: () => string | null | undefined, ) { - Statsig.logEvent(eventName, value, metadata) + getCurrentRouteName = getRouteName +} + +export function logEvent( + eventName: E & string, + rawMetadata: LogEvents[E] & FlatJSONRecord, +) { + const fullMetadata = { + ...rawMetadata, + } as Record // Statsig typings are unnecessarily strict here. + fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)' + Statsig.logEvent(eventName, null, fullMetadata) } export function useGate(gateName: string) { diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts index eb59f7da..e3682e30 100644 --- a/src/state/queries/post.ts +++ b/src/state/queries/post.ts @@ -5,6 +5,7 @@ import {Shadow} from '#/state/cache/types' import {getAgent} from '#/state/session' import {updatePostShadow} from '#/state/cache/post-shadow' import {track} from '#/lib/analytics/analytics' +import {logEvent, LogEvents} from '#/lib/statsig/statsig' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' export const RQKEY = (postUri: string) => ['post', postUri] @@ -58,12 +59,14 @@ export function useGetPost() { export function usePostLikeMutationQueue( post: Shadow, + logContext: LogEvents['post:like']['logContext'] & + LogEvents['post:unlike']['logContext'], ) { const postUri = post.uri const postCid = post.cid const initialLikeUri = post.viewer?.like - const likeMutation = usePostLikeMutation() - const unlikeMutation = usePostUnlikeMutation() + const likeMutation = usePostLikeMutation(logContext) + const unlikeMutation = usePostUnlikeMutation(logContext) const queueToggle = useToggleMutationQueue({ initialState: initialLikeUri, @@ -111,22 +114,30 @@ export function usePostLikeMutationQueue( return [queueLike, queueUnlike] } -function usePostLikeMutation() { +function usePostLikeMutation(logContext: LogEvents['post:like']['logContext']) { return useMutation< {uri: string}, // responds with the uri of the like Error, {uri: string; cid: string} // the post's uri and cid >({ - mutationFn: post => getAgent().like(post.uri, post.cid), + mutationFn: post => { + logEvent('post:like', {logContext}) + return getAgent().like(post.uri, post.cid) + }, onSuccess() { track('Post:Like') }, }) } -function usePostUnlikeMutation() { +function usePostUnlikeMutation( + logContext: LogEvents['post:unlike']['logContext'], +) { return useMutation({ - mutationFn: ({likeUri}) => getAgent().deleteLike(likeUri), + mutationFn: ({likeUri}) => { + logEvent('post:unlike', {logContext}) + return getAgent().deleteLike(likeUri) + }, onSuccess() { track('Post:Unlike') }, @@ -135,12 +146,14 @@ function usePostUnlikeMutation() { export function usePostRepostMutationQueue( post: Shadow, + logContext: LogEvents['post:repost']['logContext'] & + LogEvents['post:unrepost']['logContext'], ) { const postUri = post.uri const postCid = post.cid const initialRepostUri = post.viewer?.repost - const repostMutation = usePostRepostMutation() - const unrepostMutation = usePostUnrepostMutation() + const repostMutation = usePostRepostMutation(logContext) + const unrepostMutation = usePostUnrepostMutation(logContext) const queueToggle = useToggleMutationQueue({ initialState: initialRepostUri, @@ -188,22 +201,32 @@ export function usePostRepostMutationQueue( return [queueRepost, queueUnrepost] } -function usePostRepostMutation() { +function usePostRepostMutation( + logContext: LogEvents['post:repost']['logContext'], +) { return useMutation< {uri: string}, // responds with the uri of the repost Error, {uri: string; cid: string} // the post's uri and cid >({ - mutationFn: post => getAgent().repost(post.uri, post.cid), + mutationFn: post => { + logEvent('post:repost', {logContext}) + return getAgent().repost(post.uri, post.cid) + }, onSuccess() { track('Post:Repost') }, }) } -function usePostUnrepostMutation() { +function usePostUnrepostMutation( + logContext: LogEvents['post:unrepost']['logContext'], +) { return useMutation({ - mutationFn: ({repostUri}) => getAgent().deleteRepost(repostUri), + mutationFn: ({repostUri}) => { + logEvent('post:unrepost', {logContext}) + return getAgent().deleteRepost(repostUri) + }, onSuccess() { track('Post:Unrepost') }, diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index e81ea0f3..3c9e3e41 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -26,6 +26,7 @@ import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' import {STALE} from '#/state/queries' import {track} from '#/lib/analytics/analytics' +import {logEvent, LogEvents} from '#/lib/statsig/statsig' import {ThreadNode} from './post-thread' export const RQKEY = (did: string) => ['profile', did] @@ -186,11 +187,13 @@ export function useProfileUpdateMutation() { export function useProfileFollowMutationQueue( profile: Shadow, + logContext: LogEvents['profile:follow']['logContext'] & + LogEvents['profile:unfollow']['logContext'], ) { const did = profile.did const initialFollowingUri = profile.viewer?.following - const followMutation = useProfileFollowMutation() - const unfollowMutation = useProfileUnfollowMutation() + const followMutation = useProfileFollowMutation(logContext) + const unfollowMutation = useProfileUnfollowMutation(logContext) const queueToggle = useToggleMutationQueue({ initialState: initialFollowingUri, @@ -237,9 +240,12 @@ export function useProfileFollowMutationQueue( return [queueFollow, queueUnfollow] } -function useProfileFollowMutation() { +function useProfileFollowMutation( + logContext: LogEvents['profile:follow']['logContext'], +) { return useMutation<{uri: string; cid: string}, Error, {did: string}>({ mutationFn: async ({did}) => { + logEvent('profile:follow', {logContext}) return await getAgent().follow(did) }, onSuccess(data, variables) { @@ -248,9 +254,12 @@ function useProfileFollowMutation() { }) } -function useProfileUnfollowMutation() { +function useProfileUnfollowMutation( + logContext: LogEvents['profile:unfollow']['logContext'], +) { return useMutation({ mutationFn: async ({followUri}) => { + logEvent('profile:unfollow', {logContext}) track('Profile:Unfollow', {username: followUri}) return await getAgent().deleteFollow(followUri) }, diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 993bc34a..3023ac6c 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -57,7 +57,7 @@ export function RecommendedFollowsItem({ ) } -export function ProfileCard({ +function ProfileCard({ profile, onFollowStateChange, moderation, @@ -74,7 +74,10 @@ export function ProfileCard({ const {_} = useLingui() const [addingMoreSuggestions, setAddingMoreSuggestions] = React.useState(false) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'RecommendedFollowsItem', + ) const onToggleFollow = React.useCallback(async () => { try { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index ef965b27..97f8e519 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -65,6 +65,7 @@ import {logger} from '#/logger' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import * as Prompt from '#/components/Prompt' import {useDialogStateControlContext} from 'state/dialogs' +import {logEvent} from '#/lib/statsig/statsig' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ @@ -255,6 +256,16 @@ export const ComposePost = observer(function ComposePost({ setIsProcessing(false) return } finally { + if (postUri) { + logEvent('post:create', { + imageCount: gallery.size, + isReply: replyTo != null, + hasLink: extLink != null, + hasQuote: quote != null, + langs: langPrefs.postLanguage, + logContext: 'Composer', + }) + } track('Create Post', { imageCount: gallery.size, }) diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx index e5b747cc..45c3771f 100644 --- a/src/view/com/post-thread/PostThreadFollowBtn.tsx +++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx @@ -42,7 +42,10 @@ function PostThreadFollowBtnLoaded({ const {isTabletOrDesktop} = useWebMediaQueries() const profile: Shadow = useProfileShadow(profileUnshadowed) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'PostThreadItem', + ) const requireAuth = useRequireAuth() const isFollowing = !!profile.viewer?.following diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index e0baabed..ba5327bf 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -407,6 +407,7 @@ let PostThreadItemLoaded = ({ record={record} richText={richText} onPressReply={onPressReply} + logContext="PostThreadItem" /> @@ -560,6 +561,7 @@ let PostThreadItemLoaded = ({ record={record} richText={richText} onPressReply={onPressReply} + logContext="PostThreadItem" /> diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 5fa4da84..0fe3420b 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -220,6 +220,7 @@ function PostInner({ record={record} richText={richText} onPressReply={onPressReply} + logContext="Post" /> diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index cd3e9878..b86646a4 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -33,6 +33,7 @@ import {useLingui} from '@lingui/react' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {logEvent} from '#/lib/statsig/statsig' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} @@ -223,16 +224,29 @@ let Feed = ({ setIsPTRing(false) }, [refetch, track, setIsPTRing, onHasNew]) + const feedType = feed.split('|')[0] const onEndReached = React.useCallback(async () => { if (isFetching || !hasNextPage || isError) return + logEvent('feed:endReached', { + feedType: feedType, + itemCount: feedItems.length, + }) track('Feed:onEndReached') try { await fetchNextPage() } catch (err) { logger.error('Failed to load more posts', {message: err}) } - }, [isFetching, hasNextPage, isError, fetchNextPage, track]) + }, [ + isFetching, + hasNextPage, + isError, + fetchNextPage, + track, + feedType, + feedItems.length, + ]) const onPressTryAgain = React.useCallback(() => { refetch() diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 7d29703e..4b78dce7 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -310,6 +310,7 @@ let FeedItemInner = ({ showAppealLabelItem={ post.author.did === currentAccount?.did && isModeratedPost } + logContext="FeedItem" /> diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 9cc635b6..7b090ffe 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -13,13 +13,18 @@ export function FollowButton({ followedType = 'default', profile, labelStyle, + logContext, }: { unfollowedType?: ButtonType followedType?: ButtonType profile: Shadow labelStyle?: StyleProp + logContext: 'ProfileCard' }) { - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + logContext, + ) const {_} = useLingui() const onPressFollow = async () => { diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 266adc51..019e6c10 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -230,7 +230,9 @@ export function ProfileCardWithFollowBtn({ renderButton={ isMe ? undefined - : profileShadow => + : profileShadow => ( + + ) } /> ) diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 75e06eb9..17dc5ce1 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -103,7 +103,10 @@ let ProfileHeader = ({ const invalidHandle = isInvalidHandle(profile.handle) const {isDesktop} = useWebMediaQueries() const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'ProfileHeader', + ) const [__, queueUnblock] = useProfileBlockMutationQueue(profile) const unblockPromptControl = Prompt.usePromptControl() const moderation = useMemo( diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index ab406aee..585463f9 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -172,7 +172,10 @@ function SuggestedFollow({ const {_} = useLingui() const moderationOpts = useModerationOpts() const profile = useProfileShadow(profileUnshadowed) - const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'ProfileHeaderSuggestedFollows', + ) const onPressFollow = React.useCallback(async () => { try { diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 4153b819..7c6db1ed 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -52,7 +52,10 @@ let ProfileMenu = ({ const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) - const [, queueUnfollow] = useProfileFollowMutationQueue(profile) + const [, queueUnfollow] = useProfileFollowMutationQueue( + profile, + 'ProfileMenu', + ) const blockPromptControl = Prompt.usePromptControl() diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 1e26eecc..c96954a1 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -44,6 +44,7 @@ let PostCtrls = ({ showAppealLabelItem, style, onPressReply, + logContext, }: { big?: boolean post: Shadow @@ -52,13 +53,17 @@ let PostCtrls = ({ showAppealLabelItem?: boolean style?: StyleProp onPressReply: () => void + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' }): React.ReactNode => { const theme = useTheme() const {_} = useLingui() const {openComposer} = useComposerControls() const {closeModal} = useModalControls() - const [queueLike, queueUnlike] = usePostLikeMutationQueue(post) - const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(post) + const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) + const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( + post, + logContext, + ) const requireAuth = useRequireAuth() const defaultCtrlColor = React.useMemo(