diff --git a/.env.example b/.env.example index 6ab02256e4..a4d21ac332 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Copy this to `.env` and `.env.test` files +BITDRIFT_API_KEY= SENTRY_AUTH_TOKEN= -EXPO_PUBLIC_ENV=development EXPO_PUBLIC_LOG_LEVEL=debug EXPO_PUBLIC_LOG_DEBUG= EXPO_PUBLIC_BUNDLE_IDENTIFIER= diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f0e23263db..7eab1f490c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -54,4 +54,4 @@ jobs: run: yarn intl:build - name: Run tests run: | - NODE_ENV=test EXPO_PUBLIC_ENV=test yarn test --forceExit + NODE_ENV=test yarn test --forceExit diff --git a/Makefile b/Makefile index a40d37610e..5ed24e6ada 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ build-web-embed: ## Compile web embed bundle, copy to bskyweb/embedr* directorie .PHONY: test test: ## Run all tests - NODE_ENV=test EXPO_PUBLIC_ENV=test yarn test + NODE_ENV=test yarn test .PHONY: lint lint: ## Run style checks and verify syntax diff --git a/__e2e__/flows/curate-lists.yml b/__e2e__/flows/curate-lists.yml index 662ec84233..41c3f2c7cc 100644 --- a/__e2e__/flows/curate-lists.yml +++ b/__e2e__/flows/curate-lists.yml @@ -26,7 +26,7 @@ appId: xyz.blueskyweb.app - tapOn: "Save" - assertNotVisible: id: "createOrEditListModal" -- tapOn: "About" +- tapOn: "People" - assertVisible: "Good Ppl" - assertVisible: "They good" @@ -93,10 +93,10 @@ appId: xyz.blueskyweb.app - tapOn: "Save" - assertNotVisible: id: "createOrEditListModal" -- tapOn: "About" +- tapOn: "People" - assertVisible: "Good Ppl" - assertVisible: "They good" -- tapOn: "About" +- tapOn: "People" - tapOn: label: "Adds users on curatelists from the list" @@ -145,7 +145,7 @@ appId: xyz.blueskyweb.app id: "e2eGotoLists" - tapOn: "Good Ppl" -- tapOn: "About" +- tapOn: "People" - assertVisible: label: "Removes users on curatelists from the list" id: "user-bob.test" diff --git a/__e2e__/flows/feed-reorder.yml b/__e2e__/flows/feed-reorder.yml index c502373c42..6e37321221 100644 --- a/__e2e__/flows/feed-reorder.yml +++ b/__e2e__/flows/feed-reorder.yml @@ -11,10 +11,8 @@ appId: xyz.blueskyweb.app # Pin alice's feed - extendedWaitUntil: - visible: - id: "viewHeaderDrawerBtn" -- tapOn: - id: "viewHeaderDrawerBtn" + visible: "Open drawer menu" +- tapOn: "Open drawer menu" - tapOn: id: "profileCardButton" - tapOn: @@ -32,8 +30,7 @@ appId: xyz.blueskyweb.app text: "alice-favs" # Set alice-favs first -- tapOn: - id: "viewHeaderDrawerBtn" +- tapOn: "Open drawer menu" - tapOn: id: "menuItemButton-Feeds" - tapOn: @@ -44,8 +41,7 @@ appId: xyz.blueskyweb.app - tapOn: label: "Save button" id: "saveChangesBtn" -- tapOn: - id: "viewHeaderDrawerBtn" +- tapOn: "Go back" - assertVisible: id: "homeScreenFeedTabs-selector-0" text: "alice-favs" @@ -54,8 +50,7 @@ appId: xyz.blueskyweb.app text: "Following" # Set following first -- tapOn: - id: "viewHeaderDrawerBtn" +- tapOn: "Open drawer menu" - tapOn: id: "menuItemButton-Feeds" - tapOn: @@ -66,8 +61,7 @@ appId: xyz.blueskyweb.app - tapOn: label: "Save button" id: "saveChangesBtn" -- tapOn: - id: "viewHeaderDrawerBtn" +- tapOn: "Go back" - assertVisible: id: "homeScreenFeedTabs-selector-0" text: "Following" @@ -76,8 +70,7 @@ appId: xyz.blueskyweb.app text: "alice-favs" # Remove following -- tapOn: - id: "viewHeaderDrawerBtn" +- tapOn: "Open drawer menu" - tapOn: id: "menuItemButton-Feeds" - tapOn: @@ -88,8 +81,7 @@ appId: xyz.blueskyweb.app - tapOn: label: "Save button" id: "saveChangesBtn" -- tapOn: - id: "viewHeaderDrawerBtn" +- tapOn: "Go back" - assertVisible: id: "homeScreenFeedTabs-selector-0" text: "alice-favs" diff --git a/__e2e__/flows/home-screen.yml b/__e2e__/flows/home-screen.yml index b7e96282d7..e818168065 100644 --- a/__e2e__/flows/home-screen.yml +++ b/__e2e__/flows/home-screen.yml @@ -27,7 +27,9 @@ appId: xyz.blueskyweb.app - tapOn: id: "bottomBarHomeBtn" - tapOn: - id: "viewHeaderDrawerBtn" + id: "bottomBarHomeBtn" +- tapOn: + id: "bottomBarHomeBtn" - assertNotVisible: "Feeds ✨" - tapOn: diff --git a/__e2e__/flows/mod-lists.yml b/__e2e__/flows/mod-lists.yml index 54832a07eb..ef757c5b11 100644 --- a/__e2e__/flows/mod-lists.yml +++ b/__e2e__/flows/mod-lists.yml @@ -14,7 +14,7 @@ appId: xyz.blueskyweb.app id: "e2eGotoModeration" - tapOn: id: "moderationlistsBtn" -- tapOn: "New" +- tapOn: "New list" - tapOn: id: "editNameInput" - inputText: "Muted Users" diff --git a/__e2e__/setupApp.yml b/__e2e__/setupApp.yml index 8c3ffd2d3b..25f9aa8470 100644 --- a/__e2e__/setupApp.yml +++ b/__e2e__/setupApp.yml @@ -6,6 +6,8 @@ appId: xyz.blueskyweb.app - waitForAnimationToEnd - tapOn: "http://localhost:8081" - waitForAnimationToEnd +- extendedWaitUntil: + visible: "Continue" - swipe: from: "Bluesky" direction: DOWN diff --git a/app.config.js b/app.config.js index 8b288e1a73..d47481e620 100644 --- a/app.config.js +++ b/app.config.js @@ -15,9 +15,9 @@ module.exports = function (config) { */ const PLATFORM = process.env.EAS_BUILD_PLATFORM - const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' const IS_PRODUCTION = process.env.EXPO_PUBLIC_ENV === 'production' + const IS_DEV = !IS_TESTFLIGHT || !IS_PRODUCTION const ASSOCIATED_DOMAINS = [ 'applinks:bsky.app', @@ -222,6 +222,7 @@ module.exports = function (config) { }, ], 'react-native-compressor', + '@bitdrift/react-native', './plugins/starterPackAppClipExtension/withStarterPackAppClip.js', './plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestFCMIconPlugin.js', diff --git a/assets/icons/pin_filled_stroke2_corner0_rounded.svg b/assets/icons/pin_filled_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..a2e71b967c --- /dev/null +++ b/assets/icons/pin_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyembed/snippet/embed.ts b/bskyembed/snippet/embed.ts index 380cda5fb9..3c1b14b955 100644 --- a/bskyembed/snippet/embed.ts +++ b/bskyembed/snippet/embed.ts @@ -20,6 +20,7 @@ window.addEventListener('message', event => { return } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const id = (event.data as {id: string}).id if (!id) { return @@ -33,6 +34,7 @@ window.addEventListener('message', event => { return } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const height = (event.data as {height: number}).height if (height) { embed.style.height = `${height}px` @@ -47,7 +49,7 @@ window.addEventListener('message', event => { * @returns */ function scan(node = document) { - const embeds = node.querySelectorAll('[data-bluesky-uri]') + const embeds = node.querySelectorAll('[data-bluesky-uri]') for (let i = 0; i < embeds.length; i++) { const id = String(Math.random()).slice(2) diff --git a/bskyembed/src/color-mode.ts b/bskyembed/src/color-mode.ts new file mode 100644 index 0000000000..2b392c6178 --- /dev/null +++ b/bskyembed/src/color-mode.ts @@ -0,0 +1,17 @@ +export function applyTheme(theme: 'light' | 'dark') { + document.documentElement.classList.remove('light', 'dark') + document.documentElement.classList.add(theme) +} + +export function initColorMode() { + applyTheme( + window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light', + ) + window + .matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', mql => { + applyTheme(mql.matches ? 'dark' : 'light') + }) +} diff --git a/bskyembed/src/components/container.tsx b/bskyembed/src/components/container.tsx index 5b1b2b7fb4..8e142a25be 100644 --- a/bskyembed/src/components/container.tsx +++ b/bskyembed/src/components/container.tsx @@ -37,7 +37,7 @@ export function Container({ return (
{ if (ref.current && href) { // forwardRef requires preact/compat - let's keep it simple diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx index 74eacf16d4..20ffcb2b29 100644 --- a/bskyembed/src/components/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -78,9 +78,9 @@ export function Embed({ return ( + className="transition-colors hover:bg-neutral-100 dark:hover:bg-slate-700 border dark:border-slate-600 rounded-lg p-2 gap-1.5 w-full flex flex-col">
-
+

{record.author.displayName} - + @{record.author.handle}

@@ -209,7 +209,7 @@ function Info({children}: {children: ComponentChildren}) { return (
-

{children}

+

{children}

) } @@ -308,7 +308,7 @@ function ExternalEmbed({ return ( {content.external.thumb && ( )}
-

+

{toNiceDomain(content.external.uri)}

{content.external.title}

-

+

{content.external.description}

@@ -345,23 +345,29 @@ function GenericWithImageEmbed({ return ( + className="w-full rounded-lg border dark:border-slate-600 py-2 px-3 flex flex-col gap-2">
{image ? ( {title} ) : (
)}

{title}

-

{subtitle}

+

+ {subtitle} +

- {description &&

{description}

} + {description && ( +

+ {description} +

+ )} ) } @@ -406,7 +412,7 @@ function StarterPackEmbed({ return ( + className="w-full rounded-lg overflow-hidden border dark:border-slate-600 flex flex-col items-stretch">
@@ -415,7 +421,7 @@ function StarterPackEmbed({

{content.record.name}

-

+

Starter pack by{' '} {content.creator.displayName || `@${content.creator.handle}`}

@@ -425,7 +431,7 @@ function StarterPackEmbed({

{content.record.description}

)} {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && ( -

+

{content.joinedAllTimeCount} users have joined!

)} diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx index 4db5eeb45e..26945eb69d 100644 --- a/bskyembed/src/components/post.tsx +++ b/bskyembed/src/components/post.tsx @@ -38,7 +38,7 @@ export function Post({thread}: Props) {
-
+
+ className="text-[15px] text-textLight dark:text-textDimmed hover:underline line-clamp-1">

@{post.author.handle}

@@ -69,15 +69,15 @@ export function Post({thread}: Props) { -
+
{!!post.likeCount && (
-

+

{prettyNumber(post.likeCount)}

@@ -85,17 +85,19 @@ export function Post({thread}: Props) { {!!post.repostCount && (
-

+

{prettyNumber(post.repostCount)}

)}
-

Reply

+

+ Reply +

-

+

{post.replyCount ? `Read ${prettyNumber(post.replyCount)} ${ post.replyCount > 1 ? 'replies' : 'reply' diff --git a/bskyembed/src/index.css b/bskyembed/src/index.css index 22b2b8be5c..289e34cf00 100644 --- a/bskyembed/src/index.css +++ b/bskyembed/src/index.css @@ -5,3 +5,7 @@ .break-word { word-break: break-word; } + +:root { + color-scheme: light dark; +} diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx index a9e08cd3f2..a3448e90ac 100644 --- a/bskyembed/src/screens/landing.tsx +++ b/bskyembed/src/screens/landing.tsx @@ -6,6 +6,7 @@ import {useEffect, useMemo, useRef, useState} from 'preact/hooks' import arrowBottom from '../../assets/arrowBottom_stroke2_corner0_rounded.svg' import logo from '../../assets/logo.svg' +import {initColorMode} from '../color-mode' import {Container} from '../components/container' import {Link} from '../components/link' import {Post} from '../components/post' @@ -21,6 +22,8 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` const root = document.getElementById('app') if (!root) throw new Error('No root element') +initColorMode() + const agent = new BskyAgent({ service: 'https://public.api.bsky.app', }) @@ -108,7 +111,7 @@ function LandingPage() { }, [uri]) return ( -

+
@@ -121,20 +124,22 @@ function LandingPage() { type="text" value={uri} onInput={e => setUri(e.currentTarget.value)} - className="border rounded-lg py-3 w-full max-w-[600px] px-4" + className="border rounded-lg py-3 w-full max-w-[600px] px-4 dark:bg-dimmedBg dark:border-slate-500" placeholder={DEFAULT_POST} /> - + {loading ? ( - +
+ +
) : (
{!error && thread && uri && } {!error && thread && } {error && ( -
+

{error}

)} @@ -149,15 +154,15 @@ function Skeleton() {
-
+
-
-
+
+
-
-
-
+
+
+
) @@ -220,7 +225,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) { ref={ref} type="text" value={snippet} - className="border rounded-lg py-3 w-full px-4" + className="border rounded-lg py-3 w-full px-4 dark:bg-dimmedBg dark:border-slate-500" readOnly autoFocus onFocus={() => { @@ -228,7 +233,7 @@ function Snippet({thread}: {thread: AppBskyFeedDefs.ThreadViewPost}) { }} /> + + + + + + ) +} + +// Fine to keep this top-level. +let lastSelectedInterest = '' +let lastSearchText = '' + +function DialogInner({guide}: {guide: Follow10ProgressGuide}) { + const {_} = useLingui() + const interestsDisplayNames = useInterestsDisplayNames() + const {data: preferences} = usePreferencesQuery() + const personalizedInterests = preferences?.interests?.tags + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(personalizedInterests)) + const [selectedInterest, setSelectedInterest] = useState( + () => + lastSelectedInterest || + (personalizedInterests && interests.includes(personalizedInterests[0]) + ? personalizedInterests[0] + : interests[0]), + ) + const [searchText, setSearchText] = useState(lastSearchText) + const moderationOpts = useModerationOpts() + const listRef = useRef(null) + const inputRef = useRef(null) + const [headerHeight, setHeaderHeight] = useState(0) + const {currentAccount} = useSession() + const [suggestedAccounts, setSuggestedAccounts] = useState< + Map + >(() => new Map()) + + useEffect(() => { + lastSearchText = searchText + lastSelectedInterest = selectedInterest + }, [searchText, selectedInterest]) + + const query = searchText || selectedInterest + const { + data: searchResults, + isFetching, + error, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useActorSearchPaginated({ + query, + }) + + const hasSearchText = !!searchText + + const items = useMemo(() => { + const results = searchResults?.pages.flatMap(r => r.actors) + let _items: Item[] = [] + const seen = new Set() + + if (isError) { + _items.push({ + type: 'empty', + key: 'empty', + message: _(msg`We're having network issues, try again`), + }) + } else if (results) { + // First pass: search results + for (const profile of results) { + if (profile.did === currentAccount?.did) continue + if (profile.viewer?.following) continue + // my sincere apologies to Jake Gold - your bio is too keyword-filled and + // your page-rank too high, so you're at the top of half the categories -sfn + if ( + !hasSearchText && + profile.did === 'did:plc:tpg43qhh4lw4ksiffs4nbda3' && + // constrain to 'tech' + selectedInterest !== 'tech' + ) { + continue + } + seen.add(profile.did) + _items.push({ + type: 'profile', + // Don't share identity across tabs or typing attempts + key: query + ':' + profile.did, + profile, + isSuggestion: false, + }) + } + // Second pass: suggestions + _items = _items.flatMap(item => { + if (item.type !== 'profile') { + return item + } + const suggestions = suggestedAccounts.get(item.profile.did) + if (!suggestions) { + return item + } + const itemWithSuggestions = [item] + for (const suggested of suggestions) { + if (seen.has(suggested.did)) { + // Skip search results from previous step or already seen suggestions + continue + } + seen.add(suggested.did) + itemWithSuggestions.push({ + type: 'profile', + key: suggested.did, + profile: suggested, + isSuggestion: true, + }) + if (itemWithSuggestions.length === 1 + 3) { + break + } + } + return itemWithSuggestions + }) + } else { + const placeholders: Item[] = Array(10) + .fill(0) + .map((__, i) => ({ + type: 'placeholder', + key: i + '', + })) + + _items.push(...placeholders) + } + + return _items + }, [ + _, + searchResults, + isError, + currentAccount?.did, + hasSearchText, + selectedInterest, + suggestedAccounts, + query, + ]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + } + + const renderItems = useCallback( + ({item, index}: {item: Item; index: number}) => { + switch (item.type) { + case 'profile': { + return ( + + ) + } + case 'placeholder': { + return + } + case 'empty': { + return + } + default: + return null + } + }, + [moderationOpts], + ) + + const onSelectTab = useCallback( + (interest: string) => { + setSelectedInterest(interest) + inputRef.current?.clear() + setSearchText('') + listRef.current?.scrollToOffset({ + offset: 0, + animated: false, + }) + }, + [setSelectedInterest, setSearchText], + ) + + const listHeader = ( +
+ ) + + const onEndReached = useCallback(async () => { + if (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more people to follow', {message: err}) + } + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + + return ( + item.key} + style={[ + a.px_0, + web([a.py_0, {height: '100vh', maxHeight: 600}]), + native({height: '100%'}), + ]} + webInnerContentContainerStyle={a.py_0} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + keyboardDismissMode="on-drag" + scrollIndicatorInsets={{top: headerHeight}} + initialNumToRender={8} + maxToRenderPerBatch={8} + onEndReached={onEndReached} + itemLayoutAnimation={LinearTransition} + ListFooterComponent={ + + } + /> + ) +} + +let Header = ({ + guide, + inputRef, + listRef, + searchText, + onSelectTab, + setHeaderHeight, + setSearchText, + interests, + selectedInterest, + interestsDisplayNames, +}: { + guide: Follow10ProgressGuide + inputRef: React.RefObject + listRef: React.RefObject + onSelectTab: (v: string) => void + searchText: string + setHeaderHeight: (v: number) => void + setSearchText: (v: string) => void + interests: string[] + selectedInterest: string + interestsDisplayNames: Record +}): React.ReactNode => { + const t = useTheme() + const control = Dialog.useDialogContext() + return ( + setHeaderHeight(evt.nativeEvent.layout.height)} + style={[ + a.relative, + web(a.pt_lg), + native(a.pt_4xl), + a.pb_xs, + a.border_b, + t.atoms.border_contrast_low, + t.atoms.bg, + ]}> + + + + { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onEscape={control.close} + /> + + + + ) +} +Header = memo(Header) + +function HeaderTop({guide}: {guide: Follow10ProgressGuide}) { + const {_} = useLingui() + const t = useTheme() + const control = Dialog.useDialogContext() + return ( + + + Find people to follow + + + + + {isWeb ? ( + + ) : null} + + ) +} + +let Tabs = ({ + onSelectTab, + interests, + selectedInterest, + hasSearchText, + interestsDisplayNames, +}: { + onSelectTab: (tab: string) => void + interests: string[] + selectedInterest: string + hasSearchText: boolean + interestsDisplayNames: Record +}): React.ReactNode => { + const listRef = useRef(null) + const [scrollX, setScrollX] = useState(0) + const [totalWidth, setTotalWidth] = useState(0) + const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) + const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) + + const onInitialLayout = useNonReactiveCallback(() => { + const index = interests.indexOf(selectedInterest) + scrollIntoViewIfNeeded(index) + }) + + useEffect(() => { + if (tabOffsets) { + onInitialLayout() + } + }, [tabOffsets, onInitialLayout]) + + function scrollIntoViewIfNeeded(index: number) { + const btnLayout = tabOffsets[index] + if (!btnLayout) return + + const viewportLeftEdge = scrollX + const viewportRightEdge = scrollX + totalWidth + const shouldScrollToLeftEdge = viewportLeftEdge > btnLayout.x + const shouldScrollToRightEdge = + viewportRightEdge < btnLayout.x + btnLayout.width + + if (shouldScrollToLeftEdge) { + listRef.current?.scrollTo({ + x: btnLayout.x - tokens.space.lg, + animated: true, + }) + } else if (shouldScrollToRightEdge) { + listRef.current?.scrollTo({ + x: btnLayout.x - totalWidth + btnLayout.width + tokens.space.lg, + animated: true, + }) + } + } + + function handleSelectTab(index: number) { + const tab = interests[index] + onSelectTab(tab) + scrollIntoViewIfNeeded(index) + } + + function handleTabLayout(index: number, x: number, width: number) { + if (!tabOffsets.length) { + pendingTabOffsets.current[index] = {x, width} + if (pendingTabOffsets.current.length === interests.length) { + setTabOffsets(pendingTabOffsets.current) + } + } + } + + return ( + o.x - tokens.space.xl) + : undefined + } + onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} + scrollEventThrottle={200} // big throttle + onScroll={evt => setScrollX(evt.nativeEvent.contentOffset.x)}> + {interests.map((interest, i) => { + const active = interest === selectedInterest && !hasSearchText + return ( + + ) + })} + + ) +} +Tabs = memo(Tabs) + +let Tab = ({ + onSelectTab, + interest, + active, + index, + interestsDisplayName, + onLayout, +}: { + onSelectTab: (index: number) => void + interest: string + active: boolean + index: number + interestsDisplayName: string + onLayout: (index: number, x: number, width: number) => void +}): React.ReactNode => { + const {_} = useLingui() + const activeText = active ? _(msg` (active)`) : '' + return ( + + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) + }> + + + ) +} +Tab = memo(Tab) + +let FollowProfileCard = ({ + profile, + moderationOpts, + isSuggestion, + setSuggestedAccounts, + noBorder, +}: { + profile: AppBskyActorDefs.ProfileView + moderationOpts: ModerationOpts + isSuggestion: boolean + setSuggestedAccounts: ( + updater: ( + v: Map, + ) => Map, + ) => void + noBorder?: boolean +}): React.ReactNode => { + const [hasFollowed, setHasFollowed] = useState(false) + const followupSuggestion = useSuggestedFollowsByActorQuery({ + did: profile.did, + enabled: hasFollowed, + }) + const candidates = followupSuggestion.data?.suggestions + + useEffect(() => { + // TODO: Move out of effect. + if (hasFollowed && candidates && candidates.length > 0) { + setSuggestedAccounts(suggestions => { + const newSuggestions = new Map(suggestions) + newSuggestions.set(profile.did, candidates) + return newSuggestions + }) + } + }, [hasFollowed, profile.did, candidates, setSuggestedAccounts]) + + return ( + + + setHasFollowed(true)} + noBorder={noBorder} + /> + + + ) +} +FollowProfileCard = memo(FollowProfileCard) + +function FollowProfileCardInner({ + profile, + moderationOpts, + onFollow, + noBorder, +}: { + profile: AppBskyActorDefs.ProfileView + moderationOpts: ModerationOpts + onFollow?: () => void + noBorder?: boolean +}) { + const control = Dialog.useDialogContext() + const t = useTheme() + return ( + control.close()}> + {({hovered, pressed}) => ( + + + + + + + + + + + )} + + ) +} + +function CardOuter({ + children, + style, +}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) { + const t = useTheme() + return ( + + {children} + + ) +} + +function SearchInput({ + onChangeText, + onEscape, + inputRef, + defaultValue, +}: { + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject + defaultValue: string +}) { + const t = useTheme() + const {_} = useLingui() + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const interacted = hovered || focused + + return ( + + + + { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + accessibilityLabel={_(msg`Search profiles`)} + accessibilityHint={_(msg`Search profiles`)} + /> + + ) +} + +function ProfileCardSkeleton() { + const t = useTheme() + + return ( + + + + + + + + + ) +} + +function Empty({message}: {message: string}) { + const t = useTheme() + return ( + + + {message} + + + (╯°□°)╯︵ ┻━┻ + + ) +} + +function boostInterests(boosts?: string[]) { + return (_a: string, _b: string) => { + const indexA = boosts?.indexOf(_a) ?? -1 + const indexB = boosts?.indexOf(_b) ?? -1 + const rankA = indexA === -1 ? Infinity : indexA + const rankB = indexB === -1 ? Infinity : indexB + return rankA - rankB + } +} diff --git a/src/components/ProgressGuide/List.tsx b/src/components/ProgressGuide/List.tsx index 299d1e69fc..bbc5a0177f 100644 --- a/src/components/ProgressGuide/List.tsx +++ b/src/components/ProgressGuide/List.tsx @@ -10,12 +10,15 @@ import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' import {Text} from '#/components/Typography' +import {FollowDialog} from './FollowDialog' import {ProgressGuideTask} from './Task' export function ProgressGuideList({style}: {style?: StyleProp}) { const t = useTheme() const {_} = useLingui() - const guide = useProgressGuide('like-10-and-follow-7') + const followProgressGuide = useProgressGuide('follow-10') + const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') + const guide = followProgressGuide || followAndLikeProgressGuide const {endProgressGuide} = useProgressGuideControls() if (guide) { @@ -41,18 +44,33 @@ export function ProgressGuideList({style}: {style?: StyleProp}) { - - + {guide.guide === 'follow-10' && ( + <> + + + + )} + {guide.guide === 'like-10-and-follow-7' && ( + <> + + + + )} ) } diff --git a/src/components/ProgressGuide/Task.tsx b/src/components/ProgressGuide/Task.tsx index 973ee1ac7f..b9ba3fd9ab 100644 --- a/src/components/ProgressGuide/Task.tsx +++ b/src/components/ProgressGuide/Task.tsx @@ -10,11 +10,13 @@ export function ProgressGuideTask({ total, title, subtitle, + tabularNumsTitle, }: { current: number total: number title: string subtitle?: string + tabularNumsTitle?: boolean }) { const t = useTheme() @@ -33,8 +35,16 @@ export function ProgressGuideTask({ /> )} - - {title} + + + {title} + {subtitle && ( diff --git a/src/components/TrendingTopics.tsx b/src/components/TrendingTopics.tsx new file mode 100644 index 0000000000..6881f24bd5 --- /dev/null +++ b/src/components/TrendingTopics.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import {View} from 'react-native' +import {AtUri} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +// import {makeProfileLink} from '#/lib/routes/links' +// import {feedUriToHref} from '#/lib/strings/url-helpers' +// import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +// import {CloseQuote_Filled_Stroke2_Corner0_Rounded as Quote} from '#/components/icons/Quote' +// import {UserAvatar} from '#/view/com/util/UserAvatar' +import type {TrendingTopic} from '#/state/queries/trending/useTrendingTopics' +import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {Link as InternalLink, LinkProps} from '#/components/Link' +import {Text} from '#/components/Typography' + +export function TrendingTopic({ + topic: raw, + size, + style, +}: {topic: TrendingTopic; size?: 'large' | 'small'} & ViewStyleProp) { + const t = useTheme() + const topic = useTopic(raw) + + const isSmall = size === 'small' + // const hasAvi = topic.type === 'feed' || topic.type === 'profile' + // const aviSize = isSmall ? 16 : 20 + // const iconSize = isSmall ? 16 : 20 + + return ( + + {/* + + {topic.type === 'tag' ? ( + + ) : topic.type === 'topic' ? ( + + ) : topic.type === 'feed' ? ( + + ) : ( + + )} + + */} + + + {topic.displayName} + + + ) +} + +export function TrendingTopicSkeleton({ + size = 'large', + index = 0, +}: { + size?: 'large' | 'small' + index?: number +}) { + const t = useTheme() + const isSmall = size === 'small' + return ( + + ) +} + +export function TrendingTopicLink({ + topic: raw, + children, + ...rest +}: { + topic: TrendingTopic +} & Omit) { + const topic = useTopic(raw) + + return ( + + {children} + + ) +} + +type ParsedTrendingTopic = + | { + type: 'topic' | 'tag' | 'unknown' + label: string + displayName: string + url: string + uri: undefined + } + | { + type: 'profile' | 'feed' + label: string + displayName: string + url: string + uri: AtUri + } + +export function useTopic(raw: TrendingTopic): ParsedTrendingTopic { + const {_} = useLingui() + return React.useMemo(() => { + const {topic: displayName, link} = raw + + if (link.startsWith('/search')) { + return { + type: 'topic', + label: _(msg`Browse posts about ${displayName}`), + displayName, + uri: undefined, + url: link, + } + } else if (link.startsWith('/hashtag')) { + return { + type: 'tag', + label: _(msg`Browse posts tagged with ${displayName}`), + displayName, + // displayName: displayName.replace(/^#/, ''), + uri: undefined, + url: link, + } + } + + /* + if (!link.startsWith('at://')) { + // above logic + } else { + const urip = new AtUri(link) + switch (urip.collection) { + case 'app.bsky.actor.profile': { + return { + type: 'profile', + label: _(msg`View ${displayName}'s profile`), + displayName, + uri: urip, + url: makeProfileLink({did: urip.host, handle: urip.host}), + } + } + case 'app.bsky.feed.generator': { + return { + type: 'feed', + label: _(msg`Browse the ${displayName} feed`), + displayName, + uri: urip, + url: feedUriToHref(link), + } + } + } + } + */ + + return { + type: 'unknown', + label: _(msg`Browse topic ${displayName}`), + displayName, + uri: undefined, + url: link, + } + }, [_, raw]) +} diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index 3e202cb8f2..4ed7f83719 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -8,7 +8,6 @@ import { renderChildrenWithEmoji, TextProps, } from '#/alf/typography' -import {IS_DEV} from '#/env' export type {TextProps} /** @@ -31,7 +30,7 @@ export function Text({ flags, }) - if (IS_DEV) { + if (__DEV__) { if (!emoji && childHasEmoji(children)) { logger.warn( `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add '`, diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx index fc77eda0d3..a698574a49 100644 --- a/src/components/dialogs/PostInteractionSettingsDialog.tsx +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -311,7 +311,7 @@ export function PostInteractionSettingsForm({ onChange={onChangeQuotesEnabled} style={[a.justify_between, a.pt_xs]}> - Quote posts enabled + Allow quote posts diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx index 11d622a4dc..10cae887ba 100644 --- a/src/components/dialogs/nuxs/index.tsx +++ b/src/components/dialogs/nuxs/index.tsx @@ -15,7 +15,6 @@ import {useOnboardingState} from '#/state/shell' * NUXs */ import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' -import {IS_DEV} from '#/env' type Context = { activeNux: Nux | undefined @@ -93,10 +92,10 @@ function Inner({ setActiveNux(undefined) }, [activeNux, setActiveNux]) - if (IS_DEV && typeof window !== 'undefined') { + if (__DEV__ && typeof window !== 'undefined') { // @ts-ignore window.clearNuxDialog = (id: Nux) => { - if (!IS_DEV || !id) return + if (!__DEV__ || !id) return resetNuxs([id]) unsnooze() } diff --git a/src/components/dms/dialogs/SearchablePeopleList.tsx b/src/components/dms/dialogs/SearchablePeopleList.tsx index 0946d2a27c..50090cbcbb 100644 --- a/src/components/dms/dialogs/SearchablePeopleList.tsx +++ b/src/components/dms/dialogs/SearchablePeopleList.tsx @@ -63,6 +63,7 @@ export function SearchablePeopleList({ const {_} = useLingui() const moderationOpts = useModerationOpts() const control = Dialog.useDialogContext() + const [headerHeight, setHeaderHeight] = useState(0) const listRef = useRef(null) const {currentAccount} = useSession() const inputRef = useRef(null) @@ -237,6 +238,7 @@ export function SearchablePeopleList({ const listHeader = useMemo(() => { return ( setHeaderHeight(evt.nativeEvent.layout.height)} style={[ a.relative, web(a.pt_lg), @@ -315,6 +317,7 @@ export function SearchablePeopleList({ ]} webInnerContentContainerStyle={a.py_0} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + scrollIndicatorInsets={{top: headerHeight}} keyboardDismissMode="on-drag" /> ) diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 2bbf054225..410cc654e9 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -11,7 +11,15 @@ import { import {HITSLOP_20} from '#/lib/constants' import {mergeRefs} from '#/lib/merge-refs' -import {android, atoms as a, TextStyleProp, useTheme, web} from '#/alf' +import { + android, + applyFonts, + atoms as a, + TextStyleProp, + useAlf, + useTheme, + web, +} from '#/alf' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Props as SVGIconProps} from '#/components/icons/common' import {Text} from '#/components/Typography' @@ -148,6 +156,7 @@ export function createInput(Component: typeof TextInput) { ...rest }: InputProps) { const t = useTheme() + const {fonts} = useAlf() const ctx = React.useContext(Context) const withinRoot = Boolean(ctx.inputRef) @@ -171,6 +180,48 @@ export function createInput(Component: typeof TextInput) { const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean)) + const flattened = StyleSheet.flatten([ + a.relative, + a.z_20, + a.flex_1, + a.text_md, + t.atoms.text, + a.px_xs, + { + // paddingVertical doesn't work w/multiline - esb + paddingTop: 12, + paddingBottom: 13, + lineHeight: a.text_md.fontSize * 1.1875, + textAlignVertical: rest.multiline ? 'top' : undefined, + minHeight: rest.multiline ? 80 : undefined, + minWidth: 0, + }, + // fix for autofill styles covering border + web({ + paddingTop: 10, + paddingBottom: 11, + marginTop: 2, + marginBottom: 2, + }), + android({ + paddingTop: 8, + paddingBottom: 8, + }), + style, + ]) + + applyFonts(flattened, fonts.family) + + // should always be defined on `typography` + // @ts-ignore + if (flattened.fontSize) { + // @ts-ignore + flattened.fontSize = Math.round( + // @ts-ignore + flattened.fontSize * fonts.scaleMultiplier, + ) + } + return ( <> : null +} + +export function Inner() { + const t = useTheme() + const {_} = useLingui() + const gutters = useGutters(['wide', 'base']) + const trendingPrompt = Prompt.usePromptControl() + const {setTrendingDisabled} = useTrendingSettingsApi() + const {data: trending, error, isLoading} = useTrendingTopics() + const noTopics = !isLoading && !error && !trending?.topics?.length + + return error || noTopics ? null : ( + + + + + + Trending + + + + + BETA + + + + + + + + + {isLoading ? ( + Array(TRENDING_TOPICS_COUNT) + .fill(0) + .map((_n, i) => ) + ) : !trending?.topics ? null : ( + <> + {trending.topics.map(topic => ( + + {({hovered}) => ( + + )} + + ))} + + )} + + + setTrendingDisabled(true)} + /> + + ) +} diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx index 6815998079..f0a25959fa 100644 --- a/src/components/moderation/LabelsOnMe.tsx +++ b/src/components/moderation/LabelsOnMe.tsx @@ -1,6 +1,6 @@ import {StyleProp, View, ViewStyle} from 'react-native' import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api' -import {msg, Plural} from '@lingui/macro' +import {msg, Plural, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSession} from '#/state/session' @@ -50,17 +50,23 @@ export function LabelsOnMe({ {type === 'account' ? ( - + + {' '} + been placed on this account + ) : ( - + + {' '} + been placed on this content + )} diff --git a/src/env.ts b/src/env.ts index fbcc15f576..32ce70670b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,6 +1,3 @@ -export const IS_TEST = process.env.EXPO_PUBLIC_ENV === 'test' -export const IS_DEV = __DEV__ -export const IS_PROD = !IS_DEV export const LOG_DEBUG = process.env.EXPO_PUBLIC_LOG_DEBUG || '' export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as | 'debug' diff --git a/src/lib/api/upload-blob.web.ts b/src/lib/api/upload-blob.web.ts index d3c52190c1..45b72f7ee7 100644 --- a/src/lib/api/upload-blob.web.ts +++ b/src/lib/api/upload-blob.web.ts @@ -11,7 +11,10 @@ export async function uploadBlob( input: string | Blob, encoding?: string, ): Promise { - if (typeof input === 'string' && input.startsWith('data:')) { + if ( + typeof input === 'string' && + (input.startsWith('data:') || input.startsWith('blob:')) + ) { const blob = await fetch(input).then(r => r.blob()) return agent.uploadBlob(blob, {encoding}) } diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts index 4da70d75d8..0749087eae 100644 --- a/src/lib/app-info.ts +++ b/src/lib/app-info.ts @@ -1,9 +1,7 @@ import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application' -export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV -export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' -export const IS_INTERNAL = IS_DEV || IS_TESTFLIGHT +export const IS_INTERNAL = __DEV__ || IS_TESTFLIGHT // This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings // along with the other version info. Useful for debugging/reporting. @@ -12,9 +10,9 @@ export const BUNDLE_IDENTIFIER = process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? '' // This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used // for Statsig reporting and shouldn't be used to identify a specific bundle. export const BUNDLE_DATE = - IS_TESTFLIGHT || IS_DEV ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE) + IS_TESTFLIGHT || __DEV__ ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE) export const appVersion = `${nativeApplicationVersion}.${nativeBuildVersion}` export const bundleInfo = `${BUNDLE_IDENTIFIER} (${ - IS_DEV ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod' + __DEV__ ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod' })` diff --git a/src/lib/app-info.web.ts b/src/lib/app-info.web.ts index 742ccfe975..94c787cbcf 100644 --- a/src/lib/app-info.web.ts +++ b/src/lib/app-info.web.ts @@ -1,9 +1,7 @@ import {version} from '../../package.json' -export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV -export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' export const IS_TESTFLIGHT = false -export const IS_INTERNAL = IS_DEV +export const IS_INTERNAL = __DEV__ // This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings // along with the other version info. Useful for debugging/reporting. @@ -12,9 +10,9 @@ export const BUNDLE_IDENTIFIER = // This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used // for Statsig reporting and shouldn't be used to identify a specific bundle. -export const BUNDLE_DATE = IS_DEV +export const BUNDLE_DATE = __DEV__ ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE) export const appVersion = version -export const bundleInfo = `${BUNDLE_IDENTIFIER} (${IS_DEV ? 'dev' : 'prod'})` +export const bundleInfo = `${BUNDLE_IDENTIFIER} (${__DEV__ ? 'dev' : 'prod'})` diff --git a/src/lib/bitdrift.ts b/src/lib/bitdrift.ts new file mode 100644 index 0000000000..3f892f6b8c --- /dev/null +++ b/src/lib/bitdrift.ts @@ -0,0 +1,21 @@ +import {init} from '@bitdrift/react-native' +import {Statsig} from 'statsig-react-native-expo' +export {debug, error, info, warn} from '@bitdrift/react-native' + +import {initPromise} from './statsig/statsig' + +const BITDRIFT_API_KEY = process.env.BITDRIFT_API_KEY + +initPromise.then(() => { + let isEnabled = false + try { + if (Statsig.checkGate('enable_bitdrift')) { + isEnabled = true + } + } catch (e) { + // Statsig may complain about it being called too early. + } + if (isEnabled && BITDRIFT_API_KEY) { + init(BITDRIFT_API_KEY, {url: 'https://api-bsky.bitdrift.io'}) + } +}) diff --git a/src/lib/bitdrift.web.ts b/src/lib/bitdrift.web.ts new file mode 100644 index 0000000000..5db69450fa --- /dev/null +++ b/src/lib/bitdrift.web.ts @@ -0,0 +1,4 @@ +export function debug() {} +export function error() {} +export function info() {} +export function warn() {} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ebf4b1ee11..279c6f913d 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -23,15 +23,6 @@ export const STARTER_PACK_MAX_SIZE = 150 // -prf export const JOINED_THIS_WEEK = 2880000 // estimate as of 11/26/24 -export const DISCOVER_DEBUG_DIDS: Record = { - 'did:plc:oisofpd7lj26yvgiivf3lxsi': true, // hailey.at - 'did:plc:fpruhuo22xkm5o7ttr2ktxdo': true, // danabra.mov - 'did:plc:p2cp5gopk7mgjegy6wadk3ep': true, // samuel.bsky.team - 'did:plc:ragtjsm2j2vknwkz3zp4oxrd': true, // pfrazee.com - 'did:plc:vpkhqolt662uhesyj6nxm7ys': true, // why.bsky.team - 'did:plc:3jpt2mvvsumj2r7eqk4gzzjz': true, // esb.lol -} - const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new` export function FEEDBACK_FORM_URL({ email, diff --git a/src/lib/hooks/useEnableKeyboardController.tsx b/src/lib/hooks/useEnableKeyboardController.tsx index c7205d0160..366791c0c4 100644 --- a/src/lib/hooks/useEnableKeyboardController.tsx +++ b/src/lib/hooks/useEnableKeyboardController.tsx @@ -12,8 +12,6 @@ import { } from 'react-native-keyboard-controller' import {useFocusEffect} from '@react-navigation/native' -import {IS_DEV} from '#/env' - const KeyboardControllerRefCountContext = createContext<{ incrementRefCount: () => void decrementRefCount: () => void @@ -57,7 +55,7 @@ function KeyboardControllerProviderInner({ refCount.current-- setEnabled(refCount.current > 0) - if (IS_DEV && refCount.current < 0) { + if (__DEV__ && refCount.current < 0) { console.error('KeyboardController ref count < 0') } }, diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index 69ae536d02..2ec3fcb799 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -239,14 +239,21 @@ export function useNotificationsHandler() { ) logEvent('notifications:openApp', {}) invalidateCachedUnreadPage() - truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) + const payload = e.notification.request.trigger + .payload as NotificationPayload + truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) + if ( + payload.reason === 'mention' || + payload.reason === 'quote' || + payload.reason === 'reply' + ) { + truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) + } logger.debug('Notifications: handleNotification', { content: e.notification.request.content, payload: e.notification.request.trigger.payload, }) - handleNotification( - e.notification.request.trigger.payload as NotificationPayload, - ) + handleNotification(payload) Notifications.dismissAllNotificationsAsync() } }) diff --git a/src/lib/notifications/notifications.e2e.ts b/src/lib/notifications/notifications.e2e.ts new file mode 100644 index 0000000000..0586ac1bf8 --- /dev/null +++ b/src/lib/notifications/notifications.e2e.ts @@ -0,0 +1,11 @@ +export function useNotificationsRegistration() {} + +export function useRequestNotificationsPermission() { + return async ( + _context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home', + ) => {} +} + +export async function decrementBadgeCount(_by: number) {} + +export async function resetBadgeCount() {} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 9e3407261d..d720886e9f 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -47,6 +47,7 @@ export type CommonNavigatorParams = { AppIconSettings: undefined Search: {q?: string} Hashtag: {tag: string; author?: string} + Topic: {topic: string} MessagesConversation: {conversation: string; embed?: string} MessagesSettings: undefined NotificationSettings: undefined @@ -75,7 +76,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & { } export type NotificationsTabNavigatorParams = CommonNavigatorParams & { - Notifications: {show?: 'all'} + Notifications: undefined } export type MyProfileTabNavigatorParams = CommonNavigatorParams & { @@ -90,8 +91,9 @@ export type FlatNavigatorParams = CommonNavigatorParams & { Home: undefined Search: {q?: string} Feeds: undefined - Notifications: {show?: 'all'} + Notifications: undefined Hashtag: {tag: string; author?: string} + Topic: {topic: string} Messages: {pushToConversation?: string; animation?: 'push' | 'pop'} } @@ -102,9 +104,10 @@ export type AllNavigatorParams = CommonNavigatorParams & { Search: {q?: string} Feeds: undefined NotificationsTab: undefined - Notifications: {show?: 'all'} + Notifications: undefined MyProfileTab: undefined Hashtag: {tag: string; author?: string} + Topic: {topic: string} MessagesTab: undefined Messages: {animation?: 'push' | 'pop'} Start: {name: string; rkey: string} diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index 2c390d7de9..b2695694dd 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -7,7 +7,7 @@ import {Platform} from 'react-native' import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application' import {init} from '@sentry/react-native' -import {BUILD_ENV, IS_DEV, IS_TESTFLIGHT} from '#/lib/app-info' +import {IS_TESTFLIGHT} from '#/lib/app-info' /** * Examples: @@ -27,14 +27,14 @@ const release = nativeApplicationVersion ?? 'dev' */ const dist = `${Platform.OS}.${nativeBuildVersion}.${ IS_TESTFLIGHT ? 'tf' : '' -}${IS_DEV ? 'dev' : ''}` +}${__DEV__ ? 'dev' : ''}` init({ enabled: !__DEV__, autoSessionTracking: false, dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432', debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production - environment: BUILD_ENV ?? 'development', + environment: process.env.NODE_ENV, dist, release, }) diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index e6c9c5d135..f1dfb0a947 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -162,6 +162,7 @@ export type LogEvents = { | 'StarterPackProfilesList' | 'FeedInterstitial' | 'ProfileHeaderSuggestedFollows' + | 'PostOnboardingFindFollows' } 'profile:unfollow': { logContext: @@ -177,6 +178,7 @@ export type LogEvents = { | 'StarterPackProfilesList' | 'FeedInterstitial' | 'ProfileHeaderSuggestedFollows' + | 'PostOnboardingFindFollows' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 3cec5d5b29..455a703458 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -1,4 +1,7 @@ export type Gate = // Keep this alphabetic please. - | 'debug_show_feedcontext' // DISABLED DUE TO EME - | 'post_feed_lang_window' // DISABLED DUE TO EME + | 'debug_show_feedcontext' + | 'debug_subscriptions' + | 'new_postonboarding' + | 'remove_show_latest_button' + | 'trending_topics_beta' diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index 51d7eb98e5..e0882806d5 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -5,6 +5,7 @@ import {sha256} from 'js-sha256' import {Statsig, StatsigProvider} from 'statsig-react-native-expo' import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info' +import * as bitdrift from '#/lib/bitdrift' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' @@ -16,6 +17,8 @@ import {Gate} from './gates' const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' +export const initPromise = initialize() + type StatsigUser = { userID: string | undefined // TODO: Remove when enough users have custom.platform: @@ -95,19 +98,38 @@ export function logEvent( rawMetadata: LogEvents[E] & FlatJSONRecord, ) { try { - const fullMetadata = { - ...rawMetadata, - } as Record // Statsig typings are unnecessarily strict here. + const fullMetadata = toStringRecord(rawMetadata) fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)' if (Statsig.initializeCalled()) { Statsig.logEvent(eventName, null, fullMetadata) } + // Intentionally bypass the logger abstraction to log rich objects. + console.groupCollapsed(eventName) + console.log(fullMetadata) + console.groupEnd() + bitdrift.info(eventName, fullMetadata) } catch (e) { // A log should never interrupt the calling code, whatever happens. logger.error('Failed to log an event', {message: e}) } } +function toStringRecord( + metadata: LogEvents[E] & FlatJSONRecord, +): Record { + const record: Record = {} + for (let key in metadata) { + if (metadata.hasOwnProperty(key)) { + if (typeof metadata[key] === 'string') { + record[key] = metadata[key] + } else { + record[key] = JSON.stringify(metadata[key]) + } + } + } + return record +} + // We roll our own cache in front of Statsig because it is a singleton // and it's been difficult to get it to behave in a predictable way. // Our own cache ensures consistent evaluation within a single session. @@ -219,9 +241,6 @@ export async function tryFetchGates( // Use this for less common operations where the user would be OK with a delay. timeoutMs = 1500 } - // Note: This condition is currently false the very first render because - // Statsig has not initialized yet. In the future, we can fix this by - // doing the initialization ourselves instead of relying on the provider. if (Statsig.initializeCalled()) { await Promise.race([ timeout(timeoutMs), diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts index 00652a355a..5620ab3203 100644 --- a/src/locale/i18n.ts +++ b/src/locale/i18n.ts @@ -1,8 +1,10 @@ // Don't remove -force from these because detection is VERY slow on low-end Android. // https://github.com/formatjs/formatjs/issues/4463#issuecomment-2176070577 import '@formatjs/intl-locale/polyfill-force' +import '@formatjs/intl-datetimeformat/polyfill-force' import '@formatjs/intl-pluralrules/polyfill-force' import '@formatjs/intl-numberformat/polyfill-force' +import '@formatjs/intl-datetimeformat/locale-data/en' import '@formatjs/intl-pluralrules/locale-data/en' import '@formatjs/intl-numberformat/locale-data/en' @@ -49,6 +51,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.an: { i18n.loadAndActivate({locale, messages: messagesAn}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/es'), import('@formatjs/intl-pluralrules/locale-data/an'), import('@formatjs/intl-numberformat/locale-data/es'), ]) @@ -57,6 +60,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.ast: { i18n.loadAndActivate({locale, messages: messagesAst}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/ast'), import('@formatjs/intl-pluralrules/locale-data/ast'), import('@formatjs/intl-numberformat/locale-data/ast'), ]) @@ -65,6 +69,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.ca: { i18n.loadAndActivate({locale, messages: messagesCa}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/ca'), import('@formatjs/intl-pluralrules/locale-data/ca'), import('@formatjs/intl-numberformat/locale-data/ca'), ]) @@ -73,6 +78,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.de: { i18n.loadAndActivate({locale, messages: messagesDe}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/de'), import('@formatjs/intl-pluralrules/locale-data/de'), import('@formatjs/intl-numberformat/locale-data/de'), ]) @@ -81,6 +87,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.en_GB: { i18n.loadAndActivate({locale, messages: messagesEn_GB}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/en-GB'), import('@formatjs/intl-pluralrules/locale-data/en'), import('@formatjs/intl-numberformat/locale-data/en-GB'), ]) @@ -89,6 +96,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.es: { i18n.loadAndActivate({locale, messages: messagesEs}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/es'), import('@formatjs/intl-pluralrules/locale-data/es'), import('@formatjs/intl-numberformat/locale-data/es'), ]) @@ -97,6 +105,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.fi: { i18n.loadAndActivate({locale, messages: messagesFi}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/fi'), import('@formatjs/intl-pluralrules/locale-data/fi'), import('@formatjs/intl-numberformat/locale-data/fi'), ]) @@ -105,6 +114,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.fr: { i18n.loadAndActivate({locale, messages: messagesFr}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/fr'), import('@formatjs/intl-pluralrules/locale-data/fr'), import('@formatjs/intl-numberformat/locale-data/fr'), ]) @@ -113,6 +123,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.ga: { i18n.loadAndActivate({locale, messages: messagesGa}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/ga'), import('@formatjs/intl-pluralrules/locale-data/ga'), import('@formatjs/intl-numberformat/locale-data/ga'), ]) @@ -121,6 +132,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.gl: { i18n.loadAndActivate({locale, messages: messagesGl}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/gl'), import('@formatjs/intl-pluralrules/locale-data/gl'), import('@formatjs/intl-numberformat/locale-data/gl'), ]) @@ -129,6 +141,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.hi: { i18n.loadAndActivate({locale, messages: messagesHi}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/hi'), import('@formatjs/intl-pluralrules/locale-data/hi'), import('@formatjs/intl-numberformat/locale-data/hi'), ]) @@ -137,6 +150,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.hu: { i18n.loadAndActivate({locale, messages: messagesHu}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/hu'), import('@formatjs/intl-pluralrules/locale-data/hu'), import('@formatjs/intl-numberformat/locale-data/hu'), ]) @@ -145,6 +159,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.id: { i18n.loadAndActivate({locale, messages: messagesId}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/id'), import('@formatjs/intl-pluralrules/locale-data/id'), import('@formatjs/intl-numberformat/locale-data/id'), ]) @@ -153,6 +168,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.it: { i18n.loadAndActivate({locale, messages: messagesIt}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/it'), import('@formatjs/intl-pluralrules/locale-data/it'), import('@formatjs/intl-numberformat/locale-data/it'), ]) @@ -161,6 +177,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.ja: { i18n.loadAndActivate({locale, messages: messagesJa}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/ja'), import('@formatjs/intl-pluralrules/locale-data/ja'), import('@formatjs/intl-numberformat/locale-data/ja'), ]) @@ -169,6 +186,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.ko: { i18n.loadAndActivate({locale, messages: messagesKo}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/ko'), import('@formatjs/intl-pluralrules/locale-data/ko'), import('@formatjs/intl-numberformat/locale-data/ko'), ]) @@ -177,6 +195,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.nl: { i18n.loadAndActivate({locale, messages: messagesNl}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/nl'), import('@formatjs/intl-pluralrules/locale-data/nl'), import('@formatjs/intl-numberformat/locale-data/nl'), ]) @@ -185,6 +204,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.pl: { i18n.loadAndActivate({locale, messages: messagesPl}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/pl'), import('@formatjs/intl-pluralrules/locale-data/pl'), import('@formatjs/intl-numberformat/locale-data/pl'), ]) @@ -193,6 +213,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.pt_BR: { i18n.loadAndActivate({locale, messages: messagesPt_BR}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/pt'), import('@formatjs/intl-pluralrules/locale-data/pt'), import('@formatjs/intl-numberformat/locale-data/pt'), ]) @@ -201,6 +222,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.ru: { i18n.loadAndActivate({locale, messages: messagesRu}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/ru'), import('@formatjs/intl-pluralrules/locale-data/ru'), import('@formatjs/intl-numberformat/locale-data/ru'), ]) @@ -209,6 +231,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.th: { i18n.loadAndActivate({locale, messages: messagesTh}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/th'), import('@formatjs/intl-pluralrules/locale-data/th'), import('@formatjs/intl-numberformat/locale-data/th'), ]) @@ -217,6 +240,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.tr: { i18n.loadAndActivate({locale, messages: messagesTr}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/tr'), import('@formatjs/intl-pluralrules/locale-data/tr'), import('@formatjs/intl-numberformat/locale-data/tr'), ]) @@ -225,6 +249,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.uk: { i18n.loadAndActivate({locale, messages: messagesUk}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/uk'), import('@formatjs/intl-pluralrules/locale-data/uk'), import('@formatjs/intl-numberformat/locale-data/uk'), ]) @@ -233,6 +258,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.vi: { i18n.loadAndActivate({locale, messages: messagesVi}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/vi'), import('@formatjs/intl-pluralrules/locale-data/vi'), import('@formatjs/intl-numberformat/locale-data/vi'), ]) @@ -241,6 +267,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.zh_CN: { i18n.loadAndActivate({locale, messages: messagesZh_CN}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/zh-Hans'), import('@formatjs/intl-pluralrules/locale-data/zh'), import('@formatjs/intl-numberformat/locale-data/zh'), ]) @@ -249,6 +276,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.zh_HK: { i18n.loadAndActivate({locale, messages: messagesZh_HK}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/yue'), import('@formatjs/intl-pluralrules/locale-data/zh'), import('@formatjs/intl-numberformat/locale-data/zh'), ]) @@ -257,6 +285,7 @@ export async function dynamicActivate(locale: AppLanguage) { case AppLanguage.zh_TW: { i18n.loadAndActivate({locale, messages: messagesZh_TW}) await Promise.all([ + import('@formatjs/intl-datetimeformat/locale-data/zh-Hant'), import('@formatjs/intl-pluralrules/locale-data/zh'), import('@formatjs/intl-numberformat/locale-data/zh'), ]) diff --git a/src/locale/locales/an/messages.po b/src/locale/locales/an/messages.po index ab8872e2dd..86e6a20f1e 100644 --- a/src/locale/locales/an/messages.po +++ b/src/locale/locales/an/messages.po @@ -3613,7 +3613,7 @@ msgstr "Escribe la tuya clau" #~ msgstr "Escribe lo tuyo furnidor d'aloch preferiu" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Escribe lo tuyo identificador" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8877,7 +8877,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "La tuya canal de Seguindo ye vueda! Sigue a mas usuarios pa veyer las suyas publicacions aquí." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Lo tuyo identificador completo será" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8921,5 +8921,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Lo tuyo reporte s'ha ninviau a lo servicio de moderación de Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Lo tuyo identificador" diff --git a/src/locale/locales/ast/messages.po b/src/locale/locales/ast/messages.po index 73634d0b36..406c8ab8a4 100644 --- a/src/locale/locales/ast/messages.po +++ b/src/locale/locales/ast/messages.po @@ -3509,7 +3509,7 @@ msgstr "" #~ msgstr "" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8543,7 +8543,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "¡El feed siguiente ta baleru! Sigui a más usuarios pa ver qué pasó." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "L'identificador completu va ser" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8587,5 +8587,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "L'informe unvióse al serviciu de moderación de Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "" diff --git a/src/locale/locales/ca/messages.po b/src/locale/locales/ca/messages.po index f4dee376f7..fcaf1fc2eb 100644 --- a/src/locale/locales/ca/messages.po +++ b/src/locale/locales/ca/messages.po @@ -4268,7 +4268,7 @@ msgstr "Introdueix la teva contrasenya" #~ msgstr "Introdueix el teu proveïdor d'allotjament preferit" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Introdueix el teu identificador d'usuari" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -10404,7 +10404,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "El teu canal de seguint està buit! Segueix a més usuaris per a saber què està passant." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "El teu identificador complet serà" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -10458,5 +10458,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "El teu informe s'enviarà al servei de moderació de Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "El teu identificador d'usuari" diff --git a/src/locale/locales/de/messages.po b/src/locale/locales/de/messages.po index 1101991e0c..afe5d2f354 100644 --- a/src/locale/locales/de/messages.po +++ b/src/locale/locales/de/messages.po @@ -3742,7 +3742,7 @@ msgstr "Gib dein Passwort ein" #~ msgstr "" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Gib deinen Handle ein" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -9663,7 +9663,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Dein Following-Feed ist leer! Folge mehr Benutzern, um auf dem Laufenden zu bleiben." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Dein vollständiger Handle lautet" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -9707,5 +9707,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Dein Benutzerhandle" diff --git a/src/locale/locales/en-GB/messages.po b/src/locale/locales/en-GB/messages.po index 5e7badcc26..a61e096e8b 100644 --- a/src/locale/locales/en-GB/messages.po +++ b/src/locale/locales/en-GB/messages.po @@ -3509,7 +3509,7 @@ msgstr "" #~ msgstr "" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8543,7 +8543,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "" #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8587,5 +8587,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "" diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 97ee908555..05d57b0048 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -3509,7 +3509,7 @@ msgstr "" #~ msgstr "" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8543,7 +8543,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "" #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8587,5 +8587,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "" diff --git a/src/locale/locales/es/messages.po b/src/locale/locales/es/messages.po index ef1159f7eb..c7380350b3 100644 --- a/src/locale/locales/es/messages.po +++ b/src/locale/locales/es/messages.po @@ -3896,7 +3896,7 @@ msgstr "Introduce tu contraseña" #~ msgstr "Introduce tu proveedor de alojamiento preferido" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Introduce tu nombre de usuario" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -9518,7 +9518,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "¡Tu feed de Siguiendo esta vacío! Sigue a más usuarios para ver sus posts aquí." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Tu nombre de usuario completo será" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -9562,5 +9562,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Tu reporte ha sido enviado al servicio de moderación de Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Tu nombre de usuario" diff --git a/src/locale/locales/fi/messages.po b/src/locale/locales/fi/messages.po index 679b3f153c..da2375ca30 100644 --- a/src/locale/locales/fi/messages.po +++ b/src/locale/locales/fi/messages.po @@ -3970,7 +3970,7 @@ msgstr "Syötä salasanasi" #~ msgstr "Syötä haluamasi palveluntarjoaja" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Syötä käyttäjätunnuksesi" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -9627,7 +9627,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Seuraamiesi syöte on tyhjä! Seuraa lisää käyttäjiä nähdäksesi, mitä tapahtuu." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Käyttäjätunnuksesi tulee olemaan" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -9671,5 +9671,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Käyttäjätunnuksesi" diff --git a/src/locale/locales/fr/messages.po b/src/locale/locales/fr/messages.po index 64f3ac7cc9..c0973e4953 100644 --- a/src/locale/locales/fr/messages.po +++ b/src/locale/locales/fr/messages.po @@ -3501,7 +3501,7 @@ msgstr "Entrez votre mot de passe" #~ msgstr "Entrez votre hébergeur préféré" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Entrez votre pseudo" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8519,7 +8519,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Votre fil d’actu des comptes suivis est vide ! Suivez plus de comptes pour voir ce qui se passe." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Votre nom complet sera" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8563,5 +8563,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Votre rapport sera envoyé au Service de Modération de Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Votre pseudo" diff --git a/src/locale/locales/ga/messages.po b/src/locale/locales/ga/messages.po index 2e77bfda24..ff34bca6e2 100644 --- a/src/locale/locales/ga/messages.po +++ b/src/locale/locales/ga/messages.po @@ -3508,7 +3508,7 @@ msgstr "Cuir isteach do phasfhocal" #~ msgstr "Cuir isteach an soláthraí óstála is fearr leat" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Cuir isteach do leasainm" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8542,7 +8542,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Tá an fotha de na daoine a leanann tú folamh! Lean tuilleadh úsáideoirí le feiceáil céard atá ar siúl." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Do leasainm iomlán anseo:" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8586,5 +8586,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Seolfar do thuairisc go dtí Seirbhís Modhnóireachta Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Do leasainm" diff --git a/src/locale/locales/gl/messages.po b/src/locale/locales/gl/messages.po index 87730db003..ddb9961ea9 100644 --- a/src/locale/locales/gl/messages.po +++ b/src/locale/locales/gl/messages.po @@ -3896,7 +3896,7 @@ msgstr "Introduce o teu contrasinal" #~ msgstr "Introduce o teu proveedor de aloxamento preferido" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Introduce o teu alcume" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -9523,7 +9523,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "A seguinte canle está baleira! Sigue a máis persoas para ver o que está a acontecer." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "O teu alcume completo será" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -9567,5 +9567,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "O teu informe enviarase ao Servizo de moderación de Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "O teu alcume" diff --git a/src/locale/locales/hi/messages.po b/src/locale/locales/hi/messages.po index 358afb5b5a..2396970553 100644 --- a/src/locale/locales/hi/messages.po +++ b/src/locale/locales/hi/messages.po @@ -3780,7 +3780,7 @@ msgstr "अपना पासवर्ड दर्ज करें" #~ msgstr "अपना पसंदीदा होस्टिंग प्रदाता दर्ज करें" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "अपना उपयोगकर्ता हैंडल दर्ज करें" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -9026,7 +9026,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "आपका फ़ॉलोइंग फ़ीड खाली है! क्या चल रहा है जानने के लिए और उपयोगकर्ताओं को फ़ॉलो करें।" #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "आपका पूरा हैंडल होगा" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -9070,5 +9070,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "आपके शिकायत को Bluesky मॉडरेशन सेवा को भेजा जाएगा।" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "आपका उपयोगकर्ता हैंडल" diff --git a/src/locale/locales/hu/messages.po b/src/locale/locales/hu/messages.po index 45fcf69bb3..21f2c0ad56 100644 --- a/src/locale/locales/hu/messages.po +++ b/src/locale/locales/hu/messages.po @@ -3465,7 +3465,7 @@ msgstr "Jelszó megadása" #~ msgstr "Kívánt tárhelyszolgáltató megadása" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Felhasználónév megadása" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8439,7 +8439,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "A Követett hírfolyamod üres. Kövess több felhasználót, hogy lásd, mi történik!" #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "A teljes felhasználóneved:" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8482,5 +8482,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Ez a jelentés a Bluesky moderálási szolgáltatásának lesz elküldve" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Saját felhasználónév" diff --git a/src/locale/locales/id/messages.po b/src/locale/locales/id/messages.po index a064bb3a3f..69beaff7c1 100644 --- a/src/locale/locales/id/messages.po +++ b/src/locale/locales/id/messages.po @@ -4007,7 +4007,7 @@ msgstr "Masukkan kata sandi Anda" #~ msgstr "Masukkan penyedia hosting pilihan Anda" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Masukkan panggilan Anda" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -9678,7 +9678,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Feed mengikuti Anda kosong! Ikuti lebih banyak pengguna untuk melihat apa yang terjadi." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Panggilan lengkap Anda akan menjadi" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -9722,5 +9722,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Laporan Anda akan dikirim ke Layanan Moderasi Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Panggilan Anda" diff --git a/src/locale/locales/it/messages.po b/src/locale/locales/it/messages.po index a8c3122a13..ef715f3298 100644 --- a/src/locale/locales/it/messages.po +++ b/src/locale/locales/it/messages.po @@ -3318,7 +3318,7 @@ msgid "Input your password" msgstr "Inserisci la tua password" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Inserisci il tuo nome utente" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8016,7 +8016,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Il tuo feed seguente è vuoto! Segui più utenti per vedere cosa sta succedendo." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Il tuo nome utente completo sarà" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8056,5 +8056,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "La tua segnalazione verrà inviata al Servizio Moderazione di Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Il tuo nome utente" diff --git a/src/locale/locales/ja/messages.po b/src/locale/locales/ja/messages.po index 1652fe5742..c8b9721d6c 100644 --- a/src/locale/locales/ja/messages.po +++ b/src/locale/locales/ja/messages.po @@ -3317,7 +3317,7 @@ msgid "Input your password" msgstr "あなたのパスワードを入力" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "あなたのユーザーハンドルを入力" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8047,7 +8047,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Followingフィードは空です!もっと多くのユーザーをフォローして、近況を確認しましょう。" #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "フルハンドルは" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8087,5 +8087,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "あなたの報告はBluesky Moderation Serviceに送られます" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "あなたのユーザーハンドル" diff --git a/src/locale/locales/ko/messages.po b/src/locale/locales/ko/messages.po index ff932b4297..fe0b852b18 100644 --- a/src/locale/locales/ko/messages.po +++ b/src/locale/locales/ko/messages.po @@ -3317,7 +3317,7 @@ msgid "Input your password" msgstr "비밀번호를 입력합니다" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "사용자 핸들을 입력합니다" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8019,7 +8019,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "팔로우 중 피드가 비어 있습니다. 더 많은 사용자를 팔로우하여 무슨 일이 일어나고 있는지 확인하세요." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "내 전체 핸들:" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8059,5 +8059,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "신고가 Bluesky Moderation Service로 보내집니다." #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "내 사용자 핸들" diff --git a/src/locale/locales/nl/messages.po b/src/locale/locales/nl/messages.po index b33050a355..217db6a6a8 100644 --- a/src/locale/locales/nl/messages.po +++ b/src/locale/locales/nl/messages.po @@ -3496,7 +3496,7 @@ msgstr "Vul je wachtwoord in" #~ msgstr "Vul je voorkeurshostingprovider in" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Vul je gebruikershandle in." #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8506,7 +8506,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Je volgende feed is leeg! Volg meer gebruikers om te zien wat er gebeurt." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Je volledige handle wordt" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8550,5 +8550,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Je melding wordt verzonden naar de Bluesky-moderatieservice" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Je gebruikershandle" diff --git a/src/locale/locales/pl/messages.po b/src/locale/locales/pl/messages.po index 06886f883d..d8c9c0098b 100644 --- a/src/locale/locales/pl/messages.po +++ b/src/locale/locales/pl/messages.po @@ -3317,7 +3317,7 @@ msgid "Input your password" msgstr "Wpisz hasło" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Wpisz nazwę użytkownika" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8031,7 +8031,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Twój kanał osób obserwowanych jest pusty! Zaobserwuj trochę więcej osób." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Twoją pełna nazwa będzie" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8071,5 +8071,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Zgłoszenie będzie wysłane do usługi moderacji Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Twoja nazwa użytkownika" diff --git a/src/locale/locales/pt-BR/messages.po b/src/locale/locales/pt-BR/messages.po index e5d8cf4600..9297c95741 100644 --- a/src/locale/locales/pt-BR/messages.po +++ b/src/locale/locales/pt-BR/messages.po @@ -4003,7 +4003,7 @@ msgstr "Insira sua senha" #~ msgstr "Insira seu provedor de hospedagem" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Insira o usuário" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -9667,7 +9667,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Seu feed inicial está vazio! Siga mais usuários para acompanhar o que está acontecendo." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Seu identificador completo será" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -9711,5 +9711,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Sua denúncia será enviada para o serviço de moderação do Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Seu identificador de usuário" diff --git a/src/locale/locales/ru/messages.po b/src/locale/locales/ru/messages.po index 6a6755c6a6..4ec66227bb 100644 --- a/src/locale/locales/ru/messages.po +++ b/src/locale/locales/ru/messages.po @@ -3578,7 +3578,7 @@ msgstr "Введите ваш пароль" #~ msgstr "Введите желаемого хостинг-провайдера" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Введите ваш псевдоним" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8768,7 +8768,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Ваша домашняя лента пуста! Подпишитесь на больше пользователей чтобы получать больше постов." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Ваш полный псевдоним будет" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8812,5 +8812,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Ваш отчет будет отправлен в Службу Модерации Bluesky." #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Ваш псевдоним" diff --git a/src/locale/locales/th/messages.po b/src/locale/locales/th/messages.po index 61014c89cf..7eb3cd8a8a 100644 --- a/src/locale/locales/th/messages.po +++ b/src/locale/locales/th/messages.po @@ -3994,7 +3994,7 @@ msgstr "ป้อนรหัสผ่านของคุณ" #~ msgstr "กรอกผู้ให้บริการโฮสติ้งที่คุณต้องการ" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "กรอกชื่อผู้ใช้ของคุณ" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -9665,7 +9665,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "ฟีดผู้ติดตามของคุณว่างเปล่า! ติดตามผู้ใช้เพิ่มเติมเพื่อดูความเคลื่อนไหว" #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "ชื่อผู้ใช้เต็มของคุณจะเป็น" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -9709,5 +9709,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "รายงานของคุณจะถูกส่งไปยัง Bluesky Moderation Service" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "แฮนด์เดิลผู้ใช้ของคุณ" diff --git a/src/locale/locales/tr/messages.po b/src/locale/locales/tr/messages.po index d8fc0c23f3..f990961f74 100644 --- a/src/locale/locales/tr/messages.po +++ b/src/locale/locales/tr/messages.po @@ -4211,7 +4211,7 @@ msgstr "Şifrenizi girin" #~ msgstr "" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Kullanıcı adınızı girin" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -10212,7 +10212,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Takip ettiğiniz besleme boş! Neler olduğunu görmek için daha fazla kullanıcı takip edin." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Tam kullanıcı adınız" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -10261,5 +10261,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Kullanıcı adınız" diff --git a/src/locale/locales/uk/messages.po b/src/locale/locales/uk/messages.po index 97685ea93b..a6141a7f90 100644 --- a/src/locale/locales/uk/messages.po +++ b/src/locale/locales/uk/messages.po @@ -4007,7 +4007,7 @@ msgstr "Введіть ваш пароль" #~ msgstr "Введіть бажаного хостинг-провайдера" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Введіть ваш псевдонім" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -9678,7 +9678,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Ваша домашня стрічка порожня! Підпишіться на більше користувачів щоб отримувати більше постів." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Ваш повний псевдонім буде" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -9722,5 +9722,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Ваш псевдонім" diff --git a/src/locale/locales/vi/messages.po b/src/locale/locales/vi/messages.po index ce99522bcb..dbb00bf5e7 100644 --- a/src/locale/locales/vi/messages.po +++ b/src/locale/locales/vi/messages.po @@ -3509,7 +3509,7 @@ msgstr "Nhập mật khẩu của bạn" #~ msgstr "Nhập nhà cung cấp lưu trữ theo lựa chọn của bạn" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "Nhập tên người dùng của bạn" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8543,7 +8543,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "Bảng tin theo dõi còn trống! Hãy theo dõi thêm người dùng để biết có gì đang diễn ra." #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "Tên người dùng đầy đủ của bạn sẽ là" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8587,5 +8587,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "Báo cáo của bạn sẽ được gởi đến dịch vụ kiểm duyệt của Bluesky" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "Tên người dùng của bạn" diff --git a/src/locale/locales/zh-CN/messages.po b/src/locale/locales/zh-CN/messages.po index f3d392c398..dfdd5102aa 100644 --- a/src/locale/locales/zh-CN/messages.po +++ b/src/locale/locales/zh-CN/messages.po @@ -3314,7 +3314,7 @@ msgid "Input your password" msgstr "输入你的密码" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "输入你的账户代码" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8004,7 +8004,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "你的“Following”动态源是空的!关注更多用户去看看他们发了什么。" #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "你的完整账户代码将修改为" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8044,5 +8044,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "你的举报将发送至 Bluesky 内容审核服务" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "你的账户代码" diff --git a/src/locale/locales/zh-HK/messages.po b/src/locale/locales/zh-HK/messages.po index a5fccf312d..368d4f3115 100644 --- a/src/locale/locales/zh-HK/messages.po +++ b/src/locale/locales/zh-HK/messages.po @@ -3314,7 +3314,7 @@ msgid "Input your password" msgstr "輸入你嘅密碼" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "輸入你嘅帳號頭銜" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8004,7 +8004,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "你嘅「Following」動態源得個吉!跟多啲用戶睇吓發生緊啲咩事。" #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "你嘅完整帳號頭銜會係" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8044,5 +8044,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "你嘅上報會傳送去 Bluesky 審核服務" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "你嘅帳號頭銜" diff --git a/src/locale/locales/zh-TW/messages.po b/src/locale/locales/zh-TW/messages.po index 64f56c054c..00790512f7 100644 --- a/src/locale/locales/zh-TW/messages.po +++ b/src/locale/locales/zh-TW/messages.po @@ -3314,7 +3314,7 @@ msgid "Input your password" msgstr "輸入您的密碼" #: src/screens/Signup/StepHandle.tsx:114 -msgid "Input your user handle" +msgid "Type your desired username" msgstr "輸入您的帳號代碼" #: src/view/com/composer/threadgate/ThreadgateBtn.tsx:49 @@ -8004,7 +8004,7 @@ msgid "Your following feed is empty! Follow more users to see what's happening." msgstr "您的「Following」動態源是空的!跟隨更多用戶來看看發生了什麼事情。" #: src/screens/Signup/StepHandle.tsx:125 -msgid "Your full handle will be" +msgid "Your full username will be" msgstr "您的完整帳號代碼將修改為" #: src/screens/Settings/components/ChangeHandleDialog.tsx:219 @@ -8044,5 +8044,5 @@ msgid "Your report will be sent to the Bluesky Moderation Service" msgstr "您的檢舉將傳送至 Bluesky 內容管理服務" #: src/screens/Signup/index.tsx:142 -msgid "Your user handle" +msgid "Choose your username" msgstr "您的帳號代碼" diff --git a/src/logger/README.md b/src/logger/README.md index 1dfd5da23d..8da7deb14e 100644 --- a/src/logger/README.md +++ b/src/logger/README.md @@ -17,8 +17,8 @@ logger.error(error[, metadata]) #### Modes -The "modes" referred to here are inferred from the values exported from `#/env`. -Basically, the booleans `IS_DEV`, `IS_TEST`, and `IS_PROD`. +The "modes" referred to here are inferred from `process.env.NODE_ENV`, +which matches how React Native sets the `__DEV__` global. #### Log Levels diff --git a/src/logger/__tests__/logger.test.ts b/src/logger/__tests__/logger.test.ts index a3ccd037d5..be2391e126 100644 --- a/src/logger/__tests__/logger.test.ts +++ b/src/logger/__tests__/logger.test.ts @@ -5,9 +5,6 @@ import {nanoid} from 'nanoid/non-secure' import {Logger, LogLevel, sentryTransport} from '#/logger' jest.mock('#/env', () => ({ - IS_TEST: true, - IS_DEV: false, - IS_PROD: false, /* * Forces debug mode for tests using the default logger. Most tests create * their own logger instance. diff --git a/src/logger/bitdriftTransport.ts b/src/logger/bitdriftTransport.ts new file mode 100644 index 0000000000..159b863004 --- /dev/null +++ b/src/logger/bitdriftTransport.ts @@ -0,0 +1,22 @@ +import { + debug as bdDebug, + error as bdError, + info as bdInfo, + warn as bdWarn, +} from '../lib/bitdrift' +import {LogLevel, Transport} from './types' + +export function createBitdriftTransport(): Transport { + const logFunctions = { + [LogLevel.Debug]: bdDebug, + [LogLevel.Info]: bdInfo, + [LogLevel.Log]: bdInfo, + [LogLevel.Warn]: bdWarn, + [LogLevel.Error]: bdError, + } as const + + return (level, message) => { + const log = logFunctions[level] + log('' + message) + } +} diff --git a/src/logger/index.ts b/src/logger/index.ts index 7bd812af00..102bccef7a 100644 --- a/src/logger/index.ts +++ b/src/logger/index.ts @@ -6,74 +6,12 @@ import {DebugContext} from '#/logger/debugContext' import {add} from '#/logger/logDump' import {Sentry} from '#/logger/sentry' import * as env from '#/env' +import {createBitdriftTransport} from './bitdriftTransport' +import {Metadata} from './types' +import {ConsoleTransportEntry, LogLevel, Transport} from './types' -export enum LogLevel { - Debug = 'debug', - Info = 'info', - Log = 'log', - Warn = 'warn', - Error = 'error', -} - -type Transport = ( - level: LogLevel, - message: string | Error, - metadata: Metadata, - timestamp: number, -) => void - -/** - * A union of some of Sentry's breadcrumb properties as well as Sentry's - * `captureException` parameter, `CaptureContext`. - */ -type Metadata = { - /** - * Applied as Sentry breadcrumb types. Defaults to `default`. - * - * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types - */ - type?: - | 'default' - | 'debug' - | 'error' - | 'navigation' - | 'http' - | 'info' - | 'query' - | 'transaction' - | 'ui' - | 'user' - - /** - * Passed through to `Sentry.captureException` - * - * @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65 - */ - tags?: { - [key: string]: - | number - | string - | boolean - | bigint - | symbol - | null - | undefined - } - - /** - * Any additional data, passed through to Sentry as `extra` param on - * exceptions, or the `data` param on breadcrumbs. - */ - [key: string]: unknown -} & Parameters[1] - -export type ConsoleTransportEntry = { - id: string - timestamp: number - level: LogLevel - message: string | Error - metadata: Metadata -} +export {LogLevel} +export type {ConsoleTransportEntry, Transport} const enabledLogLevels: { [key in LogLevel]: LogLevel[] @@ -235,7 +173,7 @@ export class Logger { protected debugContextRegexes: RegExp[] = [] constructor({ - enabled = !env.IS_TEST, + enabled = process.env.NODE_ENV !== 'test', level = env.LOG_LEVEL as LogLevel, debug = env.LOG_DEBUG || '', }: { @@ -328,13 +266,18 @@ export class Logger { */ export const logger = new Logger() -if (env.IS_DEV && !env.IS_TEST) { - logger.addTransport(consoleTransport) +if (process.env.NODE_ENV !== 'test') { + logger.addTransport(createBitdriftTransport()) +} - /* - * Comment this out to disable Sentry transport in dev - */ - // logger.addTransport(sentryTransport) -} else if (env.IS_PROD) { - logger.addTransport(sentryTransport) +if (process.env.NODE_ENV !== 'test') { + if (__DEV__) { + logger.addTransport(consoleTransport) + /* + * Comment this out to enable Sentry transport in dev + */ + // logger.addTransport(sentryTransport) + } else { + logger.addTransport(sentryTransport) + } } diff --git a/src/logger/types.ts b/src/logger/types.ts new file mode 100644 index 0000000000..252e7373be --- /dev/null +++ b/src/logger/types.ts @@ -0,0 +1,69 @@ +import type {Sentry} from '#/logger/sentry' + +export enum LogLevel { + Debug = 'debug', + Info = 'info', + Log = 'log', + Warn = 'warn', + Error = 'error', +} + +export type Transport = ( + level: LogLevel, + message: string | Error, + metadata: Metadata, + timestamp: number, +) => void + +/** + * A union of some of Sentry's breadcrumb properties as well as Sentry's + * `captureException` parameter, `CaptureContext`. + */ +export type Metadata = { + /** + * Applied as Sentry breadcrumb types. Defaults to `default`. + * + * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types + */ + type?: + | 'default' + | 'debug' + | 'error' + | 'navigation' + | 'http' + | 'info' + | 'query' + | 'transaction' + | 'ui' + | 'user' + + /** + * Passed through to `Sentry.captureException` + * + * @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65 + */ + tags?: { + [key: string]: + | number + | string + | boolean + | bigint + | symbol + | null + | undefined + } + + /** + * Any additional data, passed through to Sentry as `extra` param on + * exceptions, or the `data` param on breadcrumbs. + */ + [key: string]: unknown +} & Parameters[1] + +export type ConsoleTransportEntry = { + id: string + timestamp: number + level: LogLevel + message: string | Error + metadata: Metadata +} diff --git a/src/routes.ts b/src/routes.ts index 188665d849..7cd7c0880d 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -53,6 +53,7 @@ export const router = new Router({ CopyrightPolicy: '/support/copyright', // hashtags Hashtag: '/hashtag/:tag', + Topic: '/topic/:topic', // DMs Messages: '/messages', MessagesSettings: '/messages/settings', diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx index a87487150c..83eb5b80da 100644 --- a/src/screens/Hashtag.tsx +++ b/src/screens/Hashtag.tsx @@ -107,7 +107,7 @@ export default function HashtagScreen({ return ( - + {headerTitle} @@ -134,7 +134,7 @@ export default function HashtagScreen({ ( - + section.title)} {...props} /> )} diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 6a48b1bcc7..2cd6abdd1c 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -60,6 +60,8 @@ export const LoginForm = ({ const [isProcessing, setIsProcessing] = useState(false) const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false) + const [isAuthFactorTokenValueEmpty, setIsAuthFactorTokenValueEmpty] = + useState(true) const identifierValueRef = useRef(initialHandle || '') const passwordValueRef = useRef('') const authFactorTokenValueRef = useRef('') @@ -262,6 +264,7 @@ export const LoginForm = ({ textContentType="username" blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field onChangeText={v => { + setIsAuthFactorTokenValueEmpty(v === '') authFactorTokenValueRef.current = v }} onSubmitEditing={onPressNext} @@ -269,6 +272,13 @@ export const LoginForm = ({ accessibilityHint={_( msg`Input the code which has been emailed to you`, )} + style={[ + { + textTransform: isAuthFactorTokenValueEmpty + ? 'none' + : 'uppercase', + }, + ]} /> diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx index 059cdfd5cd..bdc1664f61 100644 --- a/src/screens/Onboarding/Layout.tsx +++ b/src/screens/Onboarding/Layout.tsx @@ -21,7 +21,6 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' import {createPortalGroup} from '#/components/Portal' import {P, Text} from '#/components/Typography' -import {IS_DEV} from '#/env' const COL_WIDTH = 420 @@ -64,7 +63,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) { a.flex_1, t.atoms.bg, ]}> - {IS_DEV && ( + {__DEV__ && ( + + + + + ) + } + + return resolvedUri ? ( + + + + ) : ( + + + + + + + ) +} + +function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { + const {data: preferences} = usePreferencesQuery() + const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!preferences || !info) { + return ( + + + + + ) + } + + return ( + + ) +} + +export function ProfileFeedScreenInner({ + feedInfo, +}: { + preferences: UsePreferencesQueryResponse + feedInfo: FeedSourceFeedInfo +}) { + const {_} = useLingui() + const {hasSession} = useSession() + const {openComposer} = useComposerControls() + const isScreenFocused = useIsFocused() + + useSetTitle(feedInfo?.displayName) + + const feed = `feedgen|${feedInfo.uri}` as FeedDescriptor + + const [hasNew, setHasNew] = React.useState(false) + const [isScrolledDown, setIsScrolledDown] = React.useState(false) + const queryClient = useQueryClient() + const feedFeedback = useFeedFeedback(feed, hasSession) + const scrollElRef = useAnimatedRef() as ListRef + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: 0, // -headerHeight, + }) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, queryClient, feed, setHasNew]) + + React.useEffect(() => { + if (!isScreenFocused) { + return + } + return listenSoftReset(onScrollToTop) + }, [onScrollToTop, isScreenFocused]) + + const renderPostsEmpty = useCallback(() => { + return + }, [_]) + + return ( + <> + + + + + + + {(isScrolledDown || hasNew) && ( + + )} + + {hasSession && ( + openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + )} + + ) +} + +const styles = StyleSheet.create({ + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 7, + paddingHorizontal: 14, + borderRadius: 50, + marginLeft: 6, + }, + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, + aboutSectionContainer: { + paddingVertical: 4, + paddingHorizontal: 16, + gap: 12, + }, +}) diff --git a/src/screens/Profile/ProfileFollowers.tsx b/src/screens/Profile/ProfileFollowers.tsx new file mode 100644 index 0000000000..64292d20e6 --- /dev/null +++ b/src/screens/Profile/ProfileFollowers.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import {Plural} from '@lingui/macro' +import {useFocusEffect} from '@react-navigation/native' + +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {useProfileQuery} from '#/state/queries/profile' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' +import {useSetMinimalShellMode} from '#/state/shell' +import {ProfileFollowers as ProfileFollowersComponent} from '#/view/com/profile/ProfileFollowers' +import * as Layout from '#/components/Layout' + +type Props = NativeStackScreenProps +export const ProfileFollowersScreen = ({route}: Props) => { + const {name} = route.params + const setMinimalShellMode = useSetMinimalShellMode() + + const {data: resolvedDid} = useResolveDidQuery(name) + const {data: profile} = useProfileQuery({ + did: resolvedDid, + }) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + return ( + + + + + {profile && ( + <> + + {sanitizeDisplayName(profile.displayName || profile.handle)} + + + + + + )} + + + + + + ) +} diff --git a/src/screens/Profile/ProfileFollows.tsx b/src/screens/Profile/ProfileFollows.tsx new file mode 100644 index 0000000000..85ebccf30b --- /dev/null +++ b/src/screens/Profile/ProfileFollows.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import {Plural} from '@lingui/macro' +import {useFocusEffect} from '@react-navigation/native' + +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {useProfileQuery} from '#/state/queries/profile' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' +import {useSetMinimalShellMode} from '#/state/shell' +import {ProfileFollows as ProfileFollowsComponent} from '#/view/com/profile/ProfileFollows' +import * as Layout from '#/components/Layout' + +type Props = NativeStackScreenProps +export const ProfileFollowsScreen = ({route}: Props) => { + const {name} = route.params + const setMinimalShellMode = useSetMinimalShellMode() + + const {data: resolvedDid} = useResolveDidQuery(name) + const {data: profile} = useProfileQuery({ + did: resolvedDid, + }) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + return ( + + + + + {profile && ( + <> + + {sanitizeDisplayName(profile.displayName || profile.handle)} + + + + + + )} + + + + + + ) +} diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx new file mode 100644 index 0000000000..cf305ac4dd --- /dev/null +++ b/src/screens/Profile/components/ProfileFeedHeader.tsx @@ -0,0 +1,567 @@ +import React from 'react' +import {View} from 'react-native' +import {AtUri} from '@atproto/api' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useHaptics} from '#/lib/haptics' +import {makeProfileLink} from '#/lib/routes/links' +import {makeCustomFeedLink} from '#/lib/routes/links' +import {shareUrl} from '#/lib/sharing' +import {sanitizeHandle} from '#/lib/strings/handles' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {FeedSourceFeedInfo} from '#/state/queries/feed' +import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' +import { + useAddSavedFeedsMutation, + usePreferencesQuery, + useRemoveFeedMutation, + useUpdateSavedFeedsMutation, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {formatCount} from '#/view/com/util/numeric/format' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import {useRichText} from '#/components/hooks/useRichText' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import { + Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, + Heart2_Stroke2_Corner0_Rounded as Heart, +} from '#/components/icons/Heart2' +import { + Pin_Filled_Corner0_Rounded as PinFilled, + Pin_Stroke2_Corner0_Rounded as Pin, +} from '#/components/icons/Pin' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import * as Layout from '#/components/Layout' +import {InlineLinkText} from '#/components/Link' +import * as Menu from '#/components/Menu' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' + +export function ProfileFeedHeaderSkeleton() { + const t = useTheme() + + return ( + + + + + + + + + + + + ) +} + +export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { + const t = useTheme() + const {_, i18n} = useLingui() + const {hasSession} = useSession() + const {gtMobile} = useBreakpoints() + const infoControl = Dialog.useDialogControl() + const playHaptic = useHaptics() + + const {data: preferences} = usePreferencesQuery() + + const [likeUri, setLikeUri] = React.useState(info.likeUri || '') + const isLiked = !!likeUri + const likeCount = + isLiked && likeUri ? (info.likeCount || 0) + 1 : info.likeCount || 0 + + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = + useAddSavedFeedsMutation() + const {mutateAsync: removeFeed, isPending: isRemovePending} = + useRemoveFeedMutation() + const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = + useUpdateSavedFeedsMutation() + + const isFeedStateChangePending = + isAddSavedFeedPending || isRemovePending || isUpdateFeedPending + const savedFeedConfig = preferences?.savedFeeds?.find( + f => f.value === info.uri, + ) + const isSaved = Boolean(savedFeedConfig) + const isPinned = Boolean(savedFeedConfig?.pinned) + + const onToggleSaved = React.useCallback(async () => { + try { + playHaptic() + + if (savedFeedConfig) { + await removeFeed(savedFeedConfig) + Toast.show(_(msg`Removed from your feeds`)) + } else { + await addSavedFeeds([ + { + type: 'feed', + value: info.uri, + pinned: false, + }, + ]) + Toast.show(_(msg`Saved to your feeds`)) + } + } catch (err) { + Toast.show( + _( + msg`There was an issue updating your feeds, please check your internet connection and try again.`, + ), + 'xmark', + ) + logger.error('Failed to update feeds', {message: err}) + } + }, [_, playHaptic, info, removeFeed, addSavedFeeds, savedFeedConfig]) + + const onTogglePinned = React.useCallback(async () => { + try { + playHaptic() + + if (savedFeedConfig) { + const pinned = !savedFeedConfig.pinned + await updateSavedFeeds([ + { + ...savedFeedConfig, + pinned, + }, + ]) + + if (pinned) { + Toast.show(_(msg`Pinned ${info.displayName} to Home`)) + } else { + Toast.show(_(msg`Unpinned ${info.displayName} from Home`)) + } + } else { + await addSavedFeeds([ + { + type: 'feed', + value: info.uri, + pinned: true, + }, + ]) + Toast.show(_(msg`Pinned ${info.displayName} to Home`)) + } + } catch (e) { + Toast.show(_(msg`There was an issue contacting the server`), 'xmark') + logger.error('Failed to toggle pinned feed', {message: e}) + } + }, [playHaptic, info, _, savedFeedConfig, updateSavedFeeds, addSavedFeeds]) + + return ( + <> + + + + + + + + {hasSession && ( + + {isPinned ? ( + + + {({props}) => { + return ( + + ) + }} + + + + + {_(msg`Unpin from home`)} + + + + + {isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Save to my feeds`)} + + + + + + ) : ( + + )} + + )} + + + + + + + + + + + ) +} + +function DialogInner({ + info, + likeUri, + setLikeUri, + likeCount, + isPinned, + onTogglePinned, + isFeedStateChangePending, +}: { + info: FeedSourceFeedInfo + likeUri: string + setLikeUri: (uri: string) => void + likeCount: number + isPinned: boolean + onTogglePinned: () => void + isFeedStateChangePending: boolean +}) { + const t = useTheme() + const {_} = useLingui() + const {hasSession} = useSession() + const playHaptic = useHaptics() + const control = Dialog.useDialogContext() + const reportDialogControl = useReportDialogControl() + const [rt] = useRichText(info.description.text) + const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() + const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = + useUnlikeMutation() + + const isLiked = !!likeUri + const feedRkey = React.useMemo(() => new AtUri(info.uri).rkey, [info.uri]) + + const onToggleLiked = React.useCallback(async () => { + try { + playHaptic() + + if (isLiked && likeUri) { + await unlikeFeed({uri: likeUri}) + setLikeUri('') + } else { + const res = await likeFeed({uri: info.uri, cid: info.cid}) + setLikeUri(res.uri) + } + } catch (err) { + Toast.show( + _( + msg`There was an issue contacting the server, please check your internet connection and try again.`, + ), + 'xmark', + ) + logger.error('Failed to toggle like', {message: err}) + } + }, [playHaptic, isLiked, likeUri, unlikeFeed, setLikeUri, likeFeed, info, _]) + + const onPressShare = React.useCallback(() => { + playHaptic() + const url = toShareUrl(info.route.href) + shareUrl(url) + }, [info, playHaptic]) + + const onPressReport = React.useCallback(() => { + reportDialogControl.open() + }, [reportDialogControl]) + + return ( + + + + + + + {info.displayName} + + + + By{' '} + control.close()}> + {sanitizeHandle(info.creatorHandle, '@')} + + + + + + + + + + + + {typeof likeCount === 'number' && ( + control.close()}> + + Liked by + + + )} + + + {hasSession && ( + <> + + + + + + + + + + + Something wrong? Let us know. + + + + + + + + + )} + + ) +} diff --git a/src/screens/Search/components/ExploreRecommendations.tsx b/src/screens/Search/components/ExploreRecommendations.tsx new file mode 100644 index 0000000000..e253cfb5ab --- /dev/null +++ b/src/screens/Search/components/ExploreRecommendations.tsx @@ -0,0 +1,95 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {isWeb} from '#/platform/detection' +import {useTrendingSettings} from '#/state/preferences/trending' +import { + DEFAULT_LIMIT as RECOMMENDATIONS_COUNT, + useTrendingTopics, +} from '#/state/queries/trending/useTrendingTopics' +import {useTrendingConfig} from '#/state/trending-config' +import {atoms as a, useGutters, useTheme} from '#/alf' +import {Hashtag_Stroke2_Corner0_Rounded} from '#/components/icons/Hashtag' +import { + TrendingTopic, + TrendingTopicLink, + TrendingTopicSkeleton, +} from '#/components/TrendingTopics' +import {Text} from '#/components/Typography' + +export function ExploreRecommendations() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + return enabled && !trendingDisabled ? : null +} + +function Inner() { + const t = useTheme() + const gutters = useGutters([0, 'compact']) + const {data: trending, error, isLoading} = useTrendingTopics() + const noRecs = !isLoading && !error && !trending?.suggested?.length + + return error || noRecs ? null : ( + <> + + + + + + Recommended + + + + Feeds we think you might like. + + + + + + + {isLoading ? ( + Array(RECOMMENDATIONS_COUNT) + .fill(0) + .map((_, i) => ) + ) : !trending?.suggested ? null : ( + <> + {trending.suggested.map(topic => ( + + {({hovered}) => ( + + )} + + ))} + + )} + + + + ) +} diff --git a/src/screens/Search/components/ExploreTrendingTopics.tsx b/src/screens/Search/components/ExploreTrendingTopics.tsx new file mode 100644 index 0000000000..be347dcd4d --- /dev/null +++ b/src/screens/Search/components/ExploreTrendingTopics.tsx @@ -0,0 +1,102 @@ +import {View} from 'react-native' +import {Trans} from '@lingui/macro' + +import {isWeb} from '#/platform/detection' +import {useTrendingSettings} from '#/state/preferences/trending' +import { + DEFAULT_LIMIT as TRENDING_TOPICS_COUNT, + useTrendingTopics, +} from '#/state/queries/trending/useTrendingTopics' +import {useTrendingConfig} from '#/state/trending-config' +import {atoms as a, tokens, useGutters, useTheme} from '#/alf' +import {GradientFill} from '#/components/GradientFill' +import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' +import { + TrendingTopic, + TrendingTopicLink, + TrendingTopicSkeleton, +} from '#/components/TrendingTopics' +import {Text} from '#/components/Typography' + +export function ExploreTrendingTopics() { + const {enabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + return enabled && !trendingDisabled ? : null +} + +function Inner() { + const t = useTheme() + const gutters = useGutters([0, 'compact']) + const {data: trending, error, isLoading} = useTrendingTopics() + const noTopics = !isLoading && !error && !trending?.topics?.length + + return error || noTopics ? null : ( + <> + + + + + + Trending + + + + + BETA + + + + + What people are posting about. + + + + + + + {isLoading ? ( + Array(TRENDING_TOPICS_COUNT) + .fill(0) + .map((_, i) => ) + ) : !trending?.topics ? null : ( + <> + {trending.topics.map(topic => ( + + {({hovered}) => ( + + )} + + ))} + + )} + + + + ) +} diff --git a/src/screens/Settings/AppIconSettings.tsx b/src/screens/Settings/AppIconSettings.tsx deleted file mode 100644 index 18fcd5e305..0000000000 --- a/src/screens/Settings/AppIconSettings.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React from 'react' -import {Alert, View} from 'react-native' -import {Image} from 'expo-image' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import * as AppIcon from '@mozzius/expo-dynamic-app-icon' -import {NativeStackScreenProps} from '@react-navigation/native-stack' - -import {PressableScale} from '#/lib/custom-animations/PressableScale' -import {CommonNavigatorParams} from '#/lib/routes/types' -import {isAndroid} from '#/platform/detection' -import {atoms as a, platform} from '#/alf' -import * as Layout from '#/components/Layout' -import {Text} from '#/components/Typography' - -type Props = NativeStackScreenProps -export function AppIconSettingsScreen({}: Props) { - const {_} = useLingui() - const sets = useAppIconSets() - - return ( - - - - - - App Icon - - - - - - Defaults - - {sets.defaults.map(icon => ( - - AppIcon.setAppIcon(icon.id)}> - - - - {icon.name} - - - ))} - - - Bluesky+ - - {sets.core.map(icon => ( - - { - if (isAndroid) { - Alert.alert( - _(msg`Change app icon to "${icon.name}"`), - _(msg`The app will be restarted`), - [ - { - text: _(msg`Cancel`), - style: 'cancel', - }, - { - text: _(msg`OK`), - onPress: () => { - AppIcon.setAppIcon(icon.id) - }, - style: 'default', - }, - ], - ) - } else { - AppIcon.setAppIcon(icon.id) - } - }}> - - - - {icon.name} - - - ))} - - - - ) -} - -function useAppIconSets() { - const {_} = useLingui() - - return React.useMemo(() => { - const defaults = [ - { - id: 'default_light', - name: _('Light'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_default_light.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_default_light.png`) - }, - }, - { - id: 'default_dark', - name: _('Dark'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_default_dark.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_default_dark.png`) - }, - }, - ] - - /** - * Bluesky+ - */ - const core = [ - { - id: 'core_aurora', - name: _('Aurora'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_core_aurora.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_core_aurora.png`) - }, - }, - // { - // id: 'core_bonfire', - // name: _('Bonfire'), - // iosImage: () => { - // return require(`../../../assets/app-icons/ios_icon_core_bonfire.png`) - // }, - // androidImage: () => { - // return require(`../../../assets/app-icons/android_icon_core_bonfire.png`) - // }, - // }, - { - id: 'core_sunrise', - name: _('Sunrise'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_core_sunrise.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_core_sunrise.png`) - }, - }, - { - id: 'core_sunset', - name: _('Sunset'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_core_sunset.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_core_sunset.png`) - }, - }, - { - id: 'core_midnight', - name: _('Midnight'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_core_midnight.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_core_midnight.png`) - }, - }, - { - id: 'core_flat_blue', - name: _('Flat Blue'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_core_flat_blue.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_core_flat_blue.png`) - }, - }, - { - id: 'core_flat_white', - name: _('Flat White'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_core_flat_white.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_core_flat_white.png`) - }, - }, - { - id: 'core_flat_black', - name: _('Flat Black'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_core_flat_black.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_core_flat_black.png`) - }, - }, - { - id: 'core_classic', - name: _('Bluesky Classic™'), - iosImage: () => { - return require(`../../../assets/app-icons/ios_icon_core_classic.png`) - }, - androidImage: () => { - return require(`../../../assets/app-icons/android_icon_core_classic.png`) - }, - }, - ] - - return { - defaults, - core, - } - }, [_]) -} diff --git a/src/screens/Settings/AppIconSettings/AppIconImage.tsx b/src/screens/Settings/AppIconSettings/AppIconImage.tsx new file mode 100644 index 0000000000..e81d5d0d50 --- /dev/null +++ b/src/screens/Settings/AppIconSettings/AppIconImage.tsx @@ -0,0 +1,33 @@ +import {Image} from 'expo-image' + +import {AppIconSet} from '#/screens/Settings/AppIconSettings/types' +import {atoms as a, platform, useTheme} from '#/alf' + +export function AppIconImage({ + icon, + size = 50, +}: { + icon: AppIconSet + size: number +}) { + const t = useTheme() + return ( + + ) +} diff --git a/src/screens/Settings/AppIconSettings/SettingsListItem.tsx b/src/screens/Settings/AppIconSettings/SettingsListItem.tsx new file mode 100644 index 0000000000..add87b1d7a --- /dev/null +++ b/src/screens/Settings/AppIconSettings/SettingsListItem.tsx @@ -0,0 +1,29 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {AppIconImage} from '#/screens/Settings/AppIconSettings/AppIconImage' +import {useCurrentAppIcon} from '#/screens/Settings/AppIconSettings/useCurrentAppIcon' +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a} from '#/alf' +import {Shapes_Stroke2_Corner0_Rounded as Shapes} from '#/components/icons/Shapes' + +export function SettingsListItem() { + const {_} = useLingui() + const icon = useCurrentAppIcon() + + return ( + + + + + App Icon + + + + + ) +} diff --git a/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx b/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx new file mode 100644 index 0000000000..c7707d23fd --- /dev/null +++ b/src/screens/Settings/AppIconSettings/SettingsListItem.web.tsx @@ -0,0 +1 @@ +export function SettingsListItem() {} diff --git a/src/screens/Settings/AppIconSettings/index.tsx b/src/screens/Settings/AppIconSettings/index.tsx new file mode 100644 index 0000000000..0be2894d52 --- /dev/null +++ b/src/screens/Settings/AppIconSettings/index.tsx @@ -0,0 +1,244 @@ +import {useState} from 'react' +import {Alert, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {IS_INTERNAL} from '#/lib/app-info' +import {PressableScale} from '#/lib/custom-animations/PressableScale' +import {CommonNavigatorParams} from '#/lib/routes/types' +import {useGate} from '#/lib/statsig/statsig' +import {isAndroid} from '#/platform/detection' +import {AppIconImage} from '#/screens/Settings/AppIconSettings/AppIconImage' +import {AppIconSet} from '#/screens/Settings/AppIconSettings/types' +import {useAppIconSets} from '#/screens/Settings/AppIconSettings/useAppIconSets' +import {atoms as a, useTheme} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' + +type Props = NativeStackScreenProps +export function AppIconSettingsScreen({}: Props) { + const t = useTheme() + const {_} = useLingui() + const sets = useAppIconSets() + const gate = useGate() + const [currentAppIcon, setCurrentAppIcon] = useState(() => + getAppIconName(DynamicAppIcon.getAppIcon()), + ) + + const onSetAppIcon = (icon: string) => { + if (isAndroid) { + const next = + sets.defaults.find(i => i.id === icon) ?? + sets.core.find(i => i.id === icon) + Alert.alert( + next + ? _(msg`Change app icon to "${next.name}"`) + : _(msg`Change app icon`), + // to determine - can we stop this happening? -sfn + _(msg`The app will be restarted`), + [ + { + text: _(msg`Cancel`), + style: 'cancel', + }, + { + text: _(msg`OK`), + onPress: () => { + setCurrentAppIcon(setAppIcon(icon)) + }, + style: 'default', + }, + ], + ) + } else { + setCurrentAppIcon(setAppIcon(icon)) + } + } + + return ( + + + + + + App Icon + + + + + + + + {sets.defaults.map((icon, i) => ( + + + {icon.name} + + ))} + + + {IS_INTERNAL && gate('debug_subscriptions') && ( + <> + + Bluesky+ + + + {sets.core.map((icon, i) => ( + + + {icon.name} + + ))} + + + )} + + + ) +} + +function setAppIcon(icon: string) { + if (icon === 'default_light') { + return getAppIconName(DynamicAppIcon.setAppIcon(null)) + } else { + return getAppIconName(DynamicAppIcon.setAppIcon(icon)) + } +} + +function getAppIconName(icon: string | false) { + if (!icon || icon === 'DEFAULT') { + return 'default_light' + } else { + return icon + } +} + +function Group({ + children, + label, + value, + onChange, +}: { + children: React.ReactNode + label: string + value: string + onChange: (value: string) => void +}) { + return ( + { + if (vals[0]) onChange(vals[0]) + }}> + + {children} + + + ) +} + +function Row({ + icon, + children, + isEnd, +}: { + icon: AppIconSet + children: React.ReactNode + isEnd: boolean +}) { + const t = useTheme() + const {_} = useLingui() + + return ( + + {({hovered, pressed}) => ( + + {children} + + + )} + + ) +} + +function RowText({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + + {children} + + ) +} + +function AppIcon({icon, size = 50}: {icon: AppIconSet; size: number}) { + const {_} = useLingui() + return ( + { + if (isAndroid) { + Alert.alert( + _(msg`Change app icon to "${icon.name}"`), + _(msg`The app will be restarted`), + [ + { + text: _(msg`Cancel`), + style: 'cancel', + }, + { + text: _(msg`OK`), + onPress: () => { + DynamicAppIcon.setAppIcon(icon.id) + }, + style: 'default', + }, + ], + ) + } else { + DynamicAppIcon.setAppIcon(icon.id) + } + }}> + + + ) +} diff --git a/src/screens/Settings/AppIconSettings.web.tsx b/src/screens/Settings/AppIconSettings/index.web.tsx similarity index 100% rename from src/screens/Settings/AppIconSettings.web.tsx rename to src/screens/Settings/AppIconSettings/index.web.tsx diff --git a/src/screens/Settings/AppIconSettings/types.ts b/src/screens/Settings/AppIconSettings/types.ts new file mode 100644 index 0000000000..5010f6f025 --- /dev/null +++ b/src/screens/Settings/AppIconSettings/types.ts @@ -0,0 +1,8 @@ +import {ImageSourcePropType} from 'react-native' + +export type AppIconSet = { + id: string + name: string + iosImage: () => ImageSourcePropType + androidImage: () => ImageSourcePropType +} diff --git a/src/screens/Settings/AppIconSettings/useAppIconSets.ts b/src/screens/Settings/AppIconSettings/useAppIconSets.ts new file mode 100644 index 0000000000..47fc5a15f0 --- /dev/null +++ b/src/screens/Settings/AppIconSettings/useAppIconSets.ts @@ -0,0 +1,134 @@ +import {useMemo} from 'react' +import {useLingui} from '@lingui/react' + +import {AppIconSet} from '#/screens/Settings/AppIconSettings/types' + +export function useAppIconSets() { + const {_} = useLingui() + + return useMemo(() => { + const defaults = [ + { + id: 'default_light', + name: _('Light'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_default_light.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_default_light.png`) + }, + }, + { + id: 'default_dark', + name: _('Dark'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_default_dark.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_default_dark.png`) + }, + }, + ] satisfies AppIconSet[] + + /** + * Bluesky+ + */ + const core = [ + { + id: 'core_aurora', + name: _('Aurora'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_aurora.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_aurora.png`) + }, + }, + // { + // id: 'core_bonfire', + // name: _('Bonfire'), + // iosImage: () => { + // return require(`../../../../assets/app-icons/ios_icon_core_bonfire.png`) + // }, + // androidImage: () => { + // return require(`../../../../assets/app-icons/android_icon_core_bonfire.png`) + // }, + // }, + { + id: 'core_sunrise', + name: _('Sunrise'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_sunrise.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_sunrise.png`) + }, + }, + { + id: 'core_sunset', + name: _('Sunset'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_sunset.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_sunset.png`) + }, + }, + { + id: 'core_midnight', + name: _('Midnight'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_midnight.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_midnight.png`) + }, + }, + { + id: 'core_flat_blue', + name: _('Flat Blue'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_flat_blue.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_flat_blue.png`) + }, + }, + { + id: 'core_flat_white', + name: _('Flat White'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_flat_white.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_flat_white.png`) + }, + }, + { + id: 'core_flat_black', + name: _('Flat Black'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_flat_black.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_flat_black.png`) + }, + }, + { + id: 'core_classic', + name: _('Bluesky Classic™'), + iosImage: () => { + return require(`../../../../assets/app-icons/ios_icon_core_classic.png`) + }, + androidImage: () => { + return require(`../../../../assets/app-icons/android_icon_core_classic.png`) + }, + }, + ] satisfies AppIconSet[] + + return { + defaults, + core, + } + }, [_]) +} diff --git a/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts b/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts new file mode 100644 index 0000000000..4bc9b665a4 --- /dev/null +++ b/src/screens/Settings/AppIconSettings/useCurrentAppIcon.ts @@ -0,0 +1,27 @@ +import {useCallback, useMemo, useState} from 'react' +import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon' +import {useFocusEffect} from '@react-navigation/native' + +import {useAppIconSets} from '#/screens/Settings/AppIconSettings/useAppIconSets' + +export function useCurrentAppIcon() { + const appIconSets = useAppIconSets() + const [currentAppIcon, setCurrentAppIcon] = useState(() => + DynamicAppIcon.getAppIcon(), + ) + + // refresh current icon when screen is focused + useFocusEffect( + useCallback(() => { + setCurrentAppIcon(DynamicAppIcon.getAppIcon()) + }, []), + ) + + return useMemo(() => { + return ( + appIconSets.defaults.find(i => i.id === currentAppIcon) ?? + appIconSets.core.find(i => i.id === currentAppIcon) ?? + appIconSets.defaults[0] + ) + }, [appIconSets, currentAppIcon]) +} diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx index 48c4a2d85d..4a8a61cd23 100644 --- a/src/screens/Settings/AppearanceSettings.tsx +++ b/src/screens/Settings/AppearanceSettings.tsx @@ -8,12 +8,12 @@ import Animated, { import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' +import {IS_INTERNAL} from '#/lib/app-info' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {useGate} from '#/lib/statsig/statsig' import {isNative} from '#/platform/detection' -import {useSession} from '#/state/session' import {useSetThemePrefs, useThemePrefs} from '#/state/shell' -import {Logo} from '#/view/icons/Logo' +import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' import {atoms as a, native, useAlf, useTheme} from '#/alf' import * as ToggleButton from '#/components/forms/ToggleButton' import {Props as SVGIconProps} from '#/components/icons/common' @@ -29,6 +29,7 @@ type Props = NativeStackScreenProps export function AppearanceSettingsScreen({}: Props) { const {_} = useLingui() const {fonts} = useAlf() + const gate = useGate() const {colorMode, darkTheme} = useThemePrefs() const {setColorMode, setDarkTheme} = useSetThemePrefs() @@ -74,8 +75,6 @@ export function AppearanceSettingsScreen({}: Props) { [fonts], ) - const {currentAccount} = useSession() - return ( @@ -178,18 +177,10 @@ export function AppearanceSettingsScreen({}: Props) { onChange={onChangeFontScale} /> - {isNative && DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && ( + {isNative && IS_INTERNAL && gate('debug_subscriptions') && ( <> - - - - - App Icon - - + )} diff --git a/src/screens/Settings/ContentAndMediaSettings.tsx b/src/screens/Settings/ContentAndMediaSettings.tsx index 17f8fa5067..bdbe1d191b 100644 --- a/src/screens/Settings/ContentAndMediaSettings.tsx +++ b/src/screens/Settings/ContentAndMediaSettings.tsx @@ -9,6 +9,11 @@ import { useInAppBrowser, useSetInAppBrowser, } from '#/state/preferences/in-app-browser' +import { + useTrendingSettings, + useTrendingSettingsApi, +} from '#/state/preferences/trending' +import {useTrendingConfig} from '#/state/trending-config' import * as SettingsList from '#/screens/Settings/components/SettingsList' import * as Toggle from '#/components/forms/Toggle' import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' @@ -16,6 +21,7 @@ import {Hashtag_Stroke2_Corner0_Rounded as HashtagIcon} from '#/components/icons import {Home_Stroke2_Corner2_Rounded as HomeIcon} from '#/components/icons/Home' import {Macintosh_Stroke2_Corner2_Rounded as MacintoshIcon} from '#/components/icons/Macintosh' import {Play_Stroke2_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' +import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2' import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' import * as Layout from '#/components/Layout' @@ -29,6 +35,9 @@ export function ContentAndMediaSettingsScreen({}: Props) { const setAutoplayDisabledPref = useSetAutoplayDisabled() const inAppBrowserPref = useInAppBrowser() const setUseInAppBrowser = useSetInAppBrowser() + const {enabled: trendingEnabled} = useTrendingConfig() + const {trendingDisabled} = useTrendingSettings() + const {setTrendingDisabled} = useTrendingSettingsApi() return ( @@ -104,6 +113,24 @@ export function ContentAndMediaSettingsScreen({}: Props) { + {trendingEnabled && ( + <> + + setTrendingDisabled(!value)}> + + + + Enable trending topics + + + + + + )} diff --git a/src/screens/Settings/NotificationSettings.tsx b/src/screens/Settings/NotificationSettings.tsx index 1c77b31489..ebb230c2ca 100644 --- a/src/screens/Settings/NotificationSettings.tsx +++ b/src/screens/Settings/NotificationSettings.tsx @@ -18,7 +18,13 @@ type Props = NativeStackScreenProps export function NotificationSettingsScreen({}: Props) { const {_} = useLingui() - const {data, isError: isQueryError, refetch} = useNotificationFeedQuery() + const { + data, + isError: isQueryError, + refetch, + } = useNotificationFeedQuery({ + filter: 'all', + }) const serverPriority = data?.pages.at(0)?.priority const { diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx index 9b2c4f7c35..bb03aace17 100644 --- a/src/screens/Settings/components/ChangeHandleDialog.tsx +++ b/src/screens/Settings/components/ChangeHandleDialog.tsx @@ -17,6 +17,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query' import {HITSLOP_10} from '#/lib/constants' import {cleanError} from '#/lib/strings/errors' +import {sanitizeHandle} from '#/lib/strings/handles' import {createFullHandle, validateHandle} from '#/lib/strings/handles' import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' @@ -29,7 +30,10 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import * as ToggleButton from '#/components/forms/ToggleButton' -import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' +import { + ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, + ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, +} from '#/components/icons/Arrow' import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4' @@ -245,15 +249,14 @@ function ProvidedHandlePage({ If you have your own domain, you can use that as your handle. This - lets you self-verify your identity –{' '} + lets you self-verify your identity.{' '} - learn more + Learn more here. - . @@ -488,6 +491,18 @@ function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) { )} + {currentAccount?.handle?.endsWith('.bsky.social') && ( + + + Your current handle{' '} + + {sanitizeHandle(currentAccount?.handle || '', '@')} + {' '} + will automatically remain reserved for you. You can switch back to + it at any time from this account. + + + )} - - + diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx index 0ff0506f4e..dee7df8488 100644 --- a/src/screens/Signup/StepHandle.tsx +++ b/src/screens/Signup/StepHandle.tsx @@ -111,7 +111,7 @@ export function StepHandle() { handleValueRef.current = val setDraftValue(val) }} - label={_(msg`Input your user handle`)} + label={_(msg`Type your desired username`)} defaultValue={draftValue} autoCapitalize="none" autoCorrect={false} @@ -122,7 +122,7 @@ export function StepHandle() { {draftValue !== '' && ( - Your full handle will be{' '} + Your full username will be{' '} @{createFullHandle(draftValue, state.userDomain)} diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index 1857981a0f..5f406eb7ae 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -139,7 +139,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { {state.activeStep === SignupStep.INFO ? ( Your account ) : state.activeStep === SignupStep.HANDLE ? ( - Your user handle + Choose your username ) : ( Complete the challenge )} diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx index 1b2f61bd5e..3a3e4234f1 100644 --- a/src/screens/StarterPack/StarterPackScreen.tsx +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -407,6 +407,7 @@ function Header({ isOwner={isOwn} avatar={undefined} creator={creator} + purpose="app.bsky.graph.defs#referencelist" avatarType="starter-pack"> {hasSession ? ( diff --git a/src/screens/Topic.tsx b/src/screens/Topic.tsx new file mode 100644 index 0000000000..6cd69f05f2 --- /dev/null +++ b/src/screens/Topic.tsx @@ -0,0 +1,204 @@ +import React from 'react' +import {ListRenderItemInfo, View} from 'react-native' +import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {HITSLOP_10} from '#/lib/constants' +import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' +import {CommonNavigatorParams} from '#/lib/routes/types' +import {shareUrl} from '#/lib/sharing' +import {cleanError} from '#/lib/strings/errors' +import {enforceLen} from '#/lib/strings/helpers' +import {useSearchPostsQuery} from '#/state/queries/search-posts' +import {useSetMinimalShellMode} from '#/state/shell' +import {Pager} from '#/view/com/pager/Pager' +import {TabBar} from '#/view/com/pager/TabBar' +import {Post} from '#/view/com/post/Post' +import {List} from '#/view/com/util/List' +import {atoms as a, web} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import * as Layout from '#/components/Layout' +import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' + +const renderItem = ({item}: ListRenderItemInfo) => { + return +} + +const keyExtractor = (item: PostView, index: number) => { + return `${item.uri}-${index}` +} + +export default function TopicScreen({ + route, +}: NativeStackScreenProps) { + const {topic} = route.params + const {_} = useLingui() + + const headerTitle = React.useMemo(() => { + return enforceLen(decodeURIComponent(topic), 24, true, 'middle') + }, [topic]) + + const onShare = React.useCallback(() => { + const url = new URL('https://bsky.app') + url.pathname = `/topic/${topic}` + shareUrl(url.toString()) + }, [topic]) + + const [activeTab, setActiveTab] = React.useState(0) + const setMinimalShellMode = useSetMinimalShellMode() + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const onPageSelected = React.useCallback( + (index: number) => { + setMinimalShellMode(false) + setActiveTab(index) + }, + [setMinimalShellMode], + ) + + const sections = React.useMemo(() => { + return [ + { + title: _(msg`Top`), + component: ( + + ), + }, + { + title: _(msg`Latest`), + component: ( + + ), + }, + ] + }, [_, topic, activeTab]) + + return ( + + + + + {headerTitle} + + + + + + ( + + section.title)} {...props} /> + + )} + initialPage={0}> + {sections.map((section, i) => ( + {section.component} + ))} + + + ) +} + +function TopicScreenTab({ + topic, + sort, + active, +}: { + topic: string + sort: 'top' | 'latest' + active: boolean +}) { + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + const [isPTR, setIsPTR] = React.useState(false) + + const { + data, + isFetched, + isFetchingNextPage, + isLoading, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + } = useSearchPostsQuery({ + query: decodeURIComponent(topic), + sort, + enabled: active, + }) + + const posts = React.useMemo(() => { + return data?.pages.flatMap(page => page.posts) || [] + }, [data]) + + const onRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [refetch]) + + const onEndReached = React.useCallback(() => { + if (isFetchingNextPage || !hasNextPage || error) return + fetchNextPage() + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) + + return ( + <> + {posts.length < 1 ? ( + + ) : ( + + } + initialNumToRender={initialNumToRender} + windowSize={11} + /> + )} + + ) +} diff --git a/src/state/geolocation.tsx b/src/state/geolocation.tsx index 4d45bb574b..0024cd41dd 100644 --- a/src/state/geolocation.tsx +++ b/src/state/geolocation.tsx @@ -3,7 +3,6 @@ import EventEmitter from 'eventemitter3' import {networkRetry} from '#/lib/async/retry' import {logger} from '#/logger' -import {IS_DEV} from '#/env' import {Device, device} from '#/storage' const events = new EventEmitter() @@ -65,7 +64,7 @@ export function beginResolveGeolocation() { * In dev, IP server is unavailable, so we just set the default geolocation * and fail closed. */ - if (IS_DEV) { + if (__DEV__) { geolocationResolution = new Promise(y => y()) device.set(['geolocation'], DEFAULT_GEOLOCATION) return diff --git a/src/state/home-badge.tsx b/src/state/home-badge.tsx new file mode 100644 index 0000000000..59a9276ce8 --- /dev/null +++ b/src/state/home-badge.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +type StateContext = boolean +type ApiContext = (hasNew: boolean) => void + +const stateContext = React.createContext(false) +const apiContext = React.createContext((_: boolean) => {}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(false) + return ( + + {children} + + ) +} + +export function useHomeBadge() { + return React.useContext(stateContext) +} + +export function useSetHomeBadge() { + return React.useContext(apiContext) +} diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index 4cfc87cdb1..f28b197715 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -24,6 +24,7 @@ const _emitter = new EventEmitter() export async function init() { broadcast.onmessage = onBroadcastMessage + window.onstorage = onStorage const stored = readFromStorage() if (stored) { _state = stored @@ -90,6 +91,17 @@ export async function clearStorage() { } clearStorage satisfies PersistedApi['clearStorage'] +function onStorage() { + const next = readFromStorage() + if (next === _state) { + return + } + if (next) { + _state = next + _emitter.emit('update') + } +} + async function onBroadcastMessage({data}: MessageEvent) { if ( typeof data === 'object' && diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index f70d774630..0a9e5b2c07 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -125,6 +125,7 @@ const schema = z.object({ subtitlesEnabled: z.boolean().optional(), /** @deprecated */ mutedThreads: z.array(z.string()), + trendingDisabled: z.boolean().optional(), }) export type Schema = z.infer @@ -170,6 +171,7 @@ export const defaults: Schema = { kawaii: false, hasCheckedForStarterPack: false, subtitlesEnabled: true, + trendingDisabled: false, } export function tryParse(rawData: string): Schema | undefined { diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index c7eaf27261..8530a8d0c8 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -10,6 +10,7 @@ import {Provider as KawaiiProvider} from './kawaii' import {Provider as LanguagesProvider} from './languages' import {Provider as LargeAltBadgeProvider} from './large-alt-badge' import {Provider as SubtitlesProvider} from './subtitles' +import {Provider as TrendingSettingsProvider} from './trending' import {Provider as UsedStarterPacksProvider} from './used-starter-packs' export { @@ -39,7 +40,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { - {children} + + {children} + diff --git a/src/state/preferences/trending.tsx b/src/state/preferences/trending.tsx new file mode 100644 index 0000000000..bf5d8f13cc --- /dev/null +++ b/src/state/preferences/trending.tsx @@ -0,0 +1,69 @@ +import React from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = { + trendingDisabled: Exclude +} +type ApiContext = { + setTrendingDisabled( + hidden: Exclude, + ): void +} + +const StateContext = React.createContext({ + trendingDisabled: Boolean(persisted.defaults.trendingDisabled), +}) +const ApiContext = React.createContext({ + setTrendingDisabled() {}, +}) + +function usePersistedBooleanValue(key: T) { + const [value, _set] = React.useState(() => { + return Boolean(persisted.get(key)) + }) + const set = React.useCallback< + (value: Exclude) => void + >( + hidden => { + _set(Boolean(hidden)) + persisted.write(key, hidden) + }, + [key, _set], + ) + React.useEffect(() => { + return persisted.onUpdate(key, hidden => { + _set(Boolean(hidden)) + }) + }, [key, _set]) + + return [value, set] as const +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [trendingDisabled, setTrendingDisabled] = + usePersistedBooleanValue('trendingDisabled') + + /* + * Context + */ + const state = React.useMemo(() => ({trendingDisabled}), [trendingDisabled]) + const api = React.useMemo( + () => ({setTrendingDisabled}), + [setTrendingDisabled], + ) + + return ( + + {children} + + ) +} + +export function useTrendingSettings() { + return React.useContext(StateContext) +} + +export function useTrendingSettingsApi() { + return React.useContext(ApiContext) +} diff --git a/src/state/queries/actor-search.ts b/src/state/queries/actor-search.ts index 479fc1a9f0..6d6c46e040 100644 --- a/src/state/queries/actor-search.ts +++ b/src/state/queries/actor-search.ts @@ -1,6 +1,7 @@ import {AppBskyActorDefs, AppBskyActorSearchActors} from '@atproto/api' import { InfiniteData, + keepPreviousData, QueryClient, QueryKey, useInfiniteQuery, @@ -13,10 +14,8 @@ import {useAgent} from '#/state/session' const RQKEY_ROOT = 'actor-search' export const RQKEY = (query: string) => [RQKEY_ROOT, query] -export const RQKEY_PAGINATED = (query: string) => [ - `${RQKEY_ROOT}_paginated`, - query, -] +const RQKEY_ROOT_PAGINATED = `${RQKEY_ROOT}_paginated` +export const RQKEY_PAGINATED = (query: string) => [RQKEY_ROOT_PAGINATED, query] export function useActorSearch({ query, @@ -42,9 +41,11 @@ export function useActorSearch({ export function useActorSearchPaginated({ query, enabled, + maintainData, }: { query: string enabled?: boolean + maintainData?: boolean }) { const agent = useAgent() return useInfiniteQuery< @@ -67,6 +68,7 @@ export function useActorSearchPaginated({ enabled: enabled && !!query, initialPageParam: undefined, getNextPageParam: lastPage => lastPage.cursor, + placeholderData: maintainData ? keepPreviousData : undefined, }) } @@ -89,4 +91,20 @@ export function* findAllProfilesInQueryData( } } } + + const queryDatasPaginated = queryClient.getQueriesData< + InfiniteData + >({ + queryKey: [RQKEY_ROOT_PAGINATED], + }) + for (const [_queryKey, queryData] of queryDatasPaginated) { + if (!queryData) { + continue + } + for (const actor of queryData.pages.flatMap(page => page.actors)) { + if (actor.did === did) { + yield actor + } + } + } } diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts index 0635bf316b..d4b9d94c45 100644 --- a/src/state/queries/index.ts +++ b/src/state/queries/index.ts @@ -6,6 +6,7 @@ export const STALE = { MINUTES: { ONE: 1e3 * 60, FIVE: 1e3 * 60 * 5, + THIRTY: 1e3 * 60 * 30, }, HOURS: { ONE: 1e3 * 60 * 60, diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 19a92fc3c0..72100a6245 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -52,25 +52,22 @@ const PAGE_SIZE = 30 type RQPageParam = string | undefined const RQKEY_ROOT = 'notification-feed' -export function RQKEY(priority?: false) { - return [RQKEY_ROOT, priority] +export function RQKEY(filter: 'all' | 'mentions') { + return [RQKEY_ROOT, filter] } -export function useNotificationFeedQuery(opts?: { +export function useNotificationFeedQuery(opts: { enabled?: boolean - overridePriorityNotifications?: boolean + filter: 'all' | 'mentions' }) { const agent = useAgent() const queryClient = useQueryClient() const moderationOpts = useModerationOpts() const unreads = useUnreadNotificationsApi() - const enabled = opts?.enabled !== false + const enabled = opts.enabled !== false + const filter = opts.filter const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris() - // false: force showing all notifications - // undefined: let the server decide - const priority = opts?.overridePriorityNotifications ? false : undefined - const selectArgs = useMemo(() => { return { moderationOpts, @@ -91,14 +88,23 @@ export function useNotificationFeedQuery(opts?: { RQPageParam >({ staleTime: STALE.INFINITY, - queryKey: RQKEY(priority), + queryKey: RQKEY(filter), async queryFn({pageParam}: {pageParam: RQPageParam}) { let page - if (!pageParam) { + if (filter === 'all' && !pageParam) { // for the first page, we check the cached page held by the unread-checker first page = unreads.getCachedUnreadPage() } if (!page) { + let reasons: string[] = [] + if (filter === 'mentions') { + reasons = [ + // Anything that's a post + 'mention', + 'reply', + 'quote', + ] + } const {page: fetchedPage} = await fetchPage({ agent, limit: PAGE_SIZE, @@ -106,13 +112,13 @@ export function useNotificationFeedQuery(opts?: { queryClient, moderationOpts, fetchAdditionalData: true, - priority, + reasons, }) page = fetchedPage } - // if the first page has an unread, mark all read - if (!pageParam) { + if (filter === 'all' && !pageParam) { + // if the first page has an unread, mark all read unreads.markAllRead() } diff --git a/src/state/queries/notifications/settings.ts b/src/state/queries/notifications/settings.ts index a17fce8326..e552b65202 100644 --- a/src/state/queries/notifications/settings.ts +++ b/src/state/queries/notifications/settings.ts @@ -45,7 +45,8 @@ export function useNotificationSettingsMutation() { }, onSettled: () => { invalidateCachedUnreadPage() - queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()}) + queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('all')}) + queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('mentions')}) }, }) } @@ -54,7 +55,7 @@ function eagerlySetCachedPriority( queryClient: ReturnType, enabled: boolean, ) { - queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => { + function updateData(old: any) { if (!old) return old return { ...old, @@ -65,5 +66,7 @@ function eagerlySetCachedPriority( } }), } - }) + } + queryClient.setQueryData(RQKEY_NOTIFS('all'), updateData) + queryClient.setQueryData(RQKEY_NOTIFS('mentions'), updateData) } diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index 2ade04246f..ba2377a784 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -2,7 +2,7 @@ * A kind of companion API to ./feed.ts. See that file for more info. */ -import React from 'react' +import React, {useRef} from 'react' import {AppState} from 'react-native' import {useQueryClient} from '@tanstack/react-query' import EventEmitter from 'eventemitter3' @@ -105,6 +105,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, [setNumUnread]) + const isFetchingRef = useRef(false) + // create API const api = React.useMemo(() => { return { @@ -138,6 +140,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } } + if (isFetchingRef.current) { + return + } + // Do not move this without ensuring it gets a symmetrical reset in the finally block. + isFetchingRef.current = true + // count const {page, indexedAt: lastIndexed} = await fetchPage({ agent, @@ -145,6 +153,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { limit: 40, queryClient, moderationOpts, + reasons: [], // only fetch subjects when the page is going to be used // in the notifications query, otherwise skip it @@ -174,11 +183,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { // update & broadcast setNumUnread(unreadCountStr) if (invalidate) { - truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) + truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all')) + truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions')) } broadcast.postMessage({event: unreadCountStr}) } catch (e) { logger.warn('Failed to check unread notifications', {error: e}) + } finally { + isFetchingRef.current = false } }, diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index a251d170ec..0d72e9e929 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -31,6 +31,7 @@ export async function fetchPage({ queryClient, moderationOpts, fetchAdditionalData, + reasons, }: { agent: BskyAgent cursor: string | undefined @@ -38,7 +39,7 @@ export async function fetchPage({ queryClient: QueryClient moderationOpts: ModerationOpts | undefined fetchAdditionalData: boolean - priority?: boolean + reasons: string[] }): Promise<{ page: FeedPage indexedAt: string | undefined @@ -46,7 +47,7 @@ export async function fetchPage({ const res = await agent.listNotifications({ limit, cursor, - // priority, + reasons, }) const indexedAt = res.data.notifications[0]?.indexedAt diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 696e28f9c2..2eb604627e 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -144,11 +144,8 @@ export function usePostFeedQuery( /** * The number of posts to fetch in a single request. Because we filter * unwanted content, we may over-fetch here to try and fill pages by - * `MIN_POSTS`. + * `MIN_POSTS`. But if you're doing this, ask @why if it's ok first. */ - - // TEMPORARILY DISABLING GATE TO PREVENT EVENT CONSUMPTION @TODO EME-GATE - // const fetchLimit = gate('post_feed_lang_window') ? 100 : MIN_POSTS const fetchLimit = MIN_POSTS // Make sure this doesn't invalidate unless really needed. diff --git a/src/state/queries/service-config.ts b/src/state/queries/service-config.ts new file mode 100644 index 0000000000..9a9db78659 --- /dev/null +++ b/src/state/queries/service-config.ts @@ -0,0 +1,32 @@ +import {useQuery} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import {useAgent} from '#/state/session' + +type ServiceConfig = { + checkEmailConfirmed: boolean + topicsEnabled: boolean +} + +export function useServiceConfigQuery() { + const agent = useAgent() + return useQuery({ + refetchOnWindowFocus: true, + staleTime: STALE.MINUTES.FIVE, + queryKey: ['service-config'], + queryFn: async () => { + try { + const {data} = await agent.api.app.bsky.unspecced.getConfig() + return { + checkEmailConfirmed: Boolean(data.checkEmailConfirmed), + topicsEnabled: Boolean(data.topicsEnabled), + } + } catch (e) { + return { + checkEmailConfirmed: false, + topicsEnabled: false, + } + } + }, + }) +} diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 07e16946e7..22033c0a8c 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -103,7 +103,13 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { }) } -export function useSuggestedFollowsByActorQuery({did}: {did: string}) { +export function useSuggestedFollowsByActorQuery({ + did, + enabled, +}: { + did: string + enabled?: boolean +}) { const agent = useAgent() return useQuery({ queryKey: suggestedFollowsByActorQueryKey(did), @@ -116,6 +122,7 @@ export function useSuggestedFollowsByActorQuery({did}: {did: string}) { : res.data.suggestions.filter(profile => !profile.viewer?.following) return {suggestions} }, + enabled, }) } diff --git a/src/state/queries/trending/useTrendingTopics.ts b/src/state/queries/trending/useTrendingTopics.ts new file mode 100644 index 0000000000..310f64e9f2 --- /dev/null +++ b/src/state/queries/trending/useTrendingTopics.ts @@ -0,0 +1,49 @@ +import React from 'react' +import {AppBskyUnspeccedDefs} from '@atproto/api' +import {hasMutedWord} from '@atproto/api/dist/moderation/mutewords' +import {useQuery} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {useAgent} from '#/state/session' + +export type TrendingTopic = AppBskyUnspeccedDefs.TrendingTopic + +export const DEFAULT_LIMIT = 14 + +export const trendingTopicsQueryKey = ['trending-topics'] + +export function useTrendingTopics() { + const agent = useAgent() + const {data: preferences} = usePreferencesQuery() + const mutedWords = React.useMemo(() => { + return preferences?.moderationPrefs?.mutedWords || [] + }, [preferences?.moderationPrefs]) + + return useQuery({ + refetchOnWindowFocus: true, + staleTime: STALE.MINUTES.THIRTY, + queryKey: trendingTopicsQueryKey, + async queryFn() { + const {data} = await agent.api.app.bsky.unspecced.getTrendingTopics({ + limit: DEFAULT_LIMIT, + }) + + const {topics, suggested} = data + return { + topics: topics.filter(t => { + return !hasMutedWord({ + mutedWords, + text: t.topic + ' ' + t.displayName + ' ' + t.description, + }) + }), + suggested: suggested.filter(t => { + return !hasMutedWord({ + mutedWords, + text: t.topic + ' ' + t.displayName + ' ' + t.description, + }) + }), + } + }, + }) +} diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts index 0d6a8e99ac..887c1df0ac 100644 --- a/src/state/queries/util.ts +++ b/src/state/queries/util.ts @@ -8,7 +8,7 @@ import { } from '@atproto/api' import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query' -export function truncateAndInvalidate( +export async function truncateAndInvalidate( queryClient: QueryClient, queryKey: QueryKey, ) { @@ -21,7 +21,7 @@ export function truncateAndInvalidate( } return data }) - queryClient.invalidateQueries({queryKey}) + return queryClient.invalidateQueries({queryKey}) } // Given an AtUri, this function will check if the AtUri matches a diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 4c58c63237..dec8ec48bd 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -10,10 +10,6 @@ jest.mock('jwt-decode', () => ({ }, })) -jest.mock('expo-localization', () => ({ - getLocales: () => [], -})) - describe('session', () => { it('can log in and out', () => { let state = getInitialState([]) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index ab3352bf3a..48b2588630 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -6,7 +6,6 @@ import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' import {useCloseAllActiveElements} from '#/state/util' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' -import {IS_DEV} from '#/env' import {emitSessionDropped} from '../events' import { agentToSessionAccount, @@ -260,7 +259,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) // @ts-ignore - if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent + if (__DEV__ && isWeb) window.agent = state.currentAgentState.agent const agent = state.currentAgentState.agent as BskyAppAgent const currentAgentRef = React.useRef(agent) diff --git a/src/state/shell/light-status-bar.tsx b/src/state/shell/light-status-bar.tsx new file mode 100644 index 0000000000..6f47689d1d --- /dev/null +++ b/src/state/shell/light-status-bar.tsx @@ -0,0 +1,44 @@ +import {createContext, useContext, useEffect, useState} from 'react' + +import {isWeb} from '#/platform/detection' + +const LightStatusBarRefCountContext = createContext(false) +const SetLightStatusBarRefCountContext = createContext +> | null>(null) + +export function useLightStatusBar() { + return useContext(LightStatusBarRefCountContext) +} + +export function useSetLightStatusBar(enabled: boolean) { + const setRefCount = useContext(SetLightStatusBarRefCountContext) + useEffect(() => { + // noop on web -sfn + if (isWeb) return + + if (!setRefCount) { + if (__DEV__) + console.error( + 'useLightStatusBar was used without a SetLightStatusBarRefCountContext provider', + ) + return + } + if (enabled) { + setRefCount(prev => prev + 1) + return () => setRefCount(prev => prev - 1) + } + }, [enabled, setRefCount]) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [refCount, setRefCount] = useState(0) + + return ( + + 0}> + {children} + + + ) +} diff --git a/src/state/shell/progress-guide.tsx b/src/state/shell/progress-guide.tsx index d64e9984f5..af3d60ebbd 100644 --- a/src/state/shell/progress-guide.tsx +++ b/src/state/shell/progress-guide.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useMemo} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -16,20 +16,32 @@ export enum ProgressGuideAction { Follow = 'follow', } -type ProgressGuideName = 'like-10-and-follow-7' +type ProgressGuideName = 'like-10-and-follow-7' | 'follow-10' +/** + * Progress Guides that extend this interface must specify their name in the `guide` field, so it can be used as a discriminated union + */ interface BaseProgressGuide { - guide: string + guide: ProgressGuideName isComplete: boolean [key: string]: any } -interface Like10AndFollow7ProgressGuide extends BaseProgressGuide { +export interface Like10AndFollow7ProgressGuide extends BaseProgressGuide { + guide: 'like-10-and-follow-7' numLikes: number numFollows: number } -type ProgressGuide = Like10AndFollow7ProgressGuide | undefined +export interface Follow10ProgressGuide extends BaseProgressGuide { + guide: 'follow-10' + numFollows: number +} + +export type ProgressGuide = + | Like10AndFollow7ProgressGuide + | Follow10ProgressGuide + | undefined const ProgressGuideContext = React.createContext(undefined) @@ -61,15 +73,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const {mutateAsync, variables, isPending} = useSetActiveProgressGuideMutation() - const activeProgressGuide = ( - isPending ? variables : preferences?.bskyAppState?.activeProgressGuide - ) as ProgressGuide + const activeProgressGuide = useMemo(() => { + const rawProgressGuide = ( + isPending ? variables : preferences?.bskyAppState?.activeProgressGuide + ) as ProgressGuide + + if (!rawProgressGuide) return undefined + + // ensure the unspecced attributes have the correct types + // clone then mutate + const {...maybeWronglyTypedProgressGuide} = rawProgressGuide + if (maybeWronglyTypedProgressGuide?.guide === 'like-10-and-follow-7') { + maybeWronglyTypedProgressGuide.numLikes = + Number(maybeWronglyTypedProgressGuide.numLikes) || 0 + maybeWronglyTypedProgressGuide.numFollows = + Number(maybeWronglyTypedProgressGuide.numFollows) || 0 + } else if (maybeWronglyTypedProgressGuide?.guide === 'follow-10') { + maybeWronglyTypedProgressGuide.numFollows = + Number(maybeWronglyTypedProgressGuide.numFollows) || 0 + } - // ensure the unspecced attributes have the correct types - if (activeProgressGuide?.guide === 'like-10-and-follow-7') { - activeProgressGuide.numLikes = Number(activeProgressGuide.numLikes) || 0 - activeProgressGuide.numFollows = Number(activeProgressGuide.numFollows) || 0 - } + return maybeWronglyTypedProgressGuide + }, [isPending, variables, preferences]) const [localGuideState, setLocalGuideState] = React.useState(undefined) @@ -82,7 +107,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const firstLikeToastRef = React.useRef(null) const fifthLikeToastRef = React.useRef(null) const tenthLikeToastRef = React.useRef(null) - const guideCompleteToastRef = React.useRef(null) + + const fifthFollowToastRef = React.useRef(null) + const tenthFollowToastRef = React.useRef(null) const controls = React.useMemo(() => { return { @@ -93,7 +120,15 @@ export function Provider({children}: React.PropsWithChildren<{}>) { numLikes: 0, numFollows: 0, isComplete: false, - } + } satisfies ProgressGuide + setLocalGuideState(guideObj) + mutateAsync(guideObj) + } else if (guide === 'follow-10') { + const guideObj = { + guide: 'follow-10', + numFollows: 0, + isComplete: false, + } satisfies ProgressGuide setLocalGuideState(guideObj) mutateAsync(guideObj) } @@ -137,6 +172,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) { isComplete: true, } } + } else if (guide?.guide === 'follow-10') { + if (action === ProgressGuideAction.Follow) { + guide = { + ...guide, + numFollows: (Number(guide.numFollows) || 0) + count, + } + + if (guide.numFollows === 5) { + fifthFollowToastRef.current?.open() + } + if (guide.numFollows === 10) { + tenthFollowToastRef.current?.open() + } + } + if (Number(guide.numFollows) >= 10) { + guide = { + ...guide, + isComplete: true, + } + } } setLocalGuideState(guide) @@ -167,9 +222,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) { subtitle={_(msg`The Discover feed now knows what you like`)} /> + )} diff --git a/src/state/trending-config.tsx b/src/state/trending-config.tsx new file mode 100644 index 0000000000..a7694993fb --- /dev/null +++ b/src/state/trending-config.tsx @@ -0,0 +1,70 @@ +import React from 'react' + +import {useGate} from '#/lib/statsig/statsig' +import {useLanguagePrefs} from '#/state/preferences/languages' +import {useServiceConfigQuery} from '#/state/queries/service-config' +import {device} from '#/storage' + +type Context = { + enabled: boolean +} + +const Context = React.createContext({ + enabled: false, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const gate = useGate() + const langPrefs = useLanguagePrefs() + const {data: config, isLoading: isInitialLoad} = useServiceConfigQuery() + const ctx = React.useMemo(() => { + if (__DEV__) { + return {enabled: true} + } + + /* + * Only English during beta period + */ + if ( + !!langPrefs.contentLanguages.length && + !langPrefs.contentLanguages.includes('en') + ) { + return {enabled: false} + } + + /* + * While loading, use cached value + */ + const cachedEnabled = device.get(['trendingBetaEnabled']) + if (isInitialLoad) { + return {enabled: Boolean(cachedEnabled)} + } + + /* + * Doing an extra check here to reduce hits to statsig. If it's disabled on + * the server, we can exit early. + */ + const enabled = Boolean(config?.topicsEnabled) + if (!enabled) { + // cache for next reload + device.set(['trendingBetaEnabled'], enabled) + return {enabled: false} + } + + /* + * Service is enabled, but also check statsig in case we're rolling back. + */ + const gateEnabled = gate('trending_topics_beta') + const _enabled = enabled && gateEnabled + + // update cache + device.set(['trendingBetaEnabled'], _enabled) + + return {enabled: _enabled} + }, [isInitialLoad, config, gate, langPrefs.contentLanguages]) + return {children} +} + +export function useTrendingConfig() { + return React.useContext(Context) +} diff --git a/src/storage/index.ts b/src/storage/index.ts index 7ef226d3aa..ce17d4c366 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,6 +1,5 @@ import {MMKV} from 'react-native-mmkv' -import {IS_DEV} from '#/env' import {Device} from '#/storage/schema' export * from '#/storage/schema' @@ -74,7 +73,7 @@ export class Storage { */ export const device = new Storage<[], Device>({id: 'bsky_device'}) -if (IS_DEV && typeof window !== 'undefined') { +if (__DEV__ && typeof window !== 'undefined') { // @ts-ignore window.bsky_storage = { device, diff --git a/src/storage/schema.ts b/src/storage/schema.ts index cf410c77de..cfca9131c7 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -8,4 +8,5 @@ export type Device = { geolocation?: { countryCode: string | undefined } + trendingBetaEnabled: boolean } 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 1d5dad4861..c721729020 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -10,6 +10,7 @@ import {DismissableLayer} from '@radix-ui/react-dismissable-layer' import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' import {atoms as a} from '#/alf' +import {Portal} from '#/components/Portal' const HEIGHT_OFFSET = 40 const WIDTH_OFFSET = 100 @@ -125,39 +126,41 @@ export function EmojiPicker({state, close, pinToTop}: IProps) { } return ( - - - {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} - e.stopPropagation()}> - - evt.preventDefault()} - onDismiss={close}> - { - return (await import('./EmojiPickerData.json')).default - }} - onEmojiSelect={onInsert} - autoFocus={true} - /> - - - - - + + + + {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} + e.stopPropagation()}> + + evt.preventDefault()} + onDismiss={close}> + { + return (await import('./EmojiPickerData.json')).default + }} + onEmojiSelect={onInsert} + autoFocus={true} + /> + + + + + + ) } diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index e766b589b0..10ed60212c 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -14,6 +14,7 @@ import {s} from '#/lib/styles' import {isNative} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' +import {useSetHomeBadge} from '#/state/home-badge' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {truncateAndInvalidate} from '#/state/queries/util' @@ -59,6 +60,13 @@ export function FeedPage({ const feedFeedback = useFeedFeedback(feed, hasSession) const scrollElRef = React.useRef(null) const [hasNew, setHasNew] = React.useState(false) + const setHomeBadge = useSetHomeBadge() + + React.useEffect(() => { + if (isPageFocused) { + setHomeBadge(hasNew) + } + }, [isPageFocused, hasNew, setHomeBadge]) const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({ diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 8ed85a49bc..dbf930aa96 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -300,11 +300,14 @@ export function FeedSourceCardLoaded({ {showLikes && feed.type === 'feed' ? ( - + + Liked by{' '} + + ) : null} diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx index 7a8a7671d7..d8bbe19e68 100644 --- a/src/view/com/home/HomeHeaderLayout.web.tsx +++ b/src/view/com/home/HomeHeaderLayout.web.tsx @@ -1,10 +1,8 @@ import React from 'react' import {View} from 'react-native' -import Animated from 'react-native-reanimated' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useMinimalShellHeaderTransform} from '#/lib/hooks/useMinimalShellTransform' import {useKawaiiMode} from '#/state/preferences/kawaii' import {useSession} from '#/state/session' import {useShellLayout} from '#/state/shell/shell-layout' @@ -36,7 +34,6 @@ function HomeHeaderLayoutDesktopAndTablet({ tabBarAnchor: JSX.Element | null | undefined }) { const t = useTheme() - const headerMinimalShellTransform = useMinimalShellHeaderTransform() const {headerHeight} = useShellLayout() const {hasSession} = useSession() const {_} = useLingui() @@ -69,14 +66,11 @@ function HomeHeaderLayoutDesktopAndTablet({ )} {tabBarAnchor} - { - headerHeight.set(e.nativeEvent.layout.height) - }} - style={[headerMinimalShellTransform]}> - {children} - + style={[a.sticky, a.z_10, a.align_center, t.atoms.bg, {top: 0}]} + onLayout={e => { + headerHeight.set(e.nativeEvent.layout.height) + }}> + {children} ) diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx index 5168933aeb..5fa40b30b5 100644 --- a/src/view/com/notifications/NotificationFeed.tsx +++ b/src/view/com/notifications/NotificationFeed.tsx @@ -9,13 +9,11 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {usePalette} from '#/lib/hooks/usePalette' import {cleanError} from '#/lib/strings/errors' import {s} from '#/lib/styles' import {logger} from '#/logger' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' -import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread' import {EmptyState} from '#/view/com/util/EmptyState' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {List, ListRef} from '#/view/com/util/List' @@ -28,26 +26,26 @@ const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} const LOADING_ITEM = {_reactKey: '__loading__'} export function NotificationFeed({ + filter, + enabled, scrollElRef, onPressTryAgain, onScrolledDownChange, ListHeaderComponent, - overridePriorityNotifications, + refreshNotifications, }: { + filter: 'all' | 'mentions' + enabled: boolean scrollElRef?: ListRef onPressTryAgain?: () => void onScrolledDownChange: (isScrolledDown: boolean) => void ListHeaderComponent?: () => JSX.Element - overridePriorityNotifications?: boolean + refreshNotifications: () => Promise }) { const initialNumToRender = useInitialNumToRender() - const [isPTRing, setIsPTRing] = React.useState(false) - const pal = usePalette('default') - const {_} = useLingui() const moderationOpts = useModerationOpts() - const {checkUnread} = useUnreadNotificationsApi() const { data, isFetching, @@ -58,8 +56,8 @@ export function NotificationFeed({ isFetchingNextPage, fetchNextPage, } = useNotificationFeedQuery({ - enabled: !!moderationOpts, - overridePriorityNotifications, + enabled: enabled && !!moderationOpts, + filter, }) const isEmpty = !isFetching && !data?.pages[0]?.items.length @@ -85,7 +83,7 @@ export function NotificationFeed({ const onRefresh = React.useCallback(async () => { try { setIsPTRing(true) - await checkUnread({invalidate: true}) + await refreshNotifications() } catch (err) { logger.error('Failed to refresh notifications feed', { message: err, @@ -93,7 +91,7 @@ export function NotificationFeed({ } finally { setIsPTRing(false) } - }, [checkUnread, setIsPTRing]) + }, [refreshNotifications, setIsPTRing]) const onEndReached = React.useCallback(async () => { if (isFetching || !hasNextPage || isError) return @@ -129,21 +127,18 @@ export function NotificationFeed({ /> ) } else if (item === LOADING_ITEM) { - return ( - - - - ) + return } return ( ) }, - [moderationOpts, _, onPressRetryLoadMore, pal.border], + [moderationOpts, _, onPressRetryLoadMore, filter], ) const FeedFooter = React.useCallback( diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index b53613aef4..ed2363f767 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -79,10 +79,12 @@ interface Author { let NotificationFeedItem = ({ item, moderationOpts, + highlightUnread, hideTopBorder, }: { item: FeedNotification moderationOpts: ModerationOpts + highlightUnread: boolean hideTopBorder?: boolean }): React.ReactNode => { const queryClient = useQueryClient() @@ -151,6 +153,7 @@ let NotificationFeedItem = ({ if (!item.subject) { return null } + const isHighlighted = highlightUnread && !item.notification.isRead return ( diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx index fd4cc74632..c979f7a6dd 100644 --- a/src/view/com/pager/PagerHeaderContext.tsx +++ b/src/view/com/pager/PagerHeaderContext.tsx @@ -1,40 +1,48 @@ import React, {useContext} from 'react' import {SharedValue} from 'react-native-reanimated' -import {isIOS} from '#/platform/detection' +import {isNative} from '#/platform/detection' -export const PagerHeaderContext = - React.createContext | null>(null) +export const PagerHeaderContext = React.createContext<{ + scrollY: SharedValue + headerHeight: number +} | null>(null) /** - * Passes the scrollY value to the pager header's banner, so it can grow on - * overscroll on iOS. Not necessary to use this context provider on other platforms. + * Passes information about the scroll position and header height down via + * context for the pager header to consume. * - * @platform ios + * @platform ios, android */ export function PagerHeaderProvider({ scrollY, + headerHeight, children, }: { scrollY: SharedValue + headerHeight: number children: React.ReactNode }) { + const value = React.useMemo( + () => ({scrollY, headerHeight}), + [scrollY, headerHeight], + ) return ( - + {children} ) } export function usePagerHeaderContext() { - const scrollY = useContext(PagerHeaderContext) - if (isIOS) { - if (!scrollY) { + const ctx = useContext(PagerHeaderContext) + if (isNative) { + if (!ctx) { throw new Error( 'usePagerHeaderContext must be used within a HeaderProvider', ) } - return {scrollY} + return ctx } else { return null } diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 6174459647..1746d2ca13 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -21,6 +21,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {ScrollProvider} from '#/lib/ScrollContext' import {isIOS} from '#/platform/detection' import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager' +import {useTheme} from '#/alf' import {ListMethods} from '../util/List' import {PagerHeaderProvider} from './PagerHeaderContext' import {TabBar} from './TabBar' @@ -38,7 +39,11 @@ export interface PagerWithHeaderProps { | ((props: PagerWithHeaderChildParams) => JSX.Element) items: string[] isHeaderReady: boolean - renderHeader?: () => JSX.Element + renderHeader?: ({ + setMinimumHeight, + }: { + setMinimumHeight: (height: number) => void + }) => JSX.Element initialPage?: number onPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void @@ -83,7 +88,9 @@ export const PagerWithHeader = React.forwardRef( const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - + - renderHeader?: () => JSX.Element + renderHeader?: ({ + setMinimumHeight, + }: { + setMinimumHeight: (height: number) => void + }) => JSX.Element onHeaderOnlyLayout: (height: number) => void onTabBarLayout: (e: LayoutChangeEvent) => void onCurrentPageSelected?: (index: number) => void @@ -246,8 +257,14 @@ let PagerTabBar = ({ dragProgress: SharedValue dragState: SharedValue<'idle' | 'dragging' | 'settling'> }): React.ReactNode => { + const t = useTheme() + const [minimumHeaderHeight, setMinimumHeaderHeight] = React.useState(0) const headerTransform = useAnimatedStyle(() => { - const translateY = Math.min(scrollY.get(), headerOnlyHeight) * -1 + const translateY = + Math.min( + scrollY.get(), + Math.max(headerOnlyHeight - minimumHeaderHeight, 0), + ) * -1 return { transform: [ { @@ -262,12 +279,12 @@ let PagerTabBar = ({ return ( + style={[styles.tabBarMobile, headerTransform, t.atoms.bg]}> - {renderHeader?.()} + {renderHeader?.({setMinimumHeight: setMinimumHeaderHeight})} { // It wouldn't be enough to place `onLayout` on the parent node because // this would risk measuring before `isHeaderReady` has turned `true`. diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx index 3335532b3d..3e530358a3 100644 --- a/src/view/com/pager/PagerWithHeader.web.tsx +++ b/src/view/com/pager/PagerWithHeader.web.tsx @@ -21,7 +21,11 @@ export interface PagerWithHeaderProps { | ((props: PagerWithHeaderChildParams) => JSX.Element) items: string[] isHeaderReady: boolean - renderHeader?: () => JSX.Element + renderHeader?: ({ + setMinimumHeight, + }: { + setMinimumHeight: () => void + }) => JSX.Element initialPage?: number onPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void @@ -115,7 +119,11 @@ let PagerTabBar = ({ currentPage: number items: string[] testID?: string - renderHeader?: () => JSX.Element + renderHeader?: ({ + setMinimumHeight, + }: { + setMinimumHeight: () => void + }) => JSX.Element isHeaderReady: boolean onCurrentPageSelected?: (index: number) => void onSelect?: (index: number) => void @@ -123,17 +131,19 @@ let PagerTabBar = ({ }): React.ReactNode => { return ( <> - {renderHeader?.()} + {renderHeader?.({setMinimumHeight: noop})} {tabBarAnchor} + web([ + a.sticky, + { + top: 0, + display: isHeaderReady ? undefined : 'none', + }, + ]), + ]}> (v: T | T[]): T[] { } return [v] } + +function noop() {} diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index b08b364c52..8e89b9c1f2 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -51,6 +51,7 @@ export function TabBar({ const containerSize = useSharedValue(0) const scrollX = useSharedValue(0) const layouts = useSharedValue<{x: number; width: number}[]>([]) + const textLayouts = useSharedValue<{width: number}[]>([]) const itemsLength = items.length const scrollToOffsetJS = useCallback( @@ -211,21 +212,53 @@ export function TabBar({ [layouts], ) + const onTextLayout = useCallback( + (i: number, layout: {width: number}) => { + 'worklet' + textLayouts.modify(ls => { + ls[i] = layout + return ls + }) + }, + [textLayouts], + ) + const indicatorStyle = useAnimatedStyle(() => { if (!_WORKLET) { return {opacity: 0} } const layoutsValue = layouts.get() + const textLayoutsValue = textLayouts.get() if ( layoutsValue.length !== itemsLength || - layoutsValue.some(l => l === undefined) + textLayoutsValue.length !== itemsLength ) { return { opacity: 0, } } - if (layoutsValue.length === 1) { - return {opacity: 1} + + function getScaleX(index: number) { + const textWidth = textLayoutsValue[index].width + const itemWidth = layoutsValue[index].width + const minIndicatorWidth = 45 + const maxIndicatorWidth = itemWidth - 2 * CONTENT_PADDING + const indicatorWidth = Math.min( + Math.max(minIndicatorWidth, textWidth), + maxIndicatorWidth, + ) + return indicatorWidth / contentSize.get() + } + + if (textLayoutsValue.length === 1) { + return { + opacity: 1, + transform: [ + { + scaleX: getScaleX(0), + }, + ], + } } return { opacity: 1, @@ -240,10 +273,8 @@ export function TabBar({ { scaleX: interpolate( dragProgress.get(), - layoutsValue.map((l, i) => i), - layoutsValue.map( - l => (l.width - ITEM_PADDING * 2) / contentSize.get(), - ), + textLayoutsValue.map((l, i) => i), + textLayoutsValue.map((l, i) => getScaleX(i)), ), }, ], @@ -287,7 +318,7 @@ export function TabBar({ onLayout={e => { contentSize.set(e.nativeEvent.layout.width) }} - style={{flexDirection: 'row'}}> + style={{flexDirection: 'row', flexGrow: 1}}> {items.map((item, i) => { return ( ) })} @@ -328,6 +360,7 @@ function TabBarItem({ item, onPressItem, onItemLayout, + onTextLayout, }: { index: number testID: string | undefined @@ -335,6 +368,7 @@ function TabBarItem({ item: string onPressItem: (index: number) => void onItemLayout: (index: number, layout: {x: number; width: number}) => void + onTextLayout: (index: number, layout: {width: number}) => void }) { const t = useTheme() const style = useAnimatedStyle(() => { @@ -358,8 +392,15 @@ function TabBarItem({ [index, onItemLayout], ) + const handleTextLayout = useCallback( + (e: LayoutChangeEvent) => { + runOnUI(onTextLayout)(index, e.nativeEvent.layout) + }, + [index, onTextLayout], + ) + return ( - + + style={[styles.itemText, t.atoms.text, a.text_md, a.font_bold]} + onLayout={handleTextLayout}> {item} @@ -381,19 +423,27 @@ function TabBarItem({ const styles = StyleSheet.create({ contentContainer: { + flexGrow: 1, backgroundColor: 'transparent', paddingHorizontal: CONTENT_PADDING, }, item: { + flexGrow: 1, paddingTop: 10, paddingHorizontal: ITEM_PADDING, justifyContent: 'center', }, itemInner: { + alignItems: 'center', + flexGrow: 1, paddingBottom: 10, borderBottomWidth: 3, borderBottomColor: 'transparent', }, + itemText: { + lineHeight: 20, + textAlign: 'center', + }, outerBottomBorder: { position: 'absolute', left: 0, diff --git a/src/view/com/pager/TabBar.web.tsx b/src/view/com/pager/TabBar.web.tsx index 789f88e753..d44b7b60c4 100644 --- a/src/view/com/pager/TabBar.web.tsx +++ b/src/view/com/pager/TabBar.web.tsx @@ -25,16 +25,12 @@ export function TabBar({ testID, selectedPage, items, - indicatorColor, onSelect, onPressSelected, }: TabBarProps) { const t = useTheme() const scrollElRef = useRef(null) const itemRefs = useRef>([]) - const indicatorStyle = { - borderBottomColor: indicatorColor || t.palette.primary_500, - } const {gtMobile} = useBreakpoints() const styles = gtMobile ? desktopStyles : mobileStyles @@ -115,17 +111,26 @@ export function TabBar({ hoverStyle={t.atoms.bg_contrast_25} onPress={() => onPressItem(i)} accessibilityRole="tab"> - + {item} + @@ -140,21 +145,36 @@ export function TabBar({ const desktopStyles = StyleSheet.create({ outer: { flexDirection: 'row', - width: 598, + width: 600, }, contentContainer: { + flexGrow: 1, paddingHorizontal: 0, backgroundColor: 'transparent', }, item: { + flexGrow: 1, + alignItems: 'stretch', paddingTop: 14, paddingHorizontal: 14, justifyContent: 'center', }, itemInner: { - paddingBottom: 12, - borderBottomWidth: 3, - borderBottomColor: 'transparent', + alignItems: 'center', + overflowX: 'hidden', + }, + itemText: { + textAlign: 'center', + paddingBottom: 10 + 3, + }, + itemIndicator: { + position: 'absolute', + bottom: 0, + height: 3, + left: '50%', + transform: 'translateX(-50%)', + minWidth: 45, + width: '100%', }, outerBottomBorder: { position: 'absolute', @@ -170,18 +190,34 @@ const mobileStyles = StyleSheet.create({ flexDirection: 'row', }, contentContainer: { + flexGrow: 1, backgroundColor: 'transparent', paddingHorizontal: 6, }, item: { + flexGrow: 1, + alignItems: 'stretch', paddingTop: 10, paddingHorizontal: 10, justifyContent: 'center', }, itemInner: { - paddingBottom: 10, - borderBottomWidth: 2, - borderBottomColor: 'transparent', + flexGrow: 1, + alignItems: 'center', + overflowX: 'hidden', + }, + itemText: { + textAlign: 'center', + paddingBottom: 10 + 3, + }, + itemIndicator: { + position: 'absolute', + bottom: 0, + height: 3, + left: '50%', + transform: 'translateX(-50%)', + minWidth: 45, + width: '100%', }, outerBottomBorder: { position: 'absolute', diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index d8c2ba1183..477d77affb 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -421,9 +421,6 @@ export function PostThread({uri}: {uri: string | undefined}) { ) } else if (isThreadPost(item)) { - if (!treeView && item.ctx.hasMoreSelfThread) { - return - } const prev = isThreadPost(posts[index - 1]) ? (posts[index - 1] as ThreadPost) : undefined @@ -436,6 +433,10 @@ export function PostThread({uri}: {uri: string | undefined}) { const hasUnrevealedParents = index === 0 && skeleton?.parents && maxParents < skeleton.parents.length + if (!treeView && prev && item.ctx.hasMoreSelfThread) { + return + } + return ( void) | null>(null) const lastFetchRef = React.useRef(Date.now()) const [feedType, feedUri, feedTab] = feed.split('|') + const {gtTablet} = useBreakpoints() const opts = React.useMemo( () => ({enabled, ignoreFilterFor}), @@ -185,12 +195,16 @@ let PostFeed = ({ } try { if (await pollLatest(data.pages[0])) { - onHasNew(true) + if (isEmpty) { + refetch() + } else { + onHasNew(true) + } } } catch (e) { logger.error('Poll latest failed', {feed, message: String(e)}) } - }, [feed, data, isFetching, onHasNew, enabled, disablePoll]) + }, [feed, data, isFetching, isEmpty, onHasNew, enabled, disablePoll, refetch]) const myDid = currentAccount?.did || '' const onPostCreated = React.useCallback(() => { @@ -218,20 +232,15 @@ let PostFeed = ({ React.useEffect(() => { if (enabled && !disablePoll) { const timeSinceFirstLoad = Date.now() - lastFetchRef.current - // DISABLED need to check if this is causing random feed refreshes -prf - /*if (timeSinceFirstLoad > REFRESH_AFTER) { - // do a full refresh - scrollElRef?.current?.scrollToOffset({offset: 0, animated: false}) - queryClient.resetQueries({queryKey: RQKEY(feed)}) - } else*/ if ( - timeSinceFirstLoad > CHECK_LATEST_AFTER && + if ( + (isEmpty || timeSinceFirstLoad > CHECK_LATEST_AFTER) && checkForNewRef.current ) { // check for new on enable (aka on focus) checkForNewRef.current() } } - }, [enabled, disablePoll, feed, queryClient, scrollElRef]) + }, [enabled, disablePoll, feed, queryClient, scrollElRef, isEmpty]) React.useEffect(() => { let cleanup1: () => void | undefined, cleanup2: () => void | undefined const subscription = AppState.addEventListener('change', nextAppState => { @@ -252,6 +261,14 @@ let PostFeed = ({ } }, [pollInterval]) + const followProgressGuide = useProgressGuide('follow-10') + const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') + const {isDesktop} = useWebMediaQueries() + const showProgressIntersitial = + (followProgressGuide || followAndLikeProgressGuide) && !isDesktop + + const {trendingDisabled} = useTrendingSettings() + const feedItems: FeedRow[] = React.useMemo(() => { let feedKind: 'following' | 'discover' | 'profile' | undefined if (feedType === 'following') { @@ -292,12 +309,21 @@ let PostFeed = ({ if (hasSession) { if (feedKind === 'discover') { - if (sliceIndex === 0) { + if (sliceIndex === 0 && showProgressIntersitial) { arr.push({ type: 'interstitialProgressGuide', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, }) - } else if (sliceIndex === 20) { + } else if ( + sliceIndex === 15 && + !gtTablet && + !trendingDisabled + ) { + arr.push({ + type: 'interstitialTrending', + key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, + }) + } else if (sliceIndex === 30) { arr.push({ type: 'interstitialFollows', key: 'interstitial-' + sliceIndex + '-' + lastFetchedAt, @@ -382,6 +408,9 @@ let PostFeed = ({ feedUri, feedTab, hasSession, + showProgressIntersitial, + trendingDisabled, + gtTablet, ]) // events @@ -468,6 +497,8 @@ let PostFeed = ({ return } else if (row.type === 'interstitialProgressGuide') { return + } else if (row.type === 'interstitialTrending') { + return } else if (row.type === 'sliceItem') { const slice = row.slice if (slice.isFallbackMarker) { diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 11e8d114a7..c19233f446 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -1,6 +1,7 @@ import React from 'react' import {Pressable, View} from 'react-native' import {MeasuredDimensions, runOnJS, runOnUI} from 'react-native-reanimated' +import {AppBskyGraphDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -26,6 +27,7 @@ export function ProfileSubpageHeader({ title, avatar, isOwner, + purpose, creator, avatarType, children, @@ -35,6 +37,7 @@ export function ProfileSubpageHeader({ title: string | undefined avatar: string | undefined isOwner: boolean | undefined + purpose: AppBskyGraphDefs.ListPurpose | undefined creator: | { did: string @@ -105,7 +108,7 @@ export function ProfileSubpageHeader({ alignItems: 'flex-start', gap: 10, paddingTop: 14, - paddingBottom: 6, + paddingBottom: 14, paddingHorizontal: isMobile ? 12 : 14, }}> @@ -123,7 +126,7 @@ export function ProfileSubpageHeader({ )} - + {isLoading ? ( )} - {isLoading ? ( + {isLoading || !creator ? ( ) : ( - - {!creator ? ( - by — - ) : isOwner ? ( - by you - ) : ( - - by{' '} - - - )} + + {purpose === 'app.bsky.graph.defs#curatelist' ? ( + isOwner ? ( + List by you + ) : ( + + List by{' '} + + + ) + ) : purpose === 'app.bsky.graph.defs#modlist' ? ( + isOwner ? ( + Moderation list by you + ) : ( + + Moderation list by{' '} + + + ) + ) : purpose === 'app.bsky.graph.defs#referencelist' ? ( + isOwner ? ( + Starter pack by you + ) : ( + + Starter pack by{' '} + + + ) + ) : null} )} diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 31fd0aa1d0..5084af6129 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -53,6 +53,7 @@ let List = React.forwardRef( headerOffset, style, progressViewOffset, + automaticallyAdjustsScrollIndicatorInsets = false, ...props }, ref, @@ -151,7 +152,14 @@ let List = React.forwardRef( return ( { 'worklet' - headerMode.set(v ? V1.get() : V0.get()) + headerMode.set(() => + withSpring(v ? 1 : 0, { + overshootClamping: true, + }), + ) }, [headerMode], ) diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index b57e676aea..c602886745 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -25,7 +25,6 @@ import { import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {atoms as a, useTheme} from '#/alf' import {Text} from '#/components/Typography' -import {IS_TEST} from '#/env' const TIMEOUT = 2e3 @@ -33,7 +32,9 @@ export function show( message: string, icon: FontAwesomeProps['icon'] = 'check', ) { - if (IS_TEST) return + if (process.env.NODE_ENV === 'test') { + return + } AccessibilityInfo.announceForAccessibility(message) const item = new RootSiblings( item.destroy()} />, @@ -195,7 +196,9 @@ function Toast({ /> - {message} + + {message} + diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index 846f4d2951..e9b0e50a3c 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -1,4 +1,4 @@ -import {StyleSheet, View} from 'react-native' +import {View} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -7,12 +7,11 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {useTheme} from '#/lib/ThemeContext' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {Button} from '../forms/Button' -import {Text} from '../text/Text' -import {CenteredView} from '../Views' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' +import * as Layout from '#/components/Layout' +import {Text} from '#/components/Typography' export function ErrorScreen({ title, @@ -29,22 +28,32 @@ export function ErrorScreen({ testID?: string showHeader?: boolean }) { - const theme = useTheme() - const {isMobile} = useWebMediaQueries() + const t = useTheme() const pal = usePalette('default') const {_} = useLingui() return ( - <> - {showHeader && isMobile && ( - + + {showHeader && ( + + + + + Error + + + + )} - - + + - + {title} - {message} + {message} {details && ( - - {details} - + + + {details} + + )} {onPressTryAgain && ( - + )} - - + + ) } - -const styles = StyleSheet.create({ - outer: { - flex: 1, - paddingVertical: 30, - paddingHorizontal: 14, - }, - title: { - textAlign: 'center', - marginBottom: 10, - }, - message: { - textAlign: 'center', - marginBottom: 20, - }, - details: { - textAlign: 'center', - paddingVertical: 10, - paddingHorizontal: 14, - overflow: 'hidden', - marginBottom: 20, - }, - btnContainer: { - alignItems: 'center', - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 10, - }, - btnText: { - marginLeft: 5, - }, - errorIconContainer: { - alignItems: 'center', - marginBottom: 10, - }, - errorIcon: { - borderRadius: 25, - width: 50, - height: 50, - alignItems: 'center', - justifyContent: 'center', - }, -}) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index d98aa0fa71..b502f0b68b 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -9,6 +9,7 @@ import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {clamp} from '#/lib/numbers' +import {useGate} from '#/lib/statsig/statsig' import {colors} from '#/lib/styles' import {isWeb} from '#/platform/detection' import {useSession} from '#/state/session' @@ -34,6 +35,11 @@ export function LoadLatestBtn({ // move button inline if it starts overlapping the left nav const isTallViewport = useMediaQuery({minHeight: 700}) + const gate = useGate() + if (gate('remove_show_latest_button')) { + return null + } + // Adjust height of the fab if we have a session only on mobile web. If we don't have a session, we want to adjust // it on both tablet and mobile since we are showing the bottom bar (see createNativeStackNavigatorWithAuth) const showBottomBar = hasSession ? isMobile : isTabletOrMobile diff --git a/src/view/com/util/numeric/__tests__/format-test.ts b/src/view/com/util/numeric/__tests__/format-test.ts deleted file mode 100644 index 74df4be4c9..0000000000 --- a/src/view/com/util/numeric/__tests__/format-test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import {describe, expect, it} from '@jest/globals' - -import {APP_LANGUAGES} from '#/locale/languages' -import {formatCount} from '../format' - -const formatCountRound = (locale: string, num: number) => { - const options: Intl.NumberFormatOptions = { - notation: 'compact', - maximumFractionDigits: 1, - } - return new Intl.NumberFormat(locale, options).format(num) -} - -const formatCountTrunc = (locale: string, num: number) => { - const options: Intl.NumberFormatOptions = { - notation: 'compact', - maximumFractionDigits: 1, - // @ts-ignore - roundingMode: 'trunc', - } - return new Intl.NumberFormat(locale, options).format(num) -} - -// prettier-ignore -const testNums = [ - 1, - 5, - 9, - 11, - 55, - 99, - 111, - 555, - 999, - 1111, - 5555, - 9999, - 11111, - 55555, - 99999, - 111111, - 555555, - 999999, - 1111111, - 5555555, - 9999999, - 11111111, - 55555555, - 99999999, - 111111111, - 555555555, - 999999999, - 1111111111, - 5555555555, - 9999999999, - 11111111111, - 55555555555, - 99999999999, - 111111111111, - 555555555555, - 999999999999, - 1111111111111, - 5555555555555, - 9999999999999, - 11111111111111, - 55555555555555, - 99999999999999, - 111111111111111, - 555555555555555, - 999999999999999, - 1111111111111111, - 5555555555555555, -] - -describe('formatCount', () => { - for (const appLanguage of APP_LANGUAGES) { - const locale = appLanguage.code2 - it('truncates for ' + locale, () => { - const mockI8nn = { - locale, - number(num: number) { - return formatCountRound(locale, num) - }, - } - for (const num of testNums) { - const formatManual = formatCount(mockI8nn as any, num) - const formatOriginal = formatCountTrunc(locale, num) - expect(formatManual).toEqual(formatOriginal) - } - }) - } -}) diff --git a/src/view/com/util/numeric/format.ts b/src/view/com/util/numeric/format.ts index 053b0069b5..8f3ebd0e71 100644 --- a/src/view/com/util/numeric/format.ts +++ b/src/view/com/util/numeric/format.ts @@ -1,50 +1,10 @@ import {I18n} from '@lingui/core' -const truncateRounding = (num: number, factors: Array): number => { - for (let i = factors.length - 1; i >= 0; i--) { - let factor = factors[i] - if (num >= 10 ** factor) { - if (factor === 10) { - // CA and ES abruptly jump from "9999,9 M" to "10 mil M" - factor-- - } - const precision = 1 - const divisor = 10 ** (factor - precision) - return Math.floor(num / divisor) * divisor - } - } - return num -} - -const koFactors = [3, 4, 8, 12] -const hiFactors = [3, 5, 7, 9, 11, 13] -const esCaFactors = [3, 6, 10, 12] -const itDeFactors = [6, 9, 12] -const jaZhFactors = [4, 8, 12] -const glFactors = [6, 12] -const restFactors = [3, 6, 9, 12] - export const formatCount = (i18n: I18n, num: number) => { - const locale = i18n.locale - let truncatedNum: number - if (locale === 'hi') { - truncatedNum = truncateRounding(num, hiFactors) - } else if (locale === 'ko') { - truncatedNum = truncateRounding(num, koFactors) - } else if (locale === 'es' || locale === 'ca') { - truncatedNum = truncateRounding(num, esCaFactors) - } else if (locale === 'ja' || locale === 'zh-CN' || locale === 'zh-TW') { - truncatedNum = truncateRounding(num, jaZhFactors) - } else if (locale === 'it' || locale === 'de') { - truncatedNum = truncateRounding(num, itDeFactors) - } else if (locale === 'gl') { - truncatedNum = truncateRounding(num, glFactors) - } else { - truncatedNum = truncateRounding(num, restFactors) - } - return i18n.number(truncatedNum, { + return i18n.number(num, { notation: 'compact', maximumFractionDigits: 1, - // Ideally we'd use roundingMode: 'trunc' but it isn't supported on RN. + // @ts-expect-error - roundingMode not in the types + roundingMode: 'trunc', }) } diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index deb4b51d82..607a480ff2 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -18,12 +18,13 @@ import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' import {IS_INTERNAL} from '#/lib/app-info' -import {DISCOVER_DEBUG_DIDS, POST_CTRL_HITSLOP} from '#/lib/constants' +import {POST_CTRL_HITSLOP} from '#/lib/constants' import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' import {useHaptics} from '#/lib/haptics' import {makeProfileLink} from '#/lib/routes/links' import {shareUrl} from '#/lib/sharing' +import {useGate} from '#/lib/statsig/statsig' import {toShareUrl} from '#/lib/strings/url-helpers' import {Shadow} from '#/state/cache/types' import {useFeedFeedbackContext} from '#/state/feed-feedback' @@ -85,8 +86,8 @@ let PostCtrls = ({ const {sendInteraction} = useFeedFeedbackContext() const {captureAction} = useProgressGuideControls() const playHaptic = useHaptics() - const isDiscoverDebugUser = - IS_INTERNAL || DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] + const gate = useGate() + const isDiscoverDebugUser = IS_INTERNAL || gate('debug_show_feedcontext') const isBlocked = Boolean( post.author.viewer?.blocking || post.author.viewer?.blockedBy || @@ -258,10 +259,12 @@ let PostCtrls = ({ } }} accessibilityRole="button" - accessibilityLabel={plural(post.replyCount || 0, { - one: 'Reply (# reply)', - other: 'Reply (# replies)', - })} + accessibilityLabel={_( + msg`Reply (${plural(post.replyCount || 0, { + one: '# reply', + other: '# replies', + })})`, + )} accessibilityHint="" hitSlop={POST_CTRL_HITSLOP}> diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 06b1fcaf6b..ca1647a991 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -62,11 +62,21 @@ let RepostButton = ({ {padding: 5}, ]} hoverStyle={t.atoms.bg_contrast_25} - label={`${ + label={ isReposted - ? _(msg`Undo repost`) - : _(msg({message: 'Repost', context: 'action'})) - } (${plural(repostCount || 0, {one: '# repost', other: '# reposts'})})`} + ? _( + msg`Undo repost (${plural(repostCount || 0, { + one: '# repost', + other: '# reposts', + })})`, + ) + : _( + msg`Repost (${plural(repostCount || 0, { + one: '# repost', + other: '# reposts', + })})`, + ) + } shape="round" variant="ghost" color="secondary" diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx index 74aad64e11..96960bad47 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/Scrubber.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {isFirefox} from '#/lib/browser' +import {isFirefox, isTouchDevice} from '#/lib/browser' import {clamp} from '#/lib/numbers' import {atoms as a, useTheme, web} from '#/alf' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -148,7 +148,11 @@ export function Scrubber({ return (
& { type?: TypographyVariant @@ -49,7 +48,7 @@ function Text_DEPRECATED({ const theme = useTheme() const {fonts} = useAlf() - if (IS_DEV) { + if (__DEV__) { if (!emoji && childHasEmoji(children)) { logger.warn( `Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add '`, diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx index 74a58a56a5..4ff0a4b8bd 100644 --- a/src/view/screens/DebugMod.tsx +++ b/src/view/screens/DebugMod.tsx @@ -872,7 +872,13 @@ function MockNotifItem({

) } - return + return ( + + ) } function MockAccountCard({ diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 70ab32db0d..4794cdcd0e 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -13,7 +13,7 @@ import { } from '#/lib/routes/types' import {s} from '#/lib/styles' import {logger} from '#/logger' -import {isNative, isWeb} from '#/platform/detection' +import {isNative} from '#/platform/detection' import {emitSoftReset, listenSoftReset} from '#/state/events' import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' import { @@ -24,35 +24,173 @@ import {truncateAndInvalidate} from '#/state/queries/util' import {useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' import {NotificationFeed} from '#/view/com/notifications/NotificationFeed' +import {Pager} from '#/view/com/pager/Pager' +import {TabBar} from '#/view/com/pager/TabBar' import {FAB} from '#/view/com/util/fab/FAB' import {ListMethods} from '#/view/com/util/List' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {MainScrollProvider} from '#/view/com/util/MainScrollProvider' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' +import {atoms as a} from '#/alf' +import {web} from '#/alf' +import {ButtonIcon} from '#/components/Button' import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {Loader} from '#/components/Loader' +// We don't currently persist this across reloads since +// you gotta visit All to clear the badge anyway. +// But let's at least persist it during the sesssion. +let lastActiveTab = 0 + type Props = NativeStackScreenProps< NotificationsTabNavigatorParams, 'Notifications' > -export function NotificationsScreen({route: {params}}: Props) { - const t = useTheme() - const {gtTablet} = useBreakpoints() +export function NotificationsScreen({}: Props) { + const {_} = useLingui() + const {openComposer} = useComposerControls() + const unreadNotifs = useUnreadNotifications() + const hasNew = !!unreadNotifs + const {checkUnread: checkUnreadAll} = useUnreadNotificationsApi() + const [isLoadingAll, setIsLoadingAll] = React.useState(false) + const [isLoadingMentions, setIsLoadingMentions] = React.useState(false) + const initialActiveTab = lastActiveTab + const [activeTab, setActiveTab] = React.useState(initialActiveTab) + const isLoading = activeTab === 0 ? isLoadingAll : isLoadingMentions + + const onPageSelected = React.useCallback( + (index: number) => { + setActiveTab(index) + lastActiveTab = index + }, + [setActiveTab], + ) + + const queryClient = useQueryClient() + const checkUnreadMentions = React.useCallback( + async ({invalidate}: {invalidate: boolean}) => { + if (invalidate) { + return truncateAndInvalidate(queryClient, NOTIFS_RQKEY('mentions')) + } else { + // Background polling is not implemented for the mentions tab. + // Just ignore it. + } + }, + [queryClient], + ) + + const sections = React.useMemo(() => { + return [ + { + title: _(msg`All`), + component: ( + + ), + }, + { + title: _(msg`Mentions`), + component: ( + + ), + }, + ] + }, [ + _, + hasNew, + checkUnreadAll, + checkUnreadMentions, + activeTab, + isLoadingAll, + isLoadingMentions, + ]) + + return ( + + + + + + Notifications + + + + + + + + + ( + + section.title)} + onPressSelected={() => emitSoftReset()} + /> + + )} + initialPage={initialActiveTab}> + {sections.map((section, i) => ( + {section.component} + ))} + + openComposer({})} + icon={} + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + + ) +} + +function NotificationsTab({ + filter, + isActive, + isLoading, + hasNew, + checkUnread, + setIsLoadingLatest, +}: { + filter: 'all' | 'mentions' + isActive: boolean + isLoading: boolean + hasNew: boolean + checkUnread: ({invalidate}: {invalidate: boolean}) => Promise + setIsLoadingLatest: (v: boolean) => void +}) { const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [isScrolledDown, setIsScrolledDown] = React.useState(false) - const [isLoadingLatest, setIsLoadingLatest] = React.useState(false) const scrollElRef = React.useRef(null) const queryClient = useQueryClient() - const unreadNotifs = useUnreadNotifications() - const unreadApi = useUnreadNotificationsApi() - const hasNew = !!unreadNotifs const isScreenFocused = useIsFocused() - const {openComposer} = useComposerControls() + const isFocusedAndActive = isScreenFocused && isActive // event handlers // = @@ -65,16 +203,23 @@ export function NotificationsScreen({route: {params}}: Props) { scrollToTop() if (hasNew) { // render what we have now - truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) - } else { + truncateAndInvalidate(queryClient, NOTIFS_RQKEY(filter)) + } else if (!isLoading) { // check with the server setIsLoadingLatest(true) - unreadApi - .checkUnread({invalidate: true}) + checkUnread({invalidate: true}) .catch(() => undefined) .then(() => setIsLoadingLatest(false)) } - }, [scrollToTop, queryClient, unreadApi, hasNew, setIsLoadingLatest]) + }, [ + scrollToTop, + queryClient, + checkUnread, + hasNew, + isLoading, + setIsLoadingLatest, + filter, + ]) const onFocusCheckLatest = useNonReactiveCallback(() => { // on focus, check for latest, but only invalidate if the user @@ -87,79 +232,36 @@ export function NotificationsScreen({route: {params}}: Props) { // we're just going to look it up synchronously. currentIsScrolledDown = window.scrollY > 200 } - unreadApi.checkUnread({invalidate: !currentIsScrolledDown}) + checkUnread({invalidate: !currentIsScrolledDown}) }) // on-visible setup // = useFocusEffect( React.useCallback(() => { - setMinimalShellMode(false) - logger.debug('NotificationsScreen: Focus') - onFocusCheckLatest() - }, [setMinimalShellMode, onFocusCheckLatest]), + if (isFocusedAndActive) { + setMinimalShellMode(false) + logger.debug('NotificationsScreen: Focus') + onFocusCheckLatest() + } + }, [setMinimalShellMode, onFocusCheckLatest, isFocusedAndActive]), ) React.useEffect(() => { - if (!isScreenFocused) { + if (!isFocusedAndActive) { return } return listenSoftReset(onPressLoadLatest) - }, [onPressLoadLatest, isScreenFocused]) + }, [onPressLoadLatest, isFocusedAndActive]) return ( - - - - - - - - - - - - - + <> checkUnread({invalidate: true})} onScrolledDownChange={setIsScrolledDown} scrollElRef={scrollElRef} - overridePriorityNotifications={params?.show === 'all'} /> {(isScrolledDown || hasNew) && ( @@ -169,14 +271,6 @@ export function NotificationsScreen({route: {params}}: Props) { showIndicator={hasNew} /> )} - openComposer({})} - icon={} - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - + ) } diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 782e9b9c84..24e8719e17 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo} from 'react' import {StyleSheet} from 'react-native' +import {SafeAreaView} from 'react-native-safe-area-context' import { AppBskyActorDefs, AppBskyGraphGetActorStarterPacks, @@ -43,6 +44,7 @@ import {ListRef} from '#/view/com/util/List' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' +import {atoms as a} from '#/alf' import * as Layout from '#/components/Layout' import {ScreenHider} from '#/components/moderation/ScreenHider' import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' @@ -56,7 +58,7 @@ interface SectionRef { type Props = NativeStackScreenProps export function ProfileScreen(props: Props) { return ( - + ) @@ -121,13 +123,15 @@ function ProfileScreenInner({route}: Props) { } if (resolveError || profileError) { return ( - + + + ) } if (profile && moderationOpts) { @@ -143,13 +147,15 @@ function ProfileScreenInner({route}: Props) { } // should never happen return ( - + + + ) } @@ -329,7 +335,11 @@ function ProfileScreenLoaded({ // rendering // = - const renderHeader = () => { + const renderHeader = ({ + setMinimumHeight, + }: { + setMinimumHeight: (height: number) => void + }) => { return ( ) diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx deleted file mode 100644 index dd2f8f12b5..0000000000 --- a/src/view/screens/ProfileFeed.tsx +++ /dev/null @@ -1,623 +0,0 @@ -import React, {useCallback, useMemo} from 'react' -import {Pressable, StyleSheet, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Plural, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useIsFocused, useNavigation} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {useQueryClient} from '@tanstack/react-query' - -import {HITSLOP_20} from '#/lib/constants' -import {useHaptics} from '#/lib/haptics' -import {usePalette} from '#/lib/hooks/usePalette' -import {useSetTitle} from '#/lib/hooks/useSetTitle' -import {ComposeIcon2} from '#/lib/icons' -import {makeCustomFeedLink} from '#/lib/routes/links' -import {CommonNavigatorParams} from '#/lib/routes/types' -import {NavigationProp} from '#/lib/routes/types' -import {shareUrl} from '#/lib/sharing' -import {makeRecordUri} from '#/lib/strings/url-helpers' -import {toShareUrl} from '#/lib/strings/url-helpers' -import {s} from '#/lib/styles' -import {logger} from '#/logger' -import {isNative} from '#/platform/detection' -import {listenSoftReset} from '#/state/events' -import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' -import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' -import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' -import {FeedDescriptor} from '#/state/queries/post-feed' -import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' -import { - useAddSavedFeedsMutation, - usePreferencesQuery, - UsePreferencesQueryResponse, - useRemoveFeedMutation, - useUpdateSavedFeedsMutation, -} from '#/state/queries/preferences' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {truncateAndInvalidate} from '#/state/queries/util' -import {useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell/composer' -import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' -import {PostFeed} from '#/view/com/posts/PostFeed' -import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' -import {EmptyState} from '#/view/com/util/EmptyState' -import {FAB} from '#/view/com/util/fab/FAB' -import {Button} from '#/view/com/util/forms/Button' -import {ListRef} from '#/view/com/util/List' -import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' -import {LoadingScreen} from '#/view/com/util/LoadingScreen' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' -import {Button as NewButton, ButtonText} from '#/components/Button' -import {useRichText} from '#/components/hooks/useRichText' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import { - Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, - Heart2_Stroke2_Corner0_Rounded as HeartOutline, -} from '#/components/icons/Heart2' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import * as Layout from '#/components/Layout' -import {InlineLinkText} from '#/components/Link' -import * as Menu from '#/components/Menu' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' -import {RichText} from '#/components/RichText' - -const SECTION_TITLES = ['Posts'] - -interface SectionRef { - scrollToTop: () => void -} - -type Props = NativeStackScreenProps -export function ProfileFeedScreen(props: Props) { - const {rkey, name: handleOrDid} = props.route.params - - const pal = usePalette('default') - const {_} = useLingui() - const navigation = useNavigation() - - const uri = useMemo( - () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), - [rkey, handleOrDid], - ) - const {error, data: resolvedUri} = useResolveUriQuery(uri) - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - if (error) { - return ( - - - - - Could not load feed - - - {error.toString()} - - - - - - - - - ) - } - - return resolvedUri ? ( - - - - ) : ( - - - - ) -} - -function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { - const {data: preferences} = usePreferencesQuery() - const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) - - if (!preferences || !info) { - return - } - - return ( - - ) -} - -export function ProfileFeedScreenInner({ - preferences, - feedInfo, -}: { - preferences: UsePreferencesQueryResponse - feedInfo: FeedSourceFeedInfo -}) { - const {_} = useLingui() - const t = useTheme() - const {hasSession, currentAccount} = useSession() - const reportDialogControl = useReportDialogControl() - const {openComposer} = useComposerControls() - const playHaptic = useHaptics() - const feedSectionRef = React.useRef(null) - const isScreenFocused = useIsFocused() - - const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = - useAddSavedFeedsMutation() - const {mutateAsync: removeFeed, isPending: isRemovePending} = - useRemoveFeedMutation() - const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = - useUpdateSavedFeedsMutation() - - const isPending = - isAddSavedFeedPending || isRemovePending || isUpdateFeedPending - const savedFeedConfig = preferences.savedFeeds.find( - f => f.value === feedInfo.uri, - ) - const isSaved = Boolean(savedFeedConfig) - const isPinned = Boolean(savedFeedConfig?.pinned) - - useSetTitle(feedInfo?.displayName) - - // event handlers - // - - const onToggleSaved = React.useCallback(async () => { - try { - playHaptic() - - if (savedFeedConfig) { - await removeFeed(savedFeedConfig) - Toast.show(_(msg`Removed from your feeds`)) - } else { - await addSavedFeeds([ - { - type: 'feed', - value: feedInfo.uri, - pinned: false, - }, - ]) - Toast.show(_(msg`Saved to your feeds`)) - } - } catch (err) { - Toast.show( - _( - msg`There was an issue updating your feeds, please check your internet connection and try again.`, - ), - 'xmark', - ) - logger.error('Failed to update feeds', {message: err}) - } - }, [_, playHaptic, feedInfo, removeFeed, addSavedFeeds, savedFeedConfig]) - - const onTogglePinned = React.useCallback(async () => { - try { - playHaptic() - - if (savedFeedConfig) { - await updateSavedFeeds([ - { - ...savedFeedConfig, - pinned: !savedFeedConfig.pinned, - }, - ]) - } else { - await addSavedFeeds([ - { - type: 'feed', - value: feedInfo.uri, - pinned: true, - }, - ]) - } - } catch (e) { - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') - logger.error('Failed to toggle pinned feed', {message: e}) - } - }, [ - playHaptic, - feedInfo, - _, - savedFeedConfig, - updateSavedFeeds, - addSavedFeeds, - ]) - - const onPressShare = React.useCallback(() => { - const url = toShareUrl(feedInfo.route.href) - shareUrl(url) - }, [feedInfo]) - - const onPressReport = React.useCallback(() => { - reportDialogControl.open() - }, [reportDialogControl]) - - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - }, - [feedSectionRef], - ) - - const renderHeader = useCallback(() => { - return ( - <> - - - {feedInfo && hasSession && ( - - - {isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)} - - - )} - - - {({props, state}) => { - return ( - - - - ) - }} - - - - - {hasSession && ( - <> - - - {isSaved - ? _(msg`Remove from my feeds`) - : _(msg`Save to my feeds`)} - - - - - - {_(msg`Report feed`)} - - - - )} - - - {_(msg`Share feed`)} - - - - - - - - - - ) - }, [ - _, - hasSession, - feedInfo, - isPinned, - onTogglePinned, - onToggleSaved, - currentAccount?.did, - isSaved, - onPressReport, - onPressShare, - t, - isPending, - ]) - - return ( - <> - - - {({headerHeight, scrollElRef, isFocused}) => ( - - )} - - {hasSession && ( - openComposer({})} - icon={ - - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - )} - - ) -} - -interface FeedSectionProps { - feed: FeedDescriptor - headerHeight: number - scrollElRef: ListRef - isFocused: boolean -} -const FeedSection = React.forwardRef( - function FeedSectionImpl({feed, headerHeight, scrollElRef, isFocused}, ref) { - const {_} = useLingui() - const [hasNew, setHasNew] = React.useState(false) - const [isScrolledDown, setIsScrolledDown] = React.useState(false) - const queryClient = useQueryClient() - const isScreenFocused = useIsFocused() - const {hasSession} = useSession() - const feedFeedback = useFeedFeedback(feed, hasSession) - - const onScrollToTop = useCallback(() => { - scrollElRef.current?.scrollToOffset({ - animated: isNative, - offset: -headerHeight, - }) - truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) - setHasNew(false) - }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) - - React.useImperativeHandle(ref, () => ({ - scrollToTop: onScrollToTop, - })) - - React.useEffect(() => { - if (!isScreenFocused) { - return - } - return listenSoftReset(onScrollToTop) - }, [onScrollToTop, isScreenFocused]) - - const renderPostsEmpty = useCallback(() => { - return - }, [_]) - - return ( - - - - - {(isScrolledDown || hasNew) && ( - - )} - - ) - }, -) - -function AboutSection({ - feedOwnerDid, - feedRkey, - feedInfo, -}: { - feedOwnerDid: string - feedRkey: string - feedInfo: FeedSourceFeedInfo -}) { - const t = useTheme() - const pal = usePalette('default') - const {_} = useLingui() - const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) - const {hasSession} = useSession() - const playHaptic = useHaptics() - const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() - const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = - useUnlikeMutation() - const [resolvedRT] = useRichText(feedInfo.description.text || '') - - const isLiked = !!likeUri - const likeCount = - isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount - - const onToggleLiked = React.useCallback(async () => { - try { - playHaptic() - - if (isLiked && likeUri) { - await unlikeFeed({uri: likeUri}) - setLikeUri('') - } else { - const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid}) - setLikeUri(res.uri) - } - } catch (err) { - Toast.show( - _( - msg`There was an issue contacting the server, please check your internet connection and try again.`, - ), - 'xmark', - ) - logger.error('Failed to toggle like', {message: err}) - } - }, [playHaptic, isLiked, likeUri, unlikeFeed, likeFeed, feedInfo, _]) - - return ( - - - {feedInfo.description ? ( - - ) : ( - - No description - - )} - - - - - {isLiked ? ( - - ) : ( - - )} - - {typeof likeCount === 'number' && ( - - - - )} - - - ) -} - -const styles = StyleSheet.create({ - btn: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingVertical: 7, - paddingHorizontal: 14, - borderRadius: 50, - marginLeft: 6, - }, - notFoundContainer: { - margin: 10, - paddingHorizontal: 18, - paddingVertical: 14, - borderRadius: 6, - }, - aboutSectionContainer: { - paddingVertical: 4, - paddingHorizontal: 16, - gap: 12, - }, -}) diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx deleted file mode 100644 index 90c0a57f97..0000000000 --- a/src/view/screens/ProfileFollowers.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' - -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {isWeb} from '#/platform/detection' -import {useSetMinimalShellMode} from '#/state/shell' -import {ProfileFollowers as ProfileFollowersComponent} from '#/view/com/profile/ProfileFollowers' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {CenteredView} from '#/view/com/util/Views' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps -export const ProfileFollowersScreen = ({route}: Props) => { - const {name} = route.params - const setMinimalShellMode = useSetMinimalShellMode() - const {_} = useLingui() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - return ( - - - - - - - ) -} diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx deleted file mode 100644 index 134f799937..0000000000 --- a/src/view/screens/ProfileFollows.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' - -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {isWeb} from '#/platform/detection' -import {useSetMinimalShellMode} from '#/state/shell' -import {ProfileFollows as ProfileFollowsComponent} from '#/view/com/profile/ProfileFollows' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {CenteredView} from '#/view/com/util/Views' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps -export const ProfileFollowsScreen = ({route}: Props) => { - const {name} = route.params - const setMinimalShellMode = useSetMinimalShellMode() - const {_} = useLingui() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - return ( - - - - - - - ) -} diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 1fcc82a933..2e661ff462 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useMemo} from 'react' import {Pressable, StyleSheet, View} from 'react-native' +import {useAnimatedRef} from 'react-native-reanimated' import { AppBskyGraphDefs, AtUri, @@ -19,12 +20,11 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useSetTitle} from '#/lib/hooks/useSetTitle' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {ComposeIcon2} from '#/lib/icons' -import {makeListLink, makeProfileLink} from '#/lib/routes/links' +import {makeListLink} from '#/lib/routes/links' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' import {cleanError} from '#/lib/strings/errors' -import {sanitizeHandle} from '#/lib/strings/handles' import {toShareUrl} from '#/lib/strings/url-helpers' import {s} from '#/lib/styles' import {logger} from '#/logger' @@ -63,14 +63,13 @@ import { DropdownItem, NativeDropdown, } from '#/view/com/util/forms/NativeDropdown' -import {TextLink} from '#/view/com/util/Link' import {ListRef} from '#/view/com/util/List' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {LoadingScreen} from '#/view/com/util/LoadingScreen' import {Text} from '#/view/com/util/text/Text' import * as Toast from '#/view/com/util/Toast' import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a} from '#/alf' import {useDialogControl} from '#/components/Dialog' import * as Layout from '#/components/Layout' import * as Hider from '#/components/moderation/Hider' @@ -78,8 +77,7 @@ import * as Prompt from '#/components/Prompt' import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {RichText} from '#/components/RichText' -const SECTION_TITLES_CURATE = ['Posts', 'About'] -const SECTION_TITLES_MOD = ['About'] +const SECTION_TITLES_CURATE = ['Posts', 'People'] interface SectionRef { scrollToTop: () => void @@ -161,6 +159,7 @@ function ProfileListScreenLoaded({ const isScreenFocused = useIsFocused() const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 const isOwner = currentAccount?.did === list.creator.did + const scrollElRef = useAnimatedRef() const moderation = React.useMemo(() => { return moderateUserList(list, moderationOpts) @@ -259,19 +258,13 @@ function ProfileListScreenLoaded({ - - {({headerHeight, scrollElRef}) => ( - - )} - + {renderHeader()} + openComposer({})} @@ -652,101 +645,124 @@ function Header({ ] }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open]) + const descriptionRT = useMemo( + () => + list.description + ? new RichTextAPI({ + text: list.description, + facets: list.descriptionFacets, + }) + : undefined, + [list], + ) + return ( - - - {isCurateList ? ( - + + + + {isLoading ? ( + Array(TRENDING_LIMIT) + .fill(0) + .map((_n, i) => ( + + )) + ) : !trending?.topics ? null : ( + <> + {trending.topics.slice(0, TRENDING_LIMIT).map(topic => ( + + {({hovered}) => ( + + )} + + ))} + + )} + + + setTrendingDisabled(true)} + /> + + + ) +} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 179e8858e0..a5e97610d0 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -18,6 +18,7 @@ import { useIsDrawerSwipeDisabled, useSetDrawerOpen, } from '#/state/shell' +import {useLightStatusBar} from '#/state/shell/light-status-bar' import {useCloseAnyActiveElement} from '#/state/util' import {Lightbox} from '#/view/com/lightbox/Lightbox' import {ModalsContainer} from '#/view/com/modals/Modal' @@ -154,6 +155,7 @@ function ShellInner() { export const Shell: React.FC = function ShellImpl() { const {fullyExpandedCount} = useDialogStateControlContext() + const lightStatusBar = useLightStatusBar() const t = useTheme() useIntentHandler() @@ -165,7 +167,9 @@ export const Shell: React.FC = function ShellImpl() { 0) + t.name !== 'light' || + (isIOS && fullyExpandedCount > 0) || + lightStatusBar ? 'light' : 'dark' } diff --git a/yarn.lock b/yarn.lock index b1e600faf7..10fee56012 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20,21 +20,21 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@atproto-labs/fetch-node@0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.1.3.tgz#2581bf4710a4f957c74c75d959961de3304b3595" - integrity sha512-KX3ogPJt6dXNppWImQ9omfhrc8t73WrJaxHMphRAqQL8jXxKW5NBCTjSuwroBkJ1pj1aValBrc5NpdYu+H/9Qg== +"@atproto-labs/fetch-node@0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.1.4.tgz#03859a39556eab936e2b3bec2d087585c6408cb3" + integrity sha512-hwYx0XpgIl2zydRy13DtWvywruuHk1EX+yCjqjgUIezUm8fi35ZN4QvR6INEm0MpN2MD/kQsImPbd8ZftzZ3zw== dependencies: - "@atproto-labs/fetch" "0.1.1" + "@atproto-labs/fetch" "0.1.2" "@atproto-labs/pipe" "0.1.0" ipaddr.js "^2.1.0" psl "^1.9.0" undici "^6.14.1" -"@atproto-labs/fetch@0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.1.1.tgz#10e7f8c06cf01a63f58e130b95d9ee0d4171902c" - integrity sha512-X1zO1MDoJzEurbWXMAe1H8EZ995Xam/aXdxhGVrXmOMyPDuvBa1oxwh/kQNZRCKcMQUbiwkk+Jfq6ZkTuvGbww== +"@atproto-labs/fetch@0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.1.2.tgz#e1b9354205fb76f106ae3e1c6b56e7865a39600f" + integrity sha512-7mQQIRtVenqtdBQKCqoLjyAhPS2aA56EGEjyz5zB3sramM3qkrvzyusr55GAzGDS0tvB6cy9cDEtSLmfK7LUnA== dependencies: "@atproto-labs/pipe" "0.1.0" optionalDependencies: @@ -58,28 +58,42 @@ resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106" integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg== -"@atproto/api@^0.13.18": - version "0.13.18" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.18.tgz#cc537cc3b4c8d03f258a373f4d893fea11a77cdd" - integrity sha512-rrl5HhzGYWZ7fiC965TPBUOVItq9M4dxMb6qz8IvAVQliSkrJrKc7UD0QWL89QiiXaOBuX8w+4i5r4wrfBGddg== +"@atproto/api@^0.13.20": + version "0.13.20" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.20.tgz#5140db303c3b0981958dfe6a5fa6d7d1cd7bb3cc" + integrity sha512-z/+CvG6BEttRHf856tKSe1AeUQNfrobRJldaHAthGmFk7O3wLZQyfcI9DUmBJQ9+4wAt0dZwvKWVGLZOV9eLHA== + dependencies: + "@atproto/common-web" "^0.3.1" + "@atproto/lexicon" "^0.4.4" + "@atproto/syntax" "^0.3.1" + "@atproto/xrpc" "^0.6.5" + await-lock "^2.2.2" + multiformats "^9.9.0" + tlds "^1.234.0" + zod "^3.23.8" + +"@atproto/api@^0.13.21": + version "0.13.21" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.21.tgz#8ee27a07e5a024b5bf32408d9bd623dd598ad1cc" + integrity sha512-iOxSj2YS3Fx9IPz1NivKrSsdYPNbBgpnUH7+WhKYAMvDFDUe2PZe7taau8wsUjJAu/H3S0Mk2TDh5e/7tCRwHA== dependencies: "@atproto/common-web" "^0.3.1" - "@atproto/lexicon" "^0.4.3" + "@atproto/lexicon" "^0.4.4" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc" "^0.6.4" + "@atproto/xrpc" "^0.6.5" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" zod "^3.23.8" -"@atproto/aws@^0.2.9": - version "0.2.9" - resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.9.tgz#3539b281b725914b769451ee4afc62315dff1afc" - integrity sha512-sc9aXUePcqItkJSOJJnGNVthVfAKjhn3zMDG+RRLzKUBye6Yutrlhpt1yxNZLHQiqIK5fy2Cuc4EX3p3jeWUYw== +"@atproto/aws@^0.2.10": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.10.tgz#e0b888fd50308cc24b7086cf3ec209587c13bbe4" + integrity sha512-zQElKk6wGTQo5aKdXtmx/dINjkVgbJU9+C/xOVTs+M88I8IrrBxPvo1dASLJcMtRb9VjXh5snLJeAjgyx6qC6Q== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" - "@atproto/repo" "^0.5.5" + "@atproto/repo" "^0.6.0" "@aws-sdk/client-cloudfront" "^3.261.0" "@aws-sdk/client-kms" "^3.196.0" "@aws-sdk/client-s3" "^3.224.0" @@ -89,20 +103,20 @@ multiformats "^9.9.0" uint8arrays "3.0.0" -"@atproto/bsky@^0.0.96": - version "0.0.96" - resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.96.tgz#b89abf2828f57738357beb4efd05539667dd14b3" - integrity sha512-Tk0ppiPMKdcnPU3x+uBAVRn92vroznhr2OlqinNSy/PZ39qWViRlKAhG3CLJsU2gjSHxsNfaIwulj7tPvKCmSw== +"@atproto/bsky@^0.0.98": + version "0.0.98" + resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.98.tgz#4c4746e588568df1878647ae80cf4b963bc95924" + integrity sha512-Y+un2pD1W1H0s0IWdY6S4vLy8rgR8cpqThz9onn4wDppmGWvOBNXeD8AjNzIWC0iFlYcfR4rwCKSoccUXYzxNg== dependencies: - "@atproto/api" "^0.13.18" - "@atproto/common" "^0.4.4" + "@atproto/api" "^0.13.20" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" - "@atproto/repo" "^0.5.5" - "@atproto/sync" "^0.1.6" + "@atproto/lexicon" "^0.4.4" + "@atproto/repo" "^0.6.0" + "@atproto/sync" "^0.1.7" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc-server" "^0.7.4" "@bufbuild/protobuf" "^1.5.0" "@connectrpc/connect" "^1.1.4" "@connectrpc/connect-express" "^1.1.4" @@ -129,12 +143,12 @@ typed-emitter "^2.1.0" uint8arrays "3.0.0" -"@atproto/bsync@^0.0.9": - version "0.0.9" - resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.9.tgz#7a6d58ef776404893d3c1139bdfe606fef483612" - integrity sha512-N0+TnYOoJz4hTo6/h1jJKh6QzdbwkFuOQ1bdwugzST7ZkwMtjs5FX8o/uqgiD4gSHSqfQSRrew7+qYEHUT61Aw== +"@atproto/bsync@^0.0.10": + version "0.0.10" + resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.10.tgz#fa16acfaf67112449b703778a20c785226c94189" + integrity sha512-qviPMyYade/sqhX/9X9eTT4KaQ+FLvOyz+140LCDk/0vbZUCZPuYSEXZDCQkL5nlEXzScsQ3iyVeoYCGvV5kYw== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/syntax" "^0.3.1" "@bufbuild/protobuf" "^1.5.0" "@connectrpc/connect" "^1.1.4" @@ -175,10 +189,10 @@ pino "^8.6.1" zod "^3.14.2" -"@atproto/common@^0.4.4": - version "0.4.4" - resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.4.tgz#79096aef920f5ad7cda5c682d7ed7416d0581e1a" - integrity sha512-58tMbn6A1Zu296s/l3uIj8z9d7IRHpZvLOfsFRikaQaYrzhJpL2aPY4uFQ8GJcxnsxeUnxBCrQz9we5jVVJI5Q== +"@atproto/common@^0.4.5": + version "0.4.5" + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.5.tgz#28fd176a9b5527c723828e725586bc0be9fa9516" + integrity sha512-LFAGqHcxCI5+b31Xgk+VQQtZU258iGPpHJzNeHVcdh6teIKZi4C2l6YV+m+3CEz+yYcfP7jjUmgqesx7l9Arsg== dependencies: "@atproto/common-web" "^0.3.1" "@ipld/dag-cbor" "^7.0.3" @@ -207,23 +221,23 @@ "@noble/hashes" "^1.3.1" uint8arrays "3.0.0" -"@atproto/dev-env@^0.3.64": - version "0.3.64" - resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.64.tgz#148537785b6a86b0a56d0988e63a1ff8ea7c84e9" - integrity sha512-s7mdppgp2BS0uy5ASZwqJ3J8dez14pDGI9uqTGbsOYF/qTCbBGZKw/Vkqjci5bY1UaW+o6n787q63ECDtljM8A== +"@atproto/dev-env@^0.3.67": + version "0.3.67" + resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.67.tgz#4f6a20f0aafa8125ed9ec715abceedd11580882e" + integrity sha512-7Ize4Y5vdjQjyrxTwjBPbkxKXQdE02KpE7AJLJt6Xpvowd2vbn8l8rDXfha+LtVi6t/613U4s+Slo5c1YD3x9A== dependencies: - "@atproto/api" "^0.13.18" - "@atproto/bsky" "^0.0.96" - "@atproto/bsync" "^0.0.9" + "@atproto/api" "^0.13.20" + "@atproto/bsky" "^0.0.98" + "@atproto/bsync" "^0.0.10" "@atproto/common-web" "^0.3.1" "@atproto/crypto" "^0.4.2" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" - "@atproto/ozone" "^0.1.57" - "@atproto/pds" "^0.4.73" - "@atproto/sync" "^0.1.6" + "@atproto/lexicon" "^0.4.4" + "@atproto/ozone" "^0.1.59" + "@atproto/pds" "^0.4.76" + "@atproto/sync" "^0.1.7" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc-server" "^0.7.4" "@did-plc/lib" "^0.0.1" "@did-plc/server" "^0.0.1" axios "^0.27.2" @@ -258,10 +272,10 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/lexicon@^0.4.3": - version "0.4.3" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.3.tgz#d69f6bb363a6326df7766c48132bfa30e22622d9" - integrity sha512-lFVZXe1S1pJP0dcxvJuHP3r/a+EAIBwwU7jUK+r8iLhIja+ml6NmYv8KeFHmIJATh03spEQ9s02duDmFVdCoXg== +"@atproto/lexicon@^0.4.4": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.4.tgz#0d97314bb57b693b76f2495fa5e02872469dd93a" + integrity sha512-QFEmr3rpj/RoAmfX9ALU/asBG/rsVtQZnw+9nOB1/AuIwoxXd+ZyndR6lVUc2+DL4GEjl6W2yvBru5xbQIZWyA== dependencies: "@atproto/common-web" "^0.3.1" "@atproto/syntax" "^0.3.1" @@ -269,20 +283,20 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/oauth-provider@^0.2.7": - version "0.2.7" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.7.tgz#38a211c197ee1ce4e92a5b59a92f2e15fcacee0b" - integrity sha512-T/cEr7TGs36SqTW8JzLAt9EchumYY48zuI4rqoAepYT29eGpP37SxK+5X0+fQHOKJPKWUGlYocR9fDm4CdzAPQ== +"@atproto/oauth-provider@^0.2.10": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.10.tgz#f9820d7f82c33d3b74e81a75873f50e1e654b901" + integrity sha512-cF42lo0+Mj+Zq2RXwS2NxmobmtL7YL1vXlYcN6iKflZ8pQ5WvpR/cZKsKEZOT9cEBBTw5MARKTYxbr8CPDKlHg== dependencies: - "@atproto-labs/fetch" "0.1.1" - "@atproto-labs/fetch-node" "0.1.3" + "@atproto-labs/fetch" "0.1.2" + "@atproto-labs/fetch-node" "0.1.4" "@atproto-labs/pipe" "0.1.0" "@atproto-labs/simple-store" "0.1.1" "@atproto-labs/simple-store-memory" "0.1.1" - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/jwk" "0.1.1" "@atproto/jwk-jose" "0.1.2" - "@atproto/oauth-types" "0.2.0" + "@atproto/oauth-types" "0.2.1" "@hapi/accept" "^6.0.3" "@hapi/bourne" "^3.0.0" "@hapi/content" "^6.0.0" @@ -294,27 +308,27 @@ psl "^1.9.0" zod "^3.23.8" -"@atproto/oauth-types@0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.0.tgz#28bc861b56cba093e6c52603cec1d3d38cd2a1e7" - integrity sha512-v/4ht6eRh0yOu2iuuWujZdnJBamPKimdy8k0Xan8cVZ+a2i83UkhIIU+S/XUbbvJ4a64wLPZrS9IDd0K5XYYTQ== +"@atproto/oauth-types@0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.1.tgz#a7ace557cc91817fcde6195f023e4e1838e4aef6" + integrity sha512-hDisUXzcq5KU1HMuCYZ8Kcz7BePl7V11bFjjgZvND3mdSphiyBpJ8MCNn3QzAa6cXpFo0w9PDcYMAlCCRZHdVw== dependencies: "@atproto/jwk" "0.1.1" zod "^3.23.8" -"@atproto/ozone@^0.1.57": - version "0.1.57" - resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.57.tgz#141d66b213710575c7859d691586fd44c731f7ca" - integrity sha512-P2YKeRFPbxKc2e2yftUoMTTcWYuFV0qU1/Nkd4GxuHnBnDJcbtMPglXd7kyLf0p8plCCFau/wZ8QdY8KSDLM9Q== +"@atproto/ozone@^0.1.59": + version "0.1.59" + resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.59.tgz#219984a46617b0ac039f2f02767290eaa0b4cfc3" + integrity sha512-AD03Ocb3fZW+grxO/VwMld5iNdCLgbahFzku6xh1qEw0tLOBKp3GXSfepVd9XWu5fb1yPhGPd2JgjApV5hbJvw== dependencies: - "@atproto/api" "^0.13.18" - "@atproto/common" "^0.4.4" + "@atproto/api" "^0.13.20" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" + "@atproto/lexicon" "^0.4.4" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc" "^0.6.4" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc" "^0.6.5" + "@atproto/xrpc-server" "^0.7.4" "@did-plc/lib" "^0.0.1" axios "^1.6.7" compression "^1.7.4" @@ -331,29 +345,30 @@ typed-emitter "^2.1.0" uint8arrays "3.0.0" -"@atproto/pds@^0.4.73": - version "0.4.73" - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.73.tgz#49b7625d9b40a5a24be1cdd7cdb56faab9e25707" - integrity sha512-fzrKlgKVF5JvTTmhfvofXT9Ok1KFTfAjCzTrLJivbOcqQSqBagNTuz5CiQxAAAo/JTlSxmnyr3e7OrlJdrph1w== +"@atproto/pds@^0.4.76": + version "0.4.76" + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.76.tgz#cd7b3f13359a7c31dc9362a5e4309419512c4102" + integrity sha512-+cFVpqlgpCS0BuGac5fCQPZUugpS1r7ghnSQLVdjnTnvQJCqLRA++BlJWYbGgRP6FJrumCY2jtuwG8t59Rjt8Q== dependencies: - "@atproto-labs/fetch-node" "0.1.3" - "@atproto/api" "^0.13.18" - "@atproto/aws" "^0.2.9" - "@atproto/common" "^0.4.4" + "@atproto-labs/fetch-node" "0.1.4" + "@atproto/api" "^0.13.20" + "@atproto/aws" "^0.2.10" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" - "@atproto/oauth-provider" "^0.2.7" - "@atproto/repo" "^0.5.5" + "@atproto/lexicon" "^0.4.4" + "@atproto/oauth-provider" "^0.2.10" + "@atproto/repo" "^0.6.0" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc" "^0.6.4" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc" "^0.6.5" + "@atproto/xrpc-server" "^0.7.4" "@did-plc/lib" "^0.0.4" + "@hapi/address" "^5.1.1" better-sqlite3 "^10.0.0" bytes "^3.1.2" compression "^1.7.4" cors "^2.8.5" - disposable-email "^0.2.3" + disposable-email-domains-js "^1.5.0" express "^4.17.2" express-async-errors "^3.1.1" file-type "^16.5.4" @@ -376,49 +391,50 @@ undici "^6.19.8" zod "^3.23.8" -"@atproto/repo@^0.5.5": - version "0.5.5" - resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.5.5.tgz#73eaf1a0b35cfc4fc1c837f4e3ddeb6768d29c20" - integrity sha512-Zu1tw42KBVyFzIh1XYSIvm8V+V9oEKWJR7NnHBgeSMwCc9QwM32jO7uqgvEjZYEXgdYKanGhv/YHLyxtZa5Ckg== +"@atproto/repo@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.6.0.tgz#29e698731e6df63636b0f7c91ce106a9de50ad19" + integrity sha512-6YGVhjiHKmqCW5Ce4oY49E3NCEfbvAGowJ5ETXX2sx2l4D2bOL7a2hn5zWqsPHYpWSLjrPfnj7PVpApK0kmL7A== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/common-web" "^0.3.1" "@atproto/crypto" "^0.4.2" - "@atproto/lexicon" "^0.4.3" + "@atproto/lexicon" "^0.4.4" "@ipld/car" "^3.2.3" "@ipld/dag-cbor" "^7.0.0" multiformats "^9.9.0" uint8arrays "3.0.0" zod "^3.23.8" -"@atproto/sync@^0.1.6": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.6.tgz#fb3e61147c05caf2c3d1cd597ff94fef68abbc02" - integrity sha512-9lqe6E6fIns28TJyQufLCVefMxmK3bvEfQBhmXJBGZMHuKlH8+F5P9DfnHv6vs6ygfmHIUIjYDWqJu/rpt8pzw== +"@atproto/sync@^0.1.7": + version "0.1.7" + resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.7.tgz#c7f78d99bb40eacf93ca13fdd04134a0985bf421" + integrity sha512-liJH2EsD4AbWA8G0oRDURgbHW6Uq4NnM2rNfbrTlqgtj0kyGRY3FcVEyqeRcaQYfCuscChIg5DQKHqY421/7Mw== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" - "@atproto/repo" "^0.5.5" + "@atproto/lexicon" "^0.4.4" + "@atproto/repo" "^0.6.0" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc-server" "^0.7.4" multiformats "^9.9.0" p-queue "^6.6.2" + ws "^8.12.0" "@atproto/syntax@^0.3.1": version "0.3.1" resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.1.tgz#4346418728f9643d783d2ffcf7c77e132e1f53d4" integrity sha512-fzW0Mg1QUOVCWUD3RgEsDt6d1OZ6DdFmbKcDdbzUfh0t4rhtRAC05KbZYmxuMPWDAiJ4BbbQ5dkAc/mNypMXkw== -"@atproto/xrpc-server@^0.7.3": - version "0.7.3" - resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.3.tgz#d09b36d00edb7aacca48675d1ebb7fa796fa11bd" - integrity sha512-x0qegkN6snrbXJO3v9h2kuh9e90g6ZZkDXv3COiraGS3yRTzIm6i4bMvDSfCI50+0xCNtPKOkpn8taRoRgkyiw== +"@atproto/xrpc-server@^0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.4.tgz#dfac8f7276c1c971a35eaba627eb6372088441c3" + integrity sha512-MrAwxfJBQm/kCol3D8qc+vpQzBMzLqvtUbauSSfVVJ10PlGtxg4LlXqcjkAuhrjyrqp3dQH9LHuhDpgVQK+G3w== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" - "@atproto/lexicon" "^0.4.3" - "@atproto/xrpc" "^0.6.4" + "@atproto/lexicon" "^0.4.4" + "@atproto/xrpc" "^0.6.5" cbor-x "^1.5.1" express "^4.17.2" http-errors "^2.0.0" @@ -428,12 +444,12 @@ ws "^8.12.0" zod "^3.23.8" -"@atproto/xrpc@^0.6.4": - version "0.6.4" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.4.tgz#4cf59774f7c72e5bc821bc5f1d57f0a6ae2014db" - integrity sha512-9ZAJ8nsXTqC4XFyS0E1Wlg7bAvonhXQNQ3Ocs1L1LIwFLXvsw/4fNpIHXxvXvqTCVeyHLbImOnE9UiO1c/qIYA== +"@atproto/xrpc@^0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.5.tgz#8b180fc5f6b8374fd00c41b9e4cd7b24ead48e6b" + integrity sha512-t6u8iPEVbWge5RhzKZDahSzNDYIAxUtop6Q/X/apAZY1rgreVU0/1sSvvRoRFH19d3UIKjYdLuwFqMi9w8nY3Q== dependencies: - "@atproto/lexicon" "^0.4.3" + "@atproto/lexicon" "^0.4.4" zod "^3.23.8" "@aws-crypto/crc32@3.0.0": @@ -3364,6 +3380,11 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@bitdrift/react-native@0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@bitdrift/react-native/-/react-native-0.4.0.tgz#e6484343ef04824aa924df2a757bd9620b2106c1" + integrity sha512-KuYzWEkoGwjjP0ZurjHwV+zfRZjQXxbXa3zhijWv0iqzMI/7kbrBd9lm+wNQo8OrkqFVDlebCb8AGPc0jMZw7A== + "@braintree/sanitize-url@^6.0.2": version "6.0.4" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" @@ -3675,10 +3696,10 @@ mv "~2" safe-json-stringify "~1" -"@expo/cli@0.22.3": - version "0.22.3" - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.22.3.tgz#8dbcc9396abf01b2dd91fe7f34fc23fdd2d8cc7f" - integrity sha512-1HBtqInFDFHUJWzTJ1CJj5MR3JwvOiozmRUWF2kVQAeq/bKzSYM6We6B3XoZBM5XP6z6WtnrG87C7BjeW5E/cA== +"@expo/cli@0.22.6": + version "0.22.6" + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.22.6.tgz#dc27b685d2252027549d839437c5285c7814ec3f" + integrity sha512-eDjCnc3uHl2+SJ6aZ5seK0FkMp0W12oAdSI4A/yV8ecYtXzG8X87sfKAISEWt44B4DqJ0a1LEqCD6Vtvc783Mg== dependencies: "@0no-co/graphql.web" "^1.0.8" "@babel/runtime" "^7.20.0" @@ -3689,15 +3710,15 @@ "@expo/env" "~0.4.0" "@expo/image-utils" "^0.6.0" "@expo/json-file" "^9.0.0" - "@expo/metro-config" "~0.19.0" + "@expo/metro-config" "~0.19.8" "@expo/osascript" "^2.0.31" "@expo/package-manager" "^1.5.0" "@expo/plist" "^0.2.0" - "@expo/prebuild-config" "^8.0.22" + "@expo/prebuild-config" "^8.0.23" "@expo/rudder-sdk-node" "^1.1.1" "@expo/spawn-async" "^1.7.2" "@expo/xcpretty" "^4.3.0" - "@react-native/dev-middleware" "0.76.3" + "@react-native/dev-middleware" "0.76.5" "@urql/core" "^5.0.6" "@urql/exchange-retry" "^1.3.0" accepts "^1.3.8" @@ -3780,27 +3801,6 @@ xcode "^3.0.1" xml2js "0.6.0" -"@expo/config-plugins@~8.0.0-beta.0": - version "8.0.11" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.11.tgz#b814395a910f4c8b7cc95d9719dccb6ca53ea4c5" - integrity sha512-oALE1HwnLFthrobAcC9ocnR9KXLzfWEjgIe4CPe+rDsfC6GDs8dGYCXfRFoCEzoLN4TGYs9RdZ8r0KoCcNrm2A== - dependencies: - "@expo/config-types" "^51.0.3" - "@expo/json-file" "~8.3.0" - "@expo/plist" "^0.1.0" - "@expo/sdk-runtime-versions" "^1.0.0" - chalk "^4.1.2" - debug "^4.3.1" - find-up "~5.0.0" - getenv "^1.0.0" - glob "7.1.6" - resolve-from "^5.0.0" - semver "^7.5.4" - slash "^3.0.0" - slugify "^1.6.6" - xcode "^3.0.1" - xml2js "0.6.0" - "@expo/config-plugins@~9.0.12": version "9.0.12" resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-9.0.12.tgz#f122b2dca22e135eadf6e73442da3ced0ce8aa0a" @@ -3821,16 +3821,6 @@ xcode "^3.0.1" xml2js "0.6.0" -"@expo/config-types@^51.0.0-unreleased": - version "51.0.0" - resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-51.0.0.tgz#f5df238cd1237d7e4d9cc8217cdef3383c2a00cf" - integrity sha512-acn03/u8mQvBhdTQtA7CNhevMltUhbSrpI01FYBJwpVntufkU++ncQujWKlgY/OwIajcfygk1AY4xcNZ5ImkRA== - -"@expo/config-types@^51.0.3": - version "51.0.3" - resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-51.0.3.tgz#520bdce5fd75f9d234fd81bd0347443086419450" - integrity sha512-hMfuq++b8VySb+m9uNNrlpbvGxYc8OcFCUX9yTmi9tlx6A4k8SDabWFBgmnr4ao3wEArvWrtUQIfQCVtPRdpKA== - "@expo/config-types@^52.0.0": version "52.0.1" resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-52.0.1.tgz#327af1b72a3a9d4556f41e083e0e284dd8198b96" @@ -3874,23 +3864,6 @@ slugify "^1.3.4" sucrase "3.35.0" -"@expo/config@~9.0.0-beta.0": - version "9.0.1" - resolved "https://registry.yarnpkg.com/@expo/config/-/config-9.0.1.tgz#e7b79de5af29d5ab2a98a62c3cda31f03bd75827" - integrity sha512-0tjaXBstTbXmD4z+UMFBkh2SZFwilizSQhW6DlaTMnPG5ezuw93zSFEWAuEC3YzkpVtNQTmYzxAYjxwh6seOGg== - dependencies: - "@babel/code-frame" "~7.10.4" - "@expo/config-plugins" "~8.0.0-beta.0" - "@expo/config-types" "^51.0.0-unreleased" - "@expo/json-file" "^8.3.0" - getenv "^1.0.0" - glob "7.1.6" - require-from-string "^2.0.2" - resolve-from "^5.0.0" - semver "^7.6.0" - slugify "^1.3.4" - sucrase "3.34.0" - "@expo/devcert@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@expo/devcert/-/devcert-1.1.2.tgz#a4923b8ea5b34fde31d6e006a40d0f594096a0ed" @@ -3921,10 +3894,10 @@ dotenv-expand "~11.0.6" getenv "^1.0.0" -"@expo/fingerprint@0.11.3": - version "0.11.3" - resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.11.3.tgz#e370ae8f83e0642f752b058e2102e984a0a5bc98" - integrity sha512-9lgXmcIePvZ7Wef63XtvuN3HfCUevF4E4tQPdEbH9/dUWwpOvvwQ3KT4OJ9jdh8JJ3nTdO9eDQ/8k8xr1aQ5Kg== +"@expo/fingerprint@0.11.4": + version "0.11.4" + resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.11.4.tgz#f40bbc784e10a065b783091e0d060a7428d41a7c" + integrity sha512-FfcvHjrWjOJ17wiMfr1iQ1YDyjlj8qfxG+GDce0khrjNSkzRjVdCOIFsMvfVSBPnOPX5NuZlgMRvMkcPUtGClA== dependencies: "@expo/spawn-async" "^1.7.2" arg "^5.0.2" @@ -3942,7 +3915,7 @@ resolved "https://registry.yarnpkg.com/@expo/html-elements/-/html-elements-0.4.3.tgz#32b4ca05dd13582164ed1be34ae87e22adfd1d5b" integrity sha512-UwEEdnpyhUEIDe/AkFSBUmCuwcknjAuu73fd5L9Rm/BbHczYXCrtyZmzCNVBsAiHhwUjmhNWzFlr9cAkp/sxIA== -"@expo/image-utils@0.3.23", "@expo/image-utils@0.6.3", "@expo/image-utils@^0.3.23", "@expo/image-utils@^0.6.0": +"@expo/image-utils@0.3.23", "@expo/image-utils@0.6.3", "@expo/image-utils@^0.6.0", "@expo/image-utils@^0.6.3": version "0.6.3" resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.6.3.tgz#89c744460beefc686989b969121357bbd5520c8a" integrity sha512-v/JbCKBrHeudxn1gN1TgfPE/pWJSlLPrl29uXJBgrJFQVkViQvUHQNDhaS+UEa9wYI5HHh7XYmtzAehyG4L+GA== @@ -3958,7 +3931,7 @@ temp-dir "~2.0.0" unique-string "~2.0.0" -"@expo/json-file@^8.3.0", "@expo/json-file@~8.3.0": +"@expo/json-file@^8.3.0": version "8.3.3" resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-8.3.3.tgz#7926e3592f76030ce63d6b1308ac8f5d4d9341f4" integrity sha512-eZ5dld9AD0PrVRiIWpRkm5aIoWBw3kAyd8VkuWEy92sEthBKDDDHAnK2a0dw0Eil6j7rK7lS/Qaq/Zzngv2h5A== @@ -3976,34 +3949,10 @@ json5 "^2.2.3" write-file-atomic "^2.3.0" -"@expo/metro-config@0.19.6": - version "0.19.6" - resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.19.6.tgz#81bbe70c77a322d6c688738fd7b736a3cbb7c5bd" - integrity sha512-pRwZyOstsQa1+Ecss3wOqC28wjyjq9qxvJaQL3LH4G8Sef9x2PX+ySRApeQ01nl4ZN5nlyez6iVDF51tn/WhOw== - dependencies: - "@babel/core" "^7.20.0" - "@babel/generator" "^7.20.5" - "@babel/parser" "^7.20.0" - "@babel/types" "^7.20.0" - "@expo/config" "~10.0.4" - "@expo/env" "~0.4.0" - "@expo/json-file" "~9.0.0" - "@expo/spawn-async" "^1.7.2" - chalk "^4.1.0" - debug "^4.3.2" - fs-extra "^9.1.0" - getenv "^1.0.0" - glob "^10.4.2" - jsc-safe-url "^0.2.4" - lightningcss "~1.27.0" - minimatch "^3.0.4" - postcss "~8.4.32" - resolve-from "^5.0.0" - -"@expo/metro-config@~0.19.0": - version "0.19.4" - resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.19.4.tgz#940b6fad7809a92a8ffdb1bbe87aa805f5822c6b" - integrity sha512-2SWwYN8MZvMIRawWEr+1RBYncitPwu2VMACRYig+wBycJ9fsPb6BMVmBYi+3MHDUlJHNy/Bqfw++jn1eqBFETQ== +"@expo/metro-config@0.19.8", "@expo/metro-config@~0.19.8": + version "0.19.8" + resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.19.8.tgz#f1ea552b6fa5217093fe364ff5ca78a7e261a28b" + integrity sha512-dVAOetouQYuOTEJ2zR0OTLNPOH6zPkeEt5fY53TK0Wxi1QmtsmH6vEWg05U4zkSJ6f1aXmQ0Za77R8QxuukESA== dependencies: "@babel/core" "^7.20.0" "@babel/generator" "^7.20.5" @@ -4050,15 +3999,6 @@ split "^1.0.1" sudo-prompt "9.1.1" -"@expo/plist@^0.1.0": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.1.3.tgz#b4fbee2c4f7a88512a4853d85319f4d95713c529" - integrity sha512-GW/7hVlAylYg1tUrEASclw1MMk9FP4ZwyFAY/SUTJIhPDQHtfOlXREyWV3hhrHdX/K+pS73GNgdfT6E/e+kBbg== - dependencies: - "@xmldom/xmldom" "~0.7.7" - base64-js "^1.2.3" - xmlbuilder "^14.0.0" - "@expo/plist@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.2.0.tgz#beb014c0bfd56a993086c0972ec1ca3ef3f9d36c" @@ -4068,17 +4008,17 @@ base64-js "^1.2.3" xmlbuilder "^14.0.0" -"@expo/prebuild-config@8.0.22", "@expo/prebuild-config@^8.0.22": - version "8.0.22" - resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-8.0.22.tgz#6e2762a5f333a0519f83ae05b69da45e3e26a913" - integrity sha512-Kwlf3ymHH37W2nuNA9FzYgZvrImJScLA98939kapnOxfNGAPhmhEw26sfIGmBWAa8ymdL6p+HXQ3+b/xJ74bOg== +"@expo/prebuild-config@^8.0.23": + version "8.0.23" + resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-8.0.23.tgz#2ec6d5464f35d308bdb94ba75b7e6aba0ebb507d" + integrity sha512-Zf01kFiN2PISmLb0DhIAJh76v3J2oYUKSjiAtGZLOH0HUz59by/qdyU4mGHWdeyRdCCrLUA21Rct2MBykvRMsg== dependencies: "@expo/config" "~10.0.4" "@expo/config-plugins" "~9.0.10" "@expo/config-types" "^52.0.0" "@expo/image-utils" "^0.6.0" "@expo/json-file" "^9.0.0" - "@react-native/normalize-colors" "0.76.3" + "@react-native/normalize-colors" "0.76.5" debug "^4.3.1" fs-extra "^9.0.0" resolve-from "^5.0.0" @@ -4237,62 +4177,83 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== -"@formatjs/ecma402-abstract@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz#39197ab90b1c78b7342b129a56a7acdb8f512e17" - integrity sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g== +"@formatjs/ecma402-abstract@2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.1.tgz#cdeb3ffe1aeea9c4284b85b7e37e8e8615314c39" + integrity sha512-Ip9uV+/MpLXWRk03U/GzeJMuPeOXpJBSB5V1tjA6kJhvqssye5J5LoYLc7Z5IAHb7nR62sRoguzrFiVCP/hnzw== dependencies: - "@formatjs/intl-localematcher" "0.5.4" - tslib "^2.4.0" + "@formatjs/fast-memoize" "2.2.5" + "@formatjs/intl-localematcher" "0.5.9" + decimal.js "10" + tslib "2" -"@formatjs/intl-enumerator@1.4.7": - version "1.4.7" - resolved "https://registry.yarnpkg.com/@formatjs/intl-enumerator/-/intl-enumerator-1.4.7.tgz#6ab697f3f8f18cf0cc6a6b028cb9c40db6001f3d" - integrity sha512-03RHnFqfpB4H/jwCwlzC+wkTDk2Fi24JmVIY2PVGvTUpikN2bSr9+8oTXfOC+y7B7VxjCArUnqWXVoctkmy85w== +"@formatjs/fast-memoize@2.2.5": + version "2.2.5" + resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-2.2.5.tgz#54a4a1793d773b72c372d3dcab3595149aee7880" + integrity sha512-6PoewUMrrcqxSoBXAOJDiW1m+AmkrAj0RiXnOMD59GRaswjXhm3MDhgepXPBgonc09oSirAJTsAggzAGQf6A6g== dependencies: - tslib "^2.4.0" + tslib "2" -"@formatjs/intl-getcanonicallocales@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-2.3.0.tgz#b6c6fa1c664e30a61f27fa6399a76159d82a5842" - integrity sha512-BOXbLwqQ7nKua/l7tKqDLRN84WupDXFDhGJQMFvsMVA2dKuOdRaWTxWpL3cJ7qPkoNw11Jf+Xpj4OSPBBvW0eQ== +"@formatjs/intl-datetimeformat@^6.17.1": + version "6.17.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-datetimeformat/-/intl-datetimeformat-6.17.1.tgz#d5e800891f9d79c8f1af1999f51db51f1384eca1" + integrity sha512-a18NqRo6R73xpREuMZo8FqjO+LnYFDHoeoviTh5de4ebI46wqLSDgbAIKoceuWblTQt8bvCpJIwvKgLItea88Q== dependencies: - tslib "^2.4.0" + "@formatjs/ecma402-abstract" "2.3.1" + "@formatjs/intl-localematcher" "0.5.9" + tslib "2" -"@formatjs/intl-locale@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-4.0.0.tgz#c111a33078413eba2011e82140466261eb1d67cd" - integrity sha512-+4dbMEGsp1bvB3JB3UHH6YTjMnFTifnfdaHp4ROrCCu50NedA69RBsDCG3eivcZkbj57X9ehGhMWjLxlP+gyVw== +"@formatjs/intl-enumerator@1.8.7": + version "1.8.7" + resolved "https://registry.yarnpkg.com/@formatjs/intl-enumerator/-/intl-enumerator-1.8.7.tgz#3f004753333f80cc468ae34046bd8416772a0412" + integrity sha512-qd7UlWUivKRJ073btssUqMSqzWW9yN3Ki6EqfCZ6uvIv19mONelE5q3GMmdPWBEjgqZikBzBE2qPTqfrgJ4TCA== dependencies: - "@formatjs/ecma402-abstract" "2.0.0" - "@formatjs/intl-enumerator" "1.4.7" - "@formatjs/intl-getcanonicallocales" "2.3.0" - tslib "^2.4.0" + "@formatjs/ecma402-abstract" "2.3.1" + tslib "2" -"@formatjs/intl-localematcher@0.5.4": - version "0.5.4" - resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz#caa71f2e40d93e37d58be35cfffe57865f2b366f" - integrity sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g== +"@formatjs/intl-getcanonicallocales@2.5.4": + version "2.5.4" + resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-2.5.4.tgz#9b843e1891dea83405c51eb3d00c42ef9cb6cab9" + integrity sha512-vSDOsAcc3U+Kl/0b3de8wCQkb3W30H8LUuslyz67wTAHOPSQhPimZyquhwxXpJR+K5yy9CkzTgk5YE5kFT+PFg== dependencies: - tslib "^2.4.0" + tslib "2" -"@formatjs/intl-numberformat@^8.10.3": - version "8.10.3" - resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-8.10.3.tgz#abc97cc6a7b7f1b20da9f07a976b5589c1192ab8" - integrity sha512-lH3liLMeIjZ19Zxt8RRPnBcpPweS1YNSXRURDiFfvFmRlDZUOd8+GlcVyECcPZPkIoSH/p4lfGrnaUzepxJ92g== +"@formatjs/intl-locale@^4.2.8": + version "4.2.8" + resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-4.2.8.tgz#571d44e92b6eb43b7410b37f25e280ec384a32cf" + integrity sha512-6RY/npeA0kyoZ8QW0JRAT+VBAFBT6+4ZVeGkKCNIDjbLX2LPuU73emGR35Mbwcc6pquVFrxyo6mXxKNzib0kEA== dependencies: - "@formatjs/ecma402-abstract" "2.0.0" - "@formatjs/intl-localematcher" "0.5.4" - tslib "^2.4.0" + "@formatjs/ecma402-abstract" "2.3.1" + "@formatjs/intl-enumerator" "1.8.7" + "@formatjs/intl-getcanonicallocales" "2.5.4" + tslib "2" -"@formatjs/intl-pluralrules@^5.2.14": - version "5.2.14" - resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-5.2.14.tgz#7477bd2aa9bfde9e543d839707eff5460eb08026" - integrity sha512-l6Ev7aOGXJSh5EPDEqzsbyufdCCKXZk993QXRQebLsB0TXRhIyF4alqjdMEatLwIigK/Mka8kiVIOLeFP5Cj9Q== +"@formatjs/intl-localematcher@0.5.9": + version "0.5.9" + resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.9.tgz#43c6ee22be85b83340bcb09bdfed53657a2720db" + integrity sha512-8zkGu/sv5euxbjfZ/xmklqLyDGQSxsLqg8XOq88JW3cmJtzhCP8EtSJXlaKZnVO4beEaoiT9wj4eIoCQ9smwxA== dependencies: - "@formatjs/ecma402-abstract" "2.0.0" - "@formatjs/intl-localematcher" "0.5.4" - tslib "^2.4.0" + tslib "2" + +"@formatjs/intl-numberformat@^8.15.1": + version "8.15.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-8.15.1.tgz#b2a5b00889ed31dbef9d4e5aeee1dea3d040b068" + integrity sha512-NIouSY50xpH/SMJrRbX1Q3hMsGyQmT5MQrta/bOYhpZda1bztOlEYZAKLytk8VGs10wkGz875602mCMhtg4/LA== + dependencies: + "@formatjs/ecma402-abstract" "2.3.1" + "@formatjs/intl-localematcher" "0.5.9" + decimal.js "10" + tslib "2" + +"@formatjs/intl-pluralrules@^5.4.1": + version "5.4.1" + resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-5.4.1.tgz#1c03cd2da449e1871bb7c54ea36fec1de68b7e7e" + integrity sha512-kKK4ixTsfKAzyJIVRiJGuw4zd18nEHXiKloYBO9VmLpxrwJTgLQHv2+1hcbxQcwbbo2uc8moUFQuyvxeGEFOfw== + dependencies: + "@formatjs/ecma402-abstract" "2.3.1" + "@formatjs/intl-localematcher" "0.5.9" + decimal.js "10" + tslib "2" "@fortawesome/fontawesome-common-types@6.4.2": version "6.4.2" @@ -4348,6 +4309,13 @@ "@hapi/boom" "^10.0.1" "@hapi/hoek" "^11.0.2" +"@hapi/address@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-5.1.1.tgz#e9925fc1b65f5cc3fbea821f2b980e4652e84cb6" + integrity sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA== + dependencies: + "@hapi/hoek" "^11.0.2" + "@hapi/boom@^10.0.0", "@hapi/boom@^10.0.1": version "10.0.1" resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.1.tgz#ebb14688275ae150aa6af788dbe482e6a6062685" @@ -5042,13 +5010,13 @@ resolved "https://registry.yarnpkg.com/@miblanchard/react-native-slider/-/react-native-slider-2.3.1.tgz#79e0f1f9b1ce43ef25ee51ee9256c012e5dfa412" integrity sha512-J/hZDBWmXq8fJeOnTVHqIUVDHshqMSpJVxJ4WqwuCBKl5Rke9OBYXIdkSlgi75OgtScAr8FKK5KNkDKHUf6JIg== -"@mozzius/expo-dynamic-app-icon@^1.4.1": - version "1.4.1" - resolved "https://registry.yarnpkg.com/@mozzius/expo-dynamic-app-icon/-/expo-dynamic-app-icon-1.4.1.tgz#245e54c31347e3ec2a1ce10f0df8cf07a0c1be6e" - integrity sha512-IiL6OiuW4kP5Jz/vrZ6U1t0m4gK1rW5VZAQzszdVcZy1cadX3EdR2/uA6jMU0qSwuesk028RhO6S0uBI9ckxBw== +"@mozzius/expo-dynamic-app-icon@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@mozzius/expo-dynamic-app-icon/-/expo-dynamic-app-icon-1.5.0.tgz#c5f88c309965b6d6b89cfd5e2c00faa7bda736af" + integrity sha512-yE2yEPO+HQmOqsX7cECh7/vu/LXnqhHGsVm3UiVi/3gaK8u5hAkPTNzZ0Qu6vnMwjPnY+uFbN6X+6Aj9c9yjMQ== dependencies: - "@expo/image-utils" "^0.3.23" - expo-modules-core "^1.0.3" + "@expo/image-utils" "^0.6.3" + expo-modules-core "^2.1.1" xcode "^3.0.1" "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": @@ -5625,7 +5593,7 @@ dependencies: "@react-native/codegen" "0.76.1" -"@react-native/babel-preset@0.76.1", "@react-native/babel-preset@0.76.3": +"@react-native/babel-preset@0.76.1", "@react-native/babel-preset@0.76.3", "@react-native/babel-preset@0.76.5": version "0.76.1" resolved "https://registry.yarnpkg.com/@react-native/babel-preset/-/babel-preset-0.76.1.tgz#2b9fd113e7c7889c1e87d6a36b7cb0f36118e7a6" integrity sha512-b6YRmA13CmVuTQKHRen/Q0glHwmZFZoEDs+MJ1NL0UNHq9V5ytvdwTW1ntkmjtXuTnPMzkwYvumJBN9UTZjkBA== @@ -5726,6 +5694,11 @@ resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.76.3.tgz#531e616f6dad159a58117efc69cec20422d15b0d" integrity sha512-pMHQ3NpPB28RxXciSvm2yD+uDx3pkhzfuWkc7VFgOduyzPSIr0zotUiOJzsAtrj8++bPbOsAraCeQhCqoOTWQw== +"@react-native/debugger-frontend@0.76.5": + version "0.76.5" + resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.76.5.tgz#0e89940543fb5029506690b83f12547d0bf42cc4" + integrity sha512-5gtsLfBaSoa9WP8ToDb/8NnDBLZjv4sybQQj7rDKytKOdsXm3Pr2y4D7x7GQQtP1ZQRqzU0X0OZrhRz9xNnOqA== + "@react-native/dev-middleware@0.76.3": version "0.76.3" resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.76.3.tgz#52edc76c88e0c2c436eb989551b827bf69f2a56f" @@ -5743,6 +5716,23 @@ serve-static "^1.13.1" ws "^6.2.3" +"@react-native/dev-middleware@0.76.5": + version "0.76.5" + resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.76.5.tgz#10d02fcc6c3c9d24f6dc147c2ef95d6fa6bd3787" + integrity sha512-f8eimsxpkvMgJia7POKoUu9uqjGF6KgkxX4zqr/a6eoR1qdEAWUd6PonSAqtag3PAqvEaJpB99gLH2ZJI1nDGg== + dependencies: + "@isaacs/ttlcache" "^1.4.1" + "@react-native/debugger-frontend" "0.76.5" + chrome-launcher "^0.15.2" + chromium-edge-launcher "^0.2.0" + connect "^3.6.5" + debug "^2.2.0" + nullthrows "^1.1.1" + open "^7.0.3" + selfsigned "^2.4.1" + serve-static "^1.13.1" + ws "^6.2.3" + "@react-native/eslint-config@^0.76.2": version "0.76.2" resolved "https://registry.yarnpkg.com/@react-native/eslint-config/-/eslint-config-0.76.2.tgz#2741eee69ff194b8adc15281c0cb9695ba015ef0" @@ -5787,7 +5777,7 @@ hermes-parser "0.23.1" nullthrows "^1.1.1" -"@react-native/normalize-colors@0.76.1", "@react-native/normalize-colors@0.76.3", "@react-native/normalize-colors@^0.73.0", "@react-native/normalize-colors@^0.74.1": +"@react-native/normalize-colors@0.76.1", "@react-native/normalize-colors@0.76.3", "@react-native/normalize-colors@0.76.5", "@react-native/normalize-colors@^0.73.0", "@react-native/normalize-colors@^0.74.1": version "0.76.1" resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.76.1.tgz#df8d54d78917a9f075283382fec834f5ccaecefd" integrity sha512-/+CUk/wGWIdXbJYVLw/q6Fs8Z0x91zzfXIbNiZUdSW1TNEDmytkF371H8a1/Nx3nWa1RqCMVsaZHCG4zqxeDvg== @@ -8097,10 +8087,10 @@ babel-preset-expo@^12.0.2: babel-plugin-react-native-web "~0.19.13" react-refresh "^0.14.2" -babel-preset-expo@~12.0.3: - version "12.0.3" - resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-12.0.3.tgz#2ad62fe007517704841788cfea38b333e307663f" - integrity sha512-1695e8y3U/HjifKx33vcNnFMSUSXwPWwhFxRlL6NRx2TENN6gySH82gPOWgxcra6gi+EJgXx52xG3PcqTjwW6w== +babel-preset-expo@~12.0.4: + version "12.0.4" + resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-12.0.4.tgz#ec965530d866c8905aac1fa478562cb08ab32a55" + integrity sha512-SAzAwqpyjA+/OFrU95OOioj6oTeCv4+rRfrNmBTy5S/gJswrZKBSPJioFudIaJBy43W+BL7HA5AspBIF6tO/aA== dependencies: "@babel/plugin-proposal-decorators" "^7.12.9" "@babel/plugin-transform-export-namespace-from" "^7.22.11" @@ -8108,7 +8098,7 @@ babel-preset-expo@~12.0.3: "@babel/plugin-transform-parameters" "^7.22.15" "@babel/preset-react" "^7.22.15" "@babel/preset-typescript" "^7.23.0" - "@react-native/babel-preset" "0.76.3" + "@react-native/babel-preset" "0.76.5" babel-plugin-react-native-web "~0.19.13" react-refresh "^0.14.2" @@ -9359,7 +9349,7 @@ decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== -decimal.js@^10.4.2: +decimal.js@10, decimal.js@^10.4.2: version "10.4.3" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== @@ -9563,10 +9553,10 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" -disposable-email@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/disposable-email/-/disposable-email-0.2.3.tgz#a21a49717f6034a8ff777dc8eae3b4d994a7b988" - integrity sha512-gkBQQ5Res431ZXqLlAafrXHizG7/1FWmi8U2RTtriD78Vc10HhBUvdJun3R4eSF0KRIQQJs+wHlxjkED/Hr1EQ== +disposable-email-domains-js@^1.5.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/disposable-email-domains-js/-/disposable-email-domains-js-1.7.0.tgz#2bf859bccf7a2eb697025577e18f0434409713ec" + integrity sha512-qcIJcnXjDvH8EEt0tyAesk1sZVGU5ZFtW6Wys2wKCAcbUf5nJYfwZfT7Z0PVA/LBMlqd/Xgk9dXN2Q3fx7NFAg== dns-equal@^1.0.0: version "1.0.0" @@ -10411,12 +10401,13 @@ expo-clipboard@^7.0.0: resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-7.0.0.tgz#066b1a781fdaf05e30f282522d3a58f2e651e4cf" integrity sha512-4Vuv1zZPTOiKzIeC0BIGUN8nyzkXlE6jKchtLxcoksBjHPdG5W2eH05B+hppTrK9N3+Xh02z4j3h1cFRqPJ1fw== -expo-constants@16.0.1, expo-constants@^13.0.2, expo-constants@~17.0.0, expo-constants@~17.0.3: - version "16.0.1" - resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-16.0.1.tgz#1285e29c85513c6e88e118289e2baab72596d3f7" - integrity sha512-s6aTHtglp926EsugWtxN7KnpSsE9FCEjb7CgEjQQ78Gpu4btj4wB+IXot2tlqNwqv+x7xFe5veoPGfJDGF/kVg== +expo-constants@17.0.3, expo-constants@^13.0.2, expo-constants@~17.0.0, expo-constants@~17.0.3: + version "17.0.3" + resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-17.0.3.tgz#a05b38e0417d59759ece1642b4d483889e04dbda" + integrity sha512-lnbcX2sAu8SucHXEXxSkhiEpqH+jGrf+TF+MO6sHWIESjwOUVVYlT8qYdjR9xbxWmqFtrI4KV44FkeJf2DaFjQ== dependencies: - "@expo/config" "~9.0.0-beta.0" + "@expo/config" "~10.0.4" + "@expo/env" "~0.4.0" expo-dev-client@^5.0.4: version "5.0.4" @@ -10451,14 +10442,7 @@ expo-dev-menu@6.0.11: dependencies: expo-dev-menu-interface "1.9.2" -expo-device@6.0.2, expo-device@~4.1.1: - version "6.0.2" - resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-6.0.2.tgz#9bc3eccd16509c2819c225cc2ca8f7c3e3bdd11e" - integrity sha512-sCt91CuTmAuMXX4SlFOn4lIos2UIr8vb0jDstDDZXys6kErcj0uynC7bQAMreU5uRUTKMAl4MAMpKt9ufCXPBw== - dependencies: - ua-parser-js "^0.7.33" - -expo-device@~7.0.1: +expo-device@7.0.1, expo-device@~4.1.1, expo-device@~7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-7.0.1.tgz#3702fe8b4475eac63ed27f9d580ec8a78546e0d1" integrity sha512-/3lk0f9wvle+6svHqWSCBC1B5NYFmXp1D7hmIyecJJVYRLwzrwwTDyNs76oG/UDU5Appdu8QyDKycsx2hqv71w== @@ -10470,10 +10454,10 @@ expo-eas-client@~0.13.0: resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-0.13.1.tgz#ebca627f3f58a54906394eb3f5d22f41a1822618" integrity sha512-IyeDiM6YSJG0c45kbuEo0qt76z0KTEZtisEFEtle+b+vfn9I3N+r3jbPscaI4yS3P6gpuoDyHv81YDVC6Dmkhw== -expo-file-system@^18.0.4, expo-file-system@~18.0.4: - version "18.0.4" - resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-18.0.4.tgz#eecf8dc0b3b545e9ac5cd00352665afe2d57732f" - integrity sha512-aAWEDwnu0XHOBYvQ9Q0+QIa+483vYJaC4IDsXyWQ73Rtsg273NZh5kYowY+cAocvoSmA99G6htrLBn11ax2bTQ== +expo-file-system@^18.0.6, expo-file-system@~18.0.6: + version "18.0.6" + resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-18.0.6.tgz#43f7718530d0e2aa1f49bca7ccb721007acabf2c" + integrity sha512-gGEwIJCXV3/wpIJ/wRyhmieLOSAY7HeFFjb+wEfHs04aE63JYR+rXXV4b7rBpEh1ZgNV9U91zfet/iQG7J8HBQ== dependencies: web-streams-polyfill "^3.3.2" @@ -10556,10 +10540,10 @@ expo-media-library@~17.0.3: resolved "https://registry.yarnpkg.com/expo-media-library/-/expo-media-library-17.0.3.tgz#4ee3e6a8a2544887d910a72eaf2a15858b78cc0e" integrity sha512-vo8AqWxv1C8+U8dA5W43qs8+3dgD3VZDvcCkZBQTBnGr/2Rs7x6nNQD5s7UfYyr6qmW6102JB3+OUKHpkwEssg== -expo-modules-autolinking@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-2.0.3.tgz#c0de0129bedf1b6f9aa36093e435d00509f27fcd" - integrity sha512-Q/ALJ54eS7Cr7cmbP+unEDTkHFQivQerWWrqZxuXOrSFYGCYU22+/xAZXaJOpZwseOVsP74zSkoRY/wBimVs7w== +expo-modules-autolinking@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-2.0.4.tgz#28fcd12fb0d066a2933cca3bf3b597da0f6b2f2a" + integrity sha512-e0p+19NhmD50U7s7BV7kWIypWmTNC9n/VlJKlXS05hM/zX7pe6JKmXyb+BFnXJq3SLBalLCUY0tu2gEUF3XeVg== dependencies: "@expo/spawn-async" "^1.7.2" chalk "^4.1.0" @@ -10570,17 +10554,17 @@ expo-modules-autolinking@2.0.3: require-from-string "^2.0.2" resolve-from "^5.0.0" -expo-modules-core@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-2.1.1.tgz#970af4cfd70c8aa6fc0096dd0a6578aa003a479f" - integrity sha512-yQzYCLR2mre4BNMXuqkeJ0oSNgmGEMI6BcmIzeNZbC2NFEjiaDpKvlV9bclYCtyVhUEVNbJcEPYMr6c1Y4eR4w== +expo-modules-core@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-2.1.2.tgz#258be4fbd162b69eb4ad2789131ac2dc7e85fc08" + integrity sha512-0OhMU5S8zf9c/CRh1MwiXfOInI9wzz6yiIh5RuR/9J7N6xHRum68hInsPbaSc1UQpo08ZZLM4MPsbpoNRUoqIg== dependencies: invariant "^2.2.4" -expo-modules-core@^1.0.3: - version "1.12.26" - resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-1.12.26.tgz#86c4087dc6246abfc4d7f5e61097dc8cc4b22262" - integrity sha512-y8yDWjOi+rQRdO+HY+LnUlz8qzHerUaw/LUjKPU/mX8PRXP4UUPEEp5fjAwBU44xjNmYSHWZDwet4IBBE+yQUA== +expo-modules-core@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-2.1.1.tgz#970af4cfd70c8aa6fc0096dd0a6578aa003a479f" + integrity sha512-yQzYCLR2mre4BNMXuqkeJ0oSNgmGEMI6BcmIzeNZbC2NFEjiaDpKvlV9bclYCtyVhUEVNbJcEPYMr6c1Y4eR4w== dependencies: invariant "^2.2.4" @@ -10592,10 +10576,10 @@ expo-navigation-bar@~4.0.4: "@react-native/normalize-colors" "0.76.3" debug "^4.3.2" -expo-notifications@~0.29.10: - version "0.29.10" - resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.29.10.tgz#1ab41840db380fc775216e1fb3c07e7a1abd71bd" - integrity sha512-sNPAQxwWVR759iCM816gEU4+8MY08CAs+Cmp8VUkBCnPWZaz2pV30nEkwRhV3wjv+Sz78oIRkkVMVaHCa1XUVA== +expo-notifications@~0.29.11: + version "0.29.11" + resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.29.11.tgz#0691f88c91f6598671cec8e2ff12922ea1493edf" + integrity sha512-u/Csc3YNOPjjuyjAeyj5ne7XR/Z0ABYVquhSnyjEj2Fp8mSldOPCMvaEA01pTFj+8HTlkjX5RZDvQ7cR62ngOA== dependencies: "@expo/image-utils" "^0.6.0" "@ide/backoff" "^1.0.0" @@ -10620,12 +10604,12 @@ expo-sharing@^13.0.0: resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-13.0.0.tgz#fbc46f4afdaa265a2811fe88c2a589aae2d2de0f" integrity sha512-b23ymicRmYn/Pjj05sl9tFZHN5cH9I1f0yiqY1Yk8Q3oCx0Aznri82DnTYA4T/J6D9vrkraX0wQ4jWVMOffmlg== -expo-splash-screen@~0.29.16: - version "0.29.16" - resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-0.29.16.tgz#80b49af2605213e661a40022d724caa1ea48ccb3" - integrity sha512-1WnExDA23hEJhz+djUthVUWxUvVtDT9sqRrpCgU4srG2OfBN0NryJ+Fbnoc1V2xw2uYc4Ij3ru0nH9a1TNvW9w== +expo-splash-screen@~0.29.18: + version "0.29.18" + resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-0.29.18.tgz#96ccce3d5a03389a9061743903b0a77c22a16796" + integrity sha512-bTBY+LF6YtYen2j60yGNh2SX/tG4UXZAyBCMMriOSiZZ7LSCs3ARyEufaSiWk+ckWShTeMqItOnaAN/CAF8MJA== dependencies: - "@expo/prebuild-config" "^8.0.22" + "@expo/prebuild-config" "^8.0.23" expo-status-bar@~2.0.0: version "2.0.0" @@ -10657,10 +10641,10 @@ expo-updates-interface@~1.0.0: resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-1.0.0.tgz#b98c66b800d29561c62409556948b2af3d5316e5" integrity sha512-93oWtvULJOj+Pp+N/lpTcFfuREX1wNeHtp7Lwn8EbzYYmdn37MvZU3TPW2tYYCZuhzmKEXnUblYcruYoDu7IrQ== -expo-updates@~0.26.9: - version "0.26.9" - resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-0.26.9.tgz#9be11c482c572f90d479aa1f7bd1ac509d7fa102" - integrity sha512-eHdvM4+xXaLV3uuTn3ooWsDC69IPV9ukpuPDglsBWJKagVi40u7DJQ375oGNgjqxd91irEBeBSCr9tCWm905QA== +expo-updates@~0.26.10: + version "0.26.10" + resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-0.26.10.tgz#b39c77841b609b34e0e4e239f37e8d5e4da3c5e5" + integrity sha512-ETGUaSZRL7x72RH6MbZWRpyU9GFzCixIPNUT0kf/hcD07ojyHlW5hcwgc5ve565THSvhgiumz3yImKLbKBv2JA== dependencies: "@expo/code-signing-certificates" "0.0.5" "@expo/config" "~10.0.4" @@ -10682,26 +10666,26 @@ expo-web-browser@~14.0.1: resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-14.0.1.tgz#97f3f141b0897364bc8364d90d6e29df0beec8aa" integrity sha512-QM9F3ie+UyIOoBvqFmT6CZojb1vMc2H+7ZlMT5dEu1PL2jtYyOeK2hLfbt/EMt7CBm/w+P29H9W9Y9gdebOkuQ== -expo@~52.0.17: - version "52.0.17" - resolved "https://registry.yarnpkg.com/expo/-/expo-52.0.17.tgz#8a3edc20dabdb69a47f7b4b92e1bb96284044c14" - integrity sha512-f0WBD2T6p9r/a8v8MqkoWQq7TmbbAgPUg2zZtOp+kBrSCb3obHeNAsPDAUFzh+jEgug2qDVVkauBJa6ACe9AMg== +expo@~52.0.19: + version "52.0.19" + resolved "https://registry.yarnpkg.com/expo/-/expo-52.0.19.tgz#1b881c96ea595da0c5f3c13f578bc368c5261f4a" + integrity sha512-wOb/wbiQa0xqQRhgVBuOhLRus05TSw6fgThVMrPQgdLo24EPuT/ZAiRVcVRdjrEbwOqCDumgQCB7636B9J+jKg== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "0.22.3" + "@expo/cli" "0.22.6" "@expo/config" "~10.0.6" "@expo/config-plugins" "~9.0.12" - "@expo/fingerprint" "0.11.3" - "@expo/metro-config" "0.19.6" + "@expo/fingerprint" "0.11.4" + "@expo/metro-config" "0.19.8" "@expo/vector-icons" "^14.0.0" - babel-preset-expo "~12.0.3" + babel-preset-expo "~12.0.4" expo-asset "~11.0.1" expo-constants "~17.0.3" - expo-file-system "~18.0.4" + expo-file-system "~18.0.6" expo-font "~13.0.1" expo-keep-awake "~14.0.1" - expo-modules-autolinking "2.0.3" - expo-modules-core "2.1.1" + expo-modules-autolinking "2.0.4" + expo-modules-core "2.1.2" fbemitter "^3.0.0" web-streams-polyfill "^3.3.2" whatwg-url-without-unicode "8.0.0-3" @@ -11015,7 +10999,7 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" -find-up@^5.0.0, find-up@~5.0.0: +find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== @@ -11310,18 +11294,6 @@ glob@7.0.6: once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.1.6: - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@^10.2.2: version "10.4.1" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.1.tgz#0cfb01ab6a6b438177bfe6a58e2576f6efe909c2" @@ -16027,10 +15999,10 @@ react-native-qrcode-styled@^0.3.3: qrcode "^1.5.4" react-fast-compare "^3.2.2" -react-native-reanimated@^3.16.3: - version "3.16.3" - resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.16.3.tgz#3b559dca49e9e40abcf5de834dc27fc05f856b66" - integrity sha512-OWlA6e1oHhytTpc7WiSZ7Tmb8OYwLKYZz29Sz6d6WAg60Hm5GuAiKIWUG7Ako7FLcYhFkA0pEQ2xPMEYUo9vlw== +react-native-reanimated@3.17.0-nightly-20241211-17e89ca24: + version "3.17.0-nightly-20241211-17e89ca24" + resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-3.17.0-nightly-20241211-17e89ca24.tgz#af0c36e278646eb2f79e28ad0047cfd80d0e29f5" + integrity sha512-5p7jr0DrnID1puOzMel3VZVRw5Hl/UdMUvPCI1sEG9IA2mUaWrgeoojS2wVwW1U0Pj6HXjPNEimDSXZneZKNuQ== dependencies: "@babel/plugin-transform-arrow-functions" "^7.0.0-0" "@babel/plugin-transform-class-properties" "^7.0.0-0" @@ -17689,19 +17661,6 @@ styleq@^0.1.3: resolved "https://registry.yarnpkg.com/styleq/-/styleq-0.1.3.tgz#8efb2892debd51ce7b31dc09c227ad920decab71" integrity sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA== -sucrase@3.34.0: - version "3.34.0" - resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.34.0.tgz#1e0e2d8fcf07f8b9c3569067d92fbd8690fb576f" - integrity sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw== - dependencies: - "@jridgewell/gen-mapping" "^0.3.2" - commander "^4.0.0" - glob "7.1.6" - lines-and-columns "^1.1.6" - mz "^2.7.0" - pirates "^4.0.1" - ts-interface-checker "^0.1.9" - sucrase@3.35.0: version "3.35.0" resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" @@ -18081,6 +18040,11 @@ ts-node@^10.9.1: v8-compile-cache-lib "^3.0.1" yn "3.1.1" +tslib@2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"