Merge branch 'main' into ja-translation-17
This commit is contained in:
@@ -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
@@ -9,11 +9,11 @@ describe('isNetworkError', () => {
|
||||
]
|
||||
const outputs = [true, false, false, true]
|
||||
|
||||
it('correctly distinguishes network errors', () => {
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const result = isNetworkError(input)
|
||||
expect(result).toEqual(outputs[i])
|
||||
}
|
||||
})
|
||||
for (let i = 0; i < inputs.length; i++) {
|
||||
const input = inputs[i]
|
||||
const output = outputs[i]
|
||||
it(`correctly distinguishes network errors for ${input}`, () => {
|
||||
expect(isNetworkError(input)).toEqual(output)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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_): "Coca-Cola and Mentos! Super Reaction! #cocacola #mentos #reaction #balloon #sciencemoment #scienceexperiment #experiment #test #amazing #pvexp". оригинальный звук - Power Vision Tests.',
|
||||
}
|
||||
const output = extractHtmlMeta({html: input, hostname: 'tiktok.com'})
|
||||
expect(output).toEqual(expectedOutput)
|
||||
})
|
||||
|
||||
it('extracts title and description from a generic youtube page', () => {
|
||||
const input = youtubeHTML
|
||||
const expectedOutput = {
|
||||
title: 'HD Video (1080p) with Relaxing Music of Native American Shamans',
|
||||
description:
|
||||
'Stunning HD Video ( 1080p ) of Patagonian Nature with Relaxing Native American Shamanic Music. HD footage used from ',
|
||||
image: 'https://i.ytimg.com/vi/x6UITRjhijI/sddefault.jpg',
|
||||
}
|
||||
const output = extractHtmlMeta({html: input, hostname: 'youtube.com'})
|
||||
expect(output).toEqual(expectedOutput)
|
||||
})
|
||||
|
||||
it('extracts avatar from a youtube channel', () => {
|
||||
const input = youtubeChannelHtml
|
||||
const expectedOutput = {
|
||||
title: 'penguinz0',
|
||||
description:
|
||||
'Clips channel: https://www.youtube.com/channel/UC4EQHfzIbkL_Skit_iKt1aA\n\nTwitter: https://twitter.com/MoistCr1TiKaL\n\nInstagram: https://www.instagram.com/bigmoistcr1tikal/?hl=en\n\nTwitch: https://www.twitch.tv/moistcr1tikal\n\nSnapchat: Hugecharles\n\nTik Tok: Hugecharles\n\nI don't have any other public accounts.',
|
||||
image:
|
||||
'https://yt3.googleusercontent.com/ytc/AL5GRJWOhJOuUC6C2b7gP-5D2q6ypXbcOOckyAE1En4RUQ=s176-c-k-c0x00ffffff-no-rj',
|
||||
}
|
||||
const output = extractHtmlMeta({html: input, hostname: 'youtube.com'})
|
||||
expect(output).toEqual(expectedOutput)
|
||||
})
|
||||
|
||||
it('extracts username from the url a twitter profile page', () => {
|
||||
const expectedOutput = {
|
||||
title: '@bluesky on Twitter',
|
||||
}
|
||||
const output = extractHtmlMeta({
|
||||
html: '',
|
||||
hostname: 'twitter.com',
|
||||
pathname: '/bluesky',
|
||||
})
|
||||
expect(output).toEqual(expectedOutput)
|
||||
})
|
||||
|
||||
it('extracts username from the url a tweet', () => {
|
||||
const expectedOutput = {
|
||||
title: 'Tweet by @bluesky',
|
||||
}
|
||||
const output = extractHtmlMeta({
|
||||
html: '',
|
||||
hostname: 'twitter.com',
|
||||
pathname: '/bluesky/status/1582437529969917953',
|
||||
})
|
||||
expect(output).toEqual(expectedOutput)
|
||||
})
|
||||
|
||||
it("does not extract username from the url when it's not a tweet or profile page", () => {
|
||||
const expectedOutput = {
|
||||
title: 'Twitter',
|
||||
}
|
||||
const output = extractHtmlMeta({
|
||||
html: '',
|
||||
hostname: 'twitter.com',
|
||||
pathname: '/i/articles/follows/-1675653703?time_window=24',
|
||||
})
|
||||
expect(output).toEqual(expectedOutput)
|
||||
})
|
||||
})
|
||||
@@ -120,6 +120,7 @@
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^51.0.8",
|
||||
"expo-application": "^5.9.1",
|
||||
"expo-blur": "^13.0.2",
|
||||
"expo-build-properties": "^0.12.1",
|
||||
"expo-camera": "~15.0.9",
|
||||
"expo-clipboard": "^6.0.3",
|
||||
|
||||
@@ -305,6 +305,7 @@ export function createThemes({
|
||||
} as const
|
||||
|
||||
const light: Theme = {
|
||||
scheme: 'light',
|
||||
name: 'light',
|
||||
palette: lightPalette,
|
||||
atoms: {
|
||||
@@ -390,6 +391,7 @@ export function createThemes({
|
||||
}
|
||||
|
||||
const dark: Theme = {
|
||||
scheme: 'dark',
|
||||
name: 'dark',
|
||||
palette: darkPalette,
|
||||
atoms: {
|
||||
@@ -479,6 +481,7 @@ export function createThemes({
|
||||
|
||||
const dim: Theme = {
|
||||
...dark,
|
||||
scheme: 'dark',
|
||||
name: 'dim',
|
||||
palette: dimPalette,
|
||||
atoms: {
|
||||
|
||||
@@ -156,6 +156,7 @@ export type ThemedAtoms = {
|
||||
}
|
||||
}
|
||||
export type Theme = {
|
||||
scheme: 'light' | 'dark' // for library support
|
||||
name: ThemeName
|
||||
palette: Palette
|
||||
atoms: ThemedAtoms
|
||||
|
||||
@@ -12,15 +12,15 @@ import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||
|
||||
import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
|
||||
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {parseStarterPackUri} from '#/lib/strings/starter-pack'
|
||||
import {logger} from '#/logger'
|
||||
import {useGenerateStarterPackMutation} from 'lib/generate-starterpack'
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {parseStarterPackUri} from 'lib/strings/starter-pack'
|
||||
import {List, ListRef} from 'view/com/util/List'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {List, ListRef} from '#/view/com/util/List'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {atoms as a, ios, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||
@@ -132,6 +132,7 @@ export const ProfileStarterPacks = React.forwardRef<
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
headerOffset={headerOffset}
|
||||
progressViewOffset={ios(0)}
|
||||
contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
|
||||
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
|
||||
removeClippedSubviews={true}
|
||||
|
||||
@@ -135,6 +135,8 @@ export function createInput(Component: typeof TextInput) {
|
||||
placeholder,
|
||||
value,
|
||||
onChangeText,
|
||||
onFocus,
|
||||
onBlur,
|
||||
isInvalid,
|
||||
inputRef,
|
||||
style,
|
||||
@@ -173,8 +175,14 @@ export function createInput(Component: typeof TextInput) {
|
||||
ref={refs}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={ctx.onFocus}
|
||||
onBlur={ctx.onBlur}
|
||||
onFocus={e => {
|
||||
ctx.onFocus()
|
||||
onFocus?.(e)
|
||||
}}
|
||||
onBlur={e => {
|
||||
ctx.onBlur()
|
||||
onBlur?.(e)
|
||||
}}
|
||||
placeholder={placeholder || label}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
@@ -188,8 +196,8 @@ export function createInput(Component: typeof TextInput) {
|
||||
a.px_xs,
|
||||
{
|
||||
// paddingVertical doesn't work w/multiline - esb
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 13,
|
||||
lineHeight: a.text_md.fontSize * 1.1875,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
@@ -197,13 +205,14 @@ export function createInput(Component: typeof TextInput) {
|
||||
},
|
||||
// fix for autofill styles covering border
|
||||
web({
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 11,
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
}),
|
||||
android({
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react'
|
||||
import * as Device from 'expo-device'
|
||||
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
|
||||
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {useHapticsDisabled} from '#/state/preferences/disable-haptics'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
export function useHaptics() {
|
||||
const isHapticsDisabled = useHapticsDisabled()
|
||||
@@ -18,6 +20,11 @@ export function useHaptics() {
|
||||
? ImpactFeedbackStyle[strength]
|
||||
: ImpactFeedbackStyle.Light
|
||||
impactAsync(style)
|
||||
|
||||
// DEV ONLY - show a toast when a haptic is meant to fire on simulator
|
||||
if (__DEV__ && !Device.isDevice) {
|
||||
Toast.show(`Buzzz!`)
|
||||
}
|
||||
},
|
||||
[isHapticsDisabled],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
'debug_show_feedcontext' | 'suggested_feeds_interstitial'
|
||||
| 'debug_show_feedcontext'
|
||||
| 'post_feed_lang_window'
|
||||
| 'suggested_feeds_interstitial'
|
||||
|
||||
@@ -20,11 +20,19 @@ export function cleanError(str: any): string {
|
||||
return str
|
||||
}
|
||||
|
||||
const NETWORK_ERRORS = [
|
||||
'Abort',
|
||||
'Network request failed',
|
||||
'Failed to fetch',
|
||||
'Load failed',
|
||||
]
|
||||
|
||||
export function isNetworkError(e: unknown) {
|
||||
const str = String(e)
|
||||
return (
|
||||
str.includes('Abort') ||
|
||||
str.includes('Network request failed') ||
|
||||
str.includes('Failed to fetch')
|
||||
)
|
||||
for (const err of NETWORK_ERRORS) {
|
||||
if (str.includes(err)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+8
-2
@@ -1,10 +1,11 @@
|
||||
import format from 'date-fns/format'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {Sentry} from '#/logger/sentry'
|
||||
import * as env from '#/env'
|
||||
import {DebugContext} from '#/logger/debugContext'
|
||||
import {add} from '#/logger/logDump'
|
||||
import {Sentry} from '#/logger/sentry'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import * as env from '#/env'
|
||||
|
||||
export enum LogLevel {
|
||||
Debug = 'debug',
|
||||
@@ -160,6 +161,11 @@ export const sentryTransport: Transport = (
|
||||
timestamp: timestamp / 1000, // Sentry expects seconds
|
||||
})
|
||||
|
||||
// We don't want to send any network errors to sentry
|
||||
if (isNetworkError(message)) {
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* Send all higher levels with `captureMessage`, with appropriate severity
|
||||
* level
|
||||
|
||||
@@ -24,8 +24,9 @@ import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
import {Link} from '#/components/Link'
|
||||
@@ -203,6 +204,19 @@ function ChatListItemReady({
|
||||
onFocus={onFocus}
|
||||
onBlur={onMouseLeave}
|
||||
style={[a.relative]}>
|
||||
<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
|
||||
to={`/messages/${convo.id}`}
|
||||
label={displayName}
|
||||
@@ -236,11 +250,8 @@ function ChatListItemReady({
|
||||
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={52}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
{/* Avatar goes here */}
|
||||
<View style={{width: 52, height: 52}} />
|
||||
|
||||
<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]}>
|
||||
@@ -357,7 +368,7 @@ function ChatListItemReady({
|
||||
a.self_end,
|
||||
a.justify_center,
|
||||
{
|
||||
right: a.px_lg.paddingRight,
|
||||
right: tokens.space.lg,
|
||||
opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ProfileHeaderDisplayName({
|
||||
@@ -16,12 +16,19 @@ export function ProfileHeaderDisplayName({
|
||||
moderation: ModerationDecision
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View pointerEvents="none">
|
||||
<Text
|
||||
emoji
|
||||
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(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
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
|
||||
}
|
||||
@@ -6,19 +6,21 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {BACK_HITSLOP} from '#/lib/constants'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {ProfileImageLightbox, useLightboxControls} from '#/state/lightbox'
|
||||
import {useSession} from '#/state/session'
|
||||
import {BACK_HITSLOP} from 'lib/constants'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {UserBanner} from 'view/com/util/UserBanner'
|
||||
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {UserBanner} from '#/view/com/util/UserBanner'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
|
||||
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
|
||||
import {GrowableAvatar} from './GrowableAvatar'
|
||||
import {GrowableBanner} from './GrowableBanner'
|
||||
|
||||
interface Props {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
@@ -63,20 +65,45 @@ let ProfileHeaderShell = ({
|
||||
|
||||
return (
|
||||
<View style={t.atoms.bg} pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<View pointerEvents={isIOS ? 'auto' : 'none'}>
|
||||
{isPlaceholderProfile ? (
|
||||
<LoadingPlaceholder
|
||||
width="100%"
|
||||
height={150}
|
||||
style={{borderRadius: 0}}
|
||||
/>
|
||||
) : (
|
||||
<UserBanner
|
||||
type={profile.associated?.labeler ? 'labeler' : 'default'}
|
||||
banner={profile.banner}
|
||||
moderation={moderation.ui('banner')}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
pointerEvents={isIOS ? 'auto' : 'none'}
|
||||
style={[a.relative, {height: 150}]}>
|
||||
<GrowableBanner
|
||||
backButton={
|
||||
<>
|
||||
{!isDesktop && !hideBackButton && (
|
||||
<TouchableWithoutFeedback
|
||||
testID="profileHeaderBackBtn"
|
||||
onPress={onPressBack}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
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>
|
||||
|
||||
{children}
|
||||
@@ -93,40 +120,29 @@ let ProfileHeaderShell = ({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isDesktop && !hideBackButton && (
|
||||
<GrowableAvatar style={styles.aviPosition}>
|
||||
<TouchableWithoutFeedback
|
||||
testID="profileHeaderBackBtn"
|
||||
onPress={onPressBack}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Back`)}
|
||||
testID="profileHeaderAviButton"
|
||||
onPress={onPressAvi}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
|
||||
accessibilityHint="">
|
||||
<View style={styles.backBtnWrapper}>
|
||||
<FontAwesomeIcon size={18} icon="angle-left" color="white" />
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
</GrowableAvatar>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -144,6 +160,9 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 15,
|
||||
// @ts-ignore web only
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
backBtn: {
|
||||
width: 30,
|
||||
@@ -152,10 +171,12 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avi: {
|
||||
aviPosition: {
|
||||
position: 'absolute',
|
||||
top: 110,
|
||||
left: 10,
|
||||
},
|
||||
avi: {
|
||||
width: 94,
|
||||
height: 94,
|
||||
borderRadius: 47,
|
||||
|
||||
@@ -7,18 +7,22 @@ import {
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
|
||||
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {useTheme} from '#/alf'
|
||||
import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
|
||||
import {ProfileHeaderStandard} from './ProfileHeaderStandard'
|
||||
|
||||
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={pal.view}>
|
||||
<View style={t.atoms.bg}>
|
||||
<LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
|
||||
<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} />
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
|
||||
@@ -4,17 +4,18 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Feed} from '#/view/com/posts/Feed'
|
||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||
import {ListRef} from '#/view/com/util/List'
|
||||
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {ListRef} from 'view/com/util/List'
|
||||
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
|
||||
import {ios} from '#/alf'
|
||||
import {SectionRef} from './types'
|
||||
|
||||
interface FeedSectionProps {
|
||||
@@ -82,6 +83,7 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
onScrolledDownChange={setIsScrolledDown}
|
||||
renderEmptyState={renderPostsEmpty}
|
||||
headerOffset={headerHeight}
|
||||
progressViewOffset={ios(0)}
|
||||
renderEndOfFeed={ProfileEndOfFeed}
|
||||
ignoreFilterFor={ignoreFilterFor}
|
||||
initialNumToRender={
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useAgent} from 'state/session'
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
const RQKEY_ROOT = 'actor-starter-packs'
|
||||
export const RQKEY_ROOT = 'actor-starter-packs'
|
||||
export const RQKEY = (did?: string) => [RQKEY_ROOT, did]
|
||||
|
||||
export function useActorStarterPacksQuery({did}: {did?: string}) {
|
||||
|
||||
@@ -15,24 +15,25 @@ import {
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {AuthorFeedAPI} from '#/lib/api/feed/author'
|
||||
import {CustomFeedAPI} from '#/lib/api/feed/custom'
|
||||
import {FollowingFeedAPI} from '#/lib/api/feed/following'
|
||||
import {HomeFeedAPI} from '#/lib/api/feed/home'
|
||||
import {LikesFeedAPI} from '#/lib/api/feed/likes'
|
||||
import {ListFeedAPI} from '#/lib/api/feed/list'
|
||||
import {MergeFeedAPI} from '#/lib/api/feed/merge'
|
||||
import {FeedAPI, ReasonFeedSource} from '#/lib/api/feed/types'
|
||||
import {aggregateUserInterests} from '#/lib/api/feed/utils'
|
||||
import {FeedTuner, FeedTunerFn} from '#/lib/api/feed-manip'
|
||||
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
||||
import {BSKY_FEED_OWNER_DIDS} from '#/lib/constants'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import {AuthorFeedAPI} from 'lib/api/feed/author'
|
||||
import {CustomFeedAPI} from 'lib/api/feed/custom'
|
||||
import {FollowingFeedAPI} from 'lib/api/feed/following'
|
||||
import {LikesFeedAPI} from 'lib/api/feed/likes'
|
||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
||||
import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
|
||||
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
||||
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
||||
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||
@@ -65,7 +66,7 @@ export interface FeedParams {
|
||||
|
||||
type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined
|
||||
|
||||
const RQKEY_ROOT = 'post-feed'
|
||||
export const RQKEY_ROOT = 'post-feed'
|
||||
export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) {
|
||||
return [RQKEY_ROOT, feedDesc, params || {}]
|
||||
}
|
||||
@@ -109,13 +110,19 @@ export interface FeedPage {
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
/**
|
||||
* The minimum number of posts we want in a single "page" of results. Since we
|
||||
* filter out unwanted content, we may fetch more than this number to ensure
|
||||
* that we get _at least_ this number.
|
||||
*/
|
||||
const MIN_POSTS = 30
|
||||
|
||||
export function usePostFeedQuery(
|
||||
feedDesc: FeedDescriptor,
|
||||
params?: FeedParams,
|
||||
opts?: {enabled?: boolean; ignoreFilterFor?: string},
|
||||
) {
|
||||
const gate = useGate()
|
||||
const feedTuners = useFeedTuners(feedDesc)
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
@@ -135,6 +142,13 @@ export function usePostFeedQuery(
|
||||
} | null>(null)
|
||||
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
|
||||
|
||||
/**
|
||||
* The number of posts to fetch in a single request. Because we filter
|
||||
* unwanted content, we may over-fetch here to try and fill pages by
|
||||
* `MIN_POSTS`.
|
||||
*/
|
||||
const fetchLimit = gate('post_feed_lang_window') ? 100 : MIN_POSTS
|
||||
|
||||
// Make sure this doesn't invalidate unless really needed.
|
||||
const selectArgs = React.useMemo(
|
||||
() => ({
|
||||
@@ -175,7 +189,7 @@ export function usePostFeedQuery(
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.fetch({cursor, limit: PAGE_SIZE})
|
||||
const res = await api.fetch({cursor, limit: fetchLimit})
|
||||
|
||||
/*
|
||||
* If this is a public view, we need to check if posts fail moderation.
|
||||
@@ -373,13 +387,13 @@ export function usePostFeedQuery(
|
||||
// Now track how many items we really want, and fetch more if needed.
|
||||
if (isLoading || isRefetching) {
|
||||
// During the initial fetch, we want to get an entire page's worth of items.
|
||||
wantedItemCount.current = PAGE_SIZE
|
||||
wantedItemCount.current = MIN_POSTS
|
||||
} else if (isFetchingNextPage) {
|
||||
if (itemCount > wantedItemCount.current) {
|
||||
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||
wantedItemCount.current = itemCount + PAGE_SIZE
|
||||
wantedItemCount.current = itemCount + MIN_POSTS
|
||||
}
|
||||
} else if (hasNextPage) {
|
||||
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||
|
||||
@@ -8,7 +8,7 @@ const PAGE_SIZE = 50
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
// TODO refactor invalidate on mutate?
|
||||
const RQKEY_ROOT = 'profile-feedgens'
|
||||
export const RQKEY_ROOT = 'profile-feedgens'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
export function useProfileFeedgensQuery(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {useModerationOpts} from '../preferences/moderation-opts'
|
||||
const PAGE_SIZE = 30
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
const RQKEY_ROOT = 'profile-lists'
|
||||
export const RQKEY_ROOT = 'profile-lists'
|
||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||
|
||||
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
||||
|
||||
@@ -15,9 +15,9 @@ import {logger} from '#/logger'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
|
||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, ios, useTheme} from '#/alf'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {List, ListRef} from '../util/List'
|
||||
@@ -191,6 +191,7 @@ export const ProfileFeedgens = React.forwardRef<
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
headerOffset={headerOffset}
|
||||
progressViewOffset={ios(0)}
|
||||
contentContainerStyle={isNative && {paddingBottom: headerOffset + 100}}
|
||||
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
|
||||
removeClippedSubviews={true}
|
||||
|
||||
@@ -10,14 +10,14 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, ios, useTheme} from '#/alf'
|
||||
import * as ListCard from '#/components/ListCard'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {List, ListRef} from '../util/List'
|
||||
@@ -192,6 +192,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
headerOffset={headerOffset}
|
||||
progressViewOffset={ios(0)}
|
||||
contentContainerStyle={
|
||||
isNative && {paddingBottom: headerOffset + 100}
|
||||
}
|
||||
|
||||
@@ -625,7 +625,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
},
|
||||
layoutIcon: {
|
||||
width: 70,
|
||||
width: 60,
|
||||
alignItems: 'flex-end',
|
||||
paddingTop: 2,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,10 @@ import Animated, {
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
|
||||
import {ListMethods} from '../util/List'
|
||||
import {PagerHeaderProvider} from './PagerHeaderContext'
|
||||
import {TabBar} from './TabBar'
|
||||
|
||||
export interface PagerWithHeaderChildParams {
|
||||
@@ -41,6 +42,7 @@ export interface PagerWithHeaderProps {
|
||||
initialPage?: number
|
||||
onPageSelected?: (index: number) => void
|
||||
onCurrentPageSelected?: (index: number) => void
|
||||
allowHeaderOverScroll?: boolean
|
||||
}
|
||||
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
function PageWithHeaderImpl(
|
||||
@@ -53,6 +55,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
initialPage,
|
||||
onPageSelected,
|
||||
onCurrentPageSelected,
|
||||
allowHeaderOverScroll,
|
||||
}: PagerWithHeaderProps,
|
||||
ref,
|
||||
) {
|
||||
@@ -80,19 +83,22 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
const renderTabBar = React.useCallback(
|
||||
(props: RenderTabBarFnProps) => {
|
||||
return (
|
||||
<PagerTabBar
|
||||
headerOnlyHeight={headerOnlyHeight}
|
||||
items={items}
|
||||
isHeaderReady={isHeaderReady}
|
||||
renderHeader={renderHeader}
|
||||
currentPage={currentPage}
|
||||
onCurrentPageSelected={onCurrentPageSelected}
|
||||
onTabBarLayout={onTabBarLayout}
|
||||
onHeaderOnlyLayout={onHeaderOnlyLayout}
|
||||
onSelect={props.onSelect}
|
||||
scrollY={scrollY}
|
||||
testID={testID}
|
||||
/>
|
||||
<PagerHeaderProvider scrollY={scrollY}>
|
||||
<PagerTabBar
|
||||
headerOnlyHeight={headerOnlyHeight}
|
||||
items={items}
|
||||
isHeaderReady={isHeaderReady}
|
||||
renderHeader={renderHeader}
|
||||
currentPage={currentPage}
|
||||
onCurrentPageSelected={onCurrentPageSelected}
|
||||
onTabBarLayout={onTabBarLayout}
|
||||
onHeaderOnlyLayout={onHeaderOnlyLayout}
|
||||
onSelect={props.onSelect}
|
||||
scrollY={scrollY}
|
||||
testID={testID}
|
||||
allowHeaderOverScroll={allowHeaderOverScroll}
|
||||
/>
|
||||
</PagerHeaderProvider>
|
||||
)
|
||||
},
|
||||
[
|
||||
@@ -106,6 +112,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
|
||||
onHeaderOnlyLayout,
|
||||
scrollY,
|
||||
testID,
|
||||
allowHeaderOverScroll,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -216,6 +223,7 @@ let PagerTabBar = ({
|
||||
onTabBarLayout,
|
||||
onCurrentPageSelected,
|
||||
onSelect,
|
||||
allowHeaderOverScroll,
|
||||
}: {
|
||||
currentPage: number
|
||||
headerOnlyHeight: number
|
||||
@@ -228,14 +236,20 @@ let PagerTabBar = ({
|
||||
onTabBarLayout: (e: LayoutChangeEvent) => void
|
||||
onCurrentPageSelected?: (index: number) => void
|
||||
onSelect?: (index: number) => void
|
||||
allowHeaderOverScroll?: boolean
|
||||
}): React.ReactNode => {
|
||||
const headerTransform = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0),
|
||||
},
|
||||
],
|
||||
}))
|
||||
const headerTransform = useAnimatedStyle(() => {
|
||||
const translateY = Math.min(scrollY.value, headerOnlyHeight) * -1
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: allowHeaderOverScroll
|
||||
? translateY
|
||||
: Math.min(translateY, 0),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const headerRef = React.useRef(null)
|
||||
return (
|
||||
<Animated.View
|
||||
|
||||
@@ -4,7 +4,7 @@ import {useAnimatedRef} from 'react-native-reanimated'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
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 {TabBar} from './TabBar'
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
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 {useSession} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function PostThreadComposePrompt({
|
||||
onPressCompose,
|
||||
@@ -17,47 +20,57 @@ export function PostThreadComposePrompt({
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const pal = usePalette('default')
|
||||
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 (
|
||||
<TouchableOpacity
|
||||
testID="replyPromptBtn"
|
||||
style={[pal.view, pal.border, styles.prompt]}
|
||||
onPress={() => onPressCompose()}
|
||||
<PressableScale
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Compose reply`)}
|
||||
accessibilityHint={_(msg`Opens composer`)}>
|
||||
<UserAvatar
|
||||
avatar={profile?.avatar}
|
||||
size={38}
|
||||
type={profile?.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<Text
|
||||
type="xl"
|
||||
accessibilityHint={_(msg`Opens composer`)}
|
||||
style={[
|
||||
{paddingTop: 8, paddingBottom: isTabletOrDesktop ? 8 : 11},
|
||||
a.px_sm,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
]}
|
||||
onPress={onPress}>
|
||||
<View
|
||||
style={[
|
||||
pal.text,
|
||||
isDesktop ? styles.labelDesktopWeb : styles.labelMobile,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.p_sm,
|
||||
a.gap_sm,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Trans>Write your reply</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<UserAvatar
|
||||
size={22}
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,8 +14,11 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {listenPostCreated} from '#/state/events'
|
||||
@@ -30,9 +33,6 @@ import {
|
||||
usePostFeedQuery,
|
||||
} from '#/state/queries/post-feed'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {
|
||||
ProgressGuide,
|
||||
SuggestedFeeds,
|
||||
@@ -167,6 +167,7 @@ let Feed = ({
|
||||
renderEndOfFeed,
|
||||
testID,
|
||||
headerOffset = 0,
|
||||
progressViewOffset,
|
||||
desktopFixedHeightOffset,
|
||||
ListHeaderComponent,
|
||||
extraData,
|
||||
@@ -187,6 +188,7 @@ let Feed = ({
|
||||
renderEndOfFeed?: () => JSX.Element
|
||||
testID?: string
|
||||
headerOffset?: number
|
||||
progressViewOffset?: number
|
||||
desktopFixedHeightOffset?: number
|
||||
ListHeaderComponent?: () => JSX.Element
|
||||
extraData?: any
|
||||
@@ -548,6 +550,7 @@ let Feed = ({
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
headerOffset={headerOffset}
|
||||
progressViewOffset={progressViewOffset}
|
||||
contentContainerStyle={{
|
||||
minHeight: Dimensions.get('window').height * 1.5,
|
||||
}}
|
||||
|
||||
@@ -4,11 +4,11 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
||||
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
|
||||
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useDedupe} from '#/lib/hooks/useDedupe'
|
||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||
import {useDedupe} from 'lib/hooks/useDedupe'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {addStyle} from '#/lib/styles'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useTheme} from '#/alf'
|
||||
import {FlatList_INTERNAL} from './Views'
|
||||
|
||||
export type ListMethods = FlatList_INTERNAL
|
||||
@@ -44,12 +44,13 @@ function ListImpl<ItemT>(
|
||||
onItemSeen,
|
||||
headerOffset,
|
||||
style,
|
||||
progressViewOffset,
|
||||
...props
|
||||
}: ListProps<ItemT>,
|
||||
ref: React.Ref<ListMethods>,
|
||||
) {
|
||||
const isScrolledDown = useSharedValue(false)
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
const dedupe = useDedupe(400)
|
||||
|
||||
function handleScrolledDownChange(didScrollDown: boolean) {
|
||||
@@ -120,9 +121,9 @@ function ListImpl<ItemT>(
|
||||
<RefreshControl
|
||||
refreshing={refreshing ?? false}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={pal.colors.text}
|
||||
titleColor={pal.colors.text}
|
||||
progressViewOffset={headerOffset}
|
||||
tintColor={t.atoms.text.color}
|
||||
titleColor={t.atoms.text.color}
|
||||
progressViewOffset={progressViewOffset ?? headerOffset}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -327,7 +327,8 @@ let EditableUserAvatar = ({
|
||||
|
||||
onSelectNewAvatar(croppedImage)
|
||||
} catch (e: any) {
|
||||
if (!String(e).includes('Canceled')) {
|
||||
// Don't log errors for cancelling selection to sentry on ios or android
|
||||
if (!String(e).toLowerCase().includes('cancel')) {
|
||||
logger.error('Failed to crop banner', {error: e})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
bannerImage: {
|
||||
width: '100%',
|
||||
height: 150,
|
||||
height: '100%',
|
||||
},
|
||||
defaultBanner: {
|
||||
backgroundColor: '#0070ff',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, {useEffect, useId, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import Hls, {Events, FragChangedData, Fragment} from 'hls.js'
|
||||
import type * as HlsTypes from 'hls.js'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {atoms as a} from '#/alf'
|
||||
@@ -23,6 +23,7 @@ export function VideoEmbedInnerWeb({
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
||||
const [hlsLoading, setHlsLoading] = React.useState(false)
|
||||
const figId = useId()
|
||||
|
||||
// send error up to error boundary
|
||||
@@ -37,6 +38,7 @@ export function VideoEmbedInnerWeb({
|
||||
setHasSubtitleTrack,
|
||||
setError,
|
||||
videoRef,
|
||||
setHlsLoading,
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -77,6 +79,7 @@ export function VideoEmbedInnerWeb({
|
||||
setActive={setActive}
|
||||
focused={focused}
|
||||
setFocused={setFocused}
|
||||
hlsLoading={hlsLoading}
|
||||
onScreen={onScreen}
|
||||
fullscreenRef={containerRef}
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
@@ -99,31 +102,62 @@ export class VideoNotFoundError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
type CachedPromise<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({
|
||||
focused,
|
||||
playlist,
|
||||
setHasSubtitleTrack,
|
||||
setError,
|
||||
videoRef,
|
||||
setHlsLoading,
|
||||
}: {
|
||||
focused: boolean
|
||||
playlist: string
|
||||
setHasSubtitleTrack: (v: boolean) => void
|
||||
setError: (v: Error | null) => void
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
setHlsLoading: (v: boolean) => void
|
||||
}) {
|
||||
const hlsRef = useRef<Hls | undefined>(undefined)
|
||||
const [lowQualityFragments, setLowQualityFragments] = useState<Fragment[]>([])
|
||||
const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>(
|
||||
() => 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
|
||||
const handleFragChange = useNonReactiveCallback(
|
||||
(_event: Events.FRAG_CHANGED, {frag}: FragChangedData) => {
|
||||
(
|
||||
_event: HlsTypes.Events.FRAG_CHANGED,
|
||||
{frag}: HlsTypes.FragChangedData,
|
||||
) => {
|
||||
if (!Hls) return
|
||||
if (!hlsRef.current) return
|
||||
const hls = hlsRef.current
|
||||
|
||||
if (focused && hls.nextAutoLevel > 0) {
|
||||
// if the current quality level goes above 0, flush the low quality segments
|
||||
const flushed: Fragment[] = []
|
||||
const flushed: HlsTypes.Fragment[] = []
|
||||
|
||||
for (const lowQualFrag of lowQualityFragments) {
|
||||
// avoid if close to the current fragment
|
||||
@@ -147,12 +181,15 @@ function useHLS({
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
if (!Hls.isSupported()) throw new HLSUnsupportedError()
|
||||
if (!Hls) return
|
||||
if (!Hls.isSupported()) {
|
||||
throw new HLSUnsupportedError()
|
||||
}
|
||||
|
||||
const hls = new Hls({
|
||||
maxMaxBufferLength: 10, // only load 10s ahead
|
||||
// note: the amount buffered is affected by both maxBufferLength and maxBufferSize
|
||||
// it will buffer until it it's greater than *both* of those values
|
||||
// it will buffer until it is greater than *both* of those values
|
||||
// so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
|
||||
})
|
||||
hlsRef.current = hls
|
||||
@@ -211,7 +248,7 @@ function useHLS({
|
||||
hls.destroy()
|
||||
abortController.abort()
|
||||
}
|
||||
}, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange])
|
||||
}, [playlist, setError, setHasSubtitleTrack, videoRef, handleFragChange, Hls])
|
||||
|
||||
return hlsRef
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export function Controls({
|
||||
setFocused,
|
||||
onScreen,
|
||||
fullscreenRef,
|
||||
hlsLoading,
|
||||
hasSubtitleTrack,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
@@ -53,6 +54,7 @@ export function Controls({
|
||||
setFocused: (focused: boolean) => void
|
||||
onScreen: boolean
|
||||
fullscreenRef: React.RefObject<HTMLDivElement>
|
||||
hlsLoading: boolean
|
||||
hasSubtitleTrack: boolean
|
||||
}) {
|
||||
const {
|
||||
@@ -80,6 +82,7 @@ export function Controls({
|
||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
|
||||
const showSpinner = hlsLoading || buffering
|
||||
const {
|
||||
state: volumeHovered,
|
||||
onIn: onVolumeHover,
|
||||
@@ -409,11 +412,11 @@ export function Controls({
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{(buffering || error) && (
|
||||
{(showSpinner || error) && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
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 && (
|
||||
<Text style={{color: t.palette.white}}>
|
||||
<Trans>An error occurred</Trans>
|
||||
|
||||
@@ -37,11 +37,11 @@ import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
|
||||
import {ProfileLists} from '#/view/com/lists/ProfileLists'
|
||||
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {ListRef} from '#/view/com/util/List'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
|
||||
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
|
||||
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
|
||||
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
|
||||
@@ -363,7 +363,8 @@ function ProfileScreenLoaded({
|
||||
items={sectionTitles}
|
||||
onPageSelected={onPageSelected}
|
||||
onCurrentPageSelected={onCurrentPageSelected}
|
||||
renderHeader={renderHeader}>
|
||||
renderHeader={renderHeader}
|
||||
allowHeaderOverScroll>
|
||||
{showFiltersTab
|
||||
? ({headerHeight, isFocused, scrollElRef}) => (
|
||||
<ProfileLabelsSection
|
||||
|
||||
+321
-141
@@ -11,6 +11,7 @@ import {
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler'
|
||||
import RNPickerSelect from 'react-native-picker-select'
|
||||
import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
@@ -21,6 +22,7 @@ import {useLingui} from '@lingui/react'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {LANGUAGES} from '#/lib/../locale/languages'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {createHitslop} from '#/lib/constants'
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
@@ -35,10 +37,10 @@ import {
|
||||
SearchTabNavigatorParams,
|
||||
} from '#/lib/routes/types'
|
||||
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
import {useLanguagePrefs} from '#/state/preferences/languages'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
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 {Explore} from '#/view/screens/Search/Explore'
|
||||
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 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 {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
|
||||
function Loader() {
|
||||
const pal = usePalette('default')
|
||||
@@ -251,7 +260,7 @@ let SearchScreenUserResults = ({
|
||||
const {_} = useLingui()
|
||||
|
||||
const {data: results, isFetched} = useActorSearch({
|
||||
query: query,
|
||||
query,
|
||||
enabled: active,
|
||||
})
|
||||
|
||||
@@ -324,7 +333,138 @@ let 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 setMinimalShellMode = useSetMinimalShellMode()
|
||||
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||
@@ -349,7 +489,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
|
||||
title: _(msg`Top`),
|
||||
component: (
|
||||
<SearchScreenPostResults
|
||||
query={query}
|
||||
query={queryWithParams}
|
||||
sort="top"
|
||||
active={activeTab === 0}
|
||||
/>
|
||||
@@ -359,7 +499,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
|
||||
title: _(msg`Latest`),
|
||||
component: (
|
||||
<SearchScreenPostResults
|
||||
query={query}
|
||||
query={queryWithParams}
|
||||
sort="latest"
|
||||
active={activeTab === 1}
|
||||
/>
|
||||
@@ -378,7 +518,7 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [_, query, activeTab])
|
||||
}, [_, query, queryWithParams, activeTab])
|
||||
|
||||
return query ? (
|
||||
<Pager
|
||||
@@ -386,7 +526,15 @@ let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => {
|
||||
renderTabBar={props => (
|
||||
<CenteredView
|
||||
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} />
|
||||
</CenteredView>
|
||||
)}
|
||||
@@ -448,14 +596,14 @@ SearchScreenInner = React.memo(SearchScreenInner)
|
||||
export function SearchScreen(
|
||||
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
|
||||
) {
|
||||
const t = useThemeNew()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const textInput = React.useRef<TextInput>(null)
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const {track} = useAnalytics()
|
||||
const setDrawerOpen = useSetDrawerOpen()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries()
|
||||
|
||||
// Query terms
|
||||
const queryParam = props.route?.params?.q ?? ''
|
||||
@@ -469,6 +617,17 @@ export function SearchScreen(
|
||||
AppBskyActorDefs.ProfileViewBasic[]
|
||||
>([])
|
||||
|
||||
const {params, query, queryWithParams} = useQueryManager({
|
||||
initialQuery: queryParam,
|
||||
})
|
||||
const showFiltersButton = Boolean(query && !showAutocomplete)
|
||||
const [showFilters, setShowFilters] = React.useState(false)
|
||||
/*
|
||||
* Arbitrary sizing, so guess and check, used for sticky header alignment and
|
||||
* sizing.
|
||||
*/
|
||||
const headerHeight = 56 + (showFilters ? 40 : 0)
|
||||
|
||||
useFocusEffect(
|
||||
useNonReactiveCallback(() => {
|
||||
if (isWeb) {
|
||||
@@ -507,13 +666,6 @@ export function SearchScreen(
|
||||
textInput.current?.focus()
|
||||
}, [])
|
||||
|
||||
const onPressCancelSearch = React.useCallback(() => {
|
||||
scrollToTopWeb()
|
||||
textInput.current?.blur()
|
||||
setShowAutocomplete(false)
|
||||
setSearchText(queryParam)
|
||||
}, [queryParam])
|
||||
|
||||
const onChangeText = React.useCallback(async (text: string) => {
|
||||
scrollToTopWeb()
|
||||
setSearchText(text)
|
||||
@@ -586,6 +738,13 @@ export function SearchScreen(
|
||||
[updateSearchHistory, navigation],
|
||||
)
|
||||
|
||||
const onPressCancelSearch = React.useCallback(() => {
|
||||
scrollToTopWeb()
|
||||
textInput.current?.blur()
|
||||
setShowAutocomplete(false)
|
||||
setSearchText(queryParam)
|
||||
}, [setShowAutocomplete, setSearchText, queryParam])
|
||||
|
||||
const onSubmit = React.useCallback(() => {
|
||||
navigateToItem(searchText)
|
||||
}, [navigateToItem, searchText])
|
||||
@@ -624,6 +783,7 @@ export function SearchScreen(
|
||||
setSearchText('')
|
||||
navigation.setParams({q: ''})
|
||||
}
|
||||
setShowFilters(false)
|
||||
}, [navigation])
|
||||
|
||||
useFocusEffect(
|
||||
@@ -663,50 +823,107 @@ export function SearchScreen(
|
||||
[selectedProfiles],
|
||||
)
|
||||
|
||||
const onSearchInputFocus = React.useCallback(() => {
|
||||
if (isWeb) {
|
||||
// Prevent a jump on iPad by ensuring that
|
||||
// the initial focused render has no result list.
|
||||
requestAnimationFrame(() => {
|
||||
setShowAutocomplete(true)
|
||||
})
|
||||
} else {
|
||||
setShowAutocomplete(true)
|
||||
}
|
||||
setShowFilters(false)
|
||||
}, [setShowAutocomplete])
|
||||
|
||||
return (
|
||||
<View style={isWeb ? null : {flex: 1}}>
|
||||
<CenteredView
|
||||
style={[
|
||||
styles.header,
|
||||
pal.border,
|
||||
pal.view,
|
||||
isTabletOrDesktop && {paddingTop: 10},
|
||||
a.p_md,
|
||||
a.pb_0,
|
||||
a.gap_sm,
|
||||
t.atoms.bg,
|
||||
web({
|
||||
height: headerHeight,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
}),
|
||||
]}
|
||||
sideBorders={isTabletOrDesktop}>
|
||||
{isTabletOrMobile && (
|
||||
<Pressable
|
||||
testID="viewHeaderBackOrMenuBtn"
|
||||
onPress={onPressMenu}
|
||||
hitSlop={HITSLOP_10}
|
||||
style={styles.headerMenuBtn}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Menu`)}
|
||||
accessibilityHint={_(msg`Access navigation links and settings`)}>
|
||||
<Menu size="lg" fill={pal.colors.textLight} />
|
||||
</Pressable>
|
||||
)}
|
||||
<SearchInputBox
|
||||
textInput={textInput}
|
||||
searchText={searchText}
|
||||
showAutocomplete={showAutocomplete}
|
||||
setShowAutocomplete={setShowAutocomplete}
|
||||
onChangeText={onChangeText}
|
||||
onSubmit={onSubmit}
|
||||
onPressClearQuery={onPressClearQuery}
|
||||
/>
|
||||
{showAutocomplete && (
|
||||
<View style={[styles.headerCancelBtn]}>
|
||||
<Pressable
|
||||
sideBorders={gtMobile}>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
{!gtMobile && (
|
||||
<Button
|
||||
testID="viewHeaderBackOrMenuBtn"
|
||||
onPress={onPressMenu}
|
||||
hitSlop={HITSLOP_10}
|
||||
label={_(msg`Menu`)}
|
||||
accessibilityHint={_(msg`Access navigation links and settings`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="square">
|
||||
<ButtonIcon icon={Menu} size="lg" />
|
||||
</Button>
|
||||
)}
|
||||
<SearchInputBox
|
||||
textInput={textInput}
|
||||
searchText={searchText}
|
||||
showAutocomplete={showAutocomplete}
|
||||
onFocus={onSearchInputFocus}
|
||||
onChangeText={onChangeText}
|
||||
onSubmit={onSubmit}
|
||||
onPressClearQuery={onPressClearQuery}
|
||||
/>
|
||||
{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}
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_10}>
|
||||
<Text style={pal.text}>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</ButtonText>
|
||||
</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>
|
||||
)}
|
||||
</CenteredView>
|
||||
|
||||
<View
|
||||
style={{
|
||||
display: showAutocomplete ? 'flex' : 'none',
|
||||
@@ -737,7 +954,11 @@ export function SearchScreen(
|
||||
display: showAutocomplete ? 'none' : 'flex',
|
||||
flex: 1,
|
||||
}}>
|
||||
<SearchScreenInner query={queryParam} />
|
||||
<SearchScreenInner
|
||||
query={query}
|
||||
queryWithParams={queryWithParams}
|
||||
headerHeight={headerHeight}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -747,7 +968,7 @@ let SearchInputBox = ({
|
||||
textInput,
|
||||
searchText,
|
||||
showAutocomplete,
|
||||
setShowAutocomplete,
|
||||
onFocus,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
onPressClearQuery,
|
||||
@@ -755,83 +976,62 @@ let SearchInputBox = ({
|
||||
textInput: React.RefObject<TextInput>
|
||||
searchText: string
|
||||
showAutocomplete: boolean
|
||||
setShowAutocomplete: (show: boolean) => void
|
||||
onFocus: () => void
|
||||
onChangeText: (text: string) => void
|
||||
onSubmit: () => void
|
||||
onPressClearQuery: () => void
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const theme = useTheme()
|
||||
const t = useThemeNew()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
// This only exists only for extra hitslop so don't expose it to the a11y tree.
|
||||
accessible={false}
|
||||
focusable={false}
|
||||
// @ts-ignore web-only
|
||||
tabIndex={-1}
|
||||
style={[
|
||||
{backgroundColor: pal.colors.backgroundLight},
|
||||
styles.headerSearchContainer,
|
||||
// @ts-expect-error web only
|
||||
isWeb && {
|
||||
cursor: 'default',
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
textInput.current?.focus()
|
||||
}}>
|
||||
<MagnifyingGlassIcon
|
||||
style={[pal.icon, styles.headerSearchIcon]}
|
||||
size={20}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={MagnifyingGlass} />
|
||||
<TextField.Input
|
||||
inputRef={textInput}
|
||||
label={_(msg`Search`)}
|
||||
value={searchText}
|
||||
placeholder={_(msg`Search`)}
|
||||
returnKeyType="search"
|
||||
onChangeText={onChangeText}
|
||||
onSubmitEditing={onSubmit}
|
||||
onFocus={onFocus}
|
||||
keyboardAppearance={t.scheme}
|
||||
selectTextOnFocus={isNative}
|
||||
autoFocus={false}
|
||||
accessibilityRole="search"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
{showAutocomplete && searchText.length > 0 && (
|
||||
<Pressable
|
||||
testID="searchTextInputClearBtn"
|
||||
onPress={onPressClearQuery}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Clear search query`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}>
|
||||
<FontAwesomeIcon
|
||||
icon="xmark"
|
||||
size={16}
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</Pressable>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
a.my_auto,
|
||||
a.inset_0,
|
||||
a.justify_center,
|
||||
a.pr_sm,
|
||||
{left: 'auto'},
|
||||
]}>
|
||||
<Button
|
||||
testID="searchTextInputClearBtn"
|
||||
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)
|
||||
@@ -1029,21 +1229,7 @@ function scrollToTopWeb() {
|
||||
}
|
||||
}
|
||||
|
||||
const HEADER_HEIGHT = 46
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingLeft: 13,
|
||||
paddingVertical: 4,
|
||||
height: HEADER_HEIGHT,
|
||||
// @ts-ignore web only
|
||||
position: isWeb ? 'sticky' : '',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
headerMenuBtn: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
@@ -1075,12 +1261,6 @@ const styles = StyleSheet.create({
|
||||
zIndex: -1,
|
||||
elevation: -1, // For Android
|
||||
},
|
||||
tabBarContainer: {
|
||||
// @ts-ignore web only
|
||||
position: isWeb ? 'sticky' : '',
|
||||
top: isWeb ? HEADER_HEIGHT : 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
searchHistoryContainer: {
|
||||
width: '100%',
|
||||
paddingHorizontal: 12,
|
||||
|
||||
@@ -94,10 +94,14 @@ function SettingsAccountCard({
|
||||
/>
|
||||
</View>
|
||||
<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}
|
||||
</Text>
|
||||
<Text type="sm" style={pal.textLight} numberOfLines={1}>
|
||||
<Text emoji type="sm" style={pal.textLight} numberOfLines={1}>
|
||||
{account.handle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function Forms() {
|
||||
label="Text field"
|
||||
/>
|
||||
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<View style={[a.flex_row, a.align_start, a.gap_sm]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
@@ -8,6 +8,7 @@ import {BottomTabBarProps} from '@react-navigation/bottom-tabs'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useDedupe} from '#/lib/hooks/useDedupe'
|
||||
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 {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
|
||||
import {
|
||||
@@ -326,7 +328,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
|
||||
interface BtnProps
|
||||
extends Pick<
|
||||
ComponentProps<typeof TouchableOpacity>,
|
||||
ComponentProps<typeof PressableScale>,
|
||||
| 'accessible'
|
||||
| 'accessibilityRole'
|
||||
| 'accessibilityHint'
|
||||
@@ -350,7 +352,7 @@ function Btn({
|
||||
accessibilityLabel,
|
||||
}: BtnProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
<PressableScale
|
||||
testID={testID}
|
||||
style={styles.ctrl}
|
||||
onPress={onLongPress ? onPress : undefined}
|
||||
@@ -358,13 +360,15 @@ function Btn({
|
||||
onLongPress={onLongPress}
|
||||
accessible={accessible}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint={accessibilityHint}>
|
||||
accessibilityHint={accessibilityHint}
|
||||
targetScale={0.8}
|
||||
contentContainerStyle={[a.flex_1]}>
|
||||
{icon}
|
||||
{notificationCount ? (
|
||||
<View style={[styles.notificationCount]}>
|
||||
<View style={[styles.notificationCount, {top: -5}]}>
|
||||
<Text style={styles.notificationCountLabel}>{notificationCount}</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
</TouchableOpacity>
|
||||
</PressableScale>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12165,6 +12165,11 @@ expo-asset@~10.0.6:
|
||||
invariant "^2.2.4"
|
||||
md5-file "^3.2.3"
|
||||
|
||||
expo-blur@^13.0.2:
|
||||
version "13.0.2"
|
||||
resolved "https://registry.yarnpkg.com/expo-blur/-/expo-blur-13.0.2.tgz#c2d179b19b13830db1d8b90c51373235f462e958"
|
||||
integrity sha512-t2p7BChO3Reykued++QJRMZ/og6J3aXtSQ+bU31YcBeXhZLkHwjWEhiPKPnJka7J2/yTs4+jOCNDY0kCZmcE3w==
|
||||
|
||||
expo-build-properties@^0.12.1:
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-0.12.1.tgz#8d11759b8f382e4654e2482ddcec4f9ad4530aad"
|
||||
|
||||
Reference in New Issue
Block a user