Merge branch 'main' into 3p-moderators

This commit is contained in:
Paul Frazee
2024-03-07 23:02:14 -08:00
172 changed files with 9422 additions and 2463 deletions
+2 -25
View File
@@ -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>
+2 -2
View File
@@ -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}} />
}
+1 -1
View File
@@ -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`))
+9 -2
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])
@@ -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]
},
})
+7 -82
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>
@@ -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
}
+12 -16
View File
@@ -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}
+81 -20
View File
@@ -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,
},
})
+1 -1
View File
@@ -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',
-4
View File
@@ -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 />
-3
View File
@@ -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') {
-190
View File
@@ -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&apos;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,
},
})
+1
View File
@@ -228,6 +228,7 @@ let FeedItem = ({
text={sanitizeDisplayName(
authors[0].displayName || authors[0].handle,
)}
disableMismatchWarning
/>
{authors.length > 1 ? (
<>
+96 -27
View File
@@ -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,
},
})
+1
View File
@@ -438,6 +438,7 @@ function PostThreadLoaded({
// @ts-ignore our .web version only -prf
desktopFixedHeight
removeClippedSubviews={isAndroid ? false : undefined}
windowSize={11}
/>
)
}
+7 -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}
@@ -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}
+2
View File
@@ -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}
+4
View File
@@ -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>
)
+4
View File
@@ -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}
+5 -5
View File
@@ -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,
],
+2 -2
View File
@@ -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,
},
+15 -1
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 (
+60 -37
View File
@@ -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,
+3 -1
View File
@@ -5,4 +5,6 @@ export function CenteredView({
style,
sideBorders,
...props
}: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>)
}: React.PropsWithChildren<
ViewProps & {sideBorders?: boolean; topBorder?: boolean}
>)
+10 -1
View File
@@ -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} />
}
+17 -15
View File
@@ -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}
+2 -1
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.
+10 -2
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'
@@ -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 && {
+66 -1
View File
@@ -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>
)
}