diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go
index d9235afdee..3f46c4b00b 100644
--- a/bskyweb/cmd/bskyweb/main.go
+++ b/bskyweb/cmd/bskyweb/main.go
@@ -60,6 +60,12 @@ func run(args []string) {
Value: "",
EnvVars: []string{"LINK_HOST"},
},
+ &cli.StringFlag{
+ Name: "ipcc-host",
+ Usage: "scheme, hostname, and port of ipcc service",
+ Value: "https://localhost:8730",
+ EnvVars: []string{"IPCC_HOST"},
+ },
&cli.BoolFlag{
Name: "debug",
Usage: "Enable debug mode",
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 203ed62f4e..afd9247ace 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -1,12 +1,17 @@
package main
import (
+ "bytes"
"context"
"crypto/subtle"
+ "crypto/tls"
+ "encoding/base64"
+ "encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
+ "net/netip"
"net/url"
"os"
"os/signal"
@@ -41,6 +46,7 @@ type Config struct {
appviewHost string
ogcardHost string
linkHost string
+ ipccHost string
}
func serve(cctx *cli.Context) error {
@@ -49,6 +55,7 @@ func serve(cctx *cli.Context) error {
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
+ ipccHost := cctx.String("ipcc-host")
basicAuthPassword := cctx.String("basic-auth-password")
// Echo
@@ -91,6 +98,7 @@ func serve(cctx *cli.Context) error {
appviewHost: appviewHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
+ ipccHost: ipccHost,
},
}
@@ -261,6 +269,9 @@ func serve(cctx *cli.Context) error {
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
+ // ipcc
+ e.GET("/ipcc", server.WebIpCC)
+
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
@@ -520,3 +531,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
data["requestHost"] = req.Host
return c.Render(http.StatusOK, "profile.html", data)
}
+
+type IPCCRequest struct {
+ IP string `json:"ip"`
+}
+type IPCCResponse struct {
+ CC string `json:"countryCode"`
+}
+
+func (srv *Server) WebIpCC(c echo.Context) error {
+ realIP := c.RealIP()
+ addr, err := netip.ParseAddr(realIP)
+ if err != nil {
+ log.Warnf("could not parse IP %q %s", realIP, err)
+ return c.JSON(400, IPCCResponse{})
+ }
+ var request []byte
+ if addr.Is4() {
+ ip4 := addr.As4()
+ var dest [8]byte
+ base64.StdEncoding.Encode(dest[:], ip4[:])
+ request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
+ } else if addr.Is6() {
+ ip6 := addr.As16()
+ var dest [24]byte
+ base64.StdEncoding.Encode(dest[:], ip6[:])
+ request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
+ }
+
+ ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost)
+ if err != nil {
+ log.Errorf("ipcc misconfigured bad url %s", err)
+ return c.JSON(500, IPCCResponse{})
+ }
+ ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
+ ipccUrl := ipccUrlBuilder.String()
+ cl := http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+ postBodyReader := bytes.NewReader(request)
+ response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
+ if err != nil {
+ log.Warnf("ipcc backend error %s", err)
+ return c.JSON(500, IPCCResponse{})
+ }
+ defer response.Body.Close()
+ dec := json.NewDecoder(response.Body)
+ var outResponse IPCCResponse
+ err = dec.Decode(&outResponse)
+ if err != nil {
+ log.Warnf("ipcc bad response %s", err)
+ return c.JSON(500, IPCCResponse{})
+ }
+ return c.JSON(200, outResponse)
+}
diff --git a/package.json b/package.json
index 3f9f0bced7..eff665a649 100644
--- a/package.json
+++ b/package.json
@@ -139,7 +139,7 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
- "expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz",
+ "expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 0bf0e9f93e..2beba4f9dc 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -661,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING}
theme={theme}
onStateChange={() => {
- logEvent('router:navigate:sampled', {
- from: prevLoggedRouteName.current,
- })
- prevLoggedRouteName.current = getCurrentRouteName()
+ const routeName = getCurrentRouteName()
+ if (routeName === 'Notifications') {
+ logEvent('router:navigate:notifications:sampled', {})
+ }
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
- logEvent('router:navigate:sampled', {})
}}>
{children}
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 65e981f77a..5031f584e5 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -8,7 +8,6 @@ import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
-import {useGate} from '#/lib/statsig/statsig'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -177,14 +176,9 @@ function useExperimentalSuggestedUsersQuery() {
}
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
- const gate = useGate()
const [feedType, feedUri] = feed.split('|')
if (feedType === 'author') {
- if (gate('show_follow_suggestions_in_profile')) {
- return
- } else {
- return null
- }
+ return
} else {
return
}
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index a263d19461..6f6d68049a 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -276,8 +276,8 @@ export function DescriptionPlaceholder() {
export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
- logContext: LogEvents['profile:follow']['logContext'] &
- LogEvents['profile:unfollow']['logContext']
+ logContext: LogEvents['profile:follow:sampled']['logContext'] &
+ LogEvents['profile:unfollow:sampled']['logContext']
} & Partial
export function FollowButton(props: FollowButtonProps) {
diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx
index 6174bff021..a5c7cd1b73 100644
--- a/src/components/StarterPack/Main/ProfilesList.tsx
+++ b/src/components/StarterPack/Main/ProfilesList.tsx
@@ -40,7 +40,7 @@ export const ProfilesList = React.forwardRef(
ref,
) {
const t = useTheme()
- const bottomBarOffset = useBottomBarOffset(200)
+ const bottomBarOffset = useBottomBarOffset(300)
const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession()
const {data, refetch, isError} = useAllListMembersQuery(listUri)
diff --git a/src/components/hooks/useFollowMethods.ts b/src/components/hooks/useFollowMethods.ts
index d67c3690f9..31a1e43daa 100644
--- a/src/components/hooks/useFollowMethods.ts
+++ b/src/components/hooks/useFollowMethods.ts
@@ -15,8 +15,8 @@ export function useFollowMethods({
logContext,
}: {
profile: Shadow
- logContext: LogEvents['profile:follow']['logContext'] &
- LogEvents['profile:unfollow']['logContext']
+ logContext: LogEvents['profile:follow:sampled']['logContext'] &
+ LogEvents['profile:unfollow:sampled']['logContext']
}) {
const {_} = useLingui()
const requireAuth = useRequireAuth()
diff --git a/src/components/video/PlayButtonIcon.tsx b/src/components/video/PlayButtonIcon.tsx
index 5415084ff1..90e93f744b 100644
--- a/src/components/video/PlayButtonIcon.tsx
+++ b/src/components/video/PlayButtonIcon.tsx
@@ -2,24 +2,47 @@ import React from 'react'
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
-import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
+import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
-export function PlayButtonIcon({size = 44}: {size?: number}) {
+export function PlayButtonIcon({size = 36}: {size?: number}) {
const t = useTheme()
+ const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
+ const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
return (
-
+
+
)
}
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 4768bdc238..1871894902 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -25,7 +25,7 @@ export type LogEvents = {
secondsActive: number
}
'state:foreground:sampled': {}
- 'router:navigate:sampled': {}
+ 'router:navigate:notifications:sampled': {}
'deepLink:referrerReceived': {
to: string
referrer: string
@@ -127,25 +127,25 @@ export type LogEvents = {
langs: string
logContext: 'Composer'
}
- 'post:like': {
+ 'post:like:sampled': {
doesLikerFollowPoster: boolean | undefined
doesPosterFollowLiker: boolean | undefined
likerClout: number | undefined
postClout: number | undefined
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
- 'post:repost': {
+ 'post:repost:sampled': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
- 'post:unlike': {
+ 'post:unlike:sampled': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
- 'post:unrepost': {
+ 'post:unrepost:sampled': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:mute': {}
'post:unmute': {}
- 'profile:follow': {
+ 'profile:follow:sampled': {
didBecomeMutual: boolean | undefined
followeeClout: number | undefined
followerClout: number | undefined
@@ -162,7 +162,7 @@ export type LogEvents = {
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
}
- 'profile:unfollow': {
+ 'profile:unfollow:sampled': {
logContext:
| 'RecommendedFollowsItem'
| 'PostThreadItem'
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index be40548adf..61a48e441a 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,10 +1,8 @@
export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
- | 'fixed_bottom_bar'
| 'onboarding_minimum_interests'
| 'suggested_feeds_interstitial'
- | 'show_follow_suggestions_in_profile'
| 'video_debug' // not recommended
| 'video_upload' // upload videos
| 'video_view_on_posts' // see posted videos
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index 7d86f4078c..c50bfeb862 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -89,8 +89,9 @@ export function toClout(n: number | null | undefined): number | undefined {
}
}
+const DOWNSAMPLE_RATE = 0.95 // 95% likely
const DOWNSAMPLED_EVENTS: Set = new Set([
- 'router:navigate:sampled',
+ 'router:navigate:notifications:sampled',
'state:background:sampled',
'state:foreground:sampled',
'home:feedDisplayed:sampled',
@@ -99,8 +100,14 @@ const DOWNSAMPLED_EVENTS: Set = new Set([
'discover:clickthrough:sampled',
'discover:engaged:sampled',
'discover:seen:sampled',
+ 'post:like:sampled',
+ 'post:unlike:sampled',
+ 'post:repost:sampled',
+ 'post:unrepost:sampled',
+ 'profile:follow:sampled',
+ 'profile:unfollow:sampled',
])
-const isDownsampledSession = Math.random() < 0.9 // 90% likely
+const isDownsampledSession = Math.random() < DOWNSAMPLE_RATE
export function logEvent(
eventName: E & string,
@@ -117,12 +124,16 @@ export function logEvent(
)
}
- if (isDownsampledSession && DOWNSAMPLED_EVENTS.has(eventName)) {
+ const isDownsampledEvent = DOWNSAMPLED_EVENTS.has(eventName)
+ if (isDownsampledSession && isDownsampledEvent) {
return
}
const fullMetadata = {
...rawMetadata,
} as Record // Statsig typings are unnecessarily strict here.
+ if (isDownsampledEvent) {
+ fullMetadata.downsampleRate = DOWNSAMPLE_RATE.toString()
+ }
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
if (Statsig.initializeCalled()) {
Statsig.logEvent(eventName, null, fullMetadata)
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
index dc63a869a6..674edc41eb 100644
--- a/src/screens/Messages/Conversation/MessageInput.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.tsx
@@ -23,6 +23,7 @@ import {
useSaveMessageDraft,
} from '#/state/messages/message-drafts'
import {isIOS} from 'platform/detection'
+import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {useSharedInputStyles} from '#/components/forms/TextField'
@@ -41,6 +42,7 @@ export function MessageInput({
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
+ openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {_} = useLingui()
const t = useTheme()
diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx
index a4a8a78522..0b7e479209 100644
--- a/src/screens/Messages/Conversation/MessageInput.web.tsx
+++ b/src/screens/Messages/Conversation/MessageInput.web.tsx
@@ -12,9 +12,16 @@ import {
} from '#/state/messages/message-drafts'
import {isSafari, isTouchDevice} from 'lib/browser'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
+import {
+ Emoji,
+ EmojiPickerPosition,
+} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
import {useSharedInputStyles} from '#/components/forms/TextField'
+import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
@@ -23,11 +30,13 @@ export function MessageInput({
hasEmbed,
setEmbed,
children,
+ openEmojiPicker,
}: {
onSendMessage: (message: string) => void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
+ openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {isTabletOrDesktop} = useWebMediaQueries()
const {_} = useLingui()
@@ -40,6 +49,7 @@ export function MessageInput({
const [isFocused, setIsFocused] = React.useState(false)
const [isHovered, setIsHovered] = React.useState(false)
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
+ const textAreaRef = React.useRef(null)
const onSubmit = React.useCallback(() => {
if (!hasEmbed && message.trim() === '') {
@@ -94,6 +104,23 @@ export function MessageInput({
[],
)
+ const onEmojiInserted = React.useCallback(
+ (emoji: Emoji) => {
+ const position = textAreaRef.current?.selectionStart ?? 0
+ setMessage(
+ message =>
+ message.slice(0, position) + emoji.native + message.slice(position),
+ )
+ },
+ [setMessage],
+ )
+ React.useEffect(() => {
+ textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
+ return () => {
+ textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
+ }
+ }, [onEmojiInserted])
+
useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed)
@@ -106,7 +133,7 @@ export function MessageInput({
t.atoms.bg_contrast_25,
{
paddingRight: a.p_sm.padding - 2,
- paddingLeft: a.p_md.padding - 2,
+ paddingLeft: a.p_sm.padding - 2,
borderWidth: 1,
borderRadius: 23,
borderColor: 'transparent',
@@ -118,7 +145,44 @@ export function MessageInput({
// @ts-expect-error web only
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
+
({
+ isOpen: false,
+ pos: {top: 0, left: 0, right: 0, bottom: 0},
+ })
+
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
// the bottom.
@@ -422,13 +432,22 @@ export function MessagesList({
+ setEmbed={setEmbed}
+ openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
>
)}
+ {isWeb && (
+ setEmojiPickerState(prev => ({...prev, isOpen: false}))}
+ />
+ )}
+
{newMessagesPill.show && }
>
)
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 2036023c30..cf5fcb97e3 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -6,11 +6,9 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
@@ -23,10 +21,9 @@ import {useRequireAuth, useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useProfileShadow} from 'state/cache/profile-shadow'
-import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
@@ -59,8 +56,6 @@ let ProfileHeaderStandard = ({
}: Props): React.ReactNode => {
const profile: Shadow =
useProfileShadow(profileUnshadowed)
- const t = useTheme()
- const gate = useGate()
const {currentAccount, hasSession} = useSession()
const {_} = useLingui()
const {openModal} = useModalControls()
@@ -69,7 +64,6 @@ let ProfileHeaderStandard = ({
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
- const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileHeader',
@@ -202,34 +196,7 @@ let ProfileHeaderStandard = ({
)
) : !profile.viewer?.blockedBy ? (
<>
- {hasSession && (
- <>
-
- {!gate('show_follow_suggestions_in_profile') && (
-
- )}
- >
- )}
+ {hasSession && }
)}
- {showSuggestedFollows && (
- {
- if (showSuggestedFollows) {
- setShowSuggestedFollows(false)
- } else {
- track('ProfileHeader:SuggestedFollowsOpened')
- setShowSuggestedFollows(true)
- }
- }}
- />
- )}
,
- logContext: LogEvents['post:like']['logContext'] &
- LogEvents['post:unlike']['logContext'],
+ logContext: LogEvents['post:like:sampled']['logContext'] &
+ LogEvents['post:unlike:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -158,7 +158,7 @@ export function usePostLikeMutationQueue(
}
function usePostLikeMutation(
- logContext: LogEvents['post:like']['logContext'],
+ logContext: LogEvents['post:like:sampled']['logContext'],
post: Shadow,
) {
const {currentAccount} = useSession()
@@ -175,7 +175,7 @@ function usePostLikeMutation(
if (currentAccount) {
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
- logEvent('post:like', {
+ logEvent('post:like:sampled', {
logContext,
doesPosterFollowLiker: postAuthor.viewer
? Boolean(postAuthor.viewer.followedBy)
@@ -200,12 +200,12 @@ function usePostLikeMutation(
}
function usePostUnlikeMutation(
- logContext: LogEvents['post:unlike']['logContext'],
+ logContext: LogEvents['post:unlike:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation({
mutationFn: ({likeUri}) => {
- logEvent('post:unlike', {logContext})
+ logEvent('post:unlike:sampled', {logContext})
return agent.deleteLike(likeUri)
},
onSuccess() {
@@ -216,8 +216,8 @@ function usePostUnlikeMutation(
export function usePostRepostMutationQueue(
post: Shadow,
- logContext: LogEvents['post:repost']['logContext'] &
- LogEvents['post:unrepost']['logContext'],
+ logContext: LogEvents['post:repost:sampled']['logContext'] &
+ LogEvents['post:unrepost:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -273,7 +273,7 @@ export function usePostRepostMutationQueue(
}
function usePostRepostMutation(
- logContext: LogEvents['post:repost']['logContext'],
+ logContext: LogEvents['post:repost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<
@@ -282,7 +282,7 @@ function usePostRepostMutation(
{uri: string; cid: string} // the post's uri and cid
>({
mutationFn: post => {
- logEvent('post:repost', {logContext})
+ logEvent('post:repost:sampled', {logContext})
return agent.repost(post.uri, post.cid)
},
onSuccess() {
@@ -292,12 +292,12 @@ function usePostRepostMutation(
}
function usePostUnrepostMutation(
- logContext: LogEvents['post:unrepost']['logContext'],
+ logContext: LogEvents['post:unrepost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation({
mutationFn: ({repostUri}) => {
- logEvent('post:unrepost', {logContext})
+ logEvent('post:unrepost:sampled', {logContext})
return agent.deleteRepost(repostUri)
},
onSuccess() {
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 6682cf3c89..532b005cf4 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -219,8 +219,8 @@ export function useProfileUpdateMutation() {
export function useProfileFollowMutationQueue(
profile: Shadow,
- logContext: LogEvents['profile:follow']['logContext'] &
- LogEvents['profile:unfollow']['logContext'],
+ logContext: LogEvents['profile:follow:sampled']['logContext'] &
+ LogEvents['profile:follow:sampled']['logContext'],
) {
const agent = useAgent()
const queryClient = useQueryClient()
@@ -291,7 +291,7 @@ export function useProfileFollowMutationQueue(
}
function useProfileFollowMutation(
- logContext: LogEvents['profile:follow']['logContext'],
+ logContext: LogEvents['profile:follow:sampled']['logContext'],
profile: Shadow,
) {
const {currentAccount} = useSession()
@@ -306,7 +306,7 @@ function useProfileFollowMutation(
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
captureAction(ProgressGuideAction.Follow)
- logEvent('profile:follow', {
+ logEvent('profile:follow:sampled', {
logContext,
didBecomeMutual: profile.viewer
? Boolean(profile.viewer.followedBy)
@@ -323,12 +323,12 @@ function useProfileFollowMutation(
}
function useProfileUnfollowMutation(
- logContext: LogEvents['profile:unfollow']['logContext'],
+ logContext: LogEvents['profile:unfollow:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation({
mutationFn: async ({followUri}) => {
- logEvent('profile:unfollow', {logContext})
+ logEvent('profile:unfollow:sampled', {logContext})
track('Profile:Unfollow', {username: followUri})
return await agent.deleteFollow(followUri)
},
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index 74802a9930..612388ff86 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -34,7 +34,7 @@ export interface ComposerOpts {
quote?: ComposerOptsQuote
quoteCount?: number
mention?: string // handle of user to mention
- openPicker?: (pos: DOMRect | undefined) => void
+ openEmojiPicker?: (pos: DOMRect | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number}[]
}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 8ae92b0181..3c7868ad2d 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -133,7 +133,7 @@ export const ComposePost = observer(function ComposePost({
quote: initQuote,
quoteCount,
mention: initMention,
- openPicker,
+ openEmojiPicker,
text: initText,
imageUris: initImageUris,
cancelRef,
@@ -520,8 +520,8 @@ export const ComposePost = observer(function ComposePost({
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => {
- openPicker?.(textInput.current?.getCursorPosition())
- }, [openPicker])
+ openEmojiPicker?.(textInput.current?.getCursorPosition())
+ }, [openEmojiPicker])
const focusTextInput = useCallback(() => {
textInput.current?.focus()
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 3c4aaf7388..c477ada065 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder'
import {Text as TiptapText} from '@tiptap/extension-text'
import {generateJSON} from '@tiptap/html'
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
-import EventEmitter from 'eventemitter3'
import {usePalette} from '#/lib/hooks/usePalette'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {blobToDataUri, isUriImage} from 'lib/media/util'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
LinkFacetMatch,
suggestLinkCardUri,
@@ -46,8 +46,6 @@ interface TextInputProps {
onError: (err: string) => void
}
-export const textInputWebEmitter = new EventEmitter()
-
export const TextInput = React.forwardRef(function TextInputImpl(
{
richtext,
diff --git a/src/view/com/composer/text-input/textInputWebEmitter.ts b/src/view/com/composer/text-input/textInputWebEmitter.ts
new file mode 100644
index 0000000000..fb037cac2e
--- /dev/null
+++ b/src/view/com/composer/text-input/textInputWebEmitter.ts
@@ -0,0 +1,3 @@
+import EventEmitter from 'eventemitter3'
+
+export const textInputWebEmitter = new EventEmitter()
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index 1f4178f7f8..ad3bb30eca 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -7,8 +7,8 @@ import {
} from 'react-native'
import Picker from '@emoji-mart/react'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a} from '#/alf'
-import {textInputWebEmitter} from '../TextInput.web'
const HEIGHT_OFFSET = 40
const WIDTH_OFFSET = 100
@@ -26,22 +26,41 @@ export type Emoji = {
unified: string
}
+export interface EmojiPickerPosition {
+ top: number
+ left: number
+ right: number
+ bottom: number
+}
+
export interface EmojiPickerState {
isOpen: boolean
- pos: {top: number; left: number; right: number; bottom: number}
+ pos: EmojiPickerPosition
}
interface IProps {
state: EmojiPickerState
close: () => void
+ /**
+ * If `true`, overrides position and ensures picker is pinned to the top of
+ * the target element.
+ */
+ pinToTop?: boolean
}
-export function EmojiPicker({state, close}: IProps) {
+export function EmojiPicker({state, close, pinToTop}: IProps) {
const {height, width} = useWindowDimensions()
const isShiftDown = React.useRef(false)
const position = React.useMemo(() => {
+ if (pinToTop) {
+ return {
+ top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10,
+ left: state.pos.left,
+ }
+ }
+
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
const fitsAbove = PICKER_HEIGHT < state.pos.top
const placeOnLeft = PICKER_WIDTH < state.pos.left
@@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) {
: undefined,
}
}
- }, [state.pos, height, width])
+ }, [state.pos, height, width, pinToTop])
React.useEffect(() => {
if (!state.isOpen) return
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 28b46bae22..60b467d62b 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -60,7 +60,7 @@ export function VideoPreview({
{autoplayDisabled && (
-
+
)}
diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx
index 9473be0746..b8fd159506 100644
--- a/src/view/com/composer/videos/VideoPreview.web.tsx
+++ b/src/view/com/composer/videos/VideoPreview.web.tsx
@@ -83,7 +83,7 @@ export function VideoPreview({
/>
{autoplayDisabled && (
-
+
)}
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
deleted file mode 100644
index 356b3f09cf..0000000000
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import React from 'react'
-import {ScrollView, View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {logEvent} from '#/lib/statsig/statsig'
-import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
-import {isWeb} from 'platform/detection'
-import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
-import {Button, ButtonIcon} from '#/components/Button'
-import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
-import * as ProfileCard from '#/components/ProfileCard'
-import {Text} from '#/components/Typography'
-
-const OUTER_PADDING = a.p_md.padding
-const INNER_PADDING = a.p_lg.padding
-const TOTAL_HEIGHT = 232
-const MOBILE_CARD_WIDTH = 300
-
-function CardOuter({
- children,
- style,
-}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
- const t = useTheme()
- return (
-
- {children}
-
- )
-}
-
-export function SuggestedFollowPlaceholder() {
- const t = useTheme()
- return (
-
-
-
-
-
-
-
-
- )
-}
-
-export function ProfileHeaderSuggestedFollows({
- actorDid,
- requestDismiss,
-}: {
- actorDid: string
- requestDismiss: () => void
-}) {
- const t = useTheme()
- const {_} = useLingui()
- const {isLoading: isSuggestionsLoading, data} =
- useSuggestedFollowsByActorQuery({
- did: actorDid,
- })
- const moderationOpts = useModerationOpts()
- const isLoading = isSuggestionsLoading || !moderationOpts
-
- return (
-
-
-
-
- Similar accounts
-
-
-
-
-
-
-
- {isLoading ? (
- <>
-
-
-
-
-
- >
- ) : data ? (
- data.suggestions
- .filter(s => (s.associated?.labeler ? false : true))
- .map(profile => (
- {
- logEvent('profile:header:suggestedFollowsCard:press', {})
- }}
- style={[a.flex_1]}>
- {({hovered, pressed}) => (
-
-
-
-
-
-
-
-
-
-
- )}
-
- ))
- ) : (
-
- )}
-
-
-
-
- )
-}
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 3163d85445..c87ee209ec 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -9,7 +9,6 @@ import {
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
-import {useGate} from '#/lib/statsig/statsig'
import {useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isNative, isWeb} from 'platform/detection'
@@ -23,12 +22,10 @@ function clamp(num: number, min: number, max: number) {
export function MainScrollProvider({children}: {children: React.ReactNode}) {
const {headerHeight} = useShellLayout()
- const {headerMode, footerMode} = useMinimalShellMode()
+ const {headerMode} = useMinimalShellMode()
const startDragOffset = useSharedValue(null)
const startMode = useSharedValue(null)
const didJustRestoreScroll = useSharedValue(false)
- const gate = useGate()
- const isFixedBottomBar = gate('fixed_bottom_bar')
const setMode = React.useCallback(
(v: boolean) => {
@@ -37,14 +34,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
headerMode.value = withSpring(v ? 1 : 0, {
overshootClamping: true,
})
- if (!isFixedBottomBar) {
- cancelAnimation(footerMode)
- footerMode.value = withSpring(v ? 1 : 0, {
- overshootClamping: true,
- })
- }
},
- [headerMode, footerMode, isFixedBottomBar],
+ [headerMode],
)
useEffect(() => {
@@ -147,10 +138,6 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
// Cancel any any existing animation
cancelAnimation(headerMode)
headerMode.value = newValue
- if (!isFixedBottomBar) {
- cancelAnimation(footerMode)
- footerMode.value = newValue
- }
}
} else {
if (didJustRestoreScroll.value) {
@@ -173,12 +160,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
[
headerHeight,
headerMode,
- footerMode,
setMode,
startDragOffset,
startMode,
didJustRestoreScroll,
- isFixedBottomBar,
],
)
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index f7c6bc2c91..51e76bdc39 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -59,7 +59,6 @@ function Toast({
a.flex_1,
t.atoms.bg,
a.shadow_lg,
- a.rounded_sm,
t.atoms.border_contrast_medium,
a.rounded_sm,
a.px_md,
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index e6fb0ad465..ee8e1f47a2 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -79,6 +79,7 @@ const styles = StyleSheet.create({
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
zIndex: 1,
+ cursor: 'pointer',
},
inner: {
justifyContent: 'center',
diff --git a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
index da8c7a98c4..95fa0bb0ec 100644
--- a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
+++ b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import {useVideoPlayer, VideoPlayer} from 'expo-video'
-import {isNative} from '#/platform/detection'
+import {isAndroid, isNative} from '#/platform/detection'
const Context = React.createContext<{
activeSource: string
@@ -26,7 +26,18 @@ export function Provider({children}: {children: React.ReactNode}) {
})
const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
- setActiveSource(src ? src : '')
+ // HACK
+ // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
+ // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
+ // apply it there.
+ if (src === activeSource && isAndroid) {
+ setActiveSource('')
+ setTimeout(() => {
+ setActiveSource(src ? src : '')
+ }, 100)
+ } else {
+ setActiveSource(src ? src : '')
+ }
setActiveViewId(viewId ? viewId : '')
}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index e5457555ba..9c3a34dda8 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -71,7 +71,7 @@ function InnerWrapper({embed}: Props) {
const [playerStatus, setPlayerStatus] = useState<
VideoPlayerStatus | 'paused'
- >(player.playing ? 'readyToPlay' : 'paused')
+ >('paused')
const [isMuted, setIsMuted] = useState(player.muted)
const [isFullscreen, setIsFullscreen] = React.useState(false)
const [timeRemaining, setTimeRemaining] = React.useState(0)
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
index 31e8630382..de9a2c74c2 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {clamp} from '#/lib/numbers'
+import {isAndroid} from 'platform/detection'
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
import {atoms as a, useTheme} from '#/alf'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
@@ -61,6 +62,9 @@ export function VideoEmbedInnerNative({
PlatformInfo.setAudioActive(true)
player.muted = false
setIsFullscreen(true)
+ if (isAndroid) {
+ player.play()
+ }
}}
onFullscreenExit={() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index fb487ad6bf..c790a815b6 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,24 +1,18 @@
import React from 'react'
-import {ActivityIndicator, AppState, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
-import {useGate} from '#/lib/statsig/statsig'
import {emitSoftReset} from '#/state/events'
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
import {FeedParams} from '#/state/queries/post-feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSession} from '#/state/session'
-import {
- useMinimalShellMode,
- useSetDrawerSwipeDisabled,
- useSetMinimalShellMode,
-} from '#/state/shell'
+import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
@@ -87,7 +81,6 @@ function HomeScreenReady({
const selectedIndex = Math.max(0, maybeFoundIndex)
const selectedFeed = allFeeds[selectedIndex]
const requestNotificationsPermission = useRequestNotificationsPermission()
- const gate = useGate()
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
useOTAUpdates()
@@ -134,29 +127,6 @@ function HomeScreenReady({
}),
)
- const {footerMode} = useMinimalShellMode()
- const {isMobile} = useWebMediaQueries()
- useFocusEffect(
- React.useCallback(() => {
- if (gate('fixed_bottom_bar')) {
- // Unnecessary because it's always there.
- return
- }
- const listener = AppState.addEventListener('change', nextAppState => {
- if (nextAppState === 'active') {
- if (isMobile && footerMode.value === 1) {
- // Reveal the bottom bar so you don't miss notifications or messages.
- // TODO: Experiment with only doing it when unread > 0.
- setMinimalShellMode(false)
- }
- }
- })
- return () => {
- listener.remove()
- }
- }, [setMinimalShellMode, footerMode, isMobile, gate]),
- )
-
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 5d80dc422b..42696139e0 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -61,7 +61,7 @@ export function Composer({}: {winHeight: number}) {
quoteCount={state?.quoteCount}
onPost={state.onPost}
mention={state.mention}
- openPicker={onOpenPicker}
+ openEmojiPicker={onOpenPicker}
text={state.text}
/>
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 7d080e57b1..aed92cbb7a 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -11,7 +11,7 @@ import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as NavigationBar from 'expo-navigation-bar'
import {StatusBar} from 'expo-status-bar'
-import {useNavigationState} from '@react-navigation/native'
+import {useNavigation, useNavigationState} from '@react-navigation/native'
import {useSession} from '#/state/session'
import {
@@ -20,6 +20,7 @@ import {
useSetDrawerOpen,
} from '#/state/shell'
import {useCloseAnyActiveElement} from '#/state/util'
+import {useDedupe} from 'lib/hooks/useDedupe'
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
import {usePalette} from 'lib/hooks/usePalette'
import {useNotificationsRegistration} from 'lib/notifications/notifications'
@@ -33,6 +34,7 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
+import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {RoutesContainer, TabsNavigator} from '../../Navigation'
import {Composer} from './Composer'
import {DrawerContent} from './Drawer'
@@ -76,6 +78,27 @@ function ShellInner() {
}
}, [closeAnyActiveElement])
+ // HACK
+ // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
+ // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
+ // apply it there.
+ // The `state` event should only fire whenever we push or pop to a screen, and should not fire consecutively quickly.
+ // To be certain though, we will also dedupe these calls.
+ const navigation = useNavigation()
+ const dedupe = useDedupe(1000)
+ React.useEffect(() => {
+ if (!isAndroid) return
+ const onFocusOrBlur = () => {
+ setTimeout(() => {
+ dedupe(updateActiveViewAsync)
+ }, 500)
+ }
+ navigation.addListener('state', onFocusOrBlur)
+ return () => {
+ navigation.removeListener('state', onFocusOrBlur)
+ }
+ }, [dedupe, navigation])
+
return (
<>