Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

New progress guide - 10 follows (#7128)

* new follow-10 progress guide

* find follows dialog

* wip tabs

* flatlist version with search

* hardcode out jake gold

* lazy load followup suggestions

* Update src/components/ProgressGuide/FollowDialog.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* comment out replacing, enable paging

* rm autofocus

* find shadow profiles in paginated search

* clear search when press tabs

* better tab a11y

* fix label

* adjust scroll indicator insets

* do the same scroll indicator adjustment for searchable people list

* hardcode jake to just be 'tech'

* Retain state on close/reopen

* only change follow btn color when not followed

* add guide to inside dialog

* fix task alignment

* Enable contextual suggestions

* WIP: show multiple suggestions

* Rework so it animates well

* Show more items

* remove card style

* move tabs to own component

* split out header top

* scroll active tab into view

* rm log

* Improve perf a bit

* boost popular interests over alphabetical ones

* scroll active tab into view

* revert back to round buttons

* Fix overrenders of the tab bar items

* Fix unintended animation

* Scroll initial into view if needed

* Unlift state, the dialog thing breaks lifting

* Persist simply

* Fix empty state

* Fix incorrect gate exposure

* Fix another bad useGate

* Nit

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by

Samuel Newman
surfdude29
Dan Abramov
and committed by
GitHub
0cbb03cd 32611391

+1039 -47
+12
src/alf/atoms.ts
··· 302 302 border_0: { 303 303 borderWidth: 0, 304 304 }, 305 + border_t_0: { 306 + borderTopWidth: 0, 307 + }, 308 + border_b_0: { 309 + borderBottomWidth: 0, 310 + }, 311 + border_l_0: { 312 + borderLeftWidth: 0, 313 + }, 314 + border_r_0: { 315 + borderRightWidth: 0, 316 + }, 305 317 border: { 306 318 borderWidth: StyleSheet.hairlineWidth, 307 319 },
+1 -1
src/components/FeedInterstitials.tsx
··· 280 280 profile={profile} 281 281 moderationOpts={moderationOpts} 282 282 logContext="FeedInterstitial" 283 - color="secondary_inverted" 284 283 shape="round" 284 + colorInverted 285 285 /> 286 286 </ProfileCard.Header> 287 287 <ProfileCard.Description profile={profile} numberOfLines={2} />
+6 -1
src/components/ProfileCard.tsx
··· 285 285 moderationOpts: ModerationOpts 286 286 logContext: LogEvents['profile:follow']['logContext'] & 287 287 LogEvents['profile:unfollow']['logContext'] 288 + colorInverted?: boolean 288 289 } & Partial<ButtonProps> 289 290 290 291 export function FollowButton(props: FollowButtonProps) { ··· 297 298 profile: profileUnshadowed, 298 299 moderationOpts, 299 300 logContext, 301 + onPress: onPressProp, 302 + colorInverted, 300 303 ...rest 301 304 }: FollowButtonProps) { 302 305 const {_} = useLingui() ··· 321 324 )}`, 322 325 ), 323 326 ) 327 + onPressProp?.(e) 324 328 } catch (err: any) { 325 329 if (err?.name !== 'AbortError') { 326 330 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') ··· 341 345 )}`, 342 346 ), 343 347 ) 348 + onPressProp?.(e) 344 349 } catch (err: any) { 345 350 if (err?.name !== 'AbortError') { 346 351 Toast.show(_(msg`An issue occurred, please try again.`), 'xmark') ··· 387 392 label={followLabel} 388 393 size="small" 389 394 variant="solid" 390 - color="primary" 395 + color={colorInverted ? 'secondary_inverted' : 'primary'} 391 396 {...rest} 392 397 onPress={onPressFollow}> 393 398 <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
+829
src/components/ProgressGuide/FollowDialog.tsx
··· 1 + import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 + import {ScrollView, TextInput, useWindowDimensions, View} from 'react-native' 3 + import Animated, { 4 + LayoutAnimationConfig, 5 + LinearTransition, 6 + ZoomInEasyDown, 7 + } from 'react-native-reanimated' 8 + import {AppBskyActorDefs, ModerationOpts} from '@atproto/api' 9 + import {msg, Trans} from '@lingui/macro' 10 + import {useLingui} from '@lingui/react' 11 + 12 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 13 + import {cleanError} from '#/lib/strings/errors' 14 + import {logger} from '#/logger' 15 + import {isWeb} from '#/platform/detection' 16 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 17 + import {useActorSearchPaginated} from '#/state/queries/actor-search' 18 + import {usePreferencesQuery} from '#/state/queries/preferences' 19 + import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 20 + import {useSession} from '#/state/session' 21 + import {Follow10ProgressGuide} from '#/state/shell/progress-guide' 22 + import {ListMethods} from '#/view/com/util/List' 23 + import { 24 + popularInterests, 25 + useInterestsDisplayNames, 26 + } from '#/screens/Onboarding/state' 27 + import { 28 + atoms as a, 29 + native, 30 + tokens, 31 + useBreakpoints, 32 + useTheme, 33 + ViewStyleProp, 34 + web, 35 + } from '#/alf' 36 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 37 + import * as Dialog from '#/components/Dialog' 38 + import {useInteractionState} from '#/components/hooks/useInteractionState' 39 + import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' 40 + import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 41 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 42 + import * as ProfileCard from '#/components/ProfileCard' 43 + import {Text} from '#/components/Typography' 44 + import {ListFooter} from '../Lists' 45 + import {ProgressGuideTask} from './Task' 46 + 47 + type Item = 48 + | { 49 + type: 'profile' 50 + key: string 51 + profile: AppBskyActorDefs.ProfileView 52 + isSuggestion: boolean 53 + } 54 + | { 55 + type: 'empty' 56 + key: string 57 + message: string 58 + } 59 + | { 60 + type: 'placeholder' 61 + key: string 62 + } 63 + | { 64 + type: 'error' 65 + key: string 66 + } 67 + 68 + export function FollowDialog({guide}: {guide: Follow10ProgressGuide}) { 69 + const {_} = useLingui() 70 + const control = Dialog.useDialogControl() 71 + const {gtMobile} = useBreakpoints() 72 + const {height: minHeight} = useWindowDimensions() 73 + 74 + return ( 75 + <> 76 + <Button 77 + label={_(msg`Find people to follow`)} 78 + onPress={control.open} 79 + size={gtMobile ? 'small' : 'large'} 80 + color="primary" 81 + variant="solid"> 82 + <ButtonIcon icon={PersonGroupIcon} /> 83 + <ButtonText> 84 + <Trans>Find people to follow</Trans> 85 + </ButtonText> 86 + </Button> 87 + <Dialog.Outer control={control} nativeOptions={{minHeight}}> 88 + <Dialog.Handle /> 89 + <DialogInner guide={guide} /> 90 + </Dialog.Outer> 91 + </> 92 + ) 93 + } 94 + 95 + // Fine to keep this top-level. 96 + let lastSelectedInterest = '' 97 + let lastSearchText = '' 98 + 99 + function DialogInner({guide}: {guide: Follow10ProgressGuide}) { 100 + const {_} = useLingui() 101 + const interestsDisplayNames = useInterestsDisplayNames() 102 + const {data: preferences} = usePreferencesQuery() 103 + const personalizedInterests = preferences?.interests?.tags 104 + const interests = Object.keys(interestsDisplayNames) 105 + .sort(boostInterests(popularInterests)) 106 + .sort(boostInterests(personalizedInterests)) 107 + const [selectedInterest, setSelectedInterest] = useState( 108 + () => 109 + lastSelectedInterest || 110 + (personalizedInterests && interests.includes(personalizedInterests[0]) 111 + ? personalizedInterests[0] 112 + : interests[0]), 113 + ) 114 + const [searchText, setSearchText] = useState(lastSearchText) 115 + const moderationOpts = useModerationOpts() 116 + const listRef = useRef<ListMethods>(null) 117 + const inputRef = useRef<TextInput>(null) 118 + const [headerHeight, setHeaderHeight] = useState(0) 119 + const {currentAccount} = useSession() 120 + const [suggestedAccounts, setSuggestedAccounts] = useState< 121 + Map<string, AppBskyActorDefs.ProfileView[]> 122 + >(() => new Map()) 123 + 124 + useEffect(() => { 125 + lastSearchText = searchText 126 + lastSelectedInterest = selectedInterest 127 + }, [searchText, selectedInterest]) 128 + 129 + const query = searchText || selectedInterest 130 + const { 131 + data: searchResults, 132 + isFetching, 133 + error, 134 + isError, 135 + hasNextPage, 136 + isFetchingNextPage, 137 + fetchNextPage, 138 + } = useActorSearchPaginated({ 139 + query, 140 + }) 141 + 142 + const hasSearchText = !!searchText 143 + 144 + const items = useMemo(() => { 145 + const results = searchResults?.pages.flatMap(r => r.actors) 146 + let _items: Item[] = [] 147 + const seen = new Set<string>() 148 + 149 + if (isError) { 150 + _items.push({ 151 + type: 'empty', 152 + key: 'empty', 153 + message: _(msg`We're having network issues, try again`), 154 + }) 155 + } else if (results) { 156 + // First pass: search results 157 + for (const profile of results) { 158 + if (profile.did === currentAccount?.did) continue 159 + if (profile.viewer?.following) continue 160 + // my sincere apologies to Jake Gold - your bio is too keyword-filled and 161 + // your page-rank too high, so you're at the top of half the categories -sfn 162 + if ( 163 + !hasSearchText && 164 + profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' && 165 + // constrain to 'tech' 166 + selectedInterest !== 'tech' 167 + ) { 168 + continue 169 + } 170 + seen.add(profile.did) 171 + _items.push({ 172 + type: 'profile', 173 + // Don't share identity across tabs or typing attempts 174 + key: query + ':' + profile.did, 175 + profile, 176 + isSuggestion: false, 177 + }) 178 + } 179 + // Second pass: suggestions 180 + _items = _items.flatMap(item => { 181 + if (item.type !== 'profile') { 182 + return item 183 + } 184 + const suggestions = suggestedAccounts.get(item.profile.did) 185 + if (!suggestions) { 186 + return item 187 + } 188 + const itemWithSuggestions = [item] 189 + for (const suggested of suggestions) { 190 + if (seen.has(suggested.did)) { 191 + // Skip search results from previous step or already seen suggestions 192 + continue 193 + } 194 + seen.add(suggested.did) 195 + itemWithSuggestions.push({ 196 + type: 'profile', 197 + key: suggested.did, 198 + profile: suggested, 199 + isSuggestion: true, 200 + }) 201 + if (itemWithSuggestions.length === 1 + 3) { 202 + break 203 + } 204 + } 205 + return itemWithSuggestions 206 + }) 207 + } else { 208 + const placeholders: Item[] = Array(10) 209 + .fill(0) 210 + .map((__, i) => ({ 211 + type: 'placeholder', 212 + key: i + '', 213 + })) 214 + 215 + _items.push(...placeholders) 216 + } 217 + 218 + return _items 219 + }, [ 220 + _, 221 + searchResults, 222 + isError, 223 + currentAccount?.did, 224 + hasSearchText, 225 + selectedInterest, 226 + suggestedAccounts, 227 + query, 228 + ]) 229 + 230 + if (searchText && !isFetching && !items.length && !isError) { 231 + items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) 232 + } 233 + 234 + const renderItems = useCallback( 235 + ({item, index}: {item: Item; index: number}) => { 236 + switch (item.type) { 237 + case 'profile': { 238 + return ( 239 + <FollowProfileCard 240 + profile={item.profile} 241 + isSuggestion={item.isSuggestion} 242 + moderationOpts={moderationOpts!} 243 + setSuggestedAccounts={setSuggestedAccounts} 244 + noBorder={index === 0} 245 + /> 246 + ) 247 + } 248 + case 'placeholder': { 249 + return <ProfileCardSkeleton key={item.key} /> 250 + } 251 + case 'empty': { 252 + return <Empty key={item.key} message={item.message} /> 253 + } 254 + default: 255 + return null 256 + } 257 + }, 258 + [moderationOpts], 259 + ) 260 + 261 + const onSelectTab = useCallback( 262 + (interest: string) => { 263 + setSelectedInterest(interest) 264 + inputRef.current?.clear() 265 + setSearchText('') 266 + listRef.current?.scrollToOffset({ 267 + offset: 0, 268 + animated: false, 269 + }) 270 + }, 271 + [setSelectedInterest, setSearchText], 272 + ) 273 + 274 + const listHeader = ( 275 + <Header 276 + guide={guide} 277 + inputRef={inputRef} 278 + listRef={listRef} 279 + searchText={searchText} 280 + onSelectTab={onSelectTab} 281 + setHeaderHeight={setHeaderHeight} 282 + setSearchText={setSearchText} 283 + interests={interests} 284 + selectedInterest={selectedInterest} 285 + interestsDisplayNames={interestsDisplayNames} 286 + /> 287 + ) 288 + 289 + const onEndReached = useCallback(async () => { 290 + if (isFetchingNextPage || !hasNextPage || isError) return 291 + try { 292 + await fetchNextPage() 293 + } catch (err) { 294 + logger.error('Failed to load more people to follow', {message: err}) 295 + } 296 + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 297 + 298 + return ( 299 + <Dialog.InnerFlatList 300 + ref={listRef} 301 + data={items} 302 + renderItem={renderItems} 303 + ListHeaderComponent={listHeader} 304 + stickyHeaderIndices={[0]} 305 + keyExtractor={(item: Item) => item.key} 306 + style={[ 307 + a.px_0, 308 + web([a.py_0, {height: '100vh', maxHeight: 600}]), 309 + native({height: '100%'}), 310 + ]} 311 + webInnerContentContainerStyle={a.py_0} 312 + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 313 + keyboardDismissMode="on-drag" 314 + scrollIndicatorInsets={{top: headerHeight}} 315 + initialNumToRender={8} 316 + maxToRenderPerBatch={8} 317 + onEndReached={onEndReached} 318 + itemLayoutAnimation={LinearTransition} 319 + ListFooterComponent={ 320 + <ListFooter 321 + isFetchingNextPage={isFetchingNextPage} 322 + error={cleanError(error)} 323 + onRetry={fetchNextPage} 324 + /> 325 + } 326 + /> 327 + ) 328 + } 329 + 330 + let Header = ({ 331 + guide, 332 + inputRef, 333 + listRef, 334 + searchText, 335 + onSelectTab, 336 + setHeaderHeight, 337 + setSearchText, 338 + interests, 339 + selectedInterest, 340 + interestsDisplayNames, 341 + }: { 342 + guide: Follow10ProgressGuide 343 + inputRef: React.RefObject<TextInput> 344 + listRef: React.RefObject<ListMethods> 345 + onSelectTab: (v: string) => void 346 + searchText: string 347 + setHeaderHeight: (v: number) => void 348 + setSearchText: (v: string) => void 349 + interests: string[] 350 + selectedInterest: string 351 + interestsDisplayNames: Record<string, string> 352 + }): React.ReactNode => { 353 + const t = useTheme() 354 + const control = Dialog.useDialogContext() 355 + return ( 356 + <View 357 + onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} 358 + style={[ 359 + a.relative, 360 + web(a.pt_lg), 361 + native(a.pt_4xl), 362 + a.pb_xs, 363 + a.border_b, 364 + t.atoms.border_contrast_low, 365 + t.atoms.bg, 366 + ]}> 367 + <HeaderTop guide={guide} /> 368 + 369 + <View style={[web(a.pt_xs), a.pb_xs]}> 370 + <SearchInput 371 + inputRef={inputRef} 372 + defaultValue={searchText} 373 + onChangeText={text => { 374 + setSearchText(text) 375 + listRef.current?.scrollToOffset({offset: 0, animated: false}) 376 + }} 377 + onEscape={control.close} 378 + /> 379 + <Tabs 380 + onSelectTab={onSelectTab} 381 + interests={interests} 382 + selectedInterest={selectedInterest} 383 + hasSearchText={!!searchText} 384 + interestsDisplayNames={interestsDisplayNames} 385 + /> 386 + </View> 387 + </View> 388 + ) 389 + } 390 + Header = memo(Header) 391 + 392 + function HeaderTop({guide}: {guide: Follow10ProgressGuide}) { 393 + const {_} = useLingui() 394 + const t = useTheme() 395 + const control = Dialog.useDialogContext() 396 + return ( 397 + <View 398 + style={[ 399 + a.px_lg, 400 + a.relative, 401 + a.flex_row, 402 + a.justify_between, 403 + a.align_center, 404 + ]}> 405 + <Text 406 + style={[ 407 + a.z_10, 408 + a.text_lg, 409 + a.font_heavy, 410 + a.leading_tight, 411 + t.atoms.text_contrast_high, 412 + ]}> 413 + <Trans>Find people to follow</Trans> 414 + </Text> 415 + <View style={isWeb && {paddingRight: 36}}> 416 + <ProgressGuideTask 417 + current={guide.numFollows + 1} 418 + total={10 + 1} 419 + title={`${guide.numFollows} / 10`} 420 + tabularNumsTitle 421 + /> 422 + </View> 423 + {isWeb ? ( 424 + <Button 425 + label={_(msg`Close`)} 426 + size="small" 427 + shape="round" 428 + variant={isWeb ? 'ghost' : 'solid'} 429 + color="secondary" 430 + style={[ 431 + a.absolute, 432 + a.z_20, 433 + web({right: -4}), 434 + native({right: 0}), 435 + native({height: 32, width: 32, borderRadius: 16}), 436 + ]} 437 + onPress={() => control.close()}> 438 + <ButtonIcon icon={X} size="md" /> 439 + </Button> 440 + ) : null} 441 + </View> 442 + ) 443 + } 444 + 445 + let Tabs = ({ 446 + onSelectTab, 447 + interests, 448 + selectedInterest, 449 + hasSearchText, 450 + interestsDisplayNames, 451 + }: { 452 + onSelectTab: (tab: string) => void 453 + interests: string[] 454 + selectedInterest: string 455 + hasSearchText: boolean 456 + interestsDisplayNames: Record<string, string> 457 + }): React.ReactNode => { 458 + const listRef = useRef<ScrollView>(null) 459 + const [scrollX, setScrollX] = useState(0) 460 + const [totalWidth, setTotalWidth] = useState(0) 461 + const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) 462 + const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) 463 + 464 + const onInitialLayout = useNonReactiveCallback(() => { 465 + const index = interests.indexOf(selectedInterest) 466 + scrollIntoViewIfNeeded(index) 467 + }) 468 + 469 + useEffect(() => { 470 + if (tabOffsets) { 471 + onInitialLayout() 472 + } 473 + }, [tabOffsets, onInitialLayout]) 474 + 475 + function scrollIntoViewIfNeeded(index: number) { 476 + const btnLayout = tabOffsets[index] 477 + if (!btnLayout) return 478 + 479 + const viewportLeftEdge = scrollX 480 + const viewportRightEdge = scrollX + totalWidth 481 + const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x 482 + const shouldScrollToRightEdge = 483 + viewportRightEdge < btnLayout.x + btnLayout.width 484 + 485 + if (shouldScrollToLeftEdge) { 486 + listRef.current?.scrollTo({ 487 + x: btnLayout.x - tokens.space.lg, 488 + animated: true, 489 + }) 490 + } else if (shouldScrollToRightEdge) { 491 + listRef.current?.scrollTo({ 492 + x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg, 493 + animated: true, 494 + }) 495 + } 496 + } 497 + 498 + function handleSelectTab(index: number) { 499 + const tab = interests[index] 500 + onSelectTab(tab) 501 + scrollIntoViewIfNeeded(index) 502 + } 503 + 504 + function handleTabLayout(index: number, x: number, width: number) { 505 + if (!tabOffsets.length) { 506 + pendingTabOffsets.current[index] = {x, width} 507 + if (pendingTabOffsets.current.length === interests.length) { 508 + setTabOffsets(pendingTabOffsets.current) 509 + } 510 + } 511 + } 512 + 513 + return ( 514 + <ScrollView 515 + ref={listRef} 516 + horizontal 517 + contentContainerStyle={[a.gap_sm, a.px_lg]} 518 + showsHorizontalScrollIndicator={false} 519 + decelerationRate="fast" 520 + snapToOffsets={ 521 + tabOffsets.length === interests.length 522 + ? tabOffsets.map(o => o.x - tokens.space.xl) 523 + : undefined 524 + } 525 + onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} 526 + scrollEventThrottle={200} // big throttle 527 + onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}> 528 + {interests.map((interest, i) => { 529 + const active = interest === selectedInterest && !hasSearchText 530 + return ( 531 + <Tab 532 + key={interest} 533 + onSelectTab={handleSelectTab} 534 + active={active} 535 + index={i} 536 + interest={interest} 537 + interestsDisplayName={interestsDisplayNames[interest]} 538 + onLayout={handleTabLayout} 539 + /> 540 + ) 541 + })} 542 + </ScrollView> 543 + ) 544 + } 545 + Tabs = memo(Tabs) 546 + 547 + let Tab = ({ 548 + onSelectTab, 549 + interest, 550 + active, 551 + index, 552 + interestsDisplayName, 553 + onLayout, 554 + }: { 555 + onSelectTab: (index: number) => void 556 + interest: string 557 + active: boolean 558 + index: number 559 + interestsDisplayName: string 560 + onLayout: (index: number, x: number, width: number) => void 561 + }): React.ReactNode => { 562 + const {_} = useLingui() 563 + const activeText = active ? _(msg` (active)`) : '' 564 + return ( 565 + <View 566 + key={interest} 567 + onLayout={e => 568 + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 569 + }> 570 + <Button 571 + label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} 572 + variant={active ? 'solid' : 'outline'} 573 + color={active ? 'primary' : 'secondary'} 574 + size="small" 575 + onPress={() => onSelectTab(index)}> 576 + <ButtonIcon icon={SearchIcon} /> 577 + <ButtonText>{interestsDisplayName}</ButtonText> 578 + </Button> 579 + </View> 580 + ) 581 + } 582 + Tab = memo(Tab) 583 + 584 + let FollowProfileCard = ({ 585 + profile, 586 + moderationOpts, 587 + isSuggestion, 588 + setSuggestedAccounts, 589 + noBorder, 590 + }: { 591 + profile: AppBskyActorDefs.ProfileView 592 + moderationOpts: ModerationOpts 593 + isSuggestion: boolean 594 + setSuggestedAccounts: ( 595 + updater: ( 596 + v: Map<string, AppBskyActorDefs.ProfileView[]>, 597 + ) => Map<string, AppBskyActorDefs.ProfileView[]>, 598 + ) => void 599 + noBorder?: boolean 600 + }): React.ReactNode => { 601 + const [hasFollowed, setHasFollowed] = useState(false) 602 + const followupSuggestion = useSuggestedFollowsByActorQuery({ 603 + did: profile.did, 604 + enabled: hasFollowed, 605 + }) 606 + const candidates = followupSuggestion.data?.suggestions 607 + 608 + useEffect(() => { 609 + // TODO: Move out of effect. 610 + if (hasFollowed && candidates && candidates.length > 0) { 611 + setSuggestedAccounts(suggestions => { 612 + const newSuggestions = new Map(suggestions) 613 + newSuggestions.set(profile.did, candidates) 614 + return newSuggestions 615 + }) 616 + } 617 + }, [hasFollowed, profile.did, candidates, setSuggestedAccounts]) 618 + 619 + return ( 620 + <LayoutAnimationConfig skipEntering={!isSuggestion}> 621 + <Animated.View entering={native(ZoomInEasyDown)}> 622 + <FollowProfileCardInner 623 + profile={profile} 624 + moderationOpts={moderationOpts} 625 + onFollow={() => setHasFollowed(true)} 626 + noBorder={noBorder} 627 + /> 628 + </Animated.View> 629 + </LayoutAnimationConfig> 630 + ) 631 + } 632 + FollowProfileCard = memo(FollowProfileCard) 633 + 634 + function FollowProfileCardInner({ 635 + profile, 636 + moderationOpts, 637 + onFollow, 638 + noBorder, 639 + }: { 640 + profile: AppBskyActorDefs.ProfileView 641 + moderationOpts: ModerationOpts 642 + onFollow?: () => void 643 + noBorder?: boolean 644 + }) { 645 + const control = Dialog.useDialogContext() 646 + const t = useTheme() 647 + return ( 648 + <ProfileCard.Link 649 + profile={profile} 650 + style={[a.flex_1]} 651 + onPress={() => control.close()}> 652 + {({hovered, pressed}) => ( 653 + <CardOuter 654 + style={[ 655 + a.flex_1, 656 + noBorder && a.border_t_0, 657 + (hovered || pressed) && t.atoms.border_contrast_high, 658 + ]}> 659 + <ProfileCard.Outer> 660 + <ProfileCard.Header> 661 + <ProfileCard.Avatar 662 + profile={profile} 663 + moderationOpts={moderationOpts} 664 + /> 665 + <ProfileCard.NameAndHandle 666 + profile={profile} 667 + moderationOpts={moderationOpts} 668 + /> 669 + <ProfileCard.FollowButton 670 + profile={profile} 671 + moderationOpts={moderationOpts} 672 + logContext="PostOnboardingFindFollows" 673 + shape="round" 674 + onPress={onFollow} 675 + colorInverted 676 + /> 677 + </ProfileCard.Header> 678 + <ProfileCard.Description profile={profile} numberOfLines={2} /> 679 + </ProfileCard.Outer> 680 + </CardOuter> 681 + )} 682 + </ProfileCard.Link> 683 + ) 684 + } 685 + 686 + function CardOuter({ 687 + children, 688 + style, 689 + }: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { 690 + const t = useTheme() 691 + return ( 692 + <View 693 + style={[ 694 + a.w_full, 695 + a.py_md, 696 + a.px_lg, 697 + a.border_t, 698 + t.atoms.border_contrast_low, 699 + style, 700 + ]}> 701 + {children} 702 + </View> 703 + ) 704 + } 705 + 706 + function SearchInput({ 707 + onChangeText, 708 + onEscape, 709 + inputRef, 710 + defaultValue, 711 + }: { 712 + onChangeText: (text: string) => void 713 + onEscape: () => void 714 + inputRef: React.RefObject<TextInput> 715 + defaultValue: string 716 + }) { 717 + const t = useTheme() 718 + const {_} = useLingui() 719 + const { 720 + state: hovered, 721 + onIn: onMouseEnter, 722 + onOut: onMouseLeave, 723 + } = useInteractionState() 724 + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 725 + const interacted = hovered || focused 726 + 727 + return ( 728 + <View 729 + {...web({ 730 + onMouseEnter, 731 + onMouseLeave, 732 + })} 733 + style={[a.flex_row, a.align_center, a.gap_sm, a.px_lg, a.py_xs]}> 734 + <SearchIcon 735 + size="md" 736 + fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 737 + /> 738 + 739 + <TextInput 740 + ref={inputRef} 741 + placeholder={_(msg`Search`)} 742 + defaultValue={defaultValue} 743 + onChangeText={onChangeText} 744 + onFocus={onFocus} 745 + onBlur={onBlur} 746 + style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 747 + placeholderTextColor={t.palette.contrast_500} 748 + keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 749 + returnKeyType="search" 750 + clearButtonMode="while-editing" 751 + maxLength={50} 752 + onKeyPress={({nativeEvent}) => { 753 + if (nativeEvent.key === 'Escape') { 754 + onEscape() 755 + } 756 + }} 757 + autoCorrect={false} 758 + autoComplete="off" 759 + autoCapitalize="none" 760 + accessibilityLabel={_(msg`Search profiles`)} 761 + accessibilityHint={_(msg`Search profiles`)} 762 + /> 763 + </View> 764 + ) 765 + } 766 + 767 + function ProfileCardSkeleton() { 768 + const t = useTheme() 769 + 770 + return ( 771 + <View 772 + style={[ 773 + a.flex_1, 774 + a.py_md, 775 + a.px_lg, 776 + a.gap_md, 777 + a.align_center, 778 + a.flex_row, 779 + ]}> 780 + <View 781 + style={[ 782 + a.rounded_full, 783 + {width: 42, height: 42}, 784 + t.atoms.bg_contrast_25, 785 + ]} 786 + /> 787 + 788 + <View style={[a.flex_1, a.gap_sm]}> 789 + <View 790 + style={[ 791 + a.rounded_xs, 792 + {width: 80, height: 14}, 793 + t.atoms.bg_contrast_25, 794 + ]} 795 + /> 796 + <View 797 + style={[ 798 + a.rounded_xs, 799 + {width: 120, height: 10}, 800 + t.atoms.bg_contrast_25, 801 + ]} 802 + /> 803 + </View> 804 + </View> 805 + ) 806 + } 807 + 808 + function Empty({message}: {message: string}) { 809 + const t = useTheme() 810 + return ( 811 + <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 812 + <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 813 + {message} 814 + </Text> 815 + 816 + <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> 817 + </View> 818 + ) 819 + } 820 + 821 + function boostInterests(boosts?: string[]) { 822 + return (_a: string, _b: string) => { 823 + const indexA = boosts?.indexOf(_a) ?? -1 824 + const indexB = boosts?.indexOf(_b) ?? -1 825 + const rankA = indexA === -1 ? Infinity : indexA 826 + const rankB = indexB === -1 ? Infinity : indexB 827 + return rankA - rankB 828 + } 829 + }
+31 -13
src/components/ProgressGuide/List.tsx
··· 10 10 import {Button, ButtonIcon} from '#/components/Button' 11 11 import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 12 12 import {Text} from '#/components/Typography' 13 + import {FollowDialog} from './FollowDialog' 13 14 import {ProgressGuideTask} from './Task' 14 15 15 16 export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { 16 17 const t = useTheme() 17 18 const {_} = useLingui() 18 - const guide = useProgressGuide('like-10-and-follow-7') 19 + const followProgressGuide = useProgressGuide('follow-10') 20 + const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') 21 + const guide = followProgressGuide || followAndLikeProgressGuide 19 22 const {endProgressGuide} = useProgressGuideControls() 20 23 21 24 if (guide) { ··· 41 44 <ButtonIcon icon={Times} size="sm" /> 42 45 </Button> 43 46 </View> 44 - <ProgressGuideTask 45 - current={guide.numLikes + 1} 46 - total={10 + 1} 47 - title={_(msg`Like 10 posts`)} 48 - subtitle={_(msg`Teach our algorithm what you like`)} 49 - /> 50 - <ProgressGuideTask 51 - current={guide.numFollows + 1} 52 - total={7 + 1} 53 - title={_(msg`Follow 7 accounts`)} 54 - subtitle={_(msg`Bluesky is better with friends!`)} 55 - /> 47 + {guide.guide === 'follow-10' && ( 48 + <> 49 + <ProgressGuideTask 50 + current={guide.numFollows + 1} 51 + total={10 + 1} 52 + title={_(msg`Follow 10 accounts`)} 53 + subtitle={_(msg`Bluesky is better with friends!`)} 54 + /> 55 + <FollowDialog guide={guide} /> 56 + </> 57 + )} 58 + {guide.guide === 'like-10-and-follow-7' && ( 59 + <> 60 + <ProgressGuideTask 61 + current={guide.numLikes + 1} 62 + total={10 + 1} 63 + title={_(msg`Like 10 posts`)} 64 + subtitle={_(msg`Teach our algorithm what you like`)} 65 + /> 66 + <ProgressGuideTask 67 + current={guide.numFollows + 1} 68 + total={7 + 1} 69 + title={_(msg`Follow 7 accounts`)} 70 + subtitle={_(msg`Bluesky is better with friends!`)} 71 + /> 72 + </> 73 + )} 56 74 </View> 57 75 ) 58 76 }
+12 -2
src/components/ProgressGuide/Task.tsx
··· 10 10 total, 11 11 title, 12 12 subtitle, 13 + tabularNumsTitle, 13 14 }: { 14 15 current: number 15 16 total: number 16 17 title: string 17 18 subtitle?: string 19 + tabularNumsTitle?: boolean 18 20 }) { 19 21 const t = useTheme() 20 22 ··· 33 35 /> 34 36 )} 35 37 36 - <View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}> 37 - <Text style={[a.text_sm, a.font_bold, a.leading_tight]}>{title}</Text> 38 + <View style={[a.flex_col, a.gap_2xs, subtitle && {marginTop: -2}]}> 39 + <Text 40 + style={[ 41 + a.text_sm, 42 + a.font_bold, 43 + a.leading_tight, 44 + tabularNumsTitle && {fontVariant: ['tabular-nums']}, 45 + ]}> 46 + {title} 47 + </Text> 38 48 {subtitle && ( 39 49 <Text 40 50 style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}>
+3
src/components/dms/dialogs/SearchablePeopleList.tsx
··· 63 63 const {_} = useLingui() 64 64 const moderationOpts = useModerationOpts() 65 65 const control = Dialog.useDialogContext() 66 + const [headerHeight, setHeaderHeight] = useState(0) 66 67 const listRef = useRef<ListMethods>(null) 67 68 const {currentAccount} = useSession() 68 69 const inputRef = useRef<TextInput>(null) ··· 237 238 const listHeader = useMemo(() => { 238 239 return ( 239 240 <View 241 + onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} 240 242 style={[ 241 243 a.relative, 242 244 web(a.pt_lg), ··· 315 317 ]} 316 318 webInnerContentContainerStyle={a.py_0} 317 319 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 320 + scrollIndicatorInsets={{top: headerHeight}} 318 321 keyboardDismissMode="on-drag" 319 322 /> 320 323 )
+2
src/lib/statsig/events.ts
··· 162 162 | 'StarterPackProfilesList' 163 163 | 'FeedInterstitial' 164 164 | 'ProfileHeaderSuggestedFollows' 165 + | 'PostOnboardingFindFollows' 165 166 } 166 167 'profile:unfollow': { 167 168 logContext: ··· 177 178 | 'StarterPackProfilesList' 178 179 | 'FeedInterstitial' 179 180 | 'ProfileHeaderSuggestedFollows' 181 + | 'PostOnboardingFindFollows' 180 182 } 181 183 'chat:create': { 182 184 logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
+4 -1
src/lib/statsig/gates.ts
··· 1 1 export type Gate = 2 2 // Keep this alphabetic please. 3 - 'debug_show_feedcontext' | 'debug_subscriptions' | 'remove_show_latest_button' 3 + | 'debug_show_feedcontext' 4 + | 'debug_subscriptions' 5 + | 'new_postonboarding' 6 + | 'remove_show_latest_button'
+6 -2
src/screens/Onboarding/StepFinished.tsx
··· 14 14 TIMELINE_SAVED_FEED, 15 15 } from '#/lib/constants' 16 16 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 17 - import {logEvent} from '#/lib/statsig/statsig' 17 + import {logEvent, useGate} from '#/lib/statsig/statsig' 18 18 import {logger} from '#/logger' 19 19 import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 20 20 import {getAllListMembers} from '#/state/queries/list-members' ··· 57 57 const setActiveStarterPack = useSetActiveStarterPack() 58 58 const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack() 59 59 const {startProgressGuide} = useProgressGuideControls() 60 + const gate = useGate() 60 61 61 62 const finishOnboarding = React.useCallback(async () => { 62 63 setSaving(true) ··· 190 191 setSaving(false) 191 192 setActiveStarterPack(undefined) 192 193 setHasCheckedForStarterPack(true) 193 - startProgressGuide('like-10-and-follow-7') 194 + startProgressGuide( 195 + gate('new_postonboarding') ? 'follow-10' : 'like-10-and-follow-7', 196 + ) 194 197 dispatch({type: 'finish'}) 195 198 onboardDispatch({type: 'finish'}) 196 199 logEvent('onboarding:finished:nextPressed', { ··· 221 224 setActiveStarterPack, 222 225 setHasCheckedForStarterPack, 223 226 startProgressGuide, 227 + gate, 224 228 ]) 225 229 226 230 return (
+13
src/screens/Onboarding/state.ts
··· 72 72 } 73 73 } 74 74 75 + // most popular selected interests 76 + export const popularInterests = [ 77 + 'art', 78 + 'gaming', 79 + 'sports', 80 + 'comics', 81 + 'music', 82 + 'politics', 83 + 'photography', 84 + 'science', 85 + 'news', 86 + ] 87 + 75 88 export function useInterestsDisplayNames() { 76 89 const {_} = useLingui() 77 90
+22 -4
src/state/queries/actor-search.ts
··· 1 1 import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api' 2 2 import { 3 3 InfiniteData, 4 + keepPreviousData, 4 5 QueryClient, 5 6 QueryKey, 6 7 useInfiniteQuery, ··· 13 14 const RQKEY_ROOT = 'actor-search' 14 15 export const RQKEY = (query: string) => [RQKEY_ROOT, query] 15 16 16 - export const RQKEY_PAGINATED = (query: string) => [ 17 - `${RQKEY_ROOT}_paginated`, 18 - query, 19 - ] 17 + const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated` 18 + export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query] 20 19 21 20 export function useActorSearch({ 22 21 query, ··· 42 41 export function useActorSearchPaginated({ 43 42 query, 44 43 enabled, 44 + maintainData, 45 45 }: { 46 46 query: string 47 47 enabled?: boolean 48 + maintainData?: boolean 48 49 }) { 49 50 const agent = useAgent() 50 51 return useInfiniteQuery< ··· 67 68 enabled: enabled && !!query, 68 69 initialPageParam: undefined, 69 70 getNextPageParam: lastPage => lastPage.cursor, 71 + placeholderData: maintainData ? keepPreviousData : undefined, 70 72 }) 71 73 } 72 74 ··· 84 86 continue 85 87 } 86 88 for (const actor of queryData) { 89 + if (actor.did === did) { 90 + yield actor 91 + } 92 + } 93 + } 94 + 95 + const queryDatasPaginated = queryClient.getQueriesData< 96 + InfiniteData<AppBskyActorSearchActors.OutputSchema> 97 + >({ 98 + queryKey: [RQKEY_ROOT_PAGINATED], 99 + }) 100 + for (const [_queryKey, queryData] of queryDatasPaginated) { 101 + if (!queryData) { 102 + continue 103 + } 104 + for (const actor of queryData.pages.flatMap(page => page.actors)) { 87 105 if (actor.did === did) { 88 106 yield actor 89 107 }
+8 -1
src/state/queries/suggested-follows.ts
··· 103 103 }) 104 104 } 105 105 106 - export function useSuggestedFollowsByActorQuery({did}: {did: string}) { 106 + export function useSuggestedFollowsByActorQuery({ 107 + did, 108 + enabled, 109 + }: { 110 + did: string 111 + enabled?: boolean 112 + }) { 107 113 const agent = useAgent() 108 114 return useQuery({ 109 115 queryKey: suggestedFollowsByActorQueryKey(did), ··· 116 122 : res.data.suggestions.filter(profile => !profile.viewer?.following) 117 123 return {suggestions} 118 124 }, 125 + enabled, 119 126 }) 120 127 } 121 128
+78 -18
src/state/shell/progress-guide.tsx
··· 1 - import React from 'react' 1 + import React, {useMemo} from 'react' 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 ··· 16 16 Follow = 'follow', 17 17 } 18 18 19 - type ProgressGuideName = 'like-10-and-follow-7' 19 + type ProgressGuideName = 'like-10-and-follow-7' | 'follow-10' 20 20 21 + /** 22 + * Progress Guides that extend this interface must specify their name in the `guide` field, so it can be used as a discriminated union 23 + */ 21 24 interface BaseProgressGuide { 22 - guide: string 25 + guide: ProgressGuideName 23 26 isComplete: boolean 24 27 [key: string]: any 25 28 } 26 29 27 - interface Like10AndFollow7ProgressGuide extends BaseProgressGuide { 30 + export interface Like10AndFollow7ProgressGuide extends BaseProgressGuide { 31 + guide: 'like-10-and-follow-7' 28 32 numLikes: number 29 33 numFollows: number 30 34 } 31 35 32 - type ProgressGuide = Like10AndFollow7ProgressGuide | undefined 36 + export interface Follow10ProgressGuide extends BaseProgressGuide { 37 + guide: 'follow-10' 38 + numFollows: number 39 + } 40 + 41 + export type ProgressGuide = 42 + | Like10AndFollow7ProgressGuide 43 + | Follow10ProgressGuide 44 + | undefined 33 45 34 46 const ProgressGuideContext = React.createContext<ProgressGuide>(undefined) 35 47 ··· 61 73 const {mutateAsync, variables, isPending} = 62 74 useSetActiveProgressGuideMutation() 63 75 64 - const activeProgressGuide = ( 65 - isPending ? variables : preferences?.bskyAppState?.activeProgressGuide 66 - ) as ProgressGuide 76 + const activeProgressGuide = useMemo(() => { 77 + const rawProgressGuide = ( 78 + isPending ? variables : preferences?.bskyAppState?.activeProgressGuide 79 + ) as ProgressGuide 67 80 68 - // ensure the unspecced attributes have the correct types 69 - if (activeProgressGuide?.guide === 'like-10-and-follow-7') { 70 - activeProgressGuide.numLikes = Number(activeProgressGuide.numLikes) || 0 71 - activeProgressGuide.numFollows = Number(activeProgressGuide.numFollows) || 0 72 - } 81 + if (!rawProgressGuide) return undefined 82 + 83 + // ensure the unspecced attributes have the correct types 84 + // clone then mutate 85 + const {...maybeWronglyTypedProgressGuide} = rawProgressGuide 86 + if (maybeWronglyTypedProgressGuide?.guide === 'like-10-and-follow-7') { 87 + maybeWronglyTypedProgressGuide.numLikes = 88 + Number(maybeWronglyTypedProgressGuide.numLikes) || 0 89 + maybeWronglyTypedProgressGuide.numFollows = 90 + Number(maybeWronglyTypedProgressGuide.numFollows) || 0 91 + } else if (maybeWronglyTypedProgressGuide?.guide === 'follow-10') { 92 + maybeWronglyTypedProgressGuide.numFollows = 93 + Number(maybeWronglyTypedProgressGuide.numFollows) || 0 94 + } 95 + 96 + return maybeWronglyTypedProgressGuide 97 + }, [isPending, variables, preferences]) 73 98 74 99 const [localGuideState, setLocalGuideState] = 75 100 React.useState<ProgressGuide>(undefined) ··· 82 107 const firstLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null) 83 108 const fifthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null) 84 109 const tenthLikeToastRef = React.useRef<ProgressGuideToastRef | null>(null) 85 - const guideCompleteToastRef = React.useRef<ProgressGuideToastRef | null>(null) 110 + 111 + const fifthFollowToastRef = React.useRef<ProgressGuideToastRef | null>(null) 112 + const tenthFollowToastRef = React.useRef<ProgressGuideToastRef | null>(null) 86 113 87 114 const controls = React.useMemo(() => { 88 115 return { ··· 93 120 numLikes: 0, 94 121 numFollows: 0, 95 122 isComplete: false, 96 - } 123 + } satisfies ProgressGuide 124 + setLocalGuideState(guideObj) 125 + mutateAsync(guideObj) 126 + } else if (guide === 'follow-10') { 127 + const guideObj = { 128 + guide: 'follow-10', 129 + numFollows: 0, 130 + isComplete: false, 131 + } satisfies ProgressGuide 97 132 setLocalGuideState(guideObj) 98 133 mutateAsync(guideObj) 99 134 } ··· 137 172 isComplete: true, 138 173 } 139 174 } 175 + } else if (guide?.guide === 'follow-10') { 176 + if (action === ProgressGuideAction.Follow) { 177 + guide = { 178 + ...guide, 179 + numFollows: (Number(guide.numFollows) || 0) + count, 180 + } 181 + 182 + if (guide.numFollows === 5) { 183 + fifthFollowToastRef.current?.open() 184 + } 185 + if (guide.numFollows === 10) { 186 + tenthFollowToastRef.current?.open() 187 + } 188 + } 189 + if (Number(guide.numFollows) >= 10) { 190 + guide = { 191 + ...guide, 192 + isComplete: true, 193 + } 194 + } 140 195 } 141 196 142 197 setLocalGuideState(guide) ··· 167 222 subtitle={_(msg`The Discover feed now knows what you like`)} 168 223 /> 169 224 <ProgressGuideToast 170 - ref={guideCompleteToastRef} 171 - title={_(msg`Algorithm training complete!`)} 172 - subtitle={_(msg`The Discover feed now knows what you like`)} 225 + ref={fifthFollowToastRef} 226 + title={_(msg`Half way there!`)} 227 + subtitle={_(msg`Follow 10 accounts`)} 228 + /> 229 + <ProgressGuideToast 230 + ref={tenthFollowToastRef} 231 + title={_(msg`Task complete - 10 follows!`)} 232 + subtitle={_(msg`You've found some people to follow`)} 173 233 /> 174 234 </> 175 235 )}
+4 -2
src/view/com/posts/PostFeed.tsx
··· 253 253 } 254 254 }, [pollInterval]) 255 255 256 - const progressGuide = useProgressGuide('like-10-and-follow-7') 256 + const followProgressGuide = useProgressGuide('follow-10') 257 + const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') 257 258 const {isDesktop} = useWebMediaQueries() 258 - const showProgressIntersitial = progressGuide && !isDesktop 259 + const showProgressIntersitial = 260 + (followProgressGuide || followAndLikeProgressGuide) && !isDesktop 259 261 260 262 const feedItems: FeedRow[] = React.useMemo(() => { 261 263 let feedKind: 'following' | 'discover' | 'profile' | undefined
+5 -1
src/view/com/util/List.tsx
··· 155 155 automaticallyAdjustsScrollIndicatorInsets={ 156 156 automaticallyAdjustsScrollIndicatorInsets 157 157 } 158 - scrollIndicatorInsets={{top: headerOffset, right: 1}} 158 + scrollIndicatorInsets={{ 159 + top: headerOffset, 160 + right: 1, 161 + ...props.scrollIndicatorInsets, 162 + }} 159 163 contentOffset={contentOffset} 160 164 refreshControl={refreshControl} 161 165 onScroll={scrollHandler}
+3 -1
src/view/com/util/Toast.tsx
··· 196 196 /> 197 197 </View> 198 198 <View style={[a.h_full, a.justify_center, a.flex_1]}> 199 - <Text style={a.text_md}>{message}</Text> 199 + <Text style={a.text_md} emoji> 200 + {message} 201 + </Text> 200 202 </View> 201 203 </View> 202 204 </GestureDetector>