Merge branch 'bluesky-social:main' into patch-3

This commit is contained in:
Minseo Lee 2024-02-28 13:03:55 +09:00 committed by GitHub
commit 3767e76390
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 4654 additions and 1462 deletions

View file

@ -45,6 +45,7 @@ import {Splash} from '#/Splash'
import {Provider as PortalProvider} from '#/components/Portal'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
SplashScreen.preventAutoHideAsync()
@ -53,6 +54,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
useIntentHandler()
// init
useEffect(() => {

View file

@ -32,11 +32,13 @@ import {
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {Provider as PortalProvider} from '#/components/Portal'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
useIntentHandler()
// init
useEffect(() => {

View file

@ -460,7 +460,8 @@ const FlatNavigator = () => {
*/
const LINKING = {
prefixes: ['bsky://', 'https://bsky.app'],
// TODO figure out what we are going to use
prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
getPathFromState(state: State) {
// find the current node in the navigation tree
@ -478,6 +479,11 @@ const LINKING = {
},
getStateFromPath(path: string) {
// Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the
// intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
// intent
if (path.includes('intent/')) return
const [name, params] = router.matchPath(path)
if (isNative) {
if (name === 'Search') {

View file

@ -7,7 +7,6 @@ import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
import {InlineLink} from '#/components/Link'
import {Text, TextProps} from '#/components/Typography'
import {toShortUrl} from 'lib/strings/url-helpers'
import {getAgent} from '#/state/session'
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
import {isNative} from '#/platform/detection'
import {useInteractionState} from '#/components/hooks/useInteractionState'
@ -20,7 +19,6 @@ export function RichText({
style,
numberOfLines,
disableLinks,
resolveFacets = false,
selectable,
enableTags = false,
authorHandle,
@ -30,31 +28,16 @@ export function RichText({
testID?: string
numberOfLines?: number
disableLinks?: boolean
resolveFacets?: boolean
enableTags?: boolean
authorHandle?: string
}) {
const detected = React.useRef(false)
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
const richText = React.useMemo(
() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
[value],
)
const styles = [a.leading_snug, flatten(style)]
React.useEffect(() => {
if (!resolveFacets) return
async function detectFacets() {
const rt = new RichTextAPI({text: richText.text})
await rt.detectFacets(getAgent())
setRichText(rt)
}
if (!detected.current) {
detected.current = true
detectFacets()
}
}, [richText, setRichText, resolveFacets])
const {text, facets} = richText
if (!facets?.length) {

View file

@ -12,6 +12,8 @@ import {
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {enforceLen} from '#/lib/strings/helpers'
import {web} from '#/alf'
export function useTagMenuControl() {}
@ -40,11 +42,12 @@ export function TagMenu({
)) &&
!(optimisticRemove?.value === sanitizedTag),
)
const truncatedTag = enforceLen(tag, 15, true, 'middle')
const dropdownItems = React.useMemo(() => {
return [
{
label: _(msg`See ${tag} posts`),
label: _(msg`See ${truncatedTag} posts`),
onPress() {
navigation.navigate('Search', {
q: tag,
@ -61,7 +64,7 @@ export function TagMenu({
},
authorHandle &&
!isInvalidHandle(authorHandle) && {
label: _(msg`See ${tag} posts by this user`),
label: _(msg`See ${truncatedTag} posts by user`),
onPress() {
navigation.navigate({
name: 'Search',
@ -83,7 +86,9 @@ export function TagMenu({
label: 'separator',
},
preferences && {
label: isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`),
label: isMuted
? _(msg`Unmute ${truncatedTag}`)
: _(msg`Mute ${truncatedTag}`),
onPress() {
if (isMuted) {
removeMutedWord({value: sanitizedTag, targets: ['tag']})
@ -108,6 +113,7 @@ export function TagMenu({
navigation,
preferences,
tag,
truncatedTag,
sanitizedTag,
upsertMutedWord,
removeMutedWord,
@ -119,7 +125,10 @@ export function TagMenu({
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
accessibilityHint=""
// @ts-ignore
items={dropdownItems}>
items={dropdownItems}
triggerStyle={web({
textAlign: 'left',
})}>
{children}
</NativeDropdown>
</EventStopper>

View file

@ -10,7 +10,7 @@ import {
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {isNative} from '#/platform/detection'
import {atoms as a, useTheme, useBreakpoints, ViewStyleProp} from '#/alf'
import {atoms as a, useTheme, useBreakpoints, ViewStyleProp, web} from '#/alf'
import {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
@ -260,9 +260,21 @@ function MutedWordRow({
a.align_center,
a.justify_between,
a.rounded_md,
a.gap_md,
style,
]}>
<Text style={[a.font_bold, t.atoms.text_contrast_high]}>
<Text
style={[
a.flex_1,
a.leading_snug,
a.w_full,
a.font_bold,
t.atoms.text_contrast_high,
web({
overflowWrap: 'break-word',
wordBreak: 'break-word',
}),
]}>
{word.value}
</Text>

View file

@ -75,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20)
export const HITSLOP_30 = createHitslop(30)
export const BACK_HITSLOP = HITSLOP_30
export const MAX_POST_LINES = 25
export const BSKY_FEED_OWNER_DIDS = [
'did:plc:z72i7hdynmk6r22z27h6tvur',
'did:plc:vpkhqolt662uhesyj6nxm7ys',
'did:plc:q6gjnaw2blty4crticxkmujt',
]

View file

@ -0,0 +1,87 @@
import React from 'react'
import * as Linking from 'expo-linking'
import {isNative} from 'platform/detection'
import {useComposerControls} from 'state/shell'
import {useSession} from 'state/session'
type IntentType = 'compose'
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
export function useIntentHandler() {
const incomingUrl = Linking.useURL()
const composeIntent = useComposeIntent()
React.useEffect(() => {
const handleIncomingURL = (url: string) => {
const urlp = new URL(url)
const [_, intentTypeNative, intentTypeWeb] = urlp.pathname.split('/')
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
// intent check. On web, we have to check the first part of the path since we have an actual hostname
const intentType = isNative ? intentTypeNative : intentTypeWeb
const isIntent = isNative
? urlp.hostname === 'intent'
: intentTypeNative === 'intent'
const params = urlp.searchParams
if (!isIntent) return
switch (intentType as IntentType) {
case 'compose': {
composeIntent({
text: params.get('text'),
imageUrisStr: params.get('imageUris'),
})
}
}
}
if (incomingUrl) handleIncomingURL(incomingUrl)
}, [incomingUrl, composeIntent])
}
function useComposeIntent() {
const {openComposer} = useComposerControls()
const {hasSession} = useSession()
return React.useCallback(
({
text,
imageUrisStr,
}: {
text: string | null
imageUrisStr: string | null // unused for right now, will be used later with intents
}) => {
if (!hasSession) return
const imageUris = imageUrisStr
?.split(',')
.filter(part => {
// For some security, we're going to filter out any image uri that is external. We don't want someone to
// be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
// and we load that image
if (part.includes('https://') || part.includes('http://')) {
return false
}
// We also should just filter out cases that don't have all the info we need
if (!VALID_IMAGE_REGEX.test(part)) {
return false
}
return true
})
.map(part => {
const [uri, width, height] = part.split('|')
return {uri, width: Number(width), height: Number(height)}
})
setTimeout(() => {
openComposer({
text: text ?? undefined,
imageUris: isNative ? imageUris : undefined,
})
}, 500)
},
[openComposer, hasSession],
)
}

View file

@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
return base + 's'
}
export function enforceLen(str: string, len: number, ellipsis = false): string {
export function enforceLen(
str: string,
len: number,
ellipsis = false,
mode: 'end' | 'middle' = 'end',
): string {
str = str || ''
if (str.length > len) {
return str.slice(0, len) + (ellipsis ? '...' : '')
if (ellipsis) {
if (mode === 'end') {
return str.slice(0, len) + '…'
} else if (mode === 'middle') {
const half = Math.floor(len / 2)
return str.slice(0, half) + '…' + str.slice(-half)
} else {
// fallback
return str.slice(0, len)
}
} else {
return str.slice(0, len)
}
}
return str
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {openPicker} from 'lib/media/picker'
import {getImageDim} from 'lib/media/manip'
interface InitialImageUri {
uri: string
width: number
height: number
}
export class GalleryModel {
images: ImageModel[] = []
constructor() {
constructor(uris?: {uri: string; width: number; height: number}[]) {
makeAutoObservable(this)
if (uris) {
this.addFromUris(uris)
}
}
get isEmpty() {
@ -23,7 +33,7 @@ export class GalleryModel {
return this.images.some(image => image.altText.trim() === '')
}
async add(image_: Omit<RNImage, 'size'>) {
*add(image_: Omit<RNImage, 'size'>) {
if (this.size >= 4) {
return
}
@ -86,4 +96,15 @@ export class GalleryModel {
}),
)
}
async addFromUris(uris: InitialImageUri[]) {
for (const uriObj of uris) {
this.add({
mime: 'image/jpeg',
height: uriObj.height,
width: uriObj.width,
path: uriObj.uri,
})
}
}
}

View file

@ -1,6 +1,11 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {AppState} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
PostModeration,
} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
@ -29,6 +34,7 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {useModerationOpts} from './preferences'
import {queryClient} from 'lib/react-query'
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
type ActorDid = string
type AuthorFilter =
@ -137,24 +143,41 @@ export function usePostFeedQuery(
cursor: undefined,
}
const res = await api.fetch({cursor, limit: PAGE_SIZE})
precacheFeedPostProfiles(queryClient, res.feed)
try {
const res = await api.fetch({cursor, limit: PAGE_SIZE})
precacheFeedPostProfiles(queryClient, res.feed)
/*
* If this is a public view, we need to check if posts fail moderation.
* If all fail, we throw an error. If only some fail, we continue and let
* moderations happen later, which results in some posts being shown and
* some not.
*/
if (!getAgent().session) {
assertSomePostsPassModeration(res.feed)
}
/*
* If this is a public view, we need to check if posts fail moderation.
* If all fail, we throw an error. If only some fail, we continue and let
* moderations happen later, which results in some posts being shown and
* some not.
*/
if (!getAgent().session) {
assertSomePostsPassModeration(res.feed)
}
return {
api,
cursor: res.cursor,
feed: res.feed,
fetchedAt: Date.now(),
return {
api,
cursor: res.cursor,
feed: res.feed,
fetchedAt: Date.now(),
}
} catch (e) {
const feedDescParts = feedDesc.split('|')
const feedOwnerDid = new AtUri(feedDescParts[1]).hostname
if (
feedDescParts[0] === 'feedgen' &&
BSKY_FEED_OWNER_DIDS.includes(feedOwnerDid)
) {
logger.error(`Bluesky feed may be offline: ${feedOwnerDid}`, {
feedDesc,
jsError: e,
})
}
throw e
}
},
initialPageParam: undefined,
@ -253,7 +276,7 @@ export function usePostFeedQuery(
.success
) {
return {
_reactKey: `${slice._reactKey}-${i}`,
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
uri: item.post.uri,
post: item.post,
record: item.post.record,

View file

@ -38,6 +38,8 @@ export interface ComposerOpts {
quote?: ComposerOptsQuote
mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number}[]
}
type StateContext = ComposerOpts | undefined

View file

@ -71,6 +71,8 @@ export const ComposePost = observer(function ComposePost({
quote: initQuote,
mention: initMention,
openPicker,
text: initText,
imageUris: initImageUris,
}: Props) {
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@ -91,7 +93,9 @@ export const ComposePost = observer(function ComposePost({
const [error, setError] = useState('')
const [richtext, setRichText] = useState(
new RichText({
text: initMention
text: initText
? initText
: initMention
? insertMentionAt(
`@${initMention}`,
initMention.length + 1,
@ -110,7 +114,10 @@ export const ComposePost = observer(function ComposePost({
const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
const gallery = useMemo(() => new GalleryModel(), [])
const gallery = useMemo(
() => new GalleryModel(initImageUris),
[initImageUris],
)
const onClose = useCallback(() => {
closeComposer()
}, [closeComposer])

View file

@ -1,30 +1,24 @@
import React from 'react'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
import {useAnalytics} from 'lib/analytics/analytics'
import {useQueryClient} from '@tanstack/react-query'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {MainScrollProvider} from '../util/MainScrollProvider'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useSetMinimalShellMode} from '#/state/shell'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {ComposeIcon2} from 'lib/icons'
import {colors, s} from 'lib/styles'
import {s} from 'lib/styles'
import {View, useWindowDimensions} from 'react-native'
import {ListMethods} from '../util/List'
import {Feed} from '../posts/Feed'
import {TextLink} from '../util/Link'
import {FAB} from '../util/fab/FAB'
import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {listenSoftReset, emitSoftReset} from '#/state/events'
import {listenSoftReset} from '#/state/events'
import {truncateAndInvalidate} from '#/state/queries/util'
import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers'
import {isNative} from '#/platform/detection'
@ -47,10 +41,8 @@ export function FeedPage({
renderEndOfFeed?: () => JSX.Element
}) {
const {hasSession} = useSession()
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation()
const {isDesktop} = useWebMediaQueries()
const queryClient = useQueryClient()
const {openComposer} = useComposerControls()
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
@ -99,63 +91,6 @@ export function FeedPage({
setHasNew(false)
}, [scrollToTop, feed, queryClient, setHasNew])
const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) {
return (
<View
style={[
pal.view,
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 18,
paddingVertical: 12,
},
]}>
<TextLink
type="title-lg"
href="/"
style={[pal.text, {fontWeight: 'bold'}]}
text={
<>
Bluesky{' '}
{hasNew && (
<View
style={{
top: -8,
backgroundColor: colors.blue3,
width: 8,
height: 8,
borderRadius: 4,
}}
/>
)}
</>
}
onPress={emitSoftReset}
/>
{hasSession && (
<TextLink
type="title-lg"
href="/settings/following-feed"
style={{fontWeight: 'bold'}}
accessibilityLabel={_(msg`Feed Preferences`)}
accessibilityHint=""
text={
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
}
/>
)}
</View>
)
}
return <></>
}, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession])
return (
<View testID={testID} style={s.h100pct}>
<MainScrollProvider>
@ -171,7 +106,6 @@ export function FeedPage({
onHasNew={setHasNew}
renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed}
ListHeaderComponent={ListHeaderComponent}
headerOffset={headerOffset}
/>
</MainScrollProvider>

View file

@ -1,7 +1,6 @@
import React from 'react'
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
import {HomeHeaderLayout} from './HomeHeaderLayout'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePinnedFeedsInfos} from '#/state/queries/feed'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
@ -11,16 +10,6 @@ import {usePalette} from '#/lib/hooks/usePalette'
export function HomeHeader(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const {isDesktop} = useWebMediaQueries()
if (isDesktop) {
return null
}
return <HomeHeaderInner {...props} />
}
export function HomeHeaderInner(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const navigation = useNavigation<NavigationProp>()
const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()

View file

@ -1,11 +1,20 @@
import React from 'react'
import {StyleSheet} from 'react-native'
import {StyleSheet, View} from 'react-native'
import Animated from 'react-native-reanimated'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useShellLayout} from '#/state/shell/shell-layout'
import {Logo} from '#/view/icons/Logo'
import {Link, TextLink} from '../util/Link'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {CogIcon} from '#/lib/icons'
export function HomeHeaderLayout({children}: {children: React.ReactNode}) {
const {isMobile} = useWebMediaQueries()
@ -20,6 +29,7 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
const pal = usePalette('default')
const {headerMinimalShellTransform} = useMinimalShellMode()
const {headerHeight} = useShellLayout()
const {_} = useLingui()
return (
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
@ -28,12 +38,44 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
onLayout={e => {
headerHeight.value = e.nativeEvent.layout.height
}}>
<View style={[pal.view, styles.topBar]}>
<TextLink
type="title-lg"
href="/settings/following-feed"
accessibilityLabel={_(msg`Following Feed Preferences`)}
accessibilityHint=""
text={
<FontAwesomeIcon
icon="sliders"
style={pal.textLight as FontAwesomeIconStyle}
/>
}
/>
<Logo width={28} />
<Link
href="/settings/saved-feeds"
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={_(msg`Edit Saved Feeds`)}
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
</Link>
</View>
{children}
</Animated.View>
)
}
const styles = StyleSheet.create({
topBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 8,
marginTop: 8,
width: '100%',
},
tabBar: {
// @ts-ignore Web only
position: 'sticky',
@ -42,7 +84,7 @@ const styles = StyleSheet.create({
left: 'calc(50% - 300px)',
width: 600,
top: 0,
flexDirection: 'row',
flexDirection: 'column',
alignItems: 'center',
borderLeftWidth: 1,
borderRightWidth: 1,

View file

@ -103,7 +103,6 @@ const styles = StyleSheet.create({
right: 0,
top: 0,
flexDirection: 'column',
borderBottomWidth: 1,
},
topBar: {
flexDirection: 'row',

View file

@ -5,6 +5,7 @@ import {PressableWithHover} from '../util/PressableWithHover'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {DraggableScrollView} from './DraggableScrollView'
import {isNative} from '#/platform/detection'
export interface TabBarProps {
testID?: string
@ -15,6 +16,10 @@ export interface TabBarProps {
onPressSelected?: (index: number) => void
}
// How much of the previous/next item we're showing
// to give the user a hint there's more to scroll.
const OFFSCREEN_ITEM_WIDTH = 20
export function TabBar({
testID,
selectedPage,
@ -25,6 +30,7 @@ export function TabBar({
}: TabBarProps) {
const pal = usePalette('default')
const scrollElRef = useRef<ScrollView>(null)
const itemRefs = useRef<Array<Element>>([])
const [itemXs, setItemXs] = useState<number[]>([])
const indicatorStyle = useMemo(
() => ({borderBottomColor: indicatorColor || pal.colors.link}),
@ -33,12 +39,58 @@ export function TabBar({
const {isDesktop, isTablet} = useWebMediaQueries()
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
// scrolls to the selected item when the page changes
useEffect(() => {
scrollElRef.current?.scrollTo({
x:
(itemXs[selectedPage] || 0) - styles.contentContainer.paddingHorizontal,
})
if (isNative) {
// On native, the primary interaction is swiping.
// We adjust the scroll little by little on every tab change.
// Scroll into view but keep the end of the previous item visible.
let x = itemXs[selectedPage] || 0
x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH)
scrollElRef.current?.scrollTo({x})
} else {
// On the web, the primary interaction is tapping.
// Scrolling under tap feels disorienting so only adjust the scroll offset
// when tapping on an item out of view--and we adjust by almost an entire page.
const parent = scrollElRef?.current?.getScrollableNode?.()
if (!parent) {
return
}
const parentRect = parent.getBoundingClientRect()
if (!parentRect) {
return
}
const {
left: parentLeft,
right: parentRight,
width: parentWidth,
} = parentRect
const child = itemRefs.current[selectedPage]
if (!child) {
return
}
const childRect = child.getBoundingClientRect?.()
if (!childRect) {
return
}
const {left: childLeft, right: childRight, width: childWidth} = childRect
let dx = 0
if (childRight >= parentRight) {
dx += childRight - parentRight
dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
} else if (childLeft <= parentLeft) {
dx -= parentLeft - childLeft
dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
}
let x = parent.scrollLeft + dx
x = Math.max(0, x)
x = Math.min(x, parent.scrollWidth - parentWidth)
if (dx !== 0) {
parent.scroll({
left: x,
behavior: 'smooth',
})
}
}
}, [scrollElRef, itemXs, selectedPage, styles])
const onPressItem = useCallback(
@ -78,6 +130,7 @@ export function TabBar({
<PressableWithHover
testID={`${testID}-selector-${i}`}
key={`${item}-${i}`}
ref={node => (itemRefs.current[i] = node)}
onLayout={e => onItemLayout(e, i)}
style={styles.item}
hoverStyle={pal.viewLight}
@ -94,6 +147,7 @@ export function TabBar({
)
})}
</DraggableScrollView>
<View style={[pal.border, styles.outerBottomBorder]} />
</View>
)
}
@ -117,6 +171,13 @@ const desktopStyles = StyleSheet.create({
borderBottomWidth: 3,
borderBottomColor: 'transparent',
},
outerBottomBorder: {
position: 'absolute',
left: 0,
right: 0,
bottom: -1,
borderBottomWidth: 1,
},
})
const mobileStyles = StyleSheet.create({
@ -137,4 +198,11 @@ const mobileStyles = StyleSheet.create({
borderBottomWidth: 3,
borderBottomColor: 'transparent',
},
outerBottomBorder: {
position: 'absolute',
left: 0,
right: 0,
bottom: -1,
borderBottomWidth: 1,
},
})

View file

@ -94,6 +94,8 @@ export function PostThreadItem({
if (richText && moderation) {
return (
<PostThreadItemLoaded
// Safeguard from clobbering per-post state below:
key={postShadowed.uri}
post={postShadowed}
prevPost={prevPost}
nextPost={nextPost}

View file

@ -70,6 +70,8 @@ export function FeedItem({
if (richText && moderation) {
return (
<FeedItemInner
// Safeguard from clobbering per-post state below:
key={postShadowed.uri}
post={postShadowed}
record={record}
reason={reason}

View file

@ -20,12 +20,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
const setMode = useSetMinimalShellMode()
const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null)
const didJustRestoreScroll = useSharedValue<boolean>(false)
useEffect(() => {
if (isWeb) {
return listenToForcedWindowScroll(() => {
startDragOffset.value = null
startMode.value = null
didJustRestoreScroll.value = true
})
}
})
@ -86,6 +88,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
mode.value = newValue
}
} else {
if (didJustRestoreScroll.value) {
didJustRestoreScroll.value = false
// Don't hide/show navbar based on scroll restoratoin.
return
}
// On the web, we don't try to follow the drag because we don't know when it ends.
// Instead, show/hide immediately based on whether we're scrolling up or down.
const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
@ -98,7 +105,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
}
}
},
[headerHeight, mode, setMode, startDragOffset, startMode],
[
headerHeight,
mode,
setMode,
startDragOffset,
startMode,
didJustRestoreScroll,
],
)
return (

View file

@ -1,7 +1,7 @@
import React from 'react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as DropdownMenu from 'zeego/dropdown-menu'
import {Pressable, StyleSheet, Platform, View} from 'react-native'
import {Pressable, StyleSheet, Platform, View, ViewStyle} from 'react-native'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
import {usePalette} from 'lib/hooks/usePalette'
@ -151,6 +151,7 @@ type Props = {
testID?: string
accessibilityLabel?: string
accessibilityHint?: string
triggerStyle?: ViewStyle
}
/* The `NativeDropdown` function uses native iOS and Android dropdown menus.

View file

@ -1,7 +1,7 @@
import React from 'react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {Pressable, StyleSheet, View, Text} from 'react-native'
import {Pressable, StyleSheet, View, Text, ViewStyle} from 'react-native'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
import {usePalette} from 'lib/hooks/usePalette'
@ -53,6 +53,7 @@ type Props = {
testID?: string
accessibilityLabel?: string
accessibilityHint?: string
triggerStyle?: ViewStyle
}
export function NativeDropdown({
@ -61,6 +62,7 @@ export function NativeDropdown({
testID,
accessibilityLabel,
accessibilityHint,
triggerStyle,
}: React.PropsWithChildren<Props>) {
const pal = usePalette('default')
const theme = useTheme()
@ -120,7 +122,8 @@ export function NativeDropdown({
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
onPress={() => setOpen(o => !o)}
hitSlop={HITSLOP_10}>
hitSlop={HITSLOP_10}
style={triggerStyle}>
{children}
</Pressable>
</DropdownMenu.Trigger>

View file

@ -22,12 +22,14 @@ export function Typography() {
<Text style={[a.text_2xs]}>atoms.text_2xs</Text>
<RichText
resolveFacets
// TODO: This only supports already resolved facets.
// Resolving them on read is bad anyway.
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
/>
<RichText
selectable
resolveFacets
// TODO: This only supports already resolved facets.
// Resolving them on read is bad anyway.
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
style={[a.text_xl]}
/>

View file

@ -55,6 +55,8 @@ export const Composer = observer(function ComposerImpl({
onPost={state.onPost}
quote={state.quote}
mention={state.mention}
text={state.text}
imageUris={state.imageUris}
/>
</Animated.View>
)

View file

@ -9,7 +9,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {
EmojiPicker,
EmojiPickerState,
} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx'
} from 'view/com/composer/text-input/web/EmojiPicker.web'
const BOTTOM_BAR_HEIGHT = 61
@ -69,6 +69,7 @@ export function Composer({}: {winHeight: number}) {
onPost={state.onPost}
mention={state.mention}
openPicker={onOpenPicker}
text={state.text}
/>
</Animated.View>
<EmojiPicker state={pickerState} close={onClosePicker} />