Merge branch 'main' into 3p-moderators
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {CreateAccountState, CreateAccountDispatch, is18} from './state'
|
||||
@@ -19,7 +18,6 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
@@ -49,7 +47,6 @@ export function Step1({
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {openModal} = useModalControls()
|
||||
const serverInputControl = useDialogControl()
|
||||
|
||||
const onPressSelectService = React.useCallback(() => {
|
||||
@@ -57,10 +54,6 @@ export function Step1({
|
||||
Keyboard.dismiss()
|
||||
}, [serverInputControl])
|
||||
|
||||
const onPressWaitlist = React.useCallback(() => {
|
||||
openModal({name: 'waitlist'})
|
||||
}, [openModal])
|
||||
|
||||
const birthDate = React.useMemo(() => {
|
||||
return sanitizeDate(uiState.birthDate)
|
||||
}, [uiState.birthDate])
|
||||
@@ -164,23 +157,7 @@ export function Step1({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!uiState.inviteCode && uiState.isInviteCodeRequired ? (
|
||||
<View style={[s.flexRow, s.alignCenter]}>
|
||||
<Text style={pal.text}>
|
||||
<Trans>Don't have an invite code?</Trans>{' '}
|
||||
</Text>
|
||||
<TouchableWithoutFeedback
|
||||
onPress={onPressWaitlist}
|
||||
accessibilityLabel={_(msg`Join the waitlist.`)}
|
||||
accessibilityHint="">
|
||||
<View style={styles.touchable}>
|
||||
<Text style={pal.link}>
|
||||
<Trans>Join the waitlist.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
) : (
|
||||
{uiState.inviteCode ? (
|
||||
<>
|
||||
<View style={s.pb20}>
|
||||
<Text
|
||||
@@ -260,7 +237,7 @@ export function Step1({
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
) : undefined}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -133,8 +133,8 @@ function IsValidIcon({valid}: {valid: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!valid) {
|
||||
return <Check size="md" style={{color: t.palette.negative_500}} />
|
||||
return <Times size="md" style={{color: t.palette.negative_500}} />
|
||||
}
|
||||
|
||||
return <Times size="md" style={{color: t.palette.positive_700}} />
|
||||
return <Check size="md" style={{color: t.palette.positive_700}} />
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export const LoginForm = ({
|
||||
const errMsg = e.toString()
|
||||
setIsProcessing(false)
|
||||
if (errMsg.includes('Authentication Required')) {
|
||||
logger.info('Failed to login due to invalid credentials', {
|
||||
logger.debug('Failed to login due to invalid credentials', {
|
||||
error: errMsg,
|
||||
})
|
||||
setError(_(msg`Invalid username or password`))
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
let i = 0
|
||||
|
||||
return Array.from(richtext.segments()).map(segment => {
|
||||
const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
|
||||
return (
|
||||
<Text
|
||||
key={i++}
|
||||
style={[
|
||||
segment.facet && !isTag ? pal.link : pal.text,
|
||||
segment.facet ? pal.link : pal.text,
|
||||
styles.textInputFormatting,
|
||||
]}>
|
||||
{segment.text}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {Portal} from '#/components/Portal'
|
||||
import {Text} from '../../util/text/Text'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {TagDecorator} from './web/TagDecorator'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
@@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||
() => [
|
||||
Document,
|
||||
LinkDecorator,
|
||||
TagDecorator,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
|
||||
@@ -18,6 +18,8 @@ import {Mark} from '@tiptap/core'
|
||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
||||
import {Node as ProsemirrorNode} from '@tiptap/pm/model'
|
||||
import {Decoration, DecorationSet} from '@tiptap/pm/view'
|
||||
import {URL_REGEX} from '@atproto/api'
|
||||
|
||||
import {isValidDomain} from 'lib/strings/url-helpers'
|
||||
|
||||
export const LinkDecorator = Mark.create({
|
||||
@@ -78,8 +80,7 @@ function linkDecorator() {
|
||||
|
||||
function iterateUris(str: string, cb: (from: number, to: number) => void) {
|
||||
let match
|
||||
const re =
|
||||
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
|
||||
const re = URL_REGEX
|
||||
while ((match = re.exec(str))) {
|
||||
let uri = match[2]
|
||||
if (!uri.startsWith('http')) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* TipTap is a stateful rich-text editor, which is extremely useful
|
||||
* when you _want_ it to be stateful formatting such as bold and italics.
|
||||
*
|
||||
* However we also use "stateless" behaviors, specifically for URLs
|
||||
* where the text itself drives the formatting.
|
||||
*
|
||||
* This plugin uses a regex to detect URIs and then applies
|
||||
* link decorations (a <span> with the "autolink") class. That avoids
|
||||
* adding any stateful formatting to TipTap's document model.
|
||||
*
|
||||
* We then run the URI detection again when constructing the
|
||||
* RichText object from TipTap's output and merge their features into
|
||||
* the facet-set.
|
||||
*/
|
||||
|
||||
import {Mark} from '@tiptap/core'
|
||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
||||
import {Node as ProsemirrorNode} from '@tiptap/pm/model'
|
||||
import {Decoration, DecorationSet} from '@tiptap/pm/view'
|
||||
import {TAG_REGEX, TRAILING_PUNCTUATION_REGEX} from '@atproto/api'
|
||||
|
||||
function getDecorations(doc: ProsemirrorNode) {
|
||||
const decorations: Decoration[] = []
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
const regex = TAG_REGEX
|
||||
const textContent = node.textContent
|
||||
|
||||
let match
|
||||
while ((match = regex.exec(textContent))) {
|
||||
const [matchedString, _, tag] = match
|
||||
|
||||
if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64)
|
||||
continue
|
||||
|
||||
const [trailingPunc = ''] = tag.match(TRAILING_PUNCTUATION_REGEX) || []
|
||||
const matchedFrom = match.index + matchedString.indexOf(tag)
|
||||
const matchedTo = matchedFrom + (tag.length - trailingPunc.length)
|
||||
|
||||
/*
|
||||
* The match is exclusive of `#` so we need to adjust the start of the
|
||||
* highlight by -1 to include the `#`
|
||||
*/
|
||||
const start = pos + matchedFrom - 1
|
||||
const end = pos + matchedTo
|
||||
|
||||
decorations.push(
|
||||
Decoration.inline(start, end, {
|
||||
class: 'autolink',
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
const tagDecoratorPlugin: Plugin = new Plugin({
|
||||
key: new PluginKey('link-decorator'),
|
||||
|
||||
state: {
|
||||
init: (_, {doc}) => getDecorations(doc),
|
||||
apply: (transaction, decorationSet) => {
|
||||
if (transaction.docChanged) {
|
||||
return getDecorations(transaction.doc)
|
||||
}
|
||||
return decorationSet.map(transaction.mapping, transaction.doc)
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
return tagDecoratorPlugin.getState(state)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const TagDecorator = Mark.create({
|
||||
name: 'tag-decorator',
|
||||
priority: 1000,
|
||||
keepOnSplit: false,
|
||||
inclusive() {
|
||||
return true
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [tagDecoratorPlugin]
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -200,21 +134,12 @@ export function FeedPage({
|
||||
function useHeaderOffset() {
|
||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||
const {fontScale} = useWindowDimensions()
|
||||
const {hasSession} = useSession()
|
||||
if (isDesktop || isTablet) {
|
||||
return 0
|
||||
}
|
||||
if (hasSession) {
|
||||
const navBarPad = 16
|
||||
const navBarText = 21 * fontScale
|
||||
const tabBarPad = 20 + 3 // nav bar padding + border
|
||||
const tabBarText = 16 * fontScale
|
||||
const magic = 7 * fontScale
|
||||
return navBarPad + navBarText + tabBarPad + tabBarText + magic
|
||||
} else {
|
||||
const navBarPad = 16
|
||||
const navBarText = 21 * fontScale
|
||||
const magic = 4 * fontScale
|
||||
return navBarPad + navBarText + magic
|
||||
}
|
||||
const navBarHeight = 42
|
||||
const tabBarPad = 10 + 10 + 3 // padding + border
|
||||
const normalLineHeight = 1.2
|
||||
const tabBarText = 16 * normalLineHeight * fontScale
|
||||
return navBarHeight + tabBarPad + tabBarText
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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 {FeedSourceInfo} from '#/state/queries/feed'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {isWeb} from 'platform/detection'
|
||||
@@ -10,25 +9,22 @@ import {TabBar} from '../pager/TabBar'
|
||||
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},
|
||||
props: RenderTabBarFnProps & {
|
||||
testID?: string
|
||||
onPressSelected: () => void
|
||||
feeds: FeedSourceInfo[]
|
||||
},
|
||||
) {
|
||||
const {feeds} = props
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
|
||||
const pal = usePalette('default')
|
||||
|
||||
const hasPinnedCustom = React.useMemo<boolean>(() => {
|
||||
return feeds.some(tab => tab.uri !== '')
|
||||
}, [feeds])
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const pinnedNames = feeds.map(f => f.displayName)
|
||||
|
||||
if (!hasPinnedCustom) {
|
||||
return pinnedNames.concat('Feeds ✨')
|
||||
}
|
||||
@@ -56,7 +52,7 @@ export function HomeHeaderInner(
|
||||
)
|
||||
|
||||
return (
|
||||
<HomeHeaderLayout>
|
||||
<HomeHeaderLayout tabBarAnchor={props.tabBarAnchor}>
|
||||
<TabBar
|
||||
key={items.join(',')}
|
||||
onPressSelected={props.onPressSelected}
|
||||
|
||||
@@ -1,50 +1,111 @@
|
||||
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 {Logo} from '#/view/icons/Logo'
|
||||
import {Link} 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'
|
||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
|
||||
export function HomeHeaderLayout({children}: {children: React.ReactNode}) {
|
||||
export function HomeHeaderLayout(props: {
|
||||
children: React.ReactNode
|
||||
tabBarAnchor: JSX.Element | null | undefined
|
||||
}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
if (isMobile) {
|
||||
return <HomeHeaderLayoutMobile>{children}</HomeHeaderLayoutMobile>
|
||||
return <HomeHeaderLayoutMobile {...props} />
|
||||
} else {
|
||||
return <HomeHeaderLayoutTablet>{children}</HomeHeaderLayoutTablet>
|
||||
return <HomeHeaderLayoutDesktopAndTablet {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
|
||||
function HomeHeaderLayoutDesktopAndTablet({
|
||||
children,
|
||||
tabBarAnchor,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
tabBarAnchor: JSX.Element | null | undefined
|
||||
}) {
|
||||
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
|
||||
<Animated.View
|
||||
style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
|
||||
onLayout={e => {
|
||||
headerHeight.value = e.nativeEvent.layout.height
|
||||
}}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
<>
|
||||
<View style={[pal.view, pal.border, styles.bar, styles.topBar]}>
|
||||
<Link
|
||||
href="/settings/following-feed"
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Following Feed Preferences`)}
|
||||
accessibilityHint="">
|
||||
<FontAwesomeIcon
|
||||
icon="sliders"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</Link>
|
||||
<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>
|
||||
{tabBarAnchor}
|
||||
<Animated.View
|
||||
onLayout={e => {
|
||||
headerHeight.value = e.nativeEvent.layout.height
|
||||
}}
|
||||
style={[
|
||||
pal.view,
|
||||
pal.border,
|
||||
styles.bar,
|
||||
styles.tabBar,
|
||||
headerMinimalShellTransform,
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBar: {
|
||||
bar: {
|
||||
// @ts-ignore Web only
|
||||
position: 'sticky',
|
||||
zIndex: 1,
|
||||
// @ts-ignore Web only -prf
|
||||
left: 'calc(50% - 300px)',
|
||||
width: 600,
|
||||
top: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 18,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
tabBar: {
|
||||
// @ts-ignore Web only
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
zIndex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ export function HomeHeaderLayoutMobile({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
tabBarAnchor: JSX.Element | null | undefined
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
@@ -103,7 +104,6 @@ const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
top: 0,
|
||||
flexDirection: 'column',
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -19,7 +19,6 @@ import * as EditImageModal from './AltImage'
|
||||
import * as ReportModal from './report/Modal'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import * as ChangeHandleModal from './ChangeHandle'
|
||||
import * as WaitlistModal from './Waitlist'
|
||||
import * as InviteCodesModal from './InviteCodes'
|
||||
import * as AddAppPassword from './AddAppPasswords'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
@@ -103,9 +102,6 @@ export function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'change-handle') {
|
||||
snapPoints = ChangeHandleModal.snapPoints
|
||||
element = <ChangeHandleModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'waitlist') {
|
||||
snapPoints = WaitlistModal.snapPoints
|
||||
element = <WaitlistModal.Component />
|
||||
} else if (activeModal?.name === 'invite-codes') {
|
||||
snapPoints = InviteCodesModal.snapPoints
|
||||
element = <InviteCodesModal.Component />
|
||||
|
||||
@@ -21,7 +21,6 @@ import * as CropImageModal from './crop-image/CropImage.web'
|
||||
import * as AltTextImageModal from './AltImage'
|
||||
import * as EditImageModal from './EditImage'
|
||||
import * as ChangeHandleModal from './ChangeHandle'
|
||||
import * as WaitlistModal from './Waitlist'
|
||||
import * as InviteCodesModal from './InviteCodes'
|
||||
import * as AddAppPassword from './AddAppPasswords'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
@@ -100,8 +99,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
element = <ThreadgateModal.Component {...modal} />
|
||||
} else if (modal.name === 'change-handle') {
|
||||
element = <ChangeHandleModal.Component {...modal} />
|
||||
} else if (modal.name === 'waitlist') {
|
||||
element = <WaitlistModal.Component />
|
||||
} else if (modal.name === 'invite-codes') {
|
||||
element = <InviteCodesModal.Component />
|
||||
} else if (modal.name === 'add-app-password') {
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {TextInput} from './util'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {s, gradients} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
|
||||
export const snapPoints = ['80%']
|
||||
|
||||
export function Component({}: {}) {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {closeModal} = useModalControls()
|
||||
const [email, setEmail] = React.useState<string>('')
|
||||
const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
|
||||
const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
|
||||
const [error, setError] = React.useState<string>('')
|
||||
|
||||
const onPressSignup = async () => {
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
const res = await fetch('https://bsky.app/api/waitlist', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({email}),
|
||||
})
|
||||
const resBody = await res.json()
|
||||
if (resBody.success) {
|
||||
setIsEmailSent(true)
|
||||
} else {
|
||||
setError(
|
||||
resBody.error ||
|
||||
_(msg`Something went wrong. Check your email and try again.`),
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(cleanError(e))
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
const onCancel = () => {
|
||||
closeModal()
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, pal.view]}>
|
||||
<View style={[styles.innerContainer, pal.view]}>
|
||||
<Text type="title-xl" style={[styles.title, pal.text]}>
|
||||
<Trans>Join the waitlist</Trans>
|
||||
</Text>
|
||||
<Text type="lg" style={[styles.description, pal.text]}>
|
||||
<Trans>
|
||||
Bluesky uses invites to build a healthier community. If you don't
|
||||
know anybody with an invite, you can sign up for the waitlist and
|
||||
we'll send one soon.
|
||||
</Trans>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]}
|
||||
placeholder={_(msg`Enter your email`)}
|
||||
placeholderTextColor={pal.textLight.color}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardAppearance={theme.colorScheme}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
onSubmitEditing={onPressSignup}
|
||||
enterKeyHint="done"
|
||||
accessible={true}
|
||||
accessibilityLabel={_(msg`Email`)}
|
||||
accessibilityHint={_(
|
||||
msg`Input your email to get on the Bluesky waitlist`,
|
||||
)}
|
||||
/>
|
||||
{error ? (
|
||||
<View style={s.mt10}>
|
||||
<ErrorMessage message={error} style={styles.error} />
|
||||
</View>
|
||||
) : undefined}
|
||||
{isProcessing ? (
|
||||
<View style={[styles.btn, s.mt10]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : isEmailSent ? (
|
||||
<View style={[styles.btn, s.mt10]}>
|
||||
<FontAwesomeIcon
|
||||
icon="check"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
<Text style={[s.ml10, pal.text]}>
|
||||
<Trans>
|
||||
Your email has been saved! We'll be in touch soon.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
onPress={onPressSignup}
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(
|
||||
msg`Confirms signing up ${email} to the waitlist`,
|
||||
)}>
|
||||
<LinearGradient
|
||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[styles.btn]}>
|
||||
<Text type="button-lg" style={[s.white, s.bold]}>
|
||||
<Trans>Join Waitlist</Trans>
|
||||
</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, s.mt10]}
|
||||
onPress={onCancel}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Cancel waitlist signup`)}
|
||||
accessibilityHint={_(
|
||||
msg`Exits signing up for waitlist with ${email}`,
|
||||
)}
|
||||
onAccessibilityEscape={onCancel}>
|
||||
<Text type="button-lg" style={pal.textLight}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
innerContainer: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: 22,
|
||||
marginBottom: 10,
|
||||
},
|
||||
textInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
fontSize: 20,
|
||||
marginHorizontal: 20,
|
||||
},
|
||||
btn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 32,
|
||||
padding: 14,
|
||||
marginHorizontal: 20,
|
||||
},
|
||||
error: {
|
||||
borderRadius: 6,
|
||||
marginHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
})
|
||||
@@ -228,6 +228,7 @@ let FeedItem = ({
|
||||
text={sanitizeDisplayName(
|
||||
authors[0].displayName || authors[0].handle,
|
||||
)}
|
||||
disableMismatchWarning
|
||||
/>
|
||||
{authors.length > 1 ? (
|
||||
<>
|
||||
|
||||
@@ -4,8 +4,8 @@ import {Text} from '../util/text/Text'
|
||||
import {PressableWithHover} from '../util/PressableWithHover'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {DraggableScrollView} from './DraggableScrollView'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
export interface TabBarProps {
|
||||
testID?: string
|
||||
@@ -16,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,
|
||||
@@ -26,19 +30,68 @@ 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}),
|
||||
[indicatorColor, pal],
|
||||
)
|
||||
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,
|
||||
})
|
||||
}, [scrollElRef, itemXs, selectedPage])
|
||||
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(
|
||||
(index: number) => {
|
||||
@@ -63,8 +116,6 @@ export function TabBar({
|
||||
[],
|
||||
)
|
||||
|
||||
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
|
||||
|
||||
return (
|
||||
<View testID={testID} style={[pal.view, styles.outer]}>
|
||||
<DraggableScrollView
|
||||
@@ -79,20 +130,24 @@ 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, selected && indicatorStyle]}
|
||||
style={styles.item}
|
||||
hoverStyle={pal.viewLight}
|
||||
onPress={() => onPressItem(i)}>
|
||||
<Text
|
||||
type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
|
||||
testID={testID ? `${testID}-${item}` : undefined}
|
||||
style={selected ? pal.text : pal.textLight}>
|
||||
{item}
|
||||
</Text>
|
||||
<View style={[styles.itemInner, selected && indicatorStyle]}>
|
||||
<Text
|
||||
type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
|
||||
testID={testID ? `${testID}-${item}` : undefined}
|
||||
style={selected ? pal.text : pal.textLight}>
|
||||
{item}
|
||||
</Text>
|
||||
</View>
|
||||
</PressableWithHover>
|
||||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
<View style={[pal.border, styles.outerBottomBorder]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -103,18 +158,25 @@ const desktopStyles = StyleSheet.create({
|
||||
width: 598,
|
||||
},
|
||||
contentContainer: {
|
||||
columnGap: 8,
|
||||
marginLeft: 14,
|
||||
paddingRight: 14,
|
||||
paddingHorizontal: 0,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
item: {
|
||||
paddingTop: 14,
|
||||
paddingHorizontal: 14,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
itemInner: {
|
||||
paddingBottom: 12,
|
||||
paddingHorizontal: 10,
|
||||
borderBottomWidth: 3,
|
||||
borderBottomColor: 'transparent',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
outerBottomBorder: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -123,17 +185,24 @@ const mobileStyles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
},
|
||||
contentContainer: {
|
||||
columnGap: isWeb ? 0 : 20,
|
||||
marginLeft: isWeb ? 0 : 18,
|
||||
paddingRight: isWeb ? 0 : 36,
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
item: {
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
paddingHorizontal: isWeb ? 8 : 0,
|
||||
borderBottomWidth: 3,
|
||||
borderBottomColor: 'transparent',
|
||||
paddingHorizontal: 10,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
itemInner: {
|
||||
paddingBottom: 10,
|
||||
borderBottomWidth: 3,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
outerBottomBorder: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: -1,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -438,6 +438,7 @@ function PostThreadLoaded({
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
removeClippedSubviews={isAndroid ? false : undefined}
|
||||
windowSize={11}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -304,9 +306,11 @@ let PostThreadItemLoaded = ({
|
||||
styles.postTextLargeContainer,
|
||||
]}>
|
||||
<RichText
|
||||
enableTags
|
||||
selectable
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_xl]}
|
||||
selectable
|
||||
authorHandle={post.author.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
@@ -489,9 +493,11 @@ let PostThreadItemLoaded = ({
|
||||
{richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
authorHandle={post.author.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
@@ -190,10 +190,12 @@ function PostInner({
|
||||
{richText.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
authorHandle={post.author.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||
import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
@@ -84,6 +85,7 @@ let Feed = ({
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const {currentAccount} = useSession()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const checkForNewRef = React.useRef<(() => void) | null>(null)
|
||||
const lastFetchRef = React.useRef<number>(Date.now())
|
||||
@@ -327,6 +329,8 @@ let Feed = ({
|
||||
desktopFixedHeight={
|
||||
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -69,6 +69,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}
|
||||
@@ -339,10 +341,12 @@ let PostContent = ({
|
||||
{richText.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
authorHandle={postAuthor.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
@@ -162,7 +162,7 @@ export const TextLink = memo(function TextLink({
|
||||
dataSet,
|
||||
title,
|
||||
onPress,
|
||||
warnOnMismatchingLabel,
|
||||
disableMismatchWarning,
|
||||
navigationAction,
|
||||
...orgProps
|
||||
}: {
|
||||
@@ -175,7 +175,7 @@ export const TextLink = memo(function TextLink({
|
||||
lineHeight?: number
|
||||
dataSet?: any
|
||||
title?: string
|
||||
warnOnMismatchingLabel?: boolean
|
||||
disableMismatchWarning?: boolean
|
||||
navigationAction?: 'push' | 'replace' | 'navigate'
|
||||
} & TextProps) {
|
||||
const {...props} = useLinkProps({to: sanitizeUrl(href)})
|
||||
@@ -183,14 +183,14 @@ export const TextLink = memo(function TextLink({
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
if (warnOnMismatchingLabel && typeof text !== 'string') {
|
||||
if (!disableMismatchWarning && typeof text !== 'string') {
|
||||
console.error('Unable to detect mismatching label')
|
||||
}
|
||||
|
||||
props.onPress = React.useCallback(
|
||||
(e?: Event) => {
|
||||
const requiresWarning =
|
||||
warnOnMismatchingLabel &&
|
||||
!disableMismatchWarning &&
|
||||
linkRequiresWarning(href, typeof text === 'string' ? text : '')
|
||||
if (requiresWarning) {
|
||||
e?.preventDefault?.()
|
||||
@@ -230,7 +230,7 @@ export const TextLink = memo(function TextLink({
|
||||
navigation,
|
||||
href,
|
||||
text,
|
||||
warnOnMismatchingLabel,
|
||||
disableMismatchWarning,
|
||||
navigationAction,
|
||||
openLink,
|
||||
],
|
||||
|
||||
@@ -172,7 +172,7 @@ function ListImpl<ItemT>(
|
||||
<View
|
||||
ref={containerRef}
|
||||
style={[
|
||||
styles.contentContainer,
|
||||
!isMobile && styles.sideBorders,
|
||||
contentContainerStyle,
|
||||
desktopFixedHeight ? styles.minHeightViewport : null,
|
||||
pal.border,
|
||||
@@ -304,7 +304,7 @@ export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
sideBorders: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -13,11 +13,13 @@ import Animated from 'react-native-reanimated'
|
||||
import {useSetDrawerOpen} from '#/state/shell'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useTheme} from '#/alf'
|
||||
|
||||
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
|
||||
|
||||
export function ViewHeader({
|
||||
title,
|
||||
subtitle,
|
||||
canGoBack,
|
||||
showBackButton = true,
|
||||
hideOnScroll,
|
||||
@@ -26,6 +28,7 @@ export function ViewHeader({
|
||||
renderButton,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
canGoBack?: boolean
|
||||
showBackButton?: boolean
|
||||
hideOnScroll?: boolean
|
||||
@@ -39,6 +42,7 @@ export function ViewHeader({
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {track} = useAnalytics()
|
||||
const {isDesktop, isTablet} = useWebMediaQueries()
|
||||
const t = useTheme()
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
@@ -71,42 +75,60 @@ export function ViewHeader({
|
||||
|
||||
return (
|
||||
<Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}>
|
||||
{showBackButton ? (
|
||||
<TouchableOpacity
|
||||
testID="viewHeaderDrawerBtn"
|
||||
onPress={canGoBack ? onPressBack : onPressMenu}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
style={canGoBack ? styles.backBtn : styles.backBtnWide}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
|
||||
accessibilityHint={
|
||||
canGoBack ? '' : _(msg`Access navigation links and settings`)
|
||||
}>
|
||||
{canGoBack ? (
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="angle-left"
|
||||
style={[styles.backIcon, pal.text]}
|
||||
/>
|
||||
) : !isTablet ? (
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="bars"
|
||||
style={[styles.backIcon, pal.textLight]}
|
||||
/>
|
||||
<View style={{flex: 1}}>
|
||||
<View style={{flexDirection: 'row', alignItems: 'center'}}>
|
||||
{showBackButton ? (
|
||||
<TouchableOpacity
|
||||
testID="viewHeaderDrawerBtn"
|
||||
onPress={canGoBack ? onPressBack : onPressMenu}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
style={canGoBack ? styles.backBtn : styles.backBtnWide}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
|
||||
accessibilityHint={
|
||||
canGoBack ? '' : _(msg`Access navigation links and settings`)
|
||||
}>
|
||||
{canGoBack ? (
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="angle-left"
|
||||
style={[styles.backIcon, pal.text]}
|
||||
/>
|
||||
) : !isTablet ? (
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="bars"
|
||||
style={[styles.backIcon, pal.textLight]}
|
||||
/>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<View style={styles.titleContainer} pointerEvents="none">
|
||||
<Text type="title" style={[pal.text, styles.title]}>
|
||||
{title}
|
||||
</Text>
|
||||
<View style={styles.titleContainer} pointerEvents="none">
|
||||
<Text type="title" style={[pal.text, styles.title]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
{renderButton ? (
|
||||
renderButton()
|
||||
) : showBackButton ? (
|
||||
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
|
||||
) : null}
|
||||
</View>
|
||||
{subtitle ? (
|
||||
<View
|
||||
style={[styles.titleContainer, {marginTop: -3}]}
|
||||
pointerEvents="none">
|
||||
<Text
|
||||
style={[
|
||||
pal.text,
|
||||
styles.subtitle,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
{renderButton ? (
|
||||
renderButton()
|
||||
) : showBackButton ? (
|
||||
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
|
||||
) : null}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -185,7 +207,6 @@ function Container({
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
width: '100%',
|
||||
@@ -207,12 +228,14 @@ const styles = StyleSheet.create({
|
||||
titleContainer: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
paddingRight: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
},
|
||||
backBtn: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
|
||||
Vendored
+3
-1
@@ -5,4 +5,6 @@ export function CenteredView({
|
||||
style,
|
||||
sideBorders,
|
||||
...props
|
||||
}: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>)
|
||||
}: React.PropsWithChildren<
|
||||
ViewProps & {sideBorders?: boolean; topBorder?: boolean}
|
||||
>)
|
||||
|
||||
@@ -32,8 +32,11 @@ interface AddedProps {
|
||||
export function CenteredView({
|
||||
style,
|
||||
sideBorders,
|
||||
topBorder,
|
||||
...props
|
||||
}: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>) {
|
||||
}: React.PropsWithChildren<
|
||||
ViewProps & {sideBorders?: boolean; topBorder?: boolean}
|
||||
>) {
|
||||
const pal = usePalette('default')
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
if (!isMobile) {
|
||||
@@ -46,6 +49,12 @@ export function CenteredView({
|
||||
})
|
||||
style = addStyle(style, pal.border)
|
||||
}
|
||||
if (topBorder) {
|
||||
style = addStyle(style, {
|
||||
borderTopWidth: 1,
|
||||
})
|
||||
style = addStyle(style, pal.border)
|
||||
}
|
||||
return <View style={style} {...props} />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import React, {useState, useCallback} from 'react'
|
||||
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import DateTimePicker, {
|
||||
DateTimePickerEvent,
|
||||
} from '@react-native-community/datetimepicker'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
@@ -14,6 +11,7 @@ import {TypographyVariant} from 'lib/ThemeContext'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {getLocales} from 'expo-localization'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
|
||||
const LOCALE = getLocales()[0]
|
||||
|
||||
@@ -43,11 +41,9 @@ export function DateInput(props: Props) {
|
||||
}, [props.handleAsUTC])
|
||||
|
||||
const onChangeInternal = useCallback(
|
||||
(event: DateTimePickerEvent, date: Date | undefined) => {
|
||||
(date: Date) => {
|
||||
setShow(false)
|
||||
if (date) {
|
||||
props.onChange(date)
|
||||
}
|
||||
props.onChange(date)
|
||||
},
|
||||
[setShow, props],
|
||||
)
|
||||
@@ -56,6 +52,10 @@ export function DateInput(props: Props) {
|
||||
setShow(true)
|
||||
}, [setShow])
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
setShow(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View>
|
||||
{isAndroid && (
|
||||
@@ -80,15 +80,17 @@ export function DateInput(props: Props) {
|
||||
</Button>
|
||||
)}
|
||||
{(isIOS || show) && (
|
||||
<DateTimePicker
|
||||
testID={props.testID ? `${props.testID}-datepicker` : undefined}
|
||||
<DatePicker
|
||||
timeZoneOffsetInMinutes={0}
|
||||
modal={isAndroid}
|
||||
open={isAndroid}
|
||||
theme={theme.colorScheme}
|
||||
date={props.value}
|
||||
onDateChange={onChangeInternal}
|
||||
onConfirm={onChangeInternal}
|
||||
onCancel={onCancel}
|
||||
mode="date"
|
||||
timeZoneName={props.handleAsUTC ? 'Etc/UTC' : undefined}
|
||||
display="spinner"
|
||||
// @ts-ignore applies in iOS only -prf
|
||||
themeVariant={theme.colorScheme}
|
||||
value={props.value}
|
||||
onChange={onChangeInternal}
|
||||
testID={props.testID ? `${props.testID}-datepicker` : undefined}
|
||||
accessibilityLabel={props.accessibilityLabel}
|
||||
accessibilityHint={props.accessibilityHint}
|
||||
accessibilityLabelledBy={props.accessibilityLabelledBy}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
@@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
|
||||
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
className="nativeDropdown-item"
|
||||
{...props}
|
||||
style={StyleSheet.flatten([
|
||||
styles.item,
|
||||
@@ -52,6 +53,7 @@ type Props = {
|
||||
testID?: string
|
||||
accessibilityLabel?: string
|
||||
accessibilityHint?: string
|
||||
triggerStyle?: ViewStyle
|
||||
}
|
||||
|
||||
export function NativeDropdown({
|
||||
@@ -60,6 +62,7 @@ export function NativeDropdown({
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
accessibilityHint,
|
||||
triggerStyle,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
@@ -119,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>
|
||||
@@ -232,6 +236,10 @@ const styles = StyleSheet.create({
|
||||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
outline: 0,
|
||||
border: 0,
|
||||
},
|
||||
itemTitle: {
|
||||
fontSize: 16,
|
||||
|
||||
@@ -36,6 +36,7 @@ import {isWeb} from '#/platform/detection'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
||||
import {NEW_REPORT_DIALOG_ENABLED} from '#/lib/build-flags'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
|
||||
let PostDropdownBtn = ({
|
||||
testID,
|
||||
@@ -68,6 +69,7 @@ let PostDropdownBtn = ({
|
||||
const openLink = useOpenLink()
|
||||
const control = useReportDialogControl()
|
||||
const navigation = useNavigation()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
|
||||
const rootUri = record.reply?.root?.uri || postUri
|
||||
const isThreadMuted = mutedThreads.includes(rootUri)
|
||||
@@ -211,6 +213,20 @@ let PostDropdownBtn = ({
|
||||
web: 'comment-slash',
|
||||
},
|
||||
},
|
||||
hasSession && {
|
||||
label: _(msg`Mute words & tags`),
|
||||
onPress() {
|
||||
mutedWordsDialogControl.open()
|
||||
},
|
||||
testID: 'postDropdownMuteWordsBtn',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'speaker.slash',
|
||||
},
|
||||
android: 'ic_lock_silent_mode',
|
||||
web: 'filter',
|
||||
},
|
||||
},
|
||||
hasSession &&
|
||||
!isAuthor &&
|
||||
!isPostHidden && {
|
||||
|
||||
@@ -7,6 +7,9 @@ import {lh} from 'lib/styles'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeTagLink} from 'lib/routes/links'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
@@ -82,6 +85,7 @@ export function RichText({
|
||||
for (const segment of richText.segments()) {
|
||||
const link = segment.link
|
||||
const mention = segment.mention
|
||||
const tag = segment.tag
|
||||
if (
|
||||
!noLinks &&
|
||||
mention &&
|
||||
@@ -110,11 +114,25 @@ export function RichText({
|
||||
href={link.uri}
|
||||
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
|
||||
dataSet={WORD_WRAP}
|
||||
warnOnMismatchingLabel
|
||||
selectable={selectable}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
!noLinks &&
|
||||
tag &&
|
||||
AppBskyRichtextFacet.validateTag(tag).success
|
||||
) {
|
||||
els.push(
|
||||
<RichTextTag
|
||||
key={key}
|
||||
text={segment.text}
|
||||
type={type}
|
||||
style={style}
|
||||
lineHeightStyle={lineHeightStyle}
|
||||
selectable={selectable}
|
||||
/>,
|
||||
)
|
||||
} else {
|
||||
els.push(segment.text)
|
||||
}
|
||||
@@ -133,3 +151,50 @@ export function RichText({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function RichTextTag({
|
||||
text: tag,
|
||||
type,
|
||||
style,
|
||||
lineHeightStyle,
|
||||
selectable,
|
||||
}: {
|
||||
text: string
|
||||
type?: TypographyVariant
|
||||
style?: StyleProp<TextStyle>
|
||||
lineHeightStyle?: TextStyle
|
||||
selectable?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const control = useTagMenuControl()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
control.open()
|
||||
}, [control])
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TagMenu control={control} tag={tag}>
|
||||
{isNative ? (
|
||||
<TextLink
|
||||
type={type}
|
||||
text={tag}
|
||||
// segment.text has the leading "#" while tag.tag does not
|
||||
href={makeTagLink(tag)}
|
||||
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
|
||||
dataSet={WORD_WRAP}
|
||||
selectable={selectable}
|
||||
onPress={open}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
type={type}
|
||||
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}>
|
||||
{tag}
|
||||
</Text>
|
||||
)}
|
||||
</TagMenu>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user