Merge branch 'main' into ja-translation-17

This commit is contained in:
Takayuki KUSANO
2024-09-26 10:00:50 +09:00
51 changed files with 1188 additions and 754 deletions
-47
View File
@@ -1,47 +0,0 @@
export const exampleComHtml = `<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta name="description" content="An example website">
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -9,11 +9,11 @@ describe('isNetworkError', () => {
] ]
const outputs = [true, false, false, true] const outputs = [true, false, false, true]
it('correctly distinguishes network errors', () => { for (let i = 0; i < inputs.length; i++) {
for (let i = 0; i < inputs.length; i++) { const input = inputs[i]
const input = inputs[i] const output = outputs[i]
const result = isNetworkError(input) it(`correctly distinguishes network errors for ${input}`, () => {
expect(result).toEqual(outputs[i]) expect(isNetworkError(input)).toEqual(output)
} })
}) }
}) })
-134
View File
@@ -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>', {title: 'title'}],
['<title> aSd!@#AC </title>', {title: 'aSd!@#AC'}],
['<title>\n title\n </title>', {title: 'title'}],
['<meta name="title" content="meta title">', {title: 'meta title'}],
[
'<meta name="description" content="meta description">',
{description: 'meta description'},
],
['<meta property="og:title" content="og title">', {title: 'og title'}],
[
'<meta property="og:description" content="og description">',
{description: 'og description'},
],
[
'<meta property="og:image" content="https://ogimage.com/foo.png">',
{image: 'https://ogimage.com/foo.png'},
],
[
'<meta property="twitter:title" content="twitter title">',
{title: 'twitter title'},
],
[
'<meta property="twitter:description" content="twitter description">',
{description: 'twitter description'},
],
[
'<meta property="twitter:image" content="https://twitterimage.com/foo.png">',
{image: 'https://twitterimage.com/foo.png'},
],
['<meta\n name="title"\n content="meta title"\n>', {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_): &quot;Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp&quot;. оригинальный звук - 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&#39;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)
})
})
+1
View File
@@ -120,6 +120,7 @@
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "^51.0.8", "expo": "^51.0.8",
"expo-application": "^5.9.1", "expo-application": "^5.9.1",
"expo-blur": "^13.0.2",
"expo-build-properties": "^0.12.1", "expo-build-properties": "^0.12.1",
"expo-camera": "~15.0.9", "expo-camera": "~15.0.9",
"expo-clipboard": "^6.0.3", "expo-clipboard": "^6.0.3",
+3
View File
@@ -305,6 +305,7 @@ export function createThemes({
} as const } as const
const light: Theme = { const light: Theme = {
scheme: 'light',
name: 'light', name: 'light',
palette: lightPalette, palette: lightPalette,
atoms: { atoms: {
@@ -390,6 +391,7 @@ export function createThemes({
} }
const dark: Theme = { const dark: Theme = {
scheme: 'dark',
name: 'dark', name: 'dark',
palette: darkPalette, palette: darkPalette,
atoms: { atoms: {
@@ -479,6 +481,7 @@ export function createThemes({
const dim: Theme = { const dim: Theme = {
...dark, ...dark,
scheme: 'dark',
name: 'dim', name: 'dim',
palette: dimPalette, palette: dimPalette,
atoms: { atoms: {
+1
View File
@@ -156,6 +156,7 @@ export type ThemedAtoms = {
} }
} }
export type Theme = { export type Theme = {
scheme: 'light' | 'dark' // for library support
name: ThemeName name: ThemeName
palette: Palette palette: Palette
atoms: ThemedAtoms atoms: ThemedAtoms
@@ -12,15 +12,15 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' 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 {logger} from '#/logger'
import {useGenerateStarterPackMutation} from 'lib/generate-starterpack' import {List, ListRef} from '#/view/com/util/List'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' import {Text} from '#/view/com/util/text/Text'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {atoms as a, ios, useTheme} from '#/alf'
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 {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {LinearGradientBackground} from '#/components/LinearGradientBackground'
@@ -132,6 +132,7 @@ export const ProfileStarterPacks = React.forwardRef<
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
refreshing={isPTRing} refreshing={isPTRing}
headerOffset={headerOffset} headerOffset={headerOffset}
progressViewOffset={ios(0)}
contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}} contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
indicatorStyle={t.name === 'light' ? 'black' : 'white'} indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true} removeClippedSubviews={true}
+16 -7
View File
@@ -135,6 +135,8 @@ export function createInput(Component: typeof TextInput) {
placeholder, placeholder,
value, value,
onChangeText, onChangeText,
onFocus,
onBlur,
isInvalid, isInvalid,
inputRef, inputRef,
style, style,
@@ -173,8 +175,14 @@ export function createInput(Component: typeof TextInput) {
ref={refs} ref={refs}
value={value} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
onFocus={ctx.onFocus} onFocus={e => {
onBlur={ctx.onBlur} ctx.onFocus()
onFocus?.(e)
}}
onBlur={e => {
ctx.onBlur()
onBlur?.(e)
}}
placeholder={placeholder || label} placeholder={placeholder || label}
placeholderTextColor={t.palette.contrast_500} placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
@@ -188,8 +196,8 @@ export function createInput(Component: typeof TextInput) {
a.px_xs, a.px_xs,
{ {
// paddingVertical doesn't work w/multiline - esb // paddingVertical doesn't work w/multiline - esb
paddingTop: 14, paddingTop: 12,
paddingBottom: 14, paddingBottom: 13,
lineHeight: a.text_md.fontSize * 1.1875, lineHeight: a.text_md.fontSize * 1.1875,
textAlignVertical: rest.multiline ? 'top' : undefined, textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 80 : undefined, minHeight: rest.multiline ? 80 : undefined,
@@ -197,13 +205,14 @@ export function createInput(Component: typeof TextInput) {
}, },
// fix for autofill styles covering border // fix for autofill styles covering border
web({ web({
paddingTop: 12, paddingTop: 10,
paddingBottom: 12, paddingBottom: 11,
marginTop: 2, marginTop: 2,
marginBottom: 2, marginBottom: 2,
}), }),
android({ android({
paddingBottom: 16, paddingTop: 8,
paddingBottom: 8,
}), }),
style, style,
]} ]}
@@ -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<ViewStyle>
} & Exclude<PressableProps, 'onPressIn' | 'onPressOut'>) {
const scale = useSharedValue(1)
const animatedStyle = useAnimatedStyle(() => ({
transform: [{scale: scale.value}],
}))
return (
<Pressable
accessibilityRole="button"
onPressIn={e => {
'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}>
<Animated.View style={[animatedStyle, contentContainerStyle]}>
{children as React.ReactNode}
</Animated.View>
</Pressable>
)
}
+7
View File
@@ -1,8 +1,10 @@
import React from 'react' import React from 'react'
import * as Device from 'expo-device'
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics' import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
import {isIOS, isWeb} from '#/platform/detection' import {isIOS, isWeb} from '#/platform/detection'
import {useHapticsDisabled} from '#/state/preferences/disable-haptics' import {useHapticsDisabled} from '#/state/preferences/disable-haptics'
import * as Toast from '#/view/com/util/Toast'
export function useHaptics() { export function useHaptics() {
const isHapticsDisabled = useHapticsDisabled() const isHapticsDisabled = useHapticsDisabled()
@@ -18,6 +20,11 @@ export function useHaptics() {
? ImpactFeedbackStyle[strength] ? ImpactFeedbackStyle[strength]
: ImpactFeedbackStyle.Light : ImpactFeedbackStyle.Light
impactAsync(style) impactAsync(style)
// DEV ONLY - show a toast when a haptic is meant to fire on simulator
if (__DEV__ && !Device.isDevice) {
Toast.show(`Buzzz!`)
}
}, },
[isHapticsDisabled], [isHapticsDisabled],
) )
-71
View File
@@ -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<string, string> => {
const htmlTitleRegex = /<title.*>([^<]+)<\/title>/i
let res: Record<string, string> = {}
const match = htmlTitleRegex.exec(html)
if (match) {
res.title = match[1].trim()
}
let metaMatch
let propMatch
const metaRe = /<meta[\s]([^>]+)>/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
}
-20
View File
@@ -1,20 +0,0 @@
export const extractTwitterMeta = ({
pathname,
}: {
pathname: string
}): Record<string, string> => {
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
}
-31
View File
@@ -1,31 +0,0 @@
export const extractYoutubeMeta = (html: string): Record<string, string> => {
const res: Record<string, string> = {}
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
}
+3 -1
View File
@@ -1,3 +1,5 @@
export type Gate = export type Gate =
// Keep this alphabetic please. // Keep this alphabetic please.
'debug_show_feedcontext' | 'suggested_feeds_interstitial' | 'debug_show_feedcontext'
| 'post_feed_lang_window'
| 'suggested_feeds_interstitial'
+13 -5
View File
@@ -20,11 +20,19 @@ export function cleanError(str: any): string {
return str return str
} }
const NETWORK_ERRORS = [
'Abort',
'Network request failed',
'Failed to fetch',
'Load failed',
]
export function isNetworkError(e: unknown) { export function isNetworkError(e: unknown) {
const str = String(e) const str = String(e)
return ( for (const err of NETWORK_ERRORS) {
str.includes('Abort') || if (str.includes(err)) {
str.includes('Network request failed') || return true
str.includes('Failed to fetch') }
) }
return false
} }
+8 -2
View File
@@ -1,10 +1,11 @@
import format from 'date-fns/format' import format from 'date-fns/format'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {Sentry} from '#/logger/sentry'
import * as env from '#/env'
import {DebugContext} from '#/logger/debugContext' import {DebugContext} from '#/logger/debugContext'
import {add} from '#/logger/logDump' import {add} from '#/logger/logDump'
import {Sentry} from '#/logger/sentry'
import {isNetworkError} from 'lib/strings/errors'
import * as env from '#/env'
export enum LogLevel { export enum LogLevel {
Debug = 'debug', Debug = 'debug',
@@ -160,6 +161,11 @@ export const sentryTransport: Transport = (
timestamp: timestamp / 1000, // Sentry expects seconds 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 * Send all higher levels with `captureMessage`, with appropriate severity
* level * level
+18 -7
View File
@@ -24,8 +24,9 @@ import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed' 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 {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import * as tokens from '#/alf/tokens'
import {ConvoMenu} from '#/components/dms/ConvoMenu' import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
@@ -203,6 +204,19 @@ function ChatListItemReady({
onFocus={onFocus} onFocus={onFocus}
onBlur={onMouseLeave} onBlur={onMouseLeave}
style={[a.relative]}> style={[a.relative]}>
<View
style={[
a.z_10,
a.absolute,
{top: tokens.space.md, left: tokens.space.lg},
]}>
<PreviewableUserAvatar
profile={profile}
size={52}
moderation={moderation.ui('avatar')}
/>
</View>
<Link <Link
to={`/messages/${convo.id}`} to={`/messages/${convo.id}`}
label={displayName} label={displayName}
@@ -236,11 +250,8 @@ function ChatListItemReady({
(hovered || pressed || focused) && t.atoms.bg_contrast_25, (hovered || pressed || focused) && t.atoms.bg_contrast_25,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
]}> ]}>
<UserAvatar {/* Avatar goes here */}
avatar={profile.avatar} <View style={{width: 52, height: 52}} />
size={52}
moderation={moderation.ui('avatar')}
/>
<View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}> <View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}>
<View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}> <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}>
@@ -357,7 +368,7 @@ function ChatListItemReady({
a.self_end, a.self_end,
a.justify_center, a.justify_center,
{ {
right: a.px_lg.paddingRight, right: tokens.space.lg,
opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
}, },
]} ]}
+9 -2
View File
@@ -5,7 +5,7 @@ import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {Shadow} from '#/state/cache/types' 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' import {Text} from '#/components/Typography'
export function ProfileHeaderDisplayName({ export function ProfileHeaderDisplayName({
@@ -16,12 +16,19 @@ export function ProfileHeaderDisplayName({
moderation: ModerationDecision moderation: ModerationDecision
}) { }) {
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints()
return ( return (
<View pointerEvents="none"> <View pointerEvents="none">
<Text <Text
emoji emoji
testID="profileHeaderDisplayName" testID="profileHeaderDisplayName"
style={[t.atoms.text, a.text_4xl, a.self_start, {fontWeight: '600'}]}> style={[
t.atoms.text,
gtMobile ? a.text_4xl : a.text_3xl,
a.self_start,
{fontWeight: '600'},
]}>
{sanitizeDisplayName( {sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle), profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'), moderation.ui('displayName'),
@@ -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<ViewStyle>
}) {
const pagerContext = usePagerHeaderContext()
// pagerContext should only be present on iOS, but better safe than sorry
if (!pagerContext || !isIOS) {
return <View style={style}>{children}</View>
}
const {scrollY} = pagerContext
return (
<GrowableAvatarInner scrollY={scrollY} style={style}>
{children}
</GrowableAvatarInner>
)
}
function GrowableAvatarInner({
scrollY,
children,
style,
}: {
scrollY: SharedValue<number>
children: React.ReactNode
style?: StyleProp<ViewStyle>
}) {
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
scale: interpolate(scrollY.value, [-150, 0], [1.2, 1], {
extrapolateRight: Extrapolation.CLAMP,
}),
},
],
}))
return (
<Animated.View
style={[style, {transformOrigin: 'bottom left'}, animatedStyle]}>
{children}
</Animated.View>
)
}
@@ -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 (
<View style={[a.w_full, a.h_full]}>
{backButton}
{children}
</View>
)
}
const {scrollY} = pagerContext
return (
<GrowableBannerInner scrollY={scrollY} backButton={backButton}>
{children}
</GrowableBannerInner>
)
}
function GrowableBannerInner({
scrollY,
backButton,
children,
}: {
scrollY: SharedValue<number>
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 (
<>
<Animated.View
style={[
a.absolute,
{left: 0, right: 0, bottom: 0},
{height: 150},
{transformOrigin: 'bottom'},
animatedStyle,
]}>
{children}
<AnimatedBlurView
style={[a.absolute, a.inset_0]}
tint="dark"
animatedProps={animatedBlurViewProps}
/>
</Animated.View>
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<Animated.View style={[animatedSpinnerStyle]}>
<ActivityIndicator
key={animateSpinner ? 'spin' : 'stop'}
size="large"
color="white"
animating={animateSpinner}
hidesWhenStopped={false}
/>
</Animated.View>
</View>
<Animated.View style={[animatedBackButtonStyle]}>
{backButton}
</Animated.View>
</>
)
}
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<number>
}) {
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
}
+73 -52
View File
@@ -6,19 +6,21 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' 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 {Shadow} from '#/state/cache/types'
import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox' import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {BACK_HITSLOP} from 'lib/constants' import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {NavigationProp} from 'lib/routes/types' import {UserBanner} from '#/view/com/util/UserBanner'
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 {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe' import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts' import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {GrowableAvatar} from './GrowableAvatar'
import {GrowableBanner} from './GrowableBanner'
interface Props { interface Props {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
@@ -63,20 +65,45 @@ let ProfileHeaderShell = ({
return ( return (
<View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}> <View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View pointerEvents={isIOS ? 'auto' : 'none'}> <View
{isPlaceholderProfile ? ( pointerEvents={isIOS ? 'auto' : 'none'}
<LoadingPlaceholder style={[a.relative, {height: 150}]}>
width="100%" <GrowableBanner
height={150} backButton={
style={{borderRadius: 0}} <>
/> {!isDesktop && !hideBackButton && (
) : ( <TouchableWithoutFeedback
<UserBanner testID="profileHeaderBackBtn"
type={profile.associated?.labeler ? 'labeler' : 'default'} onPress={onPressBack}
banner={profile.banner} hitSlop={BACK_HITSLOP}
moderation={moderation.ui('banner')} accessibilityRole="button"
/> accessibilityLabel={_(msg`Back`)}
)} accessibilityHint="">
<View style={styles.backBtnWrapper}>
<FontAwesomeIcon
size={18}
icon="angle-left"
color="white"
/>
</View>
</TouchableWithoutFeedback>
)}
</>
}>
{isPlaceholderProfile ? (
<LoadingPlaceholder
width="100%"
height="100%"
style={{borderRadius: 0}}
/>
) : (
<UserBanner
type={profile.associated?.labeler ? 'labeler' : 'default'}
banner={profile.banner}
moderation={moderation.ui('banner')}
/>
)}
</GrowableBanner>
</View> </View>
{children} {children}
@@ -93,40 +120,29 @@ let ProfileHeaderShell = ({
</View> </View>
)} )}
{!isDesktop && !hideBackButton && ( <GrowableAvatar style={styles.aviPosition}>
<TouchableWithoutFeedback <TouchableWithoutFeedback
testID="profileHeaderBackBtn" testID="profileHeaderAviButton"
onPress={onPressBack} onPress={onPressAvi}
hitSlop={BACK_HITSLOP} accessibilityRole="image"
accessibilityRole="button" accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
accessibilityLabel={_(msg`Back`)}
accessibilityHint=""> accessibilityHint="">
<View style={styles.backBtnWrapper}> <View
<FontAwesomeIcon size={18} icon="angle-left" color="white" /> style={[
t.atoms.bg,
{borderColor: t.atoms.bg.backgroundColor},
styles.avi,
profile.associated?.labeler && styles.aviLabeler,
]}>
<UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={90}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
)} </GrowableAvatar>
<TouchableWithoutFeedback
testID="profileHeaderAviButton"
onPress={onPressAvi}
accessibilityRole="image"
accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
accessibilityHint="">
<View
style={[
t.atoms.bg,
{borderColor: t.atoms.bg.backgroundColor},
styles.avi,
profile.associated?.labeler && styles.aviLabeler,
]}>
<UserAvatar
type={profile.associated?.labeler ? 'labeler' : 'user'}
size={90}
avatar={profile.avatar}
moderation={moderation.ui('avatar')}
/>
</View>
</TouchableWithoutFeedback>
</View> </View>
) )
} }
@@ -144,6 +160,9 @@ const styles = StyleSheet.create({
borderRadius: 15, borderRadius: 15,
// @ts-ignore web only // @ts-ignore web only
cursor: 'pointer', cursor: 'pointer',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
alignItems: 'center',
justifyContent: 'center',
}, },
backBtn: { backBtn: {
width: 30, width: 30,
@@ -152,10 +171,12 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
avi: { aviPosition: {
position: 'absolute', position: 'absolute',
top: 110, top: 110,
left: 10, left: 10,
},
avi: {
width: 94, width: 94,
height: 94, height: 94,
borderRadius: 47, borderRadius: 47,
+9 -5
View File
@@ -7,18 +7,22 @@ import {
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } 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 {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
import {ProfileHeaderStandard} from './ProfileHeaderStandard' import {ProfileHeaderStandard} from './ProfileHeaderStandard'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => { let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
const pal = usePalette('default') const t = useTheme()
return ( return (
<View style={pal.view}> <View style={t.atoms.bg}>
<LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} /> <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
<View <View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> style={[
t.atoms.bg,
{borderColor: t.atoms.bg.backgroundColor},
styles.avi,
]}>
<LoadingPlaceholder width={90} height={90} style={styles.br45} /> <LoadingPlaceholder width={90} height={90} style={styles.br45} />
</View> </View>
<View style={styles.content}> <View style={styles.content}>
+8 -6
View File
@@ -4,17 +4,18 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {usePalette} from '#/lib/hooks/usePalette'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {FeedDescriptor} from '#/state/queries/post-feed' import {FeedDescriptor} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {Feed} from '#/view/com/posts/Feed'
import {usePalette} from 'lib/hooks/usePalette' 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 {Text} from '#/view/com/util/text/Text'
import {Feed} from 'view/com/posts/Feed' import {ios} from '#/alf'
import {EmptyState} from 'view/com/util/EmptyState'
import {ListRef} from 'view/com/util/List'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {SectionRef} from './types' import {SectionRef} from './types'
interface FeedSectionProps { interface FeedSectionProps {
@@ -82,6 +83,7 @@ export const ProfileFeedSection = React.forwardRef<
onScrolledDownChange={setIsScrolledDown} onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty} renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight} headerOffset={headerHeight}
progressViewOffset={ios(0)}
renderEndOfFeed={ProfileEndOfFeed} renderEndOfFeed={ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor} ignoreFilterFor={ignoreFilterFor}
initialNumToRender={ initialNumToRender={
@@ -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)
},
)
})
+43
View File
@@ -0,0 +1,43 @@
export type Params = Record<string, string>
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(' ')
}
+2 -2
View File
@@ -6,9 +6,9 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from '@tanstack/react-query' } 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 const RQKEY = (did?: string) => [RQKEY_ROOT, did]
export function useActorStarterPacksQuery({did}: {did?: string}) { export function useActorStarterPacksQuery({did}: {did?: string}) {
+28 -14
View File
@@ -15,24 +15,25 @@ import {
useInfiniteQuery, useInfiniteQuery,
} from '@tanstack/react-query' } 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 {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 {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip'
import {DISCOVER_FEED_URI} from '#/lib/constants' 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 {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import * as userActionHistory from '#/state/userActionHistory' 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 {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners' import {useFeedTuners} from '../preferences/feed-tuners'
import {useModerationOpts} from '../preferences/moderation-opts' import {useModerationOpts} from '../preferences/moderation-opts'
@@ -65,7 +66,7 @@ export interface FeedParams {
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined 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) { export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
return [RQKEY_ROOT, feedDesc, params || {}] return [RQKEY_ROOT, feedDesc, params || {}]
} }
@@ -109,13 +110,19 @@ export interface FeedPage {
fetchedAt: number 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( export function usePostFeedQuery(
feedDesc: FeedDescriptor, feedDesc: FeedDescriptor,
params?: FeedParams, params?: FeedParams,
opts?: {enabled?: boolean; ignoreFilterFor?: string}, opts?: {enabled?: boolean; ignoreFilterFor?: string},
) { ) {
const gate = useGate()
const feedTuners = useFeedTuners(feedDesc) const feedTuners = useFeedTuners(feedDesc)
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
@@ -135,6 +142,13 @@ export function usePostFeedQuery(
} | null>(null) } | null>(null)
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI) 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. // Make sure this doesn't invalidate unless really needed.
const selectArgs = React.useMemo( const selectArgs = React.useMemo(
() => ({ () => ({
@@ -175,7 +189,7 @@ export function usePostFeedQuery(
} }
try { 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. * 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. // Now track how many items we really want, and fetch more if needed.
if (isLoading || isRefetching) { if (isLoading || isRefetching) {
// During the initial fetch, we want to get an entire page's worth of items. // 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) { } else if (isFetchingNextPage) {
if (itemCount > wantedItemCount.current) { if (itemCount > wantedItemCount.current) {
// We have more items than wantedItemCount, so wantedItemCount must be out of date. // We have more items than wantedItemCount, so wantedItemCount must be out of date.
// Some other code must have called fetchNextPage(), for example, from onEndReached. // 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. // 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) { } else if (hasNextPage) {
// At this point we're not fetching anymore, so it's time to make a decision. // At this point we're not fetching anymore, so it's time to make a decision.
+1 -1
View File
@@ -8,7 +8,7 @@ const PAGE_SIZE = 50
type RQPageParam = string | undefined type RQPageParam = string | undefined
// TODO refactor invalidate on mutate? // 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 const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileFeedgensQuery( export function useProfileFeedgensQuery(
+1 -1
View File
@@ -7,7 +7,7 @@ import {useModerationOpts} from '../preferences/moderation-opts'
const PAGE_SIZE = 30 const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
const RQKEY_ROOT = 'profile-lists' export const RQKEY_ROOT = 'profile-lists'
export const RQKEY = (did: string) => [RQKEY_ROOT, did] export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
+3 -2
View File
@@ -15,9 +15,9 @@ import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
import {EmptyState} from '#/view/com/util/EmptyState'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {EmptyState} from 'view/com/util/EmptyState' import {atoms as a, ios, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import * as FeedCard from '#/components/FeedCard' import * as FeedCard from '#/components/FeedCard'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List' import {List, ListRef} from '../util/List'
@@ -191,6 +191,7 @@ export const ProfileFeedgens = React.forwardRef<
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={onRefresh}
headerOffset={headerOffset} headerOffset={headerOffset}
progressViewOffset={ios(0)}
contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}} contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
indicatorStyle={t.name === 'light' ? 'black' : 'white'} indicatorStyle={t.name === 'light' ? 'black' : 'white'}
removeClippedSubviews={true} removeClippedSubviews={true}
+4 -3
View File
@@ -10,14 +10,14 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists' 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 {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {EmptyState} from 'view/com/util/EmptyState' import {atoms as a, ios, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import * as ListCard from '#/components/ListCard' import * as ListCard from '#/components/ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List' import {List, ListRef} from '../util/List'
@@ -192,6 +192,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={onRefresh}
headerOffset={headerOffset} headerOffset={headerOffset}
progressViewOffset={ios(0)}
contentContainerStyle={ contentContainerStyle={
isNative && {paddingBottom: headerOffset + 100} isNative && {paddingBottom: headerOffset + 100}
} }
+1 -1
View File
@@ -625,7 +625,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
}, },
layoutIcon: { layoutIcon: {
width: 70, width: 60,
alignItems: 'flex-end', alignItems: 'flex-end',
paddingTop: 2, paddingTop: 2,
}, },
+41
View File
@@ -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<SharedValue<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.
*
* @platform ios
*/
export function PagerHeaderProvider({
scrollY,
children,
}: {
scrollY: SharedValue<number>
children: React.ReactNode
}) {
return (
<PagerHeaderContext.Provider value={scrollY}>
{children}
</PagerHeaderContext.Provider>
)
}
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
}
}
+36 -22
View File
@@ -19,9 +19,10 @@ import Animated, {
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {ScrollProvider} from '#/lib/ScrollContext' import {ScrollProvider} from '#/lib/ScrollContext'
import {isIOS} from 'platform/detection' import {isIOS} from '#/platform/detection'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
import {ListMethods} from '../util/List' import {ListMethods} from '../util/List'
import {PagerHeaderProvider} from './PagerHeaderContext'
import {TabBar} from './TabBar' import {TabBar} from './TabBar'
export interface PagerWithHeaderChildParams { export interface PagerWithHeaderChildParams {
@@ -41,6 +42,7 @@ export interface PagerWithHeaderProps {
initialPage?: number initialPage?: number
onPageSelected?: (index: number) => void onPageSelected?: (index: number) => void
onCurrentPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void
allowHeaderOverScroll?: boolean
} }
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl( function PageWithHeaderImpl(
@@ -53,6 +55,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
initialPage, initialPage,
onPageSelected, onPageSelected,
onCurrentPageSelected, onCurrentPageSelected,
allowHeaderOverScroll,
}: PagerWithHeaderProps, }: PagerWithHeaderProps,
ref, ref,
) { ) {
@@ -80,19 +83,22 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
const renderTabBar = React.useCallback( const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => { (props: RenderTabBarFnProps) => {
return ( return (
<PagerTabBar <PagerHeaderProvider scrollY={scrollY}>
headerOnlyHeight={headerOnlyHeight} <PagerTabBar
items={items} headerOnlyHeight={headerOnlyHeight}
isHeaderReady={isHeaderReady} items={items}
renderHeader={renderHeader} isHeaderReady={isHeaderReady}
currentPage={currentPage} renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected} currentPage={currentPage}
onTabBarLayout={onTabBarLayout} onCurrentPageSelected={onCurrentPageSelected}
onHeaderOnlyLayout={onHeaderOnlyLayout} onTabBarLayout={onTabBarLayout}
onSelect={props.onSelect} onHeaderOnlyLayout={onHeaderOnlyLayout}
scrollY={scrollY} onSelect={props.onSelect}
testID={testID} scrollY={scrollY}
/> testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
/>
</PagerHeaderProvider>
) )
}, },
[ [
@@ -106,6 +112,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
onHeaderOnlyLayout, onHeaderOnlyLayout,
scrollY, scrollY,
testID, testID,
allowHeaderOverScroll,
], ],
) )
@@ -216,6 +223,7 @@ let PagerTabBar = ({
onTabBarLayout, onTabBarLayout,
onCurrentPageSelected, onCurrentPageSelected,
onSelect, onSelect,
allowHeaderOverScroll,
}: { }: {
currentPage: number currentPage: number
headerOnlyHeight: number headerOnlyHeight: number
@@ -228,14 +236,20 @@ let PagerTabBar = ({
onTabBarLayout: (e: LayoutChangeEvent) => void onTabBarLayout: (e: LayoutChangeEvent) => void
onCurrentPageSelected?: (index: number) => void onCurrentPageSelected?: (index: number) => void
onSelect?: (index: number) => void onSelect?: (index: number) => void
allowHeaderOverScroll?: boolean
}): React.ReactNode => { }): React.ReactNode => {
const headerTransform = useAnimatedStyle(() => ({ const headerTransform = useAnimatedStyle(() => {
transform: [ const translateY = Math.min(scrollY.value, headerOnlyHeight) * -1
{ return {
translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0), transform: [
}, {
], translateY: allowHeaderOverScroll
})) ? translateY
: Math.min(translateY, 0),
},
],
}
})
const headerRef = React.useRef(null) const headerRef = React.useRef(null)
return ( return (
<Animated.View <Animated.View
+1 -1
View File
@@ -4,7 +4,7 @@ import {useAnimatedRef} from 'react-native-reanimated'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
import {ListMethods} from '../util/List' import {ListMethods} from '../util/List'
import {TabBar} from './TabBar' import {TabBar} from './TabBar'
@@ -1,14 +1,17 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity} from 'react-native' import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {useHapticsDisabled} from '#/state/preferences'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {atoms as a, useTheme} from '#/alf'
import {Text} from '../util/text/Text' import {Text} from '#/components/Typography'
import {UserAvatar} from '../util/UserAvatar'
export function PostThreadComposePrompt({ export function PostThreadComposePrompt({
onPressCompose, onPressCompose,
@@ -17,47 +20,57 @@ export function PostThreadComposePrompt({
}) { }) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: profile} = useProfileQuery({did: currentAccount?.did})
const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {isDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
const t = useTheme()
const playHaptics = useHaptics()
const isHapticsDisabled = useHapticsDisabled()
const onPress = () => {
playHaptics('Light')
setTimeout(
() => {
onPressCompose()
},
isHapticsDisabled ? 0 : 75,
)
}
return ( return (
<TouchableOpacity <PressableScale
testID="replyPromptBtn"
style={[pal.view, pal.border, styles.prompt]}
onPress={() => onPressCompose()}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Compose reply`)} accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint={_(msg`Opens composer`)}> accessibilityHint={_(msg`Opens composer`)}
<UserAvatar style={[
avatar={profile?.avatar} {paddingTop: 8, paddingBottom: isTabletOrDesktop ? 8 : 11},
size={38} a.px_sm,
type={profile?.associated?.labeler ? 'labeler' : 'user'} a.border_t,
/> t.atoms.border_contrast_low,
<Text t.atoms.bg,
type="xl" ]}
onPress={onPress}>
<View
style={[ style={[
pal.text, a.flex_row,
isDesktop ? styles.labelDesktopWeb : styles.labelMobile, a.align_center,
a.p_sm,
a.gap_sm,
a.rounded_full,
t.atoms.bg_contrast_25,
]}> ]}>
<Trans>Write your reply</Trans> <UserAvatar
</Text> size={22}
</TouchableOpacity> avatar={profile?.avatar}
type={profile?.associated?.labeler ? 'labeler' : 'user'}
/>
<Text
style={[
isTabletOrDesktop ? a.text_md : a.text_sm,
t.atoms.text_contrast_medium,
]}>
<Trans>Write your reply</Trans>
</Text>
</View>
</PressableScale>
) )
} }
const styles = StyleSheet.create({
prompt: {
paddingHorizontal: 16,
paddingTop: 10,
paddingBottom: 10,
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: StyleSheet.hairlineWidth,
},
labelMobile: {
paddingLeft: 12,
},
labelDesktopWeb: {
paddingLeft: 12,
},
})
+6 -3
View File
@@ -14,8 +14,11 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {logEvent, useGate} from '#/lib/statsig/statsig' import {logEvent, useGate} from '#/lib/statsig/statsig'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {listenPostCreated} from '#/state/events' import {listenPostCreated} from '#/state/events'
@@ -30,9 +33,6 @@ import {
usePostFeedQuery, usePostFeedQuery,
} from '#/state/queries/post-feed' } from '#/state/queries/post-feed'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {useTheme} from 'lib/ThemeContext'
import { import {
ProgressGuide, ProgressGuide,
SuggestedFeeds, SuggestedFeeds,
@@ -167,6 +167,7 @@ let Feed = ({
renderEndOfFeed, renderEndOfFeed,
testID, testID,
headerOffset = 0, headerOffset = 0,
progressViewOffset,
desktopFixedHeightOffset, desktopFixedHeightOffset,
ListHeaderComponent, ListHeaderComponent,
extraData, extraData,
@@ -187,6 +188,7 @@ let Feed = ({
renderEndOfFeed?: () => JSX.Element renderEndOfFeed?: () => JSX.Element
testID?: string testID?: string
headerOffset?: number headerOffset?: number
progressViewOffset?: number
desktopFixedHeightOffset?: number desktopFixedHeightOffset?: number
ListHeaderComponent?: () => JSX.Element ListHeaderComponent?: () => JSX.Element
extraData?: any extraData?: any
@@ -548,6 +550,7 @@ let Feed = ({
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={onRefresh} onRefresh={onRefresh}
headerOffset={headerOffset} headerOffset={headerOffset}
progressViewOffset={progressViewOffset}
contentContainerStyle={{ contentContainerStyle={{
minHeight: Dimensions.get('window').height * 1.5, minHeight: Dimensions.get('window').height * 1.5,
}} }}
+9 -8
View File
@@ -4,11 +4,11 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video' import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 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 {useScrollHandlers} from '#/lib/ScrollContext'
import {useDedupe} from 'lib/hooks/useDedupe' import {addStyle} from '#/lib/styles'
import {addStyle} from 'lib/styles' import {isIOS} from '#/platform/detection'
import {isIOS} from 'platform/detection' import {useTheme} from '#/alf'
import {FlatList_INTERNAL} from './Views' import {FlatList_INTERNAL} from './Views'
export type ListMethods = FlatList_INTERNAL export type ListMethods = FlatList_INTERNAL
@@ -44,12 +44,13 @@ function ListImpl<ItemT>(
onItemSeen, onItemSeen,
headerOffset, headerOffset,
style, style,
progressViewOffset,
...props ...props
}: ListProps<ItemT>, }: ListProps<ItemT>,
ref: React.Ref<ListMethods>, ref: React.Ref<ListMethods>,
) { ) {
const isScrolledDown = useSharedValue(false) const isScrolledDown = useSharedValue(false)
const pal = usePalette('default') const t = useTheme()
const dedupe = useDedupe(400) const dedupe = useDedupe(400)
function handleScrolledDownChange(didScrollDown: boolean) { function handleScrolledDownChange(didScrollDown: boolean) {
@@ -120,9 +121,9 @@ function ListImpl<ItemT>(
<RefreshControl <RefreshControl
refreshing={refreshing ?? false} refreshing={refreshing ?? false}
onRefresh={onRefresh} onRefresh={onRefresh}
tintColor={pal.colors.text} tintColor={t.atoms.text.color}
titleColor={pal.colors.text} titleColor={t.atoms.text.color}
progressViewOffset={headerOffset} progressViewOffset={progressViewOffset ?? headerOffset}
/> />
) )
} }
+2 -1
View File
@@ -327,7 +327,8 @@ let EditableUserAvatar = ({
onSelectNewAvatar(croppedImage) onSelectNewAvatar(croppedImage)
} catch (e: any) { } 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}) logger.error('Failed to crop banner', {error: e})
} }
} }
+1 -1
View File
@@ -202,7 +202,7 @@ const styles = StyleSheet.create({
}, },
bannerImage: { bannerImage: {
width: '100%', width: '100%',
height: 150, height: '100%',
}, },
defaultBanner: { defaultBanner: {
backgroundColor: '#0070ff', backgroundColor: '#0070ff',
@@ -1,7 +1,7 @@
import React, {useEffect, useId, useRef, useState} from 'react' import React, {useEffect, useId, useRef, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyEmbedVideo} from '@atproto/api' 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 {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -23,6 +23,7 @@ export function VideoEmbedInnerWeb({
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const [focused, setFocused] = useState(false) const [focused, setFocused] = useState(false)
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
const [hlsLoading, setHlsLoading] = React.useState(false)
const figId = useId() const figId = useId()
// send error up to error boundary // send error up to error boundary
@@ -37,6 +38,7 @@ export function VideoEmbedInnerWeb({
setHasSubtitleTrack, setHasSubtitleTrack,
setError, setError,
videoRef, videoRef,
setHlsLoading,
}) })
return ( return (
@@ -77,6 +79,7 @@ export function VideoEmbedInnerWeb({
setActive={setActive} setActive={setActive}
focused={focused} focused={focused}
setFocused={setFocused} setFocused={setFocused}
hlsLoading={hlsLoading}
onScreen={onScreen} onScreen={onScreen}
fullscreenRef={containerRef} fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack} hasSubtitleTrack={hasSubtitleTrack}
@@ -99,31 +102,62 @@ export class VideoNotFoundError extends Error {
} }
} }
type CachedPromise<T> = Promise<T> & {value: undefined | T}
const promiseForHls = import(
// @ts-ignore
'hls.js/dist/hls.min'
).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default>
promiseForHls.value = undefined
promiseForHls.then(Hls => {
promiseForHls.value = Hls
})
function useHLS({ function useHLS({
focused, focused,
playlist, playlist,
setHasSubtitleTrack, setHasSubtitleTrack,
setError, setError,
videoRef, videoRef,
setHlsLoading,
}: { }: {
focused: boolean focused: boolean
playlist: string playlist: string
setHasSubtitleTrack: (v: boolean) => void setHasSubtitleTrack: (v: boolean) => void
setError: (v: Error | null) => void setError: (v: Error | null) => void
videoRef: React.RefObject<HTMLVideoElement> videoRef: React.RefObject<HTMLVideoElement>
setHlsLoading: (v: boolean) => void
}) { }) {
const hlsRef = useRef<Hls | undefined>(undefined) const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>(
const [lowQualityFragments, setLowQualityFragments] = useState<Fragment[]>([]) () => promiseForHls.value,
)
useEffect(() => {
if (!Hls) {
setHlsLoading(true)
promiseForHls.then(loadedHls => {
setHls(() => loadedHls)
setHlsLoading(false)
})
}
}, [Hls, setHlsLoading])
const hlsRef = useRef<HlsTypes.default | undefined>(undefined)
const [lowQualityFragments, setLowQualityFragments] = useState<
HlsTypes.Fragment[]
>([])
// purge low quality segments from buffer on next frag change // purge low quality segments from buffer on next frag change
const handleFragChange = useNonReactiveCallback( const handleFragChange = useNonReactiveCallback(
(_event: Events.FRAG_CHANGED, {frag}: FragChangedData) => { (
_event: HlsTypes.Events.FRAG_CHANGED,
{frag}: HlsTypes.FragChangedData,
) => {
if (!Hls) return
if (!hlsRef.current) return if (!hlsRef.current) return
const hls = hlsRef.current const hls = hlsRef.current
if (focused && hls.nextAutoLevel > 0) { if (focused && hls.nextAutoLevel > 0) {
// if the current quality level goes above 0, flush the low quality segments // 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) { for (const lowQualFrag of lowQualityFragments) {
// avoid if close to the current fragment // avoid if close to the current fragment
@@ -147,12 +181,15 @@ function useHLS({
useEffect(() => { useEffect(() => {
if (!videoRef.current) return if (!videoRef.current) return
if (!Hls.isSupported()) throw new HLSUnsupportedError() if (!Hls) return
if (!Hls.isSupported()) {
throw new HLSUnsupportedError()
}
const hls = new Hls({ const hls = new Hls({
maxMaxBufferLength: 10, // only load 10s ahead maxMaxBufferLength: 10, // only load 10s ahead
// note: the amount buffered is affected by both maxBufferLength and maxBufferSize // 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 // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
}) })
hlsRef.current = hls hlsRef.current = hls
@@ -211,7 +248,7 @@ function useHLS({
hls.destroy() hls.destroy()
abortController.abort() abortController.abort()
} }
}, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange]) }, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls])
return hlsRef return hlsRef
} }
@@ -43,6 +43,7 @@ export function Controls({
setFocused, setFocused,
onScreen, onScreen,
fullscreenRef, fullscreenRef,
hlsLoading,
hasSubtitleTrack, hasSubtitleTrack,
}: { }: {
videoRef: React.RefObject<HTMLVideoElement> videoRef: React.RefObject<HTMLVideoElement>
@@ -53,6 +54,7 @@ export function Controls({
setFocused: (focused: boolean) => void setFocused: (focused: boolean) => void
onScreen: boolean onScreen: boolean
fullscreenRef: React.RefObject<HTMLDivElement> fullscreenRef: React.RefObject<HTMLDivElement>
hlsLoading: boolean
hasSubtitleTrack: boolean hasSubtitleTrack: boolean
}) { }) {
const { const {
@@ -80,6 +82,7 @@ export function Controls({
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
const showSpinner = hlsLoading || buffering
const { const {
state: volumeHovered, state: volumeHovered,
onIn: onVolumeHover, onIn: onVolumeHover,
@@ -409,11 +412,11 @@ export function Controls({
)} )}
</View> </View>
</View> </View>
{(buffering || error) && ( {(showSpinner || error) && (
<View <View
pointerEvents="none" pointerEvents="none"
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
{buffering && <Loader fill={t.palette.white} size="lg" />} {showSpinner && <Loader fill={t.palette.white} size="lg" />}
{error && ( {error && (
<Text style={{color: t.palette.white}}> <Text style={{color: t.palette.white}}>
<Trans>An error occurred</Trans> <Trans>An error occurred</Trans>
+3 -2
View File
@@ -37,11 +37,11 @@ import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens' import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
import {ProfileLists} from '#/view/com/lists/ProfileLists' import {ProfileLists} from '#/view/com/lists/ProfileLists'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {FAB} from '#/view/com/util/fab/FAB' import {FAB} from '#/view/com/util/fab/FAB'
import {ListRef} from '#/view/com/util/List' import {ListRef} from '#/view/com/util/List'
import {CenteredView} from '#/view/com/util/Views' import {CenteredView} from '#/view/com/util/Views'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
@@ -363,7 +363,8 @@ function ProfileScreenLoaded({
items={sectionTitles} items={sectionTitles}
onPageSelected={onPageSelected} onPageSelected={onPageSelected}
onCurrentPageSelected={onCurrentPageSelected} onCurrentPageSelected={onCurrentPageSelected}
renderHeader={renderHeader}> renderHeader={renderHeader}
allowHeaderOverScroll>
{showFiltersTab {showFiltersTab
? ({headerHeight, isFocused, scrollElRef}) => ( ? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileLabelsSection <ProfileLabelsSection
+321 -141
View File
@@ -11,6 +11,7 @@ import {
View, View,
} from 'react-native' } from 'react-native'
import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler' import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler'
import RNPickerSelect from 'react-native-picker-select'
import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
@@ -21,6 +22,7 @@ import {useLingui} from '@lingui/react'
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import {useFocusEffect, useNavigation} from '@react-navigation/native' import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {LANGUAGES} from '#/lib/../locale/languages'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {createHitslop} from '#/lib/constants' import {createHitslop} from '#/lib/constants'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
@@ -35,10 +37,10 @@ import {
SearchTabNavigatorParams, SearchTabNavigatorParams,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {augmentSearchQuery} from '#/lib/strings/helpers' import {augmentSearchQuery} from '#/lib/strings/helpers'
import {useTheme} from '#/lib/ThemeContext'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection' import {isNative, isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events' import {listenSoftReset} from '#/state/events'
import {useLanguagePrefs} from '#/state/preferences/languages'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useActorSearch} from '#/state/queries/actor-search' import {useActorSearch} from '#/state/queries/actor-search'
@@ -57,9 +59,16 @@ import {Text} from '#/view/com/util/text/Text'
import {CenteredView, ScrollView} from '#/view/com/util/Views' import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {Explore} from '#/view/screens/Search/Explore' import {Explore} from '#/view/screens/Search/Explore'
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
import {atoms as a, useTheme as useThemeNew} from '#/alf' import {makeSearchQuery, parseSearchQuery} from '#/screens/Search/utils'
import {atoms as a, useBreakpoints, useTheme as useThemeNew, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as FeedCard from '#/components/FeedCard' import * as FeedCard from '#/components/FeedCard'
import * as TextField from '#/components/forms/TextField'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
function Loader() { function Loader() {
const pal = usePalette('default') const pal = usePalette('default')
@@ -251,7 +260,7 @@ let SearchScreenUserResults = ({
const {_} = useLingui() const {_} = useLingui()
const {data: results, isFetched} = useActorSearch({ const {data: results, isFetched} = useActorSearch({
query: query, query,
enabled: active, enabled: active,
}) })
@@ -324,7 +333,138 @@ let SearchScreenFeedsResults = ({
} }
SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults)
let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { 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 (
<RNPickerSelect
placeholder={{}}
value={value}
onValueChange={onChange}
items={items}
Icon={() => (
<ChevronDown fill={t.atoms.text_contrast_low.color} size="sm" />
)}
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 pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
@@ -349,7 +489,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
title: _(msg`Top`), title: _(msg`Top`),
component: ( component: (
<SearchScreenPostResults <SearchScreenPostResults
query={query} query={queryWithParams}
sort="top" sort="top"
active={activeTab === 0} active={activeTab === 0}
/> />
@@ -359,7 +499,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
title: _(msg`Latest`), title: _(msg`Latest`),
component: ( component: (
<SearchScreenPostResults <SearchScreenPostResults
query={query} query={queryWithParams}
sort="latest" sort="latest"
active={activeTab === 1} active={activeTab === 1}
/> />
@@ -378,7 +518,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
), ),
}, },
] ]
}, [_, query, activeTab]) }, [_, query, queryWithParams, activeTab])
return query ? ( return query ? (
<Pager <Pager
@@ -386,7 +526,15 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
renderTabBar={props => ( renderTabBar={props => (
<CenteredView <CenteredView
sideBorders sideBorders
style={[pal.border, pal.view, styles.tabBarContainer]}> style={[
pal.border,
pal.view,
web({
position: isWeb ? 'sticky' : '',
zIndex: 1,
}),
{top: isWeb ? headerHeight : undefined},
]}>
<TabBar items={sections.map(section => section.title)} {...props} /> <TabBar items={sections.map(section => section.title)} {...props} />
</CenteredView> </CenteredView>
)} )}
@@ -448,14 +596,14 @@ SearchScreenInner = React.memo(SearchScreenInner)
export function SearchScreen( export function SearchScreen(
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
) { ) {
const t = useThemeNew()
const {gtMobile} = useBreakpoints()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const textInput = React.useRef<TextInput>(null) const textInput = React.useRef<TextInput>(null)
const {_} = useLingui() const {_} = useLingui()
const pal = usePalette('default')
const {track} = useAnalytics() const {track} = useAnalytics()
const setDrawerOpen = useSetDrawerOpen() const setDrawerOpen = useSetDrawerOpen()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
// Query terms // Query terms
const queryParam = props.route?.params?.q ?? '' const queryParam = props.route?.params?.q ?? ''
@@ -469,6 +617,17 @@ export function SearchScreen(
AppBskyActorDefs.ProfileViewBasic[] 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( useFocusEffect(
useNonReactiveCallback(() => { useNonReactiveCallback(() => {
if (isWeb) { if (isWeb) {
@@ -507,13 +666,6 @@ export function SearchScreen(
textInput.current?.focus() textInput.current?.focus()
}, []) }, [])
const onPressCancelSearch = React.useCallback(() => {
scrollToTopWeb()
textInput.current?.blur()
setShowAutocomplete(false)
setSearchText(queryParam)
}, [queryParam])
const onChangeText = React.useCallback(async (text: string) => { const onChangeText = React.useCallback(async (text: string) => {
scrollToTopWeb() scrollToTopWeb()
setSearchText(text) setSearchText(text)
@@ -586,6 +738,13 @@ export function SearchScreen(
[updateSearchHistory, navigation], [updateSearchHistory, navigation],
) )
const onPressCancelSearch = React.useCallback(() => {
scrollToTopWeb()
textInput.current?.blur()
setShowAutocomplete(false)
setSearchText(queryParam)
}, [setShowAutocomplete, setSearchText, queryParam])
const onSubmit = React.useCallback(() => { const onSubmit = React.useCallback(() => {
navigateToItem(searchText) navigateToItem(searchText)
}, [navigateToItem, searchText]) }, [navigateToItem, searchText])
@@ -624,6 +783,7 @@ export function SearchScreen(
setSearchText('') setSearchText('')
navigation.setParams({q: ''}) navigation.setParams({q: ''})
} }
setShowFilters(false)
}, [navigation]) }, [navigation])
useFocusEffect( useFocusEffect(
@@ -663,50 +823,107 @@ export function SearchScreen(
[selectedProfiles], [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 ( return (
<View style={isWeb ? null : {flex: 1}}> <View style={isWeb ? null : {flex: 1}}>
<CenteredView <CenteredView
style={[ style={[
styles.header, a.p_md,
pal.border, a.pb_0,
pal.view, a.gap_sm,
isTabletOrDesktop && {paddingTop: 10}, t.atoms.bg,
web({
height: headerHeight,
position: 'sticky',
top: 0,
zIndex: 1,
}),
]} ]}
sideBorders={isTabletOrDesktop}> sideBorders={gtMobile}>
{isTabletOrMobile && ( <View style={[a.flex_row, a.gap_sm]}>
<Pressable {!gtMobile && (
testID="viewHeaderBackOrMenuBtn" <Button
onPress={onPressMenu} testID="viewHeaderBackOrMenuBtn"
hitSlop={HITSLOP_10} onPress={onPressMenu}
style={styles.headerMenuBtn} hitSlop={HITSLOP_10}
accessibilityRole="button" label={_(msg`Menu`)}
accessibilityLabel={_(msg`Menu`)} accessibilityHint={_(msg`Access navigation links and settings`)}
accessibilityHint={_(msg`Access navigation links and settings`)}> size="large"
<Menu size="lg" fill={pal.colors.textLight} /> variant="solid"
</Pressable> color="secondary"
)} shape="square">
<SearchInputBox <ButtonIcon icon={Menu} size="lg" />
textInput={textInput} </Button>
searchText={searchText} )}
showAutocomplete={showAutocomplete} <SearchInputBox
setShowAutocomplete={setShowAutocomplete} textInput={textInput}
onChangeText={onChangeText} searchText={searchText}
onSubmit={onSubmit} showAutocomplete={showAutocomplete}
onPressClearQuery={onPressClearQuery} onFocus={onSearchInputFocus}
/> onChangeText={onChangeText}
{showAutocomplete && ( onSubmit={onSubmit}
<View style={[styles.headerCancelBtn]}> onPressClearQuery={onPressClearQuery}
<Pressable />
{showFiltersButton && (
<Button
onPress={() => setShowFilters(!showFilters)}
hitSlop={HITSLOP_10}
label={_(msg`Show advanced filters`)}
size="large"
variant="solid"
color="secondary"
shape="square">
<Gear
size="md"
fill={
showFilters
? t.palette.primary_500
: t.atoms.text_contrast_low.color
}
/>
</Button>
)}
{showAutocomplete && (
<Button
label={_(msg`Cancel search`)}
size="large"
variant="ghost"
color="secondary"
style={[a.px_sm]}
onPress={onPressCancelSearch} onPress={onPressCancelSearch}
accessibilityRole="button"
hitSlop={HITSLOP_10}> hitSlop={HITSLOP_10}>
<Text style={pal.text}> <ButtonText>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Text> </ButtonText>
</Pressable> </Button>
)}
</View>
{showFilters && (
<View
style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}>
<View style={[{width: 140}]}>
<SearchLanguageDropdown
value={params.lang}
onChange={params.setLang}
/>
</View>
</View> </View>
)} )}
</CenteredView> </CenteredView>
<View <View
style={{ style={{
display: showAutocomplete ? 'flex' : 'none', display: showAutocomplete ? 'flex' : 'none',
@@ -737,7 +954,11 @@ export function SearchScreen(
display: showAutocomplete ? 'none' : 'flex', display: showAutocomplete ? 'none' : 'flex',
flex: 1, flex: 1,
}}> }}>
<SearchScreenInner query={queryParam} /> <SearchScreenInner
query={query}
queryWithParams={queryWithParams}
headerHeight={headerHeight}
/>
</View> </View>
</View> </View>
) )
@@ -747,7 +968,7 @@ let SearchInputBox = ({
textInput, textInput,
searchText, searchText,
showAutocomplete, showAutocomplete,
setShowAutocomplete, onFocus,
onChangeText, onChangeText,
onSubmit, onSubmit,
onPressClearQuery, onPressClearQuery,
@@ -755,83 +976,62 @@ let SearchInputBox = ({
textInput: React.RefObject<TextInput> textInput: React.RefObject<TextInput>
searchText: string searchText: string
showAutocomplete: boolean showAutocomplete: boolean
setShowAutocomplete: (show: boolean) => void onFocus: () => void
onChangeText: (text: string) => void onChangeText: (text: string) => void
onSubmit: () => void onSubmit: () => void
onPressClearQuery: () => void onPressClearQuery: () => void
}): React.ReactNode => { }): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const theme = useTheme() const t = useThemeNew()
return ( return (
<Pressable <View style={[a.flex_1, a.relative]}>
// This only exists only for extra hitslop so don't expose it to the a11y tree. <TextField.Root>
accessible={false} <TextField.Icon icon={MagnifyingGlass} />
focusable={false} <TextField.Input
// @ts-ignore web-only inputRef={textInput}
tabIndex={-1} label={_(msg`Search`)}
style={[ value={searchText}
{backgroundColor: pal.colors.backgroundLight}, placeholder={_(msg`Search`)}
styles.headerSearchContainer, returnKeyType="search"
// @ts-expect-error web only onChangeText={onChangeText}
isWeb && { onSubmitEditing={onSubmit}
cursor: 'default', onFocus={onFocus}
}, keyboardAppearance={t.scheme}
]} selectTextOnFocus={isNative}
onPress={() => { autoFocus={false}
textInput.current?.focus() accessibilityRole="search"
}}> autoCorrect={false}
<MagnifyingGlassIcon autoComplete="off"
style={[pal.icon, styles.headerSearchIcon]} autoCapitalize="none"
size={20} />
/> </TextField.Root>
<TextInput
testID="searchTextInput"
ref={textInput}
placeholder={_(msg`Search`)}
placeholderTextColor={pal.colors.textLight}
returnKeyType="search"
value={searchText}
style={[pal.text, styles.headerSearchInput]}
keyboardAppearance={theme.colorScheme}
selectTextOnFocus={isNative}
onFocus={() => {
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 && ( {showAutocomplete && searchText.length > 0 && (
<Pressable <View
testID="searchTextInputClearBtn" style={[
onPress={onPressClearQuery} a.absolute,
accessibilityRole="button" a.z_10,
accessibilityLabel={_(msg`Clear search query`)} a.my_auto,
accessibilityHint="" a.inset_0,
hitSlop={HITSLOP_10}> a.justify_center,
<FontAwesomeIcon a.pr_sm,
icon="xmark" {left: 'auto'},
size={16} ]}>
style={pal.textLight as FontAwesomeIconStyle} <Button
/> testID="searchTextInputClearBtn"
</Pressable> onPress={onPressClearQuery}
label={_(msg`Clear search query`)}
hitSlop={HITSLOP_10}
size="tiny"
shape="round"
variant="ghost"
color="secondary">
<ButtonIcon icon={X} size="sm" />
</Button>
</View>
)} )}
</Pressable> </View>
) )
} }
SearchInputBox = React.memo(SearchInputBox) SearchInputBox = React.memo(SearchInputBox)
@@ -1029,21 +1229,7 @@ function scrollToTopWeb() {
} }
} }
const HEADER_HEIGHT = 46
const styles = StyleSheet.create({ 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: { headerMenuBtn: {
width: 30, width: 30,
height: 30, height: 30,
@@ -1075,12 +1261,6 @@ const styles = StyleSheet.create({
zIndex: -1, zIndex: -1,
elevation: -1, // For Android elevation: -1, // For Android
}, },
tabBarContainer: {
// @ts-ignore web only
position: isWeb ? 'sticky' : '',
top: isWeb ? HEADER_HEIGHT : 0,
zIndex: 1,
},
searchHistoryContainer: { searchHistoryContainer: {
width: '100%', width: '100%',
paddingHorizontal: 12, paddingHorizontal: 12,
+6 -2
View File
@@ -94,10 +94,14 @@ function SettingsAccountCard({
/> />
</View> </View>
<View style={[s.flex1]}> <View style={[s.flex1]}>
<Text type="md-bold" style={[pal.text, a.self_start]} numberOfLines={1}> <Text
emoji
type="md-bold"
style={[pal.text, a.self_start]}
numberOfLines={1}>
{profile?.displayName || account.handle} {profile?.displayName || account.handle}
</Text> </Text>
<Text type="sm" style={pal.textLight} numberOfLines={1}> <Text emoji type="sm" style={pal.textLight} numberOfLines={1}>
{account.handle} {account.handle}
</Text> </Text>
</View> </View>
+1 -1
View File
@@ -32,7 +32,7 @@ export function Forms() {
label="Text field" label="Text field"
/> />
<View style={[a.flex_row, a.gap_sm]}> <View style={[a.flex_row, a.align_start, a.gap_sm]}>
<View <View
style={[ style={[
{ {
+10 -6
View File
@@ -1,5 +1,5 @@
import React, {ComponentProps} from 'react' import React, {ComponentProps} from 'react'
import {GestureResponderEvent, TouchableOpacity, View} from 'react-native' import {GestureResponderEvent, View} from 'react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
@@ -8,6 +8,7 @@ import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
import {StackActions} from '@react-navigation/native' import {StackActions} from '@react-navigation/native'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useDedupe} from '#/lib/hooks/useDedupe' import {useDedupe} from '#/lib/hooks/useDedupe'
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform' import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
@@ -29,6 +30,7 @@ import {Text} from '#/view/com/util/text/Text'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype' import {Logotype} from '#/view/icons/Logotype'
import {atoms as a} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
import { import {
@@ -326,7 +328,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
interface BtnProps interface BtnProps
extends Pick< extends Pick<
ComponentProps<typeof TouchableOpacity>, ComponentProps<typeof PressableScale>,
| 'accessible' | 'accessible'
| 'accessibilityRole' | 'accessibilityRole'
| 'accessibilityHint' | 'accessibilityHint'
@@ -350,7 +352,7 @@ function Btn({
accessibilityLabel, accessibilityLabel,
}: BtnProps) { }: BtnProps) {
return ( return (
<TouchableOpacity <PressableScale
testID={testID} testID={testID}
style={styles.ctrl} style={styles.ctrl}
onPress={onLongPress ? onPress : undefined} onPress={onLongPress ? onPress : undefined}
@@ -358,13 +360,15 @@ function Btn({
onLongPress={onLongPress} onLongPress={onLongPress}
accessible={accessible} accessible={accessible}
accessibilityLabel={accessibilityLabel} accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}> accessibilityHint={accessibilityHint}
targetScale={0.8}
contentContainerStyle={[a.flex_1]}>
{icon} {icon}
{notificationCount ? ( {notificationCount ? (
<View style={[styles.notificationCount]}> <View style={[styles.notificationCount, {top: -5}]}>
<Text style={styles.notificationCountLabel}>{notificationCount}</Text> <Text style={styles.notificationCountLabel}>{notificationCount}</Text>
</View> </View>
) : undefined} ) : undefined}
</TouchableOpacity> </PressableScale>
) )
} }
+5
View File
@@ -12165,6 +12165,11 @@ expo-asset@~10.0.6:
invariant "^2.2.4" invariant "^2.2.4"
md5-file "^3.2.3" 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: expo-build-properties@^0.12.1:
version "0.12.1" version "0.12.1"
resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.12.1.tgz#8d11759b8f382e4654e2482ddcec4f9ad4530aad" resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.12.1.tgz#8d11759b8f382e4654e2482ddcec4f9ad4530aad"