diff --git a/__tests__/lib/__mocks__/exampleComHtml.ts b/__tests__/lib/__mocks__/exampleComHtml.ts
deleted file mode 100644
index 6633e40ca5..0000000000
--- a/__tests__/lib/__mocks__/exampleComHtml.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-export const exampleComHtml = `
-
-
- Example Domain
-
-
-
-
-
-
-
-
-
-
-
Example Domain
-
This domain is for use in illustrative examples in documents. You may use this
- domain in literature without prior coordination or asking for permission.
-
More information...
-
-
-`
diff --git a/__tests__/lib/__mocks__/tiktokHtml.ts b/__tests__/lib/__mocks__/tiktokHtml.ts
deleted file mode 100644
index fa3d112836..0000000000
--- a/__tests__/lib/__mocks__/tiktokHtml.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export const tiktokHtml = `
-Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #bal... | TikTok
-`
diff --git a/__tests__/lib/__mocks__/youtubeChannelHtml.ts b/__tests__/lib/__mocks__/youtubeChannelHtml.ts
deleted file mode 100644
index cc71995c45..0000000000
--- a/__tests__/lib/__mocks__/youtubeChannelHtml.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-export const youtubeChannelHtml = `
-
-
About Press Copyright Contact us Creators Advertise Developers Terms Privacy Policy & Safety How YouTube works Test new features penguinz0 - YouTube
-
-`
diff --git a/__tests__/lib/__mocks__/youtubeHtml.ts b/__tests__/lib/__mocks__/youtubeHtml.ts
deleted file mode 100644
index 7fd9f819da..0000000000
--- a/__tests__/lib/__mocks__/youtubeHtml.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-export const youtubeHTML = `
-
-YouTube
-
-`
diff --git a/__tests__/lib/errors.test.ts b/__tests__/lib/errors.test.ts
index 39e8d189e0..e721396845 100644
--- a/__tests__/lib/errors.test.ts
+++ b/__tests__/lib/errors.test.ts
@@ -9,11 +9,11 @@ describe('isNetworkError', () => {
]
const outputs = [true, false, false, true]
- it('correctly distinguishes network errors', () => {
- for (let i = 0; i < inputs.length; i++) {
- const input = inputs[i]
- const result = isNetworkError(input)
- expect(result).toEqual(outputs[i])
- }
- })
+ for (let i = 0; i < inputs.length; i++) {
+ const input = inputs[i]
+ const output = outputs[i]
+ it(`correctly distinguishes network errors for ${input}`, () => {
+ expect(isNetworkError(input)).toEqual(output)
+ })
+ }
})
diff --git a/__tests__/lib/extractHtmlMeta.test.ts b/__tests__/lib/extractHtmlMeta.test.ts
deleted file mode 100644
index cdd2a33848..0000000000
--- a/__tests__/lib/extractHtmlMeta.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import {extractHtmlMeta} from '../../src/lib/link-meta/html'
-import {exampleComHtml} from './__mocks__/exampleComHtml'
-import {youtubeHTML} from './__mocks__/youtubeHtml'
-import {tiktokHtml} from './__mocks__/tiktokHtml'
-import {youtubeChannelHtml} from './__mocks__/youtubeChannelHtml'
-
-describe('extractHtmlMeta', () => {
- const cases = [
- ['', {}],
- ['nothing', {}],
- ['title ', {title: 'title'}],
- [' aSd!@#AC ', {title: 'aSd!@#AC'}],
- ['\n title\n ', {title: 'title'}],
- [' ', {title: 'meta title'}],
- [
- ' ',
- {description: 'meta description'},
- ],
- [' ', {title: 'og title'}],
- [
- ' ',
- {description: 'og description'},
- ],
- [
- ' ',
- {image: 'https://ogimage.com/foo.png'},
- ],
- [
- ' ',
- {title: 'twitter title'},
- ],
- [
- ' ',
- {description: 'twitter description'},
- ],
- [
- ' ',
- {image: 'https://twitterimage.com/foo.png'},
- ],
- [' ', {title: 'meta title'}],
- ]
-
- it.each(cases)(
- 'given the html tag %p, returns %p',
- // @ts-ignore not worth fixing -prf
- (input, expectedResult) => {
- const output = extractHtmlMeta({html: input as string, hostname: ''})
- expect(output).toEqual(expectedResult)
- },
- )
-
- it('extracts title and description from a generic HTML page', () => {
- const input = exampleComHtml
- const expectedOutput = {
- title: 'Example Domain',
- description: 'An example website',
- }
- const output = extractHtmlMeta({html: input, hostname: 'example.com'})
- expect(output).toEqual(expectedOutput)
- })
-
- it('extracts title and description from a Tiktok HTML page', () => {
- const input = tiktokHtml
- const expectedOutput = {
- title:
- 'Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #bal... | TikTok',
- description:
- '5.5M Likes, 20.8K Comments. TikTok video from Power Vision Tests (@_powervision_): "Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp". оригинальный звук - Power Vision Tests.',
- }
- const output = extractHtmlMeta({html: input, hostname: 'tiktok.com'})
- expect(output).toEqual(expectedOutput)
- })
-
- it('extracts title and description from a generic youtube page', () => {
- const input = youtubeHTML
- const expectedOutput = {
- title: 'HD Video (1080p) with Relaxing Music of Native American Shamans',
- description:
- 'Stunning HD Video ( 1080p ) of Patagonian Nature with Relaxing Native American Shamanic Music. HD footage used from ',
- image: 'https://i.ytimg.com/vi/x6UITRjhijI/sddefault.jpg',
- }
- const output = extractHtmlMeta({html: input, hostname: 'youtube.com'})
- expect(output).toEqual(expectedOutput)
- })
-
- it('extracts avatar from a youtube channel', () => {
- const input = youtubeChannelHtml
- const expectedOutput = {
- title: 'penguinz0',
- description:
- 'Clips channel: https://www.youtube.com/channel/UC4EQHfzIbkL_Skit_iKt1aA\n\nTwitter: https://twitter.com/MoistCr1TiKaL\n\nInstagram: https://www.instagram.com/bigmoistcr1tikal/?hl=en\n\nTwitch: https://www.twitch.tv/moistcr1tikal\n\nSnapchat: Hugecharles\n\nTik Tok: Hugecharles\n\nI don't have any other public accounts.',
- image:
- 'https://yt3.googleusercontent.com/ytc/AL5GRJWOhJOuUC6C2b7gP-5D2q6ypXbcOOckyAE1En4RUQ=s176-c-k-c0x00ffffff-no-rj',
- }
- const output = extractHtmlMeta({html: input, hostname: 'youtube.com'})
- expect(output).toEqual(expectedOutput)
- })
-
- it('extracts username from the url a twitter profile page', () => {
- const expectedOutput = {
- title: '@bluesky on Twitter',
- }
- const output = extractHtmlMeta({
- html: '',
- hostname: 'twitter.com',
- pathname: '/bluesky',
- })
- expect(output).toEqual(expectedOutput)
- })
-
- it('extracts username from the url a tweet', () => {
- const expectedOutput = {
- title: 'Tweet by @bluesky',
- }
- const output = extractHtmlMeta({
- html: '',
- hostname: 'twitter.com',
- pathname: '/bluesky/status/1582437529969917953',
- })
- expect(output).toEqual(expectedOutput)
- })
-
- it("does not extract username from the url when it's not a tweet or profile page", () => {
- const expectedOutput = {
- title: 'Twitter',
- }
- const output = extractHtmlMeta({
- html: '',
- hostname: 'twitter.com',
- pathname: '/i/articles/follows/-1675653703?time_window=24',
- })
- expect(output).toEqual(expectedOutput)
- })
-})
diff --git a/package.json b/package.json
index 4b3486545e..5e4d896cce 100644
--- a/package.json
+++ b/package.json
@@ -120,6 +120,7 @@
"eventemitter3": "^5.0.1",
"expo": "^51.0.8",
"expo-application": "^5.9.1",
+ "expo-blur": "^13.0.2",
"expo-build-properties": "^0.12.1",
"expo-camera": "~15.0.9",
"expo-clipboard": "^6.0.3",
diff --git a/src/alf/themes.ts b/src/alf/themes.ts
index 9f7ec5c673..0cfe09aadc 100644
--- a/src/alf/themes.ts
+++ b/src/alf/themes.ts
@@ -305,6 +305,7 @@ export function createThemes({
} as const
const light: Theme = {
+ scheme: 'light',
name: 'light',
palette: lightPalette,
atoms: {
@@ -390,6 +391,7 @@ export function createThemes({
}
const dark: Theme = {
+ scheme: 'dark',
name: 'dark',
palette: darkPalette,
atoms: {
@@ -479,6 +481,7 @@ export function createThemes({
const dim: Theme = {
...dark,
+ scheme: 'dark',
name: 'dim',
palette: dimPalette,
atoms: {
diff --git a/src/alf/types.ts b/src/alf/types.ts
index 41822b8dd5..08ec593927 100644
--- a/src/alf/types.ts
+++ b/src/alf/types.ts
@@ -156,6 +156,7 @@ export type ThemedAtoms = {
}
}
export type Theme = {
+ scheme: 'light' | 'dark' // for library support
name: ThemeName
palette: Palette
atoms: ThemedAtoms
diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx
index 7fb0545a21..00afbdcfe9 100644
--- a/src/components/StarterPack/ProfileStarterPacks.tsx
+++ b/src/components/StarterPack/ProfileStarterPacks.tsx
@@ -12,15 +12,15 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
+import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {NavigationProp} from '#/lib/routes/types'
+import {parseStarterPackUri} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
-import {useGenerateStarterPackMutation} from 'lib/generate-starterpack'
-import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {NavigationProp} from 'lib/routes/types'
-import {parseStarterPackUri} from 'lib/strings/starter-pack'
-import {List, ListRef} from 'view/com/util/List'
-import {Text} from 'view/com/util/text/Text'
-import {atoms as a, useTheme} from '#/alf'
+import {List, ListRef} from '#/view/com/util/List'
+import {Text} from '#/view/com/util/text/Text'
+import {atoms as a, ios, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
@@ -132,6 +132,7 @@ export const ProfileStarterPacks = React.forwardRef<
keyExtractor={keyExtractor}
refreshing={isPTRing}
headerOffset={headerOffset}
+ progressViewOffset={ios(0)}
contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 94ee261e38..21928d3df3 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -135,6 +135,8 @@ export function createInput(Component: typeof TextInput) {
placeholder,
value,
onChangeText,
+ onFocus,
+ onBlur,
isInvalid,
inputRef,
style,
@@ -173,8 +175,14 @@ export function createInput(Component: typeof TextInput) {
ref={refs}
value={value}
onChangeText={onChangeText}
- onFocus={ctx.onFocus}
- onBlur={ctx.onBlur}
+ onFocus={e => {
+ ctx.onFocus()
+ onFocus?.(e)
+ }}
+ onBlur={e => {
+ ctx.onBlur()
+ onBlur?.(e)
+ }}
placeholder={placeholder || label}
placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
@@ -188,8 +196,8 @@ export function createInput(Component: typeof TextInput) {
a.px_xs,
{
// paddingVertical doesn't work w/multiline - esb
- paddingTop: 14,
- paddingBottom: 14,
+ paddingTop: 12,
+ paddingBottom: 13,
lineHeight: a.text_md.fontSize * 1.1875,
textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 80 : undefined,
@@ -197,13 +205,14 @@ export function createInput(Component: typeof TextInput) {
},
// fix for autofill styles covering border
web({
- paddingTop: 12,
- paddingBottom: 12,
+ paddingTop: 10,
+ paddingBottom: 11,
marginTop: 2,
marginBottom: 2,
}),
android({
- paddingBottom: 16,
+ paddingTop: 8,
+ paddingBottom: 8,
}),
style,
]}
diff --git a/src/lib/custom-animations/PressableScale.tsx b/src/lib/custom-animations/PressableScale.tsx
new file mode 100644
index 0000000000..d6eabf8b22
--- /dev/null
+++ b/src/lib/custom-animations/PressableScale.tsx
@@ -0,0 +1,58 @@
+import React from 'react'
+import {Pressable, PressableProps, StyleProp, ViewStyle} from 'react-native'
+import Animated, {
+ cancelAnimation,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from 'react-native-reanimated'
+
+import {isTouchDevice} from '#/lib/browser'
+import {isNative} from '#/platform/detection'
+
+const DEFAULT_TARGET_SCALE = isNative || isTouchDevice ? 0.98 : 1
+
+export function PressableScale({
+ targetScale = DEFAULT_TARGET_SCALE,
+ children,
+ contentContainerStyle,
+ onPressIn,
+ onPressOut,
+ ...rest
+}: {
+ targetScale?: number
+ contentContainerStyle?: StyleProp
+} & Exclude) {
+ const scale = useSharedValue(1)
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [{scale: scale.value}],
+ }))
+
+ return (
+ {
+ 'worklet'
+ if (onPressIn) {
+ runOnJS(onPressIn)(e)
+ }
+ cancelAnimation(scale)
+ scale.value = withTiming(targetScale, {duration: 100})
+ }}
+ onPressOut={e => {
+ 'worklet'
+ if (onPressOut) {
+ runOnJS(onPressOut)(e)
+ }
+ cancelAnimation(scale)
+ scale.value = withTiming(1, {duration: 100})
+ }}
+ {...rest}>
+
+ {children as React.ReactNode}
+
+
+ )
+}
diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts
index f588808fc3..234be777d3 100644
--- a/src/lib/haptics.ts
+++ b/src/lib/haptics.ts
@@ -1,8 +1,10 @@
import React from 'react'
+import * as Device from 'expo-device'
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
import {isIOS, isWeb} from '#/platform/detection'
import {useHapticsDisabled} from '#/state/preferences/disable-haptics'
+import * as Toast from '#/view/com/util/Toast'
export function useHaptics() {
const isHapticsDisabled = useHapticsDisabled()
@@ -18,6 +20,11 @@ export function useHaptics() {
? ImpactFeedbackStyle[strength]
: ImpactFeedbackStyle.Light
impactAsync(style)
+
+ // DEV ONLY - show a toast when a haptic is meant to fire on simulator
+ if (__DEV__ && !Device.isDevice) {
+ Toast.show(`Buzzz!`)
+ }
},
[isHapticsDisabled],
)
diff --git a/src/lib/link-meta/html.ts b/src/lib/link-meta/html.ts
deleted file mode 100644
index 220f8431d5..0000000000
--- a/src/lib/link-meta/html.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import {extractTwitterMeta} from './twitter'
-import {extractYoutubeMeta} from './youtube'
-
-interface ExtractHtmlMetaInput {
- html: string
- hostname?: string
- pathname?: string
-}
-
-export const extractHtmlMeta = ({
- html,
- hostname,
- pathname,
-}: ExtractHtmlMetaInput): Record => {
- const htmlTitleRegex = /([^<]+)<\/title>/i
-
- let res: Record = {}
-
- const match = htmlTitleRegex.exec(html)
-
- if (match) {
- res.title = match[1].trim()
- }
-
- let metaMatch
- let propMatch
- const metaRe = / ]+)>/gis
- while ((metaMatch = metaRe.exec(html))) {
- let propName
- let propValue
- const propRe = /(name|property|content)="([^"]+)"/gis
- while ((propMatch = propRe.exec(metaMatch[1]))) {
- if (propMatch[1] === 'content') {
- propValue = propMatch[2]
- } else {
- propName = propMatch[2]
- }
- }
- if (!propName || !propValue) {
- continue
- }
- switch (propName?.trim()) {
- case 'title':
- case 'og:title':
- case 'twitter:title':
- res.title = propValue?.trim()
- break
- case 'description':
- case 'og:description':
- case 'twitter:description':
- res.description = propValue?.trim()
- break
- case 'og:image':
- case 'twitter:image':
- res.image = propValue?.trim()
- break
- }
- }
-
- const isYoutubeUrl =
- hostname?.includes('youtube.') || hostname?.includes('youtu.be')
- const isTwitterUrl = hostname?.includes('twitter.')
- // Workaround for some websites not having a title or description in the meta tags in the initial serve
- if (isYoutubeUrl) {
- res = {...res, ...extractYoutubeMeta(html)}
- } else if (isTwitterUrl && pathname) {
- res = {...extractTwitterMeta({pathname})}
- }
-
- return res
-}
diff --git a/src/lib/link-meta/twitter.ts b/src/lib/link-meta/twitter.ts
deleted file mode 100644
index d785903c00..0000000000
--- a/src/lib/link-meta/twitter.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-export const extractTwitterMeta = ({
- pathname,
-}: {
- pathname: string
-}): Record => {
- const res = {title: 'Twitter'}
- const parsedPathname = pathname.split('/')
- if (parsedPathname.length <= 1 || parsedPathname[1].length <= 1) {
- // Excluding one letter usernames as they're reserved by twitter for things like cases like twitter.com/i/articles/follows/-1675653703
- return res
- }
- const username = parsedPathname?.[1]
- const isUserProfile = parsedPathname?.length === 2
-
- res.title = isUserProfile
- ? `@${username} on Twitter`
- : `Tweet by @${username}`
-
- return res
-}
diff --git a/src/lib/link-meta/youtube.ts b/src/lib/link-meta/youtube.ts
deleted file mode 100644
index 42eed51e8f..0000000000
--- a/src/lib/link-meta/youtube.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-export const extractYoutubeMeta = (html: string): Record => {
- const res: Record = {}
- const youtubeTitleRegex = /"videoDetails":.*"title":"([^"]*)"/i
- const youtubeDescriptionRegex =
- /"videoDetails":.*"shortDescription":"([^"]*)"/i
- const youtubeThumbnailRegex = /"videoDetails":.*"url":"(.*)(default\.jpg)/i
- const youtubeAvatarRegex =
- /"avatar":{"thumbnails":\[{.*?url.*?url.*?url":"([^"]*)"/i
- const youtubeTitleMatch = youtubeTitleRegex.exec(html)
- const youtubeDescriptionMatch = youtubeDescriptionRegex.exec(html)
- const youtubeThumbnailMatch = youtubeThumbnailRegex.exec(html)
- const youtubeAvatarMatch = youtubeAvatarRegex.exec(html)
-
- if (youtubeTitleMatch && youtubeTitleMatch.length >= 1) {
- res.title = decodeURI(youtubeTitleMatch[1])
- }
- if (youtubeDescriptionMatch && youtubeDescriptionMatch.length >= 1) {
- res.description = decodeURI(youtubeDescriptionMatch[1]).replace(
- /\\n/g,
- '\n',
- )
- }
- if (youtubeThumbnailMatch && youtubeThumbnailMatch.length >= 2) {
- res.image = youtubeThumbnailMatch[1] + 'default.jpg'
- }
- if (!res.image && youtubeAvatarMatch && youtubeAvatarMatch.length >= 1) {
- res.image = youtubeAvatarMatch[1]
- }
-
- return res
-}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 7966767d1b..866d87aef0 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,3 +1,5 @@
export type Gate =
// Keep this alphabetic please.
- 'debug_show_feedcontext' | 'suggested_feeds_interstitial'
+ | 'debug_show_feedcontext'
+ | 'post_feed_lang_window'
+ | 'suggested_feeds_interstitial'
diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts
index 899d8ebce4..7d00c5e7f5 100644
--- a/src/lib/strings/errors.ts
+++ b/src/lib/strings/errors.ts
@@ -20,11 +20,19 @@ export function cleanError(str: any): string {
return str
}
+const NETWORK_ERRORS = [
+ 'Abort',
+ 'Network request failed',
+ 'Failed to fetch',
+ 'Load failed',
+]
+
export function isNetworkError(e: unknown) {
const str = String(e)
- return (
- str.includes('Abort') ||
- str.includes('Network request failed') ||
- str.includes('Failed to fetch')
- )
+ for (const err of NETWORK_ERRORS) {
+ if (str.includes(err)) {
+ return true
+ }
+ }
+ return false
}
diff --git a/src/logger/index.ts b/src/logger/index.ts
index d6d8d9fc1d..98635c6a97 100644
--- a/src/logger/index.ts
+++ b/src/logger/index.ts
@@ -1,10 +1,11 @@
import format from 'date-fns/format'
import {nanoid} from 'nanoid/non-secure'
-import {Sentry} from '#/logger/sentry'
-import * as env from '#/env'
import {DebugContext} from '#/logger/debugContext'
import {add} from '#/logger/logDump'
+import {Sentry} from '#/logger/sentry'
+import {isNetworkError} from 'lib/strings/errors'
+import * as env from '#/env'
export enum LogLevel {
Debug = 'debug',
@@ -160,6 +161,11 @@ export const sentryTransport: Transport = (
timestamp: timestamp / 1000, // Sentry expects seconds
})
+ // We don't want to send any network errors to sentry
+ if (isNetworkError(message)) {
+ return
+ }
+
/**
* Send all higher levels with `captureMessage`, with appropriate severity
* level
diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx
index e9668b4e11..11c071082b 100644
--- a/src/screens/Messages/List/ChatListItem.tsx
+++ b/src/screens/Messages/List/ChatListItem.tsx
@@ -24,8 +24,9 @@ import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
-import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import * as tokens from '#/alf/tokens'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Link} from '#/components/Link'
@@ -203,6 +204,19 @@ function ChatListItemReady({
onFocus={onFocus}
onBlur={onMouseLeave}
style={[a.relative]}>
+
+
+
+
-
+ {/* Avatar goes here */}
+
@@ -357,7 +368,7 @@ function ChatListItemReady({
a.self_end,
a.justify_center,
{
- right: a.px_lg.paddingRight,
+ right: tokens.space.lg,
opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
},
]}
diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx
index e30162c3af..bcc56d7f66 100644
--- a/src/screens/Profile/Header/DisplayName.tsx
+++ b/src/screens/Profile/Header/DisplayName.tsx
@@ -5,7 +5,7 @@ import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {Shadow} from '#/state/cache/types'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
export function ProfileHeaderDisplayName({
@@ -16,12 +16,19 @@ export function ProfileHeaderDisplayName({
moderation: ModerationDecision
}) {
const t = useTheme()
+ const {gtMobile} = useBreakpoints()
+
return (
+ style={[
+ t.atoms.text,
+ gtMobile ? a.text_4xl : a.text_3xl,
+ a.self_start,
+ {fontWeight: '600'},
+ ]}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
diff --git a/src/screens/Profile/Header/GrowableAvatar.tsx b/src/screens/Profile/Header/GrowableAvatar.tsx
new file mode 100644
index 0000000000..20ac14892d
--- /dev/null
+++ b/src/screens/Profile/Header/GrowableAvatar.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import Animated, {
+ Extrapolation,
+ interpolate,
+ SharedValue,
+ useAnimatedStyle,
+} from 'react-native-reanimated'
+
+import {isIOS} from '#/platform/detection'
+import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
+
+export function GrowableAvatar({
+ children,
+ style,
+}: {
+ children: React.ReactNode
+ style?: StyleProp
+}) {
+ const pagerContext = usePagerHeaderContext()
+
+ // pagerContext should only be present on iOS, but better safe than sorry
+ if (!pagerContext || !isIOS) {
+ return {children}
+ }
+
+ const {scrollY} = pagerContext
+
+ return (
+
+ {children}
+
+ )
+}
+
+function GrowableAvatarInner({
+ scrollY,
+ children,
+ style,
+}: {
+ scrollY: SharedValue
+ children: React.ReactNode
+ style?: StyleProp
+}) {
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ scale: interpolate(scrollY.value, [-150, 0], [1.2, 1], {
+ extrapolateRight: Extrapolation.CLAMP,
+ }),
+ },
+ ],
+ }))
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/screens/Profile/Header/GrowableBanner.tsx b/src/screens/Profile/Header/GrowableBanner.tsx
new file mode 100644
index 0000000000..e1bb8e00ef
--- /dev/null
+++ b/src/screens/Profile/Header/GrowableBanner.tsx
@@ -0,0 +1,212 @@
+import React, {useEffect, useState} from 'react'
+import {View} from 'react-native'
+import {ActivityIndicator} from 'react-native'
+import Animated, {
+ Extrapolation,
+ interpolate,
+ runOnJS,
+ SharedValue,
+ useAnimatedProps,
+ useAnimatedReaction,
+ useAnimatedStyle,
+} from 'react-native-reanimated'
+import {BlurView} from 'expo-blur'
+import {useIsFetching} from '@tanstack/react-query'
+
+import {isIOS} from '#/platform/detection'
+import {RQKEY_ROOT as STARTERPACK_RQKEY_ROOT} from '#/state/queries/actor-starter-packs'
+import {RQKEY_ROOT as FEED_RQKEY_ROOT} from '#/state/queries/post-feed'
+import {RQKEY_ROOT as FEEDGEN_RQKEY_ROOT} from '#/state/queries/profile-feedgens'
+import {RQKEY_ROOT as LIST_RQKEY_ROOT} from '#/state/queries/profile-lists'
+import {usePagerHeaderContext} from '#/view/com/pager/PagerHeaderContext'
+import {atoms as a} from '#/alf'
+
+const AnimatedBlurView = Animated.createAnimatedComponent(BlurView)
+
+export function GrowableBanner({
+ backButton,
+ children,
+}: {
+ backButton?: React.ReactNode
+ children: React.ReactNode
+}) {
+ const pagerContext = usePagerHeaderContext()
+
+ // pagerContext should only be present on iOS, but better safe than sorry
+ if (!pagerContext || !isIOS) {
+ return (
+
+ {backButton}
+ {children}
+
+ )
+ }
+
+ const {scrollY} = pagerContext
+
+ return (
+
+ {children}
+
+ )
+}
+
+function GrowableBannerInner({
+ scrollY,
+ backButton,
+ children,
+}: {
+ scrollY: SharedValue
+ backButton?: React.ReactNode
+ children: React.ReactNode
+}) {
+ const isFetching = useIsProfileFetching()
+ const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY})
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ scale: interpolate(scrollY.value, [-150, 0], [2, 1], {
+ extrapolateRight: Extrapolation.CLAMP,
+ }),
+ },
+ ],
+ }))
+
+ const animatedBlurViewProps = useAnimatedProps(() => {
+ return {
+ intensity: interpolate(
+ scrollY.value,
+ [-300, -65, -15],
+ [50, 40, 0],
+ Extrapolation.CLAMP,
+ ),
+ }
+ })
+
+ const animatedSpinnerStyle = useAnimatedStyle(() => {
+ return {
+ display: scrollY.value < 0 ? 'flex' : 'none',
+ opacity: interpolate(
+ scrollY.value,
+ [-60, -15],
+ [1, 0],
+ Extrapolation.CLAMP,
+ ),
+ transform: [
+ {translateY: interpolate(scrollY.value, [-150, 0], [-75, 0])},
+ {rotate: '90deg'},
+ ],
+ }
+ })
+
+ const animatedBackButtonStyle = useAnimatedStyle(() => ({
+ transform: [
+ {
+ translateY: interpolate(scrollY.value, [-150, 60], [-150, 60], {
+ extrapolateRight: Extrapolation.CLAMP,
+ }),
+ },
+ ],
+ }))
+
+ return (
+ <>
+
+ {children}
+
+
+
+
+
+
+
+
+ {backButton}
+
+ >
+ )
+}
+
+function useIsProfileFetching() {
+ // are any of the profile-related queries fetching?
+ return [
+ useIsFetching({queryKey: [FEED_RQKEY_ROOT]}),
+ useIsFetching({queryKey: [FEEDGEN_RQKEY_ROOT]}),
+ useIsFetching({queryKey: [LIST_RQKEY_ROOT]}),
+ useIsFetching({queryKey: [STARTERPACK_RQKEY_ROOT]}),
+ ].some(isFetching => isFetching)
+}
+
+function useShouldAnimateSpinner({
+ isFetching,
+ scrollY,
+}: {
+ isFetching: boolean
+ scrollY: SharedValue
+}) {
+ const [isOverscrolled, setIsOverscrolled] = useState(false)
+ // HACK: it reports a scroll pos of 0 for a tick when fetching finishes
+ // so paper over that by keeping it true for a bit -sfn
+ const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10)
+
+ useAnimatedReaction(
+ () => scrollY.value < -5,
+ (value, prevValue) => {
+ if (value !== prevValue) {
+ runOnJS(setIsOverscrolled)(value)
+ }
+ },
+ [scrollY],
+ )
+
+ const [isAnimating, setIsAnimating] = useState(isFetching)
+
+ if (isFetching && !isAnimating) {
+ setIsAnimating(true)
+ }
+
+ if (!isFetching && isAnimating && !stickyIsOverscrolled) {
+ setIsAnimating(false)
+ }
+
+ return isAnimating
+}
+
+// stayed true for at least `delay` ms before returning to false
+function useStickyToggle(value: boolean, delay: number) {
+ const [prevValue, setPrevValue] = useState(value)
+ const [isSticking, setIsSticking] = useState(false)
+
+ useEffect(() => {
+ if (isSticking) {
+ const timeout = setTimeout(() => setIsSticking(false), delay)
+ return () => clearTimeout(timeout)
+ }
+ }, [isSticking, delay])
+
+ if (value !== prevValue) {
+ setIsSticking(prevValue) // Going true -> false should stick.
+ setPrevValue(value)
+ return prevValue ? true : value
+ }
+
+ return isSticking ? true : value
+}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
index 90c2830907..f7011fd359 100644
--- a/src/screens/Profile/Header/Shell.tsx
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -6,19 +6,21 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
+import {BACK_HITSLOP} from '#/lib/constants'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {NavigationProp} from '#/lib/routes/types'
+import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox'
import {useSession} from '#/state/session'
-import {BACK_HITSLOP} from 'lib/constants'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {NavigationProp} from 'lib/routes/types'
-import {isIOS} from 'platform/detection'
-import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
-import {UserAvatar} from 'view/com/util/UserAvatar'
-import {UserBanner} from 'view/com/util/UserBanner'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {UserBanner} from '#/view/com/util/UserBanner'
import {atoms as a, useTheme} from '#/alf'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
+import {GrowableAvatar} from './GrowableAvatar'
+import {GrowableBanner} from './GrowableBanner'
interface Props {
profile: Shadow
@@ -63,20 +65,45 @@ let ProfileHeaderShell = ({
return (
-
- {isPlaceholderProfile ? (
-
- ) : (
-
- )}
+
+
+ {!isDesktop && !hideBackButton && (
+
+
+
+
+
+ )}
+ >
+ }>
+ {isPlaceholderProfile ? (
+
+ ) : (
+
+ )}
+
{children}
@@ -93,40 +120,29 @@ let ProfileHeaderShell = ({
)}
- {!isDesktop && !hideBackButton && (
+
-
-
+
+
- )}
-
-
-
-
-
+
)
}
@@ -144,6 +160,9 @@ const styles = StyleSheet.create({
borderRadius: 15,
// @ts-ignore web only
cursor: 'pointer',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ alignItems: 'center',
+ justifyContent: 'center',
},
backBtn: {
width: 30,
@@ -152,10 +171,12 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
- avi: {
+ aviPosition: {
position: 'absolute',
top: 110,
left: 10,
+ },
+ avi: {
width: 94,
height: 94,
borderRadius: 47,
diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx
index c7ef34b701..cdb0667d06 100644
--- a/src/screens/Profile/Header/index.tsx
+++ b/src/screens/Profile/Header/index.tsx
@@ -7,18 +7,22 @@ import {
RichText as RichTextAPI,
} from '@atproto/api'
-import {usePalette} from 'lib/hooks/usePalette'
-import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
+import {useTheme} from '#/alf'
import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
import {ProfileHeaderStandard} from './ProfileHeaderStandard'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
- const pal = usePalette('default')
+ const t = useTheme()
return (
-
+
+ style={[
+ t.atoms.bg,
+ {borderColor: t.atoms.bg.backgroundColor},
+ styles.avi,
+ ]}>
diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx
index fc4eff02c8..22ac5df9a7 100644
--- a/src/screens/Profile/Sections/Feed.tsx
+++ b/src/screens/Profile/Sections/Feed.tsx
@@ -4,17 +4,18 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {usePalette} from '#/lib/hooks/usePalette'
import {isNative} from '#/platform/detection'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util'
-import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
-import {usePalette} from 'lib/hooks/usePalette'
+import {Feed} from '#/view/com/posts/Feed'
+import {EmptyState} from '#/view/com/util/EmptyState'
+import {ListRef} from '#/view/com/util/List'
+import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {Text} from '#/view/com/util/text/Text'
-import {Feed} from 'view/com/posts/Feed'
-import {EmptyState} from 'view/com/util/EmptyState'
-import {ListRef} from 'view/com/util/List'
-import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
+import {ios} from '#/alf'
import {SectionRef} from './types'
interface FeedSectionProps {
@@ -82,6 +83,7 @@ export const ProfileFeedSection = React.forwardRef<
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
+ progressViewOffset={ios(0)}
renderEndOfFeed={ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
initialNumToRender={
diff --git a/src/screens/Search/__tests__/utils.test.ts b/src/screens/Search/__tests__/utils.test.ts
new file mode 100644
index 0000000000..81610cc59a
--- /dev/null
+++ b/src/screens/Search/__tests__/utils.test.ts
@@ -0,0 +1,43 @@
+import {describe, expect, it} from '@jest/globals'
+
+import {parseSearchQuery} from '#/screens/Search/utils'
+
+describe(`parseSearchQuery`, () => {
+ const tests = [
+ {
+ input: `bluesky`,
+ output: {query: `bluesky`, params: {}},
+ },
+ {
+ input: `bluesky from:esb.lol`,
+ output: {query: `bluesky`, params: {from: `esb.lol`}},
+ },
+ {
+ input: `bluesky "from:esb.lol"`,
+ output: {query: `bluesky "from:esb.lol"`, params: {}},
+ },
+ {
+ input: `bluesky mentions:@esb.lol`,
+ output: {query: `bluesky`, params: {mentions: `@esb.lol`}},
+ },
+ {
+ input: `bluesky since:2021-01-01:00:00:00`,
+ output: {query: `bluesky`, params: {since: `2021-01-01:00:00:00`}},
+ },
+ {
+ input: `bluesky lang:"en"`,
+ output: {query: `bluesky`, params: {lang: `en`}},
+ },
+ {
+ input: `bluesky "literal" lang:en "from:invalid"`,
+ output: {query: `bluesky "literal" "from:invalid"`, params: {lang: `en`}},
+ },
+ ]
+
+ it.each(tests)(
+ `$input -> $output.query $output.params`,
+ ({input, output}) => {
+ expect(parseSearchQuery(input)).toEqual(output)
+ },
+ )
+})
diff --git a/src/screens/Search/utils.ts b/src/screens/Search/utils.ts
new file mode 100644
index 0000000000..dcf92c0926
--- /dev/null
+++ b/src/screens/Search/utils.ts
@@ -0,0 +1,43 @@
+export type Params = Record
+
+export function parseSearchQuery(rawQuery: string) {
+ let base = rawQuery
+ const rawLiterals = rawQuery.match(/[^:\w\d]".+?"/gi) || []
+
+ // remove literals from base
+ for (const literal of rawLiterals) {
+ base = base.replace(literal.trim(), '')
+ }
+
+ // find remaining params in base
+ const rawParams = base.match(/[a-z]+:[a-z-\.@\d:"]+/gi) || []
+
+ for (const param of rawParams) {
+ base = base.replace(param, '')
+ }
+
+ base = base.trim()
+
+ const params = rawParams.reduce((params, param) => {
+ const [name, ...value] = param.split(/:/)
+ params[name] = value.join(':').replace(/"/g, '') // dates can contain additional colons
+ return params
+ }, {} as Params)
+ const literals = rawLiterals.map(l => String(l).trim())
+
+ return {
+ query: [base, literals.join(' ')].filter(Boolean).join(' '),
+ params,
+ }
+}
+
+export function makeSearchQuery(query: string, params: Params) {
+ return [
+ query,
+ Object.entries(params)
+ .map(([name, value]) => `${name}:${value}`)
+ .join(' '),
+ ]
+ .filter(Boolean)
+ .join(' ')
+}
diff --git a/src/state/queries/actor-starter-packs.ts b/src/state/queries/actor-starter-packs.ts
index 9de80b07de..487bcdfd99 100644
--- a/src/state/queries/actor-starter-packs.ts
+++ b/src/state/queries/actor-starter-packs.ts
@@ -6,9 +6,9 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
-import {useAgent} from 'state/session'
+import {useAgent} from '#/state/session'
-const RQKEY_ROOT = 'actor-starter-packs'
+export const RQKEY_ROOT = 'actor-starter-packs'
export const RQKEY = (did?: string) => [RQKEY_ROOT, did]
export function useActorStarterPacksQuery({did}: {did?: string}) {
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 7daf441adb..07c5da81b7 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -15,24 +15,25 @@ import {
useInfiniteQuery,
} from '@tanstack/react-query'
+import {AuthorFeedAPI} from '#/lib/api/feed/author'
+import {CustomFeedAPI} from '#/lib/api/feed/custom'
+import {FollowingFeedAPI} from '#/lib/api/feed/following'
import {HomeFeedAPI} from '#/lib/api/feed/home'
+import {LikesFeedAPI} from '#/lib/api/feed/likes'
+import {ListFeedAPI} from '#/lib/api/feed/list'
+import {MergeFeedAPI} from '#/lib/api/feed/merge'
+import {FeedAPI, ReasonFeedSource} from '#/lib/api/feed/types'
import {aggregateUserInterests} from '#/lib/api/feed/utils'
+import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants'
+import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory'
-import {AuthorFeedAPI} from 'lib/api/feed/author'
-import {CustomFeedAPI} from 'lib/api/feed/custom'
-import {FollowingFeedAPI} from 'lib/api/feed/following'
-import {LikesFeedAPI} from 'lib/api/feed/likes'
-import {ListFeedAPI} from 'lib/api/feed/list'
-import {MergeFeedAPI} from 'lib/api/feed/merge'
-import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
-import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
-import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
import {useModerationOpts} from '../preferences/moderation-opts'
@@ -65,7 +66,7 @@ export interface FeedParams {
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
-const RQKEY_ROOT = 'post-feed'
+export const RQKEY_ROOT = 'post-feed'
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
return [RQKEY_ROOT, feedDesc, params || {}]
}
@@ -109,13 +110,19 @@ export interface FeedPage {
fetchedAt: number
}
-const PAGE_SIZE = 30
+/**
+ * The minimum number of posts we want in a single "page" of results. Since we
+ * filter out unwanted content, we may fetch more than this number to ensure
+ * that we get _at least_ this number.
+ */
+const MIN_POSTS = 30
export function usePostFeedQuery(
feedDesc: FeedDescriptor,
params?: FeedParams,
opts?: {enabled?: boolean; ignoreFilterFor?: string},
) {
+ const gate = useGate()
const feedTuners = useFeedTuners(feedDesc)
const moderationOpts = useModerationOpts()
const {data: preferences} = usePreferencesQuery()
@@ -135,6 +142,13 @@ export function usePostFeedQuery(
} | null>(null)
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
+ /**
+ * 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`.
+ */
+ const fetchLimit = gate('post_feed_lang_window') ? 100 : MIN_POSTS
+
// Make sure this doesn't invalidate unless really needed.
const selectArgs = React.useMemo(
() => ({
@@ -175,7 +189,7 @@ export function usePostFeedQuery(
}
try {
- const res = await api.fetch({cursor, limit: PAGE_SIZE})
+ const res = await api.fetch({cursor, limit: fetchLimit})
/*
* If this is a public view, we need to check if posts fail moderation.
@@ -373,13 +387,13 @@ export function usePostFeedQuery(
// Now track how many items we really want, and fetch more if needed.
if (isLoading || isRefetching) {
// During the initial fetch, we want to get an entire page's worth of items.
- wantedItemCount.current = PAGE_SIZE
+ wantedItemCount.current = MIN_POSTS
} else if (isFetchingNextPage) {
if (itemCount > wantedItemCount.current) {
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
// Some other code must have called fetchNextPage(), for example, from onEndReached.
// Adjust the wantedItemCount to reflect that we want one more full page of items.
- wantedItemCount.current = itemCount + PAGE_SIZE
+ wantedItemCount.current = itemCount + MIN_POSTS
}
} else if (hasNextPage) {
// At this point we're not fetching anymore, so it's time to make a decision.
diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts
index b50a2a2890..79d9735c98 100644
--- a/src/state/queries/profile-feedgens.ts
+++ b/src/state/queries/profile-feedgens.ts
@@ -8,7 +8,7 @@ const PAGE_SIZE = 50
type RQPageParam = string | undefined
// TODO refactor invalidate on mutate?
-const RQKEY_ROOT = 'profile-feedgens'
+export const RQKEY_ROOT = 'profile-feedgens'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileFeedgensQuery(
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
index 03c983ff80..5c9f9f0d6f 100644
--- a/src/state/queries/profile-lists.ts
+++ b/src/state/queries/profile-lists.ts
@@ -7,7 +7,7 @@ import {useModerationOpts} from '../preferences/moderation-opts'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
-const RQKEY_ROOT = 'profile-lists'
+export const RQKEY_ROOT = 'profile-lists'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index 6f98cc49a4..693a8e361a 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -15,9 +15,9 @@ import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
+import {EmptyState} from '#/view/com/util/EmptyState'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import {EmptyState} from 'view/com/util/EmptyState'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, ios, useTheme} from '#/alf'
import * as FeedCard from '#/components/FeedCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
@@ -191,6 +191,7 @@ export const ProfileFeedgens = React.forwardRef<
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
+ progressViewOffset={ios(0)}
contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true}
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index f633774c7a..117164413f 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -10,14 +10,14 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
+import {useAnalytics} from '#/lib/analytics/analytics'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
-import {useAnalytics} from 'lib/analytics/analytics'
+import {EmptyState} from '#/view/com/util/EmptyState'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
-import {EmptyState} from 'view/com/util/EmptyState'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, ios, useTheme} from '#/alf'
import * as ListCard from '#/components/ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
@@ -192,6 +192,7 @@ export const ProfileLists = React.forwardRef(
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
+ progressViewOffset={ios(0)}
contentContainerStyle={
isNative && {paddingBottom: headerOffset + 100}
}
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 5fbaaa1550..3c1f51249f 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -625,7 +625,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
},
layoutIcon: {
- width: 70,
+ width: 60,
alignItems: 'flex-end',
paddingTop: 2,
},
diff --git a/src/view/com/pager/PagerHeaderContext.tsx b/src/view/com/pager/PagerHeaderContext.tsx
new file mode 100644
index 0000000000..fd4cc74632
--- /dev/null
+++ b/src/view/com/pager/PagerHeaderContext.tsx
@@ -0,0 +1,41 @@
+import React, {useContext} from 'react'
+import {SharedValue} from 'react-native-reanimated'
+
+import {isIOS} from '#/platform/detection'
+
+export const PagerHeaderContext =
+ React.createContext | 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.
+ *
+ * @platform ios
+ */
+export function PagerHeaderProvider({
+ scrollY,
+ children,
+}: {
+ scrollY: SharedValue
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function usePagerHeaderContext() {
+ const scrollY = useContext(PagerHeaderContext)
+ if (isIOS) {
+ if (!scrollY) {
+ throw new Error(
+ 'usePagerHeaderContext must be used within a HeaderProvider',
+ )
+ }
+ return {scrollY}
+ } else {
+ return null
+ }
+}
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 7b1d8b78f4..528f7fdf2e 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -19,9 +19,10 @@ import Animated, {
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 {isIOS} from '#/platform/detection'
+import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
import {ListMethods} from '../util/List'
+import {PagerHeaderProvider} from './PagerHeaderContext'
import {TabBar} from './TabBar'
export interface PagerWithHeaderChildParams {
@@ -41,6 +42,7 @@ export interface PagerWithHeaderProps {
initialPage?: number
onPageSelected?: (index: number) => void
onCurrentPageSelected?: (index: number) => void
+ allowHeaderOverScroll?: boolean
}
export const PagerWithHeader = React.forwardRef(
function PageWithHeaderImpl(
@@ -53,6 +55,7 @@ export const PagerWithHeader = React.forwardRef(
initialPage,
onPageSelected,
onCurrentPageSelected,
+ allowHeaderOverScroll,
}: PagerWithHeaderProps,
ref,
) {
@@ -80,19 +83,22 @@ export const PagerWithHeader = React.forwardRef(
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
-
+
+
+
)
},
[
@@ -106,6 +112,7 @@ export const PagerWithHeader = React.forwardRef(
onHeaderOnlyLayout,
scrollY,
testID,
+ allowHeaderOverScroll,
],
)
@@ -216,6 +223,7 @@ let PagerTabBar = ({
onTabBarLayout,
onCurrentPageSelected,
onSelect,
+ allowHeaderOverScroll,
}: {
currentPage: number
headerOnlyHeight: number
@@ -228,14 +236,20 @@ let PagerTabBar = ({
onTabBarLayout: (e: LayoutChangeEvent) => void
onCurrentPageSelected?: (index: number) => void
onSelect?: (index: number) => void
+ allowHeaderOverScroll?: boolean
}): React.ReactNode => {
- const headerTransform = useAnimatedStyle(() => ({
- transform: [
- {
- translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0),
- },
- ],
- }))
+ const headerTransform = useAnimatedStyle(() => {
+ const translateY = Math.min(scrollY.value, headerOnlyHeight) * -1
+ return {
+ transform: [
+ {
+ translateY: allowHeaderOverScroll
+ ? translateY
+ : Math.min(translateY, 0),
+ },
+ ],
+ }
+ })
const headerRef = React.useRef(null)
return (
{
+ playHaptics('Light')
+ setTimeout(
+ () => {
+ onPressCompose()
+ },
+ isHapticsDisabled ? 0 : 75,
+ )
+ }
+
return (
- onPressCompose()}
+
-
-
+
- Write your reply
-
-
+
+
+ Write your reply
+
+
+
)
}
-
-const styles = StyleSheet.create({
- prompt: {
- paddingHorizontal: 16,
- paddingTop: 10,
- paddingBottom: 10,
- flexDirection: 'row',
- alignItems: 'center',
- borderTopWidth: StyleSheet.hairlineWidth,
- },
- labelMobile: {
- paddingLeft: 12,
- },
- labelDesktopWeb: {
- paddingLeft: 12,
- },
-})
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index d712605d6e..498ab48ee6 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -14,8 +14,11 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
+import {useAnalytics} from '#/lib/analytics/analytics'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {logEvent, useGate} from '#/lib/statsig/statsig'
+import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {listenPostCreated} from '#/state/events'
@@ -30,9 +33,6 @@ import {
usePostFeedQuery,
} from '#/state/queries/post-feed'
import {useSession} from '#/state/session'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
-import {useTheme} from 'lib/ThemeContext'
import {
ProgressGuide,
SuggestedFeeds,
@@ -167,6 +167,7 @@ let Feed = ({
renderEndOfFeed,
testID,
headerOffset = 0,
+ progressViewOffset,
desktopFixedHeightOffset,
ListHeaderComponent,
extraData,
@@ -187,6 +188,7 @@ let Feed = ({
renderEndOfFeed?: () => JSX.Element
testID?: string
headerOffset?: number
+ progressViewOffset?: number
desktopFixedHeightOffset?: number
ListHeaderComponent?: () => JSX.Element
extraData?: any
@@ -548,6 +550,7 @@ let Feed = ({
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
+ progressViewOffset={progressViewOffset}
contentContainerStyle={{
minHeight: Dimensions.get('window').height * 1.5,
}}
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index f9aeae1a86..53b0547d45 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -4,11 +4,11 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
-import {usePalette} from '#/lib/hooks/usePalette'
+import {useDedupe} from '#/lib/hooks/useDedupe'
import {useScrollHandlers} from '#/lib/ScrollContext'
-import {useDedupe} from 'lib/hooks/useDedupe'
-import {addStyle} from 'lib/styles'
-import {isIOS} from 'platform/detection'
+import {addStyle} from '#/lib/styles'
+import {isIOS} from '#/platform/detection'
+import {useTheme} from '#/alf'
import {FlatList_INTERNAL} from './Views'
export type ListMethods = FlatList_INTERNAL
@@ -44,12 +44,13 @@ function ListImpl(
onItemSeen,
headerOffset,
style,
+ progressViewOffset,
...props
}: ListProps,
ref: React.Ref,
) {
const isScrolledDown = useSharedValue(false)
- const pal = usePalette('default')
+ const t = useTheme()
const dedupe = useDedupe(400)
function handleScrolledDownChange(didScrollDown: boolean) {
@@ -120,9 +121,9 @@ function ListImpl(
)
}
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 76d9d1503e..2b4376b698 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -327,7 +327,8 @@ let EditableUserAvatar = ({
onSelectNewAvatar(croppedImage)
} catch (e: any) {
- if (!String(e).includes('Canceled')) {
+ // Don't log errors for cancelling selection to sentry on ios or android
+ if (!String(e).toLowerCase().includes('cancel')) {
logger.error('Failed to crop banner', {error: e})
}
}
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index 13f4081fce..0e07a57454 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -202,7 +202,7 @@ const styles = StyleSheet.create({
},
bannerImage: {
width: '100%',
- height: 150,
+ height: '100%',
},
defaultBanner: {
backgroundColor: '#0070ff',
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index b49c49e4ab..fa2b7e3d3f 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -1,7 +1,7 @@
import React, {useEffect, useId, useRef, useState} from 'react'
import {View} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api'
-import Hls, {Events, FragChangedData, Fragment} from 'hls.js'
+import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf'
@@ -23,6 +23,7 @@ export function VideoEmbedInnerWeb({
const videoRef = useRef(null)
const [focused, setFocused] = useState(false)
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
+ const [hlsLoading, setHlsLoading] = React.useState(false)
const figId = useId()
// send error up to error boundary
@@ -37,6 +38,7 @@ export function VideoEmbedInnerWeb({
setHasSubtitleTrack,
setError,
videoRef,
+ setHlsLoading,
})
return (
@@ -77,6 +79,7 @@ export function VideoEmbedInnerWeb({
setActive={setActive}
focused={focused}
setFocused={setFocused}
+ hlsLoading={hlsLoading}
onScreen={onScreen}
fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack}
@@ -99,31 +102,62 @@ export class VideoNotFoundError extends Error {
}
}
+type CachedPromise = Promise & {value: undefined | T}
+const promiseForHls = import(
+ // @ts-ignore
+ 'hls.js/dist/hls.min'
+).then(mod => mod.default) as CachedPromise
+promiseForHls.value = undefined
+promiseForHls.then(Hls => {
+ promiseForHls.value = Hls
+})
+
function useHLS({
focused,
playlist,
setHasSubtitleTrack,
setError,
videoRef,
+ setHlsLoading,
}: {
focused: boolean
playlist: string
setHasSubtitleTrack: (v: boolean) => void
setError: (v: Error | null) => void
videoRef: React.RefObject
+ setHlsLoading: (v: boolean) => void
}) {
- const hlsRef = useRef(undefined)
- const [lowQualityFragments, setLowQualityFragments] = useState([])
+ const [Hls, setHls] = useState(
+ () => promiseForHls.value,
+ )
+ useEffect(() => {
+ if (!Hls) {
+ setHlsLoading(true)
+ promiseForHls.then(loadedHls => {
+ setHls(() => loadedHls)
+ setHlsLoading(false)
+ })
+ }
+ }, [Hls, setHlsLoading])
+
+ const hlsRef = useRef(undefined)
+ const [lowQualityFragments, setLowQualityFragments] = useState<
+ HlsTypes.Fragment[]
+ >([])
// purge low quality segments from buffer on next frag change
const handleFragChange = useNonReactiveCallback(
- (_event: Events.FRAG_CHANGED, {frag}: FragChangedData) => {
+ (
+ _event: HlsTypes.Events.FRAG_CHANGED,
+ {frag}: HlsTypes.FragChangedData,
+ ) => {
+ if (!Hls) return
if (!hlsRef.current) return
const hls = hlsRef.current
if (focused && hls.nextAutoLevel > 0) {
// if the current quality level goes above 0, flush the low quality segments
- const flushed: Fragment[] = []
+ const flushed: HlsTypes.Fragment[] = []
for (const lowQualFrag of lowQualityFragments) {
// avoid if close to the current fragment
@@ -147,12 +181,15 @@ function useHLS({
useEffect(() => {
if (!videoRef.current) return
- if (!Hls.isSupported()) throw new HLSUnsupportedError()
+ if (!Hls) return
+ if (!Hls.isSupported()) {
+ throw new HLSUnsupportedError()
+ }
const hls = new Hls({
maxMaxBufferLength: 10, // only load 10s ahead
// note: the amount buffered is affected by both maxBufferLength and maxBufferSize
- // it will buffer until it it's greater than *both* of those values
+ // it will buffer until it is greater than *both* of those values
// so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
})
hlsRef.current = hls
@@ -211,7 +248,7 @@ function useHLS({
hls.destroy()
abortController.abort()
}
- }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange])
+ }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls])
return hlsRef
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
index 2d1427347d..dd0dafc335 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/web-controls/VideoControls.tsx
@@ -43,6 +43,7 @@ export function Controls({
setFocused,
onScreen,
fullscreenRef,
+ hlsLoading,
hasSubtitleTrack,
}: {
videoRef: React.RefObject
@@ -53,6 +54,7 @@ export function Controls({
setFocused: (focused: boolean) => void
onScreen: boolean
fullscreenRef: React.RefObject
+ hlsLoading: boolean
hasSubtitleTrack: boolean
}) {
const {
@@ -80,6 +82,7 @@ export function Controls({
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
+ const showSpinner = hlsLoading || buffering
const {
state: volumeHovered,
onIn: onVolumeHover,
@@ -409,11 +412,11 @@ export function Controls({
)}
- {(buffering || error) && (
+ {(showSpinner || error) && (
- {buffering && }
+ {showSpinner && }
{error && (
An error occurred
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 879632e9ef..b37445fad6 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -37,11 +37,11 @@ import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer'
import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
import {ProfileLists} from '#/view/com/lists/ProfileLists'
+import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {FAB} from '#/view/com/util/fab/FAB'
import {ListRef} from '#/view/com/util/List'
import {CenteredView} from '#/view/com/util/Views'
-import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
@@ -363,7 +363,8 @@ function ProfileScreenLoaded({
items={sectionTitles}
onPageSelected={onPageSelected}
onCurrentPageSelected={onCurrentPageSelected}
- renderHeader={renderHeader}>
+ renderHeader={renderHeader}
+ allowHeaderOverScroll>
{showFiltersTab
? ({headerHeight, isFocused, scrollElRef}) => (
{
+function SearchLanguageDropdown({
+ value,
+ onChange,
+}: {
+ value: string
+ onChange(value: string): void
+}) {
+ const t = useThemeNew()
+ const {contentLanguages} = useLanguagePrefs()
+
+ const items = React.useMemo(() => {
+ return LANGUAGES.filter(l => Boolean(l.code2))
+ .map(l => ({
+ label: l.name,
+ inputLabel: l.name,
+ value: l.code2,
+ key: l.code2 + l.code3,
+ }))
+ .sort(a => (contentLanguages.includes(a.value) ? -1 : 1))
+ }, [contentLanguages])
+
+ const style = {
+ backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
+ color: t.atoms.text.color,
+ fontSize: a.text_xs.fontSize,
+ fontFamily: 'inherit',
+ fontWeight: a.font_bold.fontWeight,
+ paddingHorizontal: 14,
+ paddingRight: 32,
+ paddingVertical: 8,
+ borderRadius: a.rounded_full.borderRadius,
+ borderWidth: a.border.borderWidth,
+ borderColor: t.atoms.border_contrast_low.borderColor,
+ }
+
+ return (
+ (
+
+ )}
+ useNativeAndroidPickerStyle={false}
+ style={{
+ iconContainer: {
+ pointerEvents: 'none',
+ right: a.px_sm.paddingRight,
+ top: 0,
+ bottom: 0,
+ display: 'flex',
+ justifyContent: 'center',
+ },
+ inputAndroid: {
+ ...style,
+ paddingVertical: 2,
+ },
+ inputIOS: {
+ ...style,
+ },
+ inputWeb: web({
+ ...style,
+ cursor: 'pointer',
+ // @ts-ignore web only
+ '-moz-appearance': 'none',
+ '-webkit-appearance': 'none',
+ appearance: 'none',
+ outline: 0,
+ borderWidth: 0,
+ overflow: 'hidden',
+ whiteSpace: 'nowrap',
+ textOverflow: 'ellipsis',
+ }),
+ }}
+ />
+ )
+}
+
+function useQueryManager({initialQuery}: {initialQuery: string}) {
+ const {contentLanguages} = useLanguagePrefs()
+ const {query, params: initialParams} = React.useMemo(() => {
+ return parseSearchQuery(initialQuery || '')
+ }, [initialQuery])
+ const prevInitialQuery = React.useRef(initialQuery)
+ const [lang, setLang] = React.useState(
+ initialParams.lang || contentLanguages[0],
+ )
+
+ if (initialQuery !== prevInitialQuery.current) {
+ // handle new queryParam change (from manual search entry)
+ prevInitialQuery.current = initialQuery
+ setLang(initialParams.lang || contentLanguages[0])
+ }
+
+ const params = React.useMemo(
+ () => ({
+ // default stuff
+ ...initialParams,
+ // managed stuff
+ lang,
+ }),
+ [lang, initialParams],
+ )
+ const handlers = React.useMemo(
+ () => ({
+ setLang,
+ }),
+ [setLang],
+ )
+
+ return React.useMemo(() => {
+ return {
+ query,
+ queryWithParams: makeSearchQuery(query, params),
+ params: {
+ ...params,
+ ...handlers,
+ },
+ }
+ }, [query, params, handlers])
+}
+
+let SearchScreenInner = ({
+ query,
+ queryWithParams,
+ headerHeight,
+}: {
+ query: string
+ queryWithParams: string
+ headerHeight: number
+}): React.ReactNode => {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
@@ -349,7 +489,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
title: _(msg`Top`),
component: (
@@ -359,7 +499,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
title: _(msg`Latest`),
component: (
@@ -378,7 +518,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
),
},
]
- }, [_, query, activeTab])
+ }, [_, query, queryWithParams, activeTab])
return query ? (
{
renderTabBar={props => (
+ style={[
+ pal.border,
+ pal.view,
+ web({
+ position: isWeb ? 'sticky' : '',
+ zIndex: 1,
+ }),
+ {top: isWeb ? headerHeight : undefined},
+ ]}>
section.title)} {...props} />
)}
@@ -448,14 +596,14 @@ SearchScreenInner = React.memo(SearchScreenInner)
export function SearchScreen(
props: NativeStackScreenProps,
) {
+ const t = useThemeNew()
+ const {gtMobile} = useBreakpoints()
const navigation = useNavigation()
const textInput = React.useRef(null)
const {_} = useLingui()
- const pal = usePalette('default')
const {track} = useAnalytics()
const setDrawerOpen = useSetDrawerOpen()
const setMinimalShellMode = useSetMinimalShellMode()
- const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
// Query terms
const queryParam = props.route?.params?.q ?? ''
@@ -469,6 +617,17 @@ export function SearchScreen(
AppBskyActorDefs.ProfileViewBasic[]
>([])
+ const {params, query, queryWithParams} = useQueryManager({
+ initialQuery: queryParam,
+ })
+ const showFiltersButton = Boolean(query && !showAutocomplete)
+ const [showFilters, setShowFilters] = React.useState(false)
+ /*
+ * Arbitrary sizing, so guess and check, used for sticky header alignment and
+ * sizing.
+ */
+ const headerHeight = 56 + (showFilters ? 40 : 0)
+
useFocusEffect(
useNonReactiveCallback(() => {
if (isWeb) {
@@ -507,13 +666,6 @@ export function SearchScreen(
textInput.current?.focus()
}, [])
- const onPressCancelSearch = React.useCallback(() => {
- scrollToTopWeb()
- textInput.current?.blur()
- setShowAutocomplete(false)
- setSearchText(queryParam)
- }, [queryParam])
-
const onChangeText = React.useCallback(async (text: string) => {
scrollToTopWeb()
setSearchText(text)
@@ -586,6 +738,13 @@ export function SearchScreen(
[updateSearchHistory, navigation],
)
+ const onPressCancelSearch = React.useCallback(() => {
+ scrollToTopWeb()
+ textInput.current?.blur()
+ setShowAutocomplete(false)
+ setSearchText(queryParam)
+ }, [setShowAutocomplete, setSearchText, queryParam])
+
const onSubmit = React.useCallback(() => {
navigateToItem(searchText)
}, [navigateToItem, searchText])
@@ -624,6 +783,7 @@ export function SearchScreen(
setSearchText('')
navigation.setParams({q: ''})
}
+ setShowFilters(false)
}, [navigation])
useFocusEffect(
@@ -663,50 +823,107 @@ export function SearchScreen(
[selectedProfiles],
)
+ const onSearchInputFocus = React.useCallback(() => {
+ if (isWeb) {
+ // Prevent a jump on iPad by ensuring that
+ // the initial focused render has no result list.
+ requestAnimationFrame(() => {
+ setShowAutocomplete(true)
+ })
+ } else {
+ setShowAutocomplete(true)
+ }
+ setShowFilters(false)
+ }, [setShowAutocomplete])
+
return (
- {isTabletOrMobile && (
-
-
-
- )}
-
- {showAutocomplete && (
-
-
+
+ {!gtMobile && (
+
+
+
+ )}
+
+ {showFiltersButton && (
+ setShowFilters(!showFilters)}
+ hitSlop={HITSLOP_10}
+ label={_(msg`Show advanced filters`)}
+ size="large"
+ variant="solid"
+ color="secondary"
+ shape="square">
+
+
+ )}
+ {showAutocomplete && (
+
-
+
Cancel
-
-
+
+
+ )}
+
+
+ {showFilters && (
+
+
+
+
)}
+
-
+
)
@@ -747,7 +968,7 @@ let SearchInputBox = ({
textInput,
searchText,
showAutocomplete,
- setShowAutocomplete,
+ onFocus,
onChangeText,
onSubmit,
onPressClearQuery,
@@ -755,83 +976,62 @@ let SearchInputBox = ({
textInput: React.RefObject
searchText: string
showAutocomplete: boolean
- setShowAutocomplete: (show: boolean) => void
+ onFocus: () => void
onChangeText: (text: string) => void
onSubmit: () => void
onPressClearQuery: () => void
}): React.ReactNode => {
- const pal = usePalette('default')
const {_} = useLingui()
- const theme = useTheme()
+ const t = useThemeNew()
+
return (
- {
- textInput.current?.focus()
- }}>
-
- {
- if (isWeb) {
- // Prevent a jump on iPad by ensuring that
- // the initial focused render has no result list.
- requestAnimationFrame(() => {
- setShowAutocomplete(true)
- })
- } else {
- setShowAutocomplete(true)
- }
- }}
- onChangeText={onChangeText}
- onSubmitEditing={onSubmit}
- autoFocus={false}
- accessibilityRole="search"
- accessibilityLabel={_(msg`Search`)}
- accessibilityHint=""
- autoCorrect={false}
- autoComplete="off"
- autoCapitalize="none"
- />
+
+
+
+
+
+
{showAutocomplete && searchText.length > 0 && (
-
-
-
+
+
+
+
+
)}
-
+
)
}
SearchInputBox = React.memo(SearchInputBox)
@@ -1029,21 +1229,7 @@ function scrollToTopWeb() {
}
}
-const HEADER_HEIGHT = 46
-
const styles = StyleSheet.create({
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 12,
- paddingLeft: 13,
- paddingVertical: 4,
- height: HEADER_HEIGHT,
- // @ts-ignore web only
- position: isWeb ? 'sticky' : '',
- top: 0,
- zIndex: 1,
- },
headerMenuBtn: {
width: 30,
height: 30,
@@ -1075,12 +1261,6 @@ const styles = StyleSheet.create({
zIndex: -1,
elevation: -1, // For Android
},
- tabBarContainer: {
- // @ts-ignore web only
- position: isWeb ? 'sticky' : '',
- top: isWeb ? HEADER_HEIGHT : 0,
- zIndex: 1,
- },
searchHistoryContainer: {
width: '100%',
paddingHorizontal: 12,
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
index 737ca2d28a..73bfaa83ef 100644
--- a/src/view/screens/Settings/index.tsx
+++ b/src/view/screens/Settings/index.tsx
@@ -94,10 +94,14 @@ function SettingsAccountCard({
/>
-
+
{profile?.displayName || account.handle}
-
+
{account.handle}
diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx
index fc414d31f3..8ec118ae3e 100644
--- a/src/view/screens/Storybook/Forms.tsx
+++ b/src/view/screens/Storybook/Forms.tsx
@@ -32,7 +32,7 @@ export function Forms() {
label="Text field"
/>
-
+
,
+ ComponentProps,
| 'accessible'
| 'accessibilityRole'
| 'accessibilityHint'
@@ -350,7 +352,7 @@ function Btn({
accessibilityLabel,
}: BtnProps) {
return (
-
+ accessibilityHint={accessibilityHint}
+ targetScale={0.8}
+ contentContainerStyle={[a.flex_1]}>
{icon}
{notificationCount ? (
-
+
{notificationCount}
) : undefined}
-
+
)
}
diff --git a/yarn.lock b/yarn.lock
index 17fe862372..db8a707a40 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -12165,6 +12165,11 @@ expo-asset@~10.0.6:
invariant "^2.2.4"
md5-file "^3.2.3"
+expo-blur@^13.0.2:
+ version "13.0.2"
+ resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-13.0.2.tgz#c2d179b19b13830db1d8b90c51373235f462e958"
+ integrity sha512-t2p7BChO3Reykued++QJRMZ/og6J3aXtSQ+bU31YcBeXhZLkHwjWEhiPKPnJka7J2/yTs4+jOCNDY0kCZmcE3w==
+
expo-build-properties@^0.12.1:
version "0.12.1"
resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.12.1.tgz#8d11759b8f382e4654e2482ddcec4f9ad4530aad"