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

Add search event analytics (#9949)

Co-authored-by: Eric Bailey <git@esb.lol>

authored by

DS Boyce
Eric Bailey
and committed by
GitHub
7dc3b4fa 37a82761

+291 -120
+26
src/analytics/metrics/types.ts
··· 649 649 tab: string 650 650 } 651 651 652 + 'search:query': { 653 + source: 'typed' | 'history' | 'autocomplete' 654 + } 655 + 656 + 'search:results:loaded': { 657 + tab: 'top' | 'latest' | 'people' | 'feeds' 658 + initialCount: number 659 + } 660 + 661 + 'search:result:press': { 662 + tab?: 'top' | 'latest' | 'people' | 'feeds' 663 + resultType: 'post' | 'profile' | 'feed' 664 + position: number 665 + uri: string 666 + } 667 + 668 + 'search:recent:press': { 669 + profileDid: string 670 + position: number 671 + } 672 + 673 + 'search:autocomplete:press': { 674 + profileDid: string 675 + position: number 676 + } 677 + 652 678 'progressGuide:hide': {} 653 679 'progressGuide:followDialog:open': {} 654 680
+23 -25
src/components/FeedCard.tsx
··· 1 - import React, {useMemo} from 'react' 1 + import {useCallback, useEffect, useMemo} from 'react' 2 2 import {type GestureResponderEvent, View} from 'react-native' 3 3 import { 4 4 type AppBskyFeedDefs, ··· 6 6 AtUri, 7 7 RichText as RichTextApi, 8 8 } from '@atproto/api' 9 - import {msg} from '@lingui/core/macro' 10 - import {useLingui} from '@lingui/react' 11 - import {Plural, Trans} from '@lingui/react/macro' 9 + import {Plural, Trans, useLingui} from '@lingui/react/macro' 12 10 import {useQueryClient} from '@tanstack/react-query' 13 11 14 12 import {sanitizeHandle} from '#/lib/strings/handles' ··· 73 71 }: Props & Omit<LinkProps, 'to' | 'label'>) { 74 72 const queryClient = useQueryClient() 75 73 76 - const href = React.useMemo(() => { 74 + const href = useMemo(() => { 77 75 return createProfileFeedHref({feed: view}) 78 76 }, [view]) 79 77 80 - React.useEffect(() => { 78 + useEffect(() => { 81 79 precacheFeedFromGeneratorView(queryClient, view) 82 80 }, [view, queryClient]) 83 81 ··· 212 210 description, 213 211 ...rest 214 212 }: {description?: string} & Partial<RichTextProps>) { 215 - const rt = React.useMemo(() => { 213 + const rt = useMemo(() => { 216 214 if (!description) return 217 215 const rt = new RichTextApi({text: description || ''}) 218 216 rt.detectFacetsWithoutResolution() ··· 279 277 pin?: boolean 280 278 text?: boolean 281 279 } & Partial<ButtonProps>) { 282 - const {_} = useLingui() 280 + const {t: l} = useLingui() 283 281 const {data: preferences} = usePreferencesQuery() 284 282 const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = 285 283 useAddSavedFeedsMutation() ··· 289 287 const uri = view.uri 290 288 const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list' 291 289 292 - const savedFeedConfig = React.useMemo(() => { 290 + const savedFeedConfig = useMemo(() => { 293 291 return preferences?.savedFeeds?.find(feed => feed.value === uri) 294 292 }, [preferences?.savedFeeds, uri]) 295 293 const removePromptControl = Prompt.usePromptControl() 296 294 const isPending = isAddSavedFeedPending || isRemovePending 297 295 298 - const toggleSave = React.useCallback( 296 + const toggleSave = useCallback( 299 297 async (e: GestureResponderEvent) => { 300 298 e.preventDefault() 301 299 e.stopPropagation() ··· 312 310 }, 313 311 ]) 314 312 } 315 - Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) 313 + Toast.show(l({message: 'Feeds updated!', context: 'toast'})) 316 314 } catch (err: any) { 317 315 logger.error(err, {message: `FeedCard: failed to update feeds`, pin}) 318 - Toast.show(_(msg`Failed to update feeds`), 'xmark') 316 + Toast.show(l`Failed to update feeds`, 'xmark') 319 317 } 320 318 }, 321 - [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], 319 + [l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], 322 320 ) 323 321 324 - const onPrompRemoveFeed = React.useCallback( 325 - async (e: GestureResponderEvent) => { 322 + const onPromptRemoveFeed = useCallback( 323 + (e: GestureResponderEvent) => { 326 324 e.preventDefault() 327 325 e.stopPropagation() 328 326 ··· 335 333 <> 336 334 <Button 337 335 disabled={isPending} 338 - label={_(msg`Add this feed to your feeds`)} 336 + label={l`Add this feed to your feeds`} 339 337 size="small" 340 338 variant="solid" 341 339 color={savedFeedConfig ? 'secondary' : 'primary'} 342 - onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave} 340 + onPress={(e: GestureResponderEvent) => 341 + savedFeedConfig ? onPromptRemoveFeed(e) : void toggleSave(e) 342 + } 343 343 {...buttonProps}> 344 344 {savedFeedConfig ? ( 345 345 <> ··· 350 350 )} 351 351 {text && ( 352 352 <ButtonText> 353 - <Trans>Unpin Feed</Trans> 353 + <Trans>Unpin feed</Trans> 354 354 </ButtonText> 355 355 )} 356 356 </> ··· 359 359 <ButtonIcon size="md" icon={isPending ? Loader : PinIcon} /> 360 360 {text && ( 361 361 <ButtonText> 362 - <Trans>Pin Feed</Trans> 362 + <Trans>Pin feed</Trans> 363 363 </ButtonText> 364 364 )} 365 365 </> ··· 368 368 369 369 <Prompt.Basic 370 370 control={removePromptControl} 371 - title={_(msg`Remove from your feeds?`)} 372 - description={_( 373 - msg`Are you sure you want to remove this from your feeds?`, 374 - )} 375 - onConfirm={toggleSave} 376 - confirmButtonCta={_(msg`Remove`)} 371 + title={l`Remove from your feeds?`} 372 + description={l`Are you sure you want to remove this from your feeds?`} 373 + onConfirm={(e: GestureResponderEvent) => void toggleSave(e)} 374 + confirmButtonCta={l`Remove`} 377 375 confirmButtonColor="negative" 378 376 /> 379 377 </>
+42 -46
src/components/ProfileCard.tsx
··· 11 11 type ModerationOpts, 12 12 RichText as RichTextApi, 13 13 } from '@atproto/api' 14 - import {msg} from '@lingui/core/macro' 15 - import {useLingui} from '@lingui/react' 14 + import {useLingui} from '@lingui/react/macro' 16 15 17 16 import {getModerationCauseKey} from '#/lib/moderation' 18 17 import {forceLTR} from '#/lib/strings/bidi' ··· 56 55 testID, 57 56 position, 58 57 contextProfileDid, 58 + onPress, 59 59 }: { 60 60 profile: bsky.profile.AnyProfileView 61 61 moderationOpts: ModerationOpts ··· 63 63 testID?: string 64 64 position?: number 65 65 contextProfileDid?: string 66 + onPress?: (e: GestureResponderEvent) => void 66 67 }) { 67 68 return ( 68 - <Link testID={testID} profile={profile}> 69 + <Link testID={testID} profile={profile} onPress={onPress}> 69 70 <Card 70 71 profile={profile} 71 72 moderationOpts={moderationOpts} ··· 135 136 }: { 136 137 profile: bsky.profile.AnyProfileView 137 138 } & Omit<LinkProps, 'to' | 'label'>) { 138 - const {_} = useLingui() 139 + const {t: l} = useLingui() 140 + 139 141 return ( 140 142 <InternalLink 141 - label={_( 142 - msg`View ${ 143 - profile.displayName || sanitizeHandle(profile.handle) 144 - }'s profile`, 145 - )} 143 + label={l`View ${ 144 + profile.displayName || sanitizeHandle(profile.handle) 145 + }’s profile`} 146 146 to={{ 147 147 screen: 'Profile', 148 148 params: {name: profile.did}, ··· 488 488 contextProfileDid, 489 489 ...rest 490 490 }: FollowButtonProps) { 491 - const {_} = useLingui() 491 + const {t: l} = useLingui() 492 492 const profile = useProfileShadow(profileUnshadowed) 493 493 const moderation = moderateProfile(profile, moderationOpts) 494 494 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( ··· 505 505 try { 506 506 await queueFollow() 507 507 Toast.show( 508 - _( 509 - msg`Following ${sanitizeDisplayName( 510 - profile.displayName || profile.handle, 511 - moderation.ui('displayName'), 512 - )}`, 513 - ), 508 + l`Following ${sanitizeDisplayName( 509 + profile.displayName || profile.handle, 510 + moderation.ui('displayName'), 511 + )}`, 514 512 ) 515 513 onPressProp?.(e) 516 514 onFollow?.() 517 - } catch (err: any) { 515 + } catch (e) { 516 + const err = e as Error 518 517 if (err?.name !== 'AbortError') { 519 - Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 518 + Toast.show(l`An issue occurred, please try again.`, 'xmark') 520 519 } 521 520 } 522 521 } ··· 527 526 try { 528 527 await queueUnfollow() 529 528 Toast.show( 530 - _( 531 - msg`No longer following ${sanitizeDisplayName( 532 - profile.displayName || profile.handle, 533 - moderation.ui('displayName'), 534 - )}`, 535 - ), 529 + l`No longer following ${sanitizeDisplayName( 530 + profile.displayName || profile.handle, 531 + moderation.ui('displayName'), 532 + )}`, 536 533 ) 537 534 onPressProp?.(e) 538 - } catch (err: any) { 535 + } catch (e) { 536 + const err = e as Error 539 537 if (err?.name !== 'AbortError') { 540 - Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') 538 + Toast.show(l`An issue occurred, please try again.`, 'xmark') 541 539 } 542 540 } 543 541 } 544 542 545 - const unfollowLabel = _( 546 - msg({ 547 - message: 'Following', 548 - comment: 'User is following this account, click to unfollow', 549 - }), 550 - ) 543 + const unfollowLabel = l({ 544 + message: 'Following', 545 + comment: 'User is following this account, click to unfollow', 546 + }) 551 547 const followLabel = profile.viewer?.followedBy 552 - ? _( 553 - msg({ 554 - message: 'Follow back', 555 - comment: 'User is not following this account, click to follow back', 556 - }), 557 - ) 558 - : _( 559 - msg({ 560 - message: 'Follow', 561 - comment: 'User is not following this account, click to follow', 562 - }), 563 - ) 548 + ? l({ 549 + message: 'Follow back', 550 + comment: 'User is not following this account, click to follow back', 551 + }) 552 + : l({ 553 + message: 'Follow', 554 + comment: 'User is not following this account, click to follow', 555 + }) 564 556 565 557 if (!profile.viewer) return null 566 558 if ( ··· 579 571 variant="solid" 580 572 color="secondary" 581 573 {...rest} 582 - onPress={onPressUnfollow}> 574 + onPress={(e: GestureResponderEvent) => { 575 + void onPressUnfollow(e) 576 + }}> 583 577 {withIcon && ( 584 578 <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 585 579 )} ··· 592 586 variant="solid" 593 587 color={colorInverted ? 'secondary_inverted' : 'primary'} 594 588 {...rest} 595 - onPress={onPressFollow}> 589 + onPress={(e: GestureResponderEvent) => { 590 + void onPressFollow(e) 591 + }}> 596 592 {withIcon && ( 597 593 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 598 594 )}
+7 -8
src/components/forms/SearchInput.tsx
··· 1 - import React from 'react' 1 + import {forwardRef} from 'react' 2 2 import {type TextInput, View} from 'react-native' 3 - import {msg} from '@lingui/core/macro' 4 - import {useLingui} from '@lingui/react' 3 + import {useLingui} from '@lingui/react/macro' 5 4 6 5 import {HITSLOP_10} from '#/lib/constants' 7 6 import {atoms as a, useTheme} from '#/alf' ··· 19 18 onClearText?: () => void 20 19 } 21 20 22 - export const SearchInput = React.forwardRef<TextInput, SearchInputProps>( 21 + export const SearchInput = forwardRef<TextInput, SearchInputProps>( 23 22 function SearchInput({value, label, onClearText, ...rest}, ref) { 24 23 const t = useTheme() 25 - const {_} = useLingui() 24 + const {t: l} = useLingui() 26 25 const showClear = value && value.length > 0 27 26 28 27 return ( ··· 31 30 <TextField.Icon icon={MagnifyingGlassIcon} /> 32 31 <TextField.Input 33 32 inputRef={ref} 34 - label={label || _(msg`Search`)} 33 + label={label || l`Search`} 35 34 value={value} 36 - placeholder={_(msg`Search`)} 35 + placeholder={l`Search`} 37 36 returnKeyType="search" 38 37 keyboardAppearance={t.scheme} 39 38 selectTextOnFocus={IS_NATIVE} ··· 67 66 <Button 68 67 testID="searchTextInputClearBtn" 69 68 onPress={onClearText} 70 - label={_(msg`Clear search query`)} 69 + label={l`Clear search query`} 71 70 hitSlop={HITSLOP_10} 72 71 size="tiny" 73 72 shape="round"
+136 -11
src/screens/Search/SearchResults.tsx
··· 5 5 6 6 import {urls} from '#/lib/constants' 7 7 import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 8 + import {useCallOnce} from '#/lib/once' 8 9 import {cleanError} from '#/lib/strings/errors' 9 10 import {augmentSearchQuery} from '#/lib/strings/helpers' 10 11 import {useActorSearch} from '#/state/queries/actor-search' ··· 25 26 import {ListFooter} from '#/components/Lists' 26 27 import {SearchError} from '#/components/SearchError' 27 28 import {Text} from '#/components/Typography' 29 + import {type Metrics, useAnalytics} from '#/analytics' 28 30 import type * as bsky from '#/types/bsky' 29 31 30 32 let SearchResults = ({ ··· 161 163 ) 162 164 } 163 165 164 - function NoResultsText({query}: {query: string}) { 166 + function NoResultsText({ 167 + query, 168 + }: { 169 + sort?: 'top' | 'latest' | 'people' | 'feeds' 170 + query: string 171 + }) { 165 172 const t = useTheme() 166 173 const {t: l} = useLingui() 167 174 ··· 185 192 })} 186 193 to={urls.website.blog.searchTipsAndTricks} 187 194 style={[a.text_md, a.leading_snug]}> 188 - read about how to use search filters 195 + read about how to use search filters. 189 196 </InlineLinkText> 190 197 . 191 198 </Trans> ··· 214 221 sort?: 'top' | 'latest' 215 222 active: boolean 216 223 }): React.ReactNode => { 224 + const ax = useAnalytics() 217 225 const {t: l} = useLingui() 218 226 const {currentAccount, hasSession} = useSession() 219 227 const [isPTR, setIsPTR] = useState(false) ··· 277 285 const closeAllActiveElements = useCloseAllActiveElements() 278 286 const {requestSwitchToAccount} = useLoggedOutViewControls() 279 287 288 + const fireTracking = useCallOnce(() => { 289 + if (sort) { 290 + // ts only 291 + ax.metric('search:results:loaded', { 292 + tab: sort, 293 + initialCount: items.length, 294 + }) 295 + } 296 + }) 297 + if (isFetched && sort) { 298 + fireTracking() 299 + } 300 + 280 301 const showSignIn = () => { 281 302 closeAllActiveElements() 282 303 requestSwitchToAccount({requestedAccount: 'none'}) ··· 292 313 <SearchError title={l`Search is currently unavailable when logged out`}> 293 314 <Text style={[a.text_md, a.text_center, a.leading_snug]}> 294 315 <Trans> 295 - <InlineLinkText label={l`Sign in`} to={'#'} onPress={showSignIn}> 316 + <InlineLinkText label={l`Sign in`} to="#" onPress={showSignIn}> 296 317 Sign in 297 318 </InlineLinkText> 298 319 <Text style={t.atoms.text_contrast_medium}> or </Text> ··· 315 336 316 337 return error ? ( 317 338 <EmptyState 318 - messageText={l`We're sorry, but your search could not be completed. Please try again in a few minutes.`} 339 + messageText={l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`} 319 340 error={cleanError(error)} 320 341 /> 321 342 ) : ( ··· 325 346 {posts.length ? ( 326 347 <List 327 348 data={items} 328 - renderItem={({item}: {item: SearchResultSlice}) => { 349 + renderItem={({ 350 + item, 351 + index, 352 + }: { 353 + item: SearchResultSlice 354 + index: number 355 + }) => { 329 356 if (item.type === 'post') { 330 - return <Post post={item.post} /> 357 + return ( 358 + <SearchPost from={sort} position={index} post={item.post} /> 359 + ) 331 360 } else { 332 361 return null 333 362 } ··· 363 392 } 364 393 SearchScreenPostResults = memo(SearchScreenPostResults) 365 394 395 + function SearchPost({ 396 + from, 397 + position, 398 + post, 399 + }: { 400 + from: Metrics['search:result:press']['tab'] 401 + position: Metrics['search:result:press']['position'] 402 + post: AppBskyFeedDefs.PostView 403 + }) { 404 + const ax = useAnalytics() 405 + 406 + const onBeforePress = useCallback(() => { 407 + ax.metric('search:result:press', { 408 + tab: from, 409 + resultType: 'post', 410 + position, 411 + uri: post.uri, 412 + }) 413 + }, [ax, from, position, post]) 414 + 415 + return <Post post={post} onBeforePress={onBeforePress} /> 416 + } 417 + 366 418 let SearchScreenUserResults = ({ 367 419 query, 368 420 active, ··· 370 422 query: string 371 423 active: boolean 372 424 }): React.ReactNode => { 425 + const ax = useAnalytics() 373 426 const {t: l} = useLingui() 374 427 const {hasSession} = useSession() 375 428 const [isPTR, setIsPTR] = useState(false) ··· 403 456 return results?.pages.flatMap(page => page.actors) || [] 404 457 }, [results]) 405 458 459 + const fireTracking = useCallOnce(() => { 460 + ax.metric('search:results:loaded', { 461 + tab: 'people', 462 + initialCount: profiles.length, 463 + }) 464 + }) 465 + if (isFetched) { 466 + fireTracking() 467 + } 468 + 406 469 if (error) { 407 470 return ( 408 471 <EmptyState ··· 417 480 {profiles.length ? ( 418 481 <List 419 482 data={profiles} 420 - renderItem={({item}: {item: bsky.profile.AnyProfileView}) => ( 421 - <ProfileCardWithFollowBtn profile={item} /> 422 - )} 483 + renderItem={({ 484 + item, 485 + index, 486 + }: { 487 + item: bsky.profile.AnyProfileView 488 + index: number 489 + }) => <SearchScreenProfileButton position={index} profile={item} />} 423 490 keyExtractor={(item: bsky.profile.AnyProfileView) => item.did} 424 491 refreshing={isPTR} 425 492 onRefresh={() => void onPullToRefresh()} ··· 442 509 } 443 510 SearchScreenUserResults = memo(SearchScreenUserResults) 444 511 512 + function SearchScreenProfileButton({ 513 + position, 514 + profile, 515 + }: { 516 + position: number 517 + profile: bsky.profile.AnyProfileView 518 + }) { 519 + const ax = useAnalytics() 520 + 521 + const handlePress = () => { 522 + ax.metric('search:result:press', { 523 + tab: 'people', 524 + resultType: 'profile', 525 + position, 526 + uri: profile.did, 527 + }) 528 + } 529 + return <ProfileCardWithFollowBtn profile={profile} onPress={handlePress} /> 530 + } 531 + 445 532 let SearchScreenFeedsResults = ({ 446 533 query, 447 534 active, ··· 449 536 query: string 450 537 active: boolean 451 538 }): React.ReactNode => { 539 + const ax = useAnalytics() 452 540 const t = useTheme() 453 541 454 542 const {data: results, isFetched} = usePopularFeedsSearch({ ··· 456 544 enabled: active, 457 545 }) 458 546 547 + const fireTracking = useCallOnce(() => { 548 + ax.metric('search:results:loaded', { 549 + tab: 'feeds', 550 + initialCount: results?.length ?? 0, 551 + }) 552 + }) 553 + if (isFetched) { 554 + fireTracking() 555 + } 556 + 459 557 return isFetched && results ? ( 460 558 <> 461 559 {results.length ? ( 462 560 <List 463 561 data={results} 464 - renderItem={({item}: {item: AppBskyFeedDefs.GeneratorView}) => ( 562 + renderItem={({ 563 + item, 564 + index, 565 + }: { 566 + item: AppBskyFeedDefs.GeneratorView 567 + index: number 568 + }) => ( 465 569 <View 466 570 style={[ 467 571 a.border_t, ··· 469 573 a.px_lg, 470 574 a.py_lg, 471 575 ]}> 472 - <FeedCard.Default view={item} /> 576 + <SearchFeedCard position={index} view={item} /> 473 577 </View> 474 578 )} 475 579 keyExtractor={(item: AppBskyFeedDefs.GeneratorView) => item.uri} ··· 485 589 ) 486 590 } 487 591 SearchScreenFeedsResults = memo(SearchScreenFeedsResults) 592 + 593 + function SearchFeedCard({ 594 + position, 595 + view, 596 + }: { 597 + position: number 598 + view: AppBskyFeedDefs.GeneratorView 599 + }) { 600 + const ax = useAnalytics() 601 + 602 + const handleOnPress = () => { 603 + ax.metric('search:result:press', { 604 + tab: 'feeds', 605 + resultType: 'feed', 606 + position, 607 + uri: view.uri, 608 + }) 609 + } 610 + 611 + return <FeedCard.Default view={view} onPress={handleOnPress} /> 612 + }
+13 -5
src/screens/Search/Shell.tsx
··· 38 38 import {SearchInput} from '#/components/forms/SearchInput' 39 39 import * as Layout from '#/components/Layout' 40 40 import {Text} from '#/components/Typography' 41 + import {useAnalytics} from '#/analytics' 41 42 import {IS_WEB} from '#/env' 42 43 import {account, useStorage} from '#/storage' 43 44 import type * as bsky from '#/types/bsky' ··· 79 80 inputPlaceholder?: string 80 81 isExplore?: boolean 81 82 }) { 83 + const ax = useAnalytics() 82 84 const t = useTheme() 83 85 const {gtMobile} = useBreakpoints() 84 86 const navigation = useNavigation<NavigationProp>() ··· 225 227 } 226 228 }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) 227 229 228 - const onSubmit = useCallback(() => { 229 - navigateToItem(searchText) 230 - }, [navigateToItem, searchText]) 230 + const onSubmit = useCallback( 231 + (source: 'typed' | 'autocomplete') => () => { 232 + ax.metric('search:query', { 233 + source, 234 + }) 235 + navigateToItem(searchText) 236 + }, 237 + [ax, navigateToItem, searchText], 238 + ) 231 239 232 240 const onAutocompleteResultPress = useCallback(() => { 233 241 if (IS_WEB) { ··· 367 375 onFocus={onSearchInputFocus} 368 376 onChangeText={onChangeText} 369 377 onClearText={onPressClearQuery} 370 - onSubmitEditing={onSubmit} 378 + onSubmitEditing={onSubmit('typed')} 371 379 placeholder={ 372 380 inputPlaceholder ?? l`Search for posts, users, or feeds` 373 381 } ··· 420 428 isAutocompleteFetching={isAutocompleteFetching} 421 429 autocompleteData={autocompleteData} 422 430 searchText={searchText} 423 - onSubmit={onSubmit} 431 + onSubmit={onSubmit('autocomplete')} 424 432 onResultPress={onAutocompleteResultPress} 425 433 onProfileClick={handleProfileClick} 426 434 />
+7 -1
src/screens/Search/components/AutocompleteResults.tsx
··· 9 9 import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard' 10 10 import {atoms as a, native} from '#/alf' 11 11 import * as Layout from '#/components/Layout' 12 + import {useAnalytics} from '#/analytics' 12 13 import {IS_NATIVE} from '#/env' 13 14 14 15 let AutocompleteResults = ({ ··· 26 27 onResultPress: () => void 27 28 onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void 28 29 }): React.ReactNode => { 30 + const ax = useAnalytics() 29 31 const {_} = useLingui() 30 32 const moderationOpts = useModerationOpts() 31 33 return ( ··· 51 53 } 52 54 style={a.border_b} 53 55 /> 54 - {autocompleteData?.map(item => ( 56 + {autocompleteData?.map((item, index) => ( 55 57 <SearchProfileCard 56 58 key={item.did} 57 59 profile={item} 58 60 moderationOpts={moderationOpts} 59 61 onPress={() => { 62 + ax.metric('search:autocomplete:press', { 63 + profileDid: item.did, 64 + position: index, 65 + }) 60 66 onProfileClick(item) 61 67 onResultPress() 62 68 }}
+22 -11
src/screens/Search/components/SearchHistory.tsx
··· 1 1 import {Pressable, ScrollView, View} from 'react-native' 2 2 import {moderateProfile, type ModerationOpts} from '@atproto/api' 3 - import {msg} from '@lingui/core/macro' 4 - import {useLingui} from '@lingui/react' 5 - import {Trans} from '@lingui/react/macro' 3 + import {Trans, useLingui} from '@lingui/react/macro' 6 4 7 5 import {createHitslop, HITSLOP_10} from '#/lib/constants' 8 6 import {makeProfileLink} from '#/lib/routes/links' ··· 19 17 import {Text} from '#/components/Typography' 20 18 import {useSimpleVerificationState} from '#/components/verification' 21 19 import {VerificationCheck} from '#/components/verification/VerificationCheck' 20 + import {useAnalytics} from '#/analytics' 22 21 import type * as bsky from '#/types/bsky' 23 22 24 23 export function SearchHistory({ ··· 36 35 onRemoveItemClick: (item: string) => void 37 36 onRemoveProfileClick: (profile: bsky.profile.AnyProfileView) => void 38 37 }) { 39 - const {_} = useLingui() 38 + const ax = useAnalytics() 39 + const {t: l} = useLingui() 40 40 const moderationOpts = useModerationOpts() 41 41 42 42 return ( ··· 47 47 {(searchHistory.length > 0 || selectedProfiles.length > 0) && ( 48 48 <View style={[a.px_lg, a.pt_sm]}> 49 49 <Text style={[a.text_md, a.font_semi_bold]}> 50 - <Trans>Recent Searches</Trans> 50 + <Trans>Recent searches</Trans> 51 51 </Text> 52 52 </View> 53 53 )} ··· 66 66 a.gap_xl, 67 67 ]}> 68 68 {moderationOpts && 69 - selectedProfiles.map(profile => ( 69 + selectedProfiles.map((profile, index) => ( 70 70 <RecentProfileItem 71 71 key={profile.did} 72 72 profile={profile} 73 73 moderationOpts={moderationOpts} 74 - onPress={() => onProfileClick(profile)} 74 + onPress={() => { 75 + ax.metric('search:recent:press', { 76 + profileDid: profile.did, 77 + position: index, 78 + }) 79 + onProfileClick(profile) 80 + }} 75 81 onRemove={() => onRemoveProfileClick(profile)} 76 82 /> 77 83 ))} ··· 86 92 <View key={index} style={[a.flex_row, a.align_center]}> 87 93 <Pressable 88 94 accessibilityRole="button" 89 - onPress={() => onItemClick(historyItem)} 95 + onPress={() => { 96 + ax.metric('search:query', { 97 + source: 'history', 98 + }) 99 + onItemClick(historyItem) 100 + }} 90 101 hitSlop={HITSLOP_10} 91 102 style={[a.flex_1, a.py_sm]}> 92 103 <Text style={[a.text_md]}>{historyItem}</Text> 93 104 </Pressable> 94 105 <Button 95 - label={_(msg`Remove ${historyItem}`)} 106 + label={l`Remove ${historyItem}`} 96 107 onPress={() => onRemoveItemClick(historyItem)} 97 108 size="small" 98 109 variant="ghost" ··· 120 131 onPress: () => void 121 132 onRemove: () => void 122 133 }) { 123 - const {_} = useLingui() 134 + const {t: l} = useLingui() 124 135 const width = 80 125 136 126 137 const moderation = moderateProfile(profile, moderationOpts) ··· 165 176 </View> 166 177 </Link> 167 178 <Button 168 - label={_(msg`Remove profile`)} 179 + label={l`Remove profile`} 169 180 hitSlop={createHitslop(6)} 170 181 size="tiny" 171 182 variant="outline"
+4 -1
src/view/com/profile/ProfileCard.tsx
··· 1 - import {View} from 'react-native' 1 + import {type GestureResponderEvent, View} from 'react-native' 2 2 3 3 import {useModerationOpts} from '#/state/preferences/moderation-opts' 4 4 import {atoms as a, useTheme} from '#/alf' ··· 11 11 logContext = 'ProfileCard', 12 12 position, 13 13 contextProfileDid, 14 + onPress, 14 15 }: { 15 16 profile: bsky.profile.AnyProfileView 16 17 noBorder?: boolean 17 18 logContext?: 'ProfileCard' | 'StarterPackProfilesList' 18 19 position?: number 19 20 contextProfileDid?: string 21 + onPress?: (e: GestureResponderEvent) => void 20 22 }) { 21 23 const t = useTheme() 22 24 const moderationOpts = useModerationOpts() ··· 36 38 logContext={logContext} 37 39 position={position} 38 40 contextProfileDid={contextProfileDid} 41 + onPress={onPress} 39 42 /> 40 43 </View> 41 44 )
+11 -12
src/view/shell/desktop/Search.tsx
··· 1 - import React from 'react' 1 + import {memo, useCallback, useState} from 'react' 2 2 import { 3 3 ActivityIndicator, 4 4 StyleSheet, ··· 6 6 View, 7 7 type ViewStyle, 8 8 } from 'react-native' 9 - import {msg} from '@lingui/core/macro' 10 - import {useLingui} from '@lingui/react' 9 + import {useLingui} from '@lingui/react/macro' 11 10 import {StackActions, useNavigation} from '@react-navigation/native' 12 11 13 12 import {usePalette} from '#/lib/hooks/usePalette' ··· 68 67 </Link> 69 68 ) 70 69 } 71 - SearchLinkCard = React.memo(SearchLinkCard) 70 + SearchLinkCard = memo(SearchLinkCard) 72 71 export {SearchLinkCard} 73 72 74 73 export function DesktopSearch() { 75 - const {_} = useLingui() 74 + const {t: l} = useLingui() 76 75 const pal = usePalette('default') 77 76 const navigation = useNavigation<NavigationProp>() 78 - const [isActive, setIsActive] = React.useState<boolean>(false) 79 - const [query, setQuery] = React.useState<string>('') 77 + const [isActive, setIsActive] = useState<boolean>(false) 78 + const [query, setQuery] = useState<string>('') 80 79 const {data: autocompleteData, isFetching} = useActorAutocompleteQuery( 81 80 query, 82 81 true, ··· 84 83 85 84 const moderationOpts = useModerationOpts() 86 85 87 - const onChangeText = React.useCallback((text: string) => { 86 + const onChangeText = useCallback((text: string) => { 88 87 setQuery(text) 89 88 setIsActive(text.length > 0) 90 89 }, []) 91 90 92 - const onPressCancelSearch = React.useCallback(() => { 91 + const onPressCancelSearch = useCallback(() => { 93 92 setQuery('') 94 93 setIsActive(false) 95 94 }, [setQuery]) 96 95 97 - const onSubmit = React.useCallback(() => { 96 + const onSubmit = useCallback(() => { 98 97 setIsActive(false) 99 98 if (!query.length) return 100 99 navigation.dispatch(StackActions.push('Search', {q: query})) 101 100 }, [query, navigation]) 102 101 103 - const onSearchProfileCardPress = React.useCallback(() => { 102 + const onSearchProfileCardPress = useCallback(() => { 104 103 setQuery('') 105 104 setIsActive(false) 106 105 }, []) ··· 128 127 ) : ( 129 128 <> 130 129 <SearchLinkCard 131 - label={_(msg`Search for "${query}"`)} 130 + label={l`Search for "${query}"`} 132 131 to={`/search?q=${encodeURIComponent(query)}`} 133 132 style={ 134 133 (autocompleteData?.length ?? 0) > 0