Merge branch 'bluesky-social:main' into main
This commit is contained in:
commit
963a44ab87
131 changed files with 7094 additions and 1712 deletions
|
|
@ -45,6 +45,7 @@ import {Splash} from '#/Splash'
|
|||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ function InnerApp() {
|
|||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -32,11 +32,13 @@ import {
|
|||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -460,7 +460,8 @@ const FlatNavigator = () => {
|
|||
*/
|
||||
|
||||
const LINKING = {
|
||||
prefixes: ['bsky://', 'https://bsky.app'],
|
||||
// TODO figure out what we are going to use
|
||||
prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
|
||||
|
||||
getPathFromState(state: State) {
|
||||
// find the current node in the navigation tree
|
||||
|
|
@ -478,6 +479,11 @@ const LINKING = {
|
|||
},
|
||||
|
||||
getStateFromPath(path: string) {
|
||||
// Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the
|
||||
// intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
|
||||
// intent
|
||||
if (path.includes('intent/')) return
|
||||
|
||||
const [name, params] = router.matchPath(path)
|
||||
if (isNative) {
|
||||
if (name === 'Search') {
|
||||
|
|
@ -497,7 +503,8 @@ const LINKING = {
|
|||
},
|
||||
])
|
||||
} else {
|
||||
return buildStateObject('Flat', name, params)
|
||||
const res = buildStateObject('Flat', name, params)
|
||||
return res
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
|||
|
||||
const logoAnimations =
|
||||
reduceMotion === true ? reducedLogoAnimation : logoAnimation
|
||||
// special off-spec color for dark mode
|
||||
const logoBg = isDarkMode ? '#0F1824' : '#fff'
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}} onLayout={onLayout}>
|
||||
|
|
@ -232,7 +234,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
|||
},
|
||||
]}>
|
||||
<AnimatedLogo
|
||||
fill="#fff"
|
||||
fill={logoBg}
|
||||
style={[{opacity: 0}, logoAnimations]}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
|
@ -253,7 +255,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
|||
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
|
||||
},
|
||||
]}>
|
||||
<AnimatedLogo fill="#fff" style={[logoAnimations]} />
|
||||
<AnimatedLogo fill={logoBg} style={[logoAnimations]} />
|
||||
</Animated.View>
|
||||
}>
|
||||
{!isAnimationComplete && (
|
||||
|
|
@ -261,10 +263,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
|||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
{
|
||||
backgroundColor: isDarkMode
|
||||
? // special off-spec color for dark mode
|
||||
'#0F1824'
|
||||
: '#fff',
|
||||
backgroundColor: logoBg,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import {web, native} from '#/alf/util/platform'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
|
||||
export const atoms = {
|
||||
|
|
@ -113,6 +114,9 @@ export const atoms = {
|
|||
flex_wrap: {
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
flex_0: {
|
||||
flex: web('0 0 auto') || (native(0) as number),
|
||||
},
|
||||
flex_1: {
|
||||
flex: 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
import {Platform} from 'react-native'
|
||||
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
||||
|
||||
export function web(value: any) {
|
||||
return Platform.select({
|
||||
web: value,
|
||||
})
|
||||
if (isWeb) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function ios(value: any) {
|
||||
return Platform.select({
|
||||
ios: value,
|
||||
})
|
||||
if (isIOS) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function android(value: any) {
|
||||
return Platform.select({
|
||||
android: value,
|
||||
})
|
||||
if (isAndroid) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function native(value: any) {
|
||||
return Platform.select({
|
||||
native: value,
|
||||
})
|
||||
if (isNative) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import React from 'react'
|
|||
import {useDialogStateContext} from '#/state/dialogs'
|
||||
import {
|
||||
DialogContextProps,
|
||||
DialogControlProps,
|
||||
DialogControlRefProps,
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ export function useDialogContext() {
|
|||
|
||||
export function useDialogControl(): DialogOuterProps['control'] {
|
||||
const id = React.useId()
|
||||
const control = React.useRef<DialogControlProps>({
|
||||
const control = React.useRef<DialogControlRefProps>({
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
})
|
||||
|
|
@ -32,8 +32,13 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
|||
}, [id, activeDialogs])
|
||||
|
||||
return {
|
||||
id,
|
||||
ref: control,
|
||||
open: () => control.current.open(),
|
||||
close: cb => control.current.close(cb),
|
||||
open: () => {
|
||||
control.current.open()
|
||||
},
|
||||
close: cb => {
|
||||
control.current.close(cb)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|||
import {useTheme, atoms as a, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {logger} from '#/logger'
|
||||
import {useDialogStateContext} from '#/state/dialogs'
|
||||
|
||||
import {
|
||||
DialogOuterProps,
|
||||
|
|
@ -36,6 +38,7 @@ export function Outer({
|
|||
const hasSnapPoints = !!sheetOptions.snapPoints
|
||||
const insets = useSafeAreaInsets()
|
||||
const closeCallback = React.useRef<() => void>()
|
||||
const {openDialogs} = useDialogStateContext()
|
||||
|
||||
/*
|
||||
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
|
||||
|
|
@ -49,14 +52,15 @@ export function Outer({
|
|||
|
||||
const open = React.useCallback<DialogControlProps['open']>(
|
||||
({index} = {}) => {
|
||||
openDialogs.current.add(control.id)
|
||||
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
|
||||
setOpenIndex(index || 0)
|
||||
},
|
||||
[setOpenIndex],
|
||||
[setOpenIndex, openDialogs, control.id],
|
||||
)
|
||||
|
||||
const close = React.useCallback<DialogControlProps['close']>(cb => {
|
||||
if (cb) {
|
||||
if (cb && typeof cb === 'function') {
|
||||
closeCallback.current = cb
|
||||
}
|
||||
sheet.current?.close()
|
||||
|
|
@ -74,13 +78,22 @@ export function Outer({
|
|||
const onChange = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
closeCallback.current?.()
|
||||
closeCallback.current = undefined
|
||||
try {
|
||||
closeCallback.current?.()
|
||||
} catch (e: any) {
|
||||
logger.error(`Dialog closeCallback failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
} finally {
|
||||
closeCallback.current = undefined
|
||||
}
|
||||
|
||||
openDialogs.current.delete(control.id)
|
||||
onClose?.()
|
||||
setOpenIndex(-1)
|
||||
}
|
||||
},
|
||||
[onClose, setOpenIndex],
|
||||
[onClose, setOpenIndex, openDialogs, control.id],
|
||||
)
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
|
|||
import {Context} from '#/components/Dialog/context'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {useDialogStateContext} from '#/state/dialogs'
|
||||
|
||||
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/types'
|
||||
|
|
@ -29,18 +30,21 @@ export function Outer({
|
|||
const {gtMobile} = useBreakpoints()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [isVisible, setIsVisible] = React.useState(true)
|
||||
const {openDialogs} = useDialogStateContext()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
setIsOpen(true)
|
||||
}, [setIsOpen])
|
||||
openDialogs.current.add(control.id)
|
||||
}, [setIsOpen, openDialogs, control.id])
|
||||
|
||||
const close = React.useCallback(async () => {
|
||||
setIsVisible(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
setIsOpen(false)
|
||||
setIsVisible(true)
|
||||
openDialogs.current.delete(control.id)
|
||||
onClose?.()
|
||||
}, [onClose, setIsOpen])
|
||||
}, [onClose, setIsOpen, openDialogs, control.id])
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
|
|
@ -188,9 +192,9 @@ export function Close() {
|
|||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={close}
|
||||
onPress={() => close()}
|
||||
label={_(msg`Close active dialog`)}>
|
||||
<ButtonIcon icon={X} size="md" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,26 @@ import {ViewStyleProp} from '#/alf'
|
|||
|
||||
type A11yProps = Required<AccessibilityProps>
|
||||
|
||||
/**
|
||||
* Mutated by useImperativeHandle to provide a public API for controlling the
|
||||
* dialog. The methods here will actually become the handlers defined within
|
||||
* the `Dialog.Outer` component.
|
||||
*/
|
||||
export type DialogControlRefProps = {
|
||||
open: (options?: DialogControlOpenOptions) => void
|
||||
close: (callback?: () => void) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The return type of the useDialogControl hook.
|
||||
*/
|
||||
export type DialogControlProps = DialogControlRefProps & {
|
||||
id: string
|
||||
ref: React.RefObject<DialogControlRefProps>
|
||||
}
|
||||
|
||||
export type DialogContextProps = {
|
||||
close: () => void
|
||||
close: DialogControlProps['close']
|
||||
}
|
||||
|
||||
export type DialogControlOpenOptions = {
|
||||
|
|
@ -20,15 +38,8 @@ export type DialogControlOpenOptions = {
|
|||
index?: number
|
||||
}
|
||||
|
||||
export type DialogControlProps = {
|
||||
open: (options?: DialogControlOpenOptions) => void
|
||||
close: (callback?: () => void) => void
|
||||
}
|
||||
|
||||
export type DialogOuterProps = {
|
||||
control: {
|
||||
ref: React.RefObject<DialogControlProps>
|
||||
} & DialogControlProps
|
||||
control: DialogControlProps
|
||||
onClose?: () => void
|
||||
nativeOptions?: {
|
||||
sheet?: Omit<BottomSheetProps, 'children'>
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export function Cancel({
|
|||
color="secondary"
|
||||
size="small"
|
||||
label={_(msg`Cancel`)}
|
||||
onPress={close}>
|
||||
onPress={() => close()}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import React from 'react'
|
||||
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, TextStyleProp, flatten} from '#/alf'
|
||||
import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
|
|
@ -15,37 +19,25 @@ export function RichText({
|
|||
style,
|
||||
numberOfLines,
|
||||
disableLinks,
|
||||
resolveFacets = false,
|
||||
selectable,
|
||||
enableTags = false,
|
||||
authorHandle,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
resolveFacets?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
}) {
|
||||
const detected = React.useRef(false)
|
||||
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
[value],
|
||||
)
|
||||
const styles = [a.leading_snug, flatten(style)]
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolveFacets) return
|
||||
|
||||
async function detectFacets() {
|
||||
const rt = new RichTextAPI({text: richText.text})
|
||||
await rt.detectFacets(getAgent())
|
||||
setRichText(rt)
|
||||
}
|
||||
|
||||
if (!detected.current) {
|
||||
detected.current = true
|
||||
detectFacets()
|
||||
}
|
||||
}, [richText, setRichText, resolveFacets])
|
||||
|
||||
const {text, facets} = richText
|
||||
|
||||
if (!facets?.length) {
|
||||
|
|
@ -85,6 +77,7 @@ export function RichText({
|
|||
for (const segment of richText.segments()) {
|
||||
const link = segment.link
|
||||
const mention = segment.mention
|
||||
const tag = segment.tag
|
||||
if (
|
||||
mention &&
|
||||
AppBskyRichtextFacet.validateMention(mention).success &&
|
||||
|
|
@ -118,6 +111,21 @@ export function RichText({
|
|||
</InlineLink>,
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
!disableLinks &&
|
||||
enableTags &&
|
||||
tag &&
|
||||
AppBskyRichtextFacet.validateTag(tag).success
|
||||
) {
|
||||
els.push(
|
||||
<RichTextTag
|
||||
key={key}
|
||||
text={segment.text}
|
||||
style={styles}
|
||||
selectable={selectable}
|
||||
authorHandle={authorHandle}
|
||||
/>,
|
||||
)
|
||||
} else {
|
||||
els.push(segment.text)
|
||||
}
|
||||
|
|
@ -136,3 +144,79 @@ export function RichText({
|
|||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function RichTextTag({
|
||||
text: tag,
|
||||
style,
|
||||
selectable,
|
||||
authorHandle,
|
||||
}: {
|
||||
text: string
|
||||
selectable?: boolean
|
||||
authorHandle?: string
|
||||
} & TextStyleProp) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const control = useTagMenuControl()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
control.open()
|
||||
}, [control])
|
||||
|
||||
/*
|
||||
* N.B. On web, this is wrapped in another pressable comopnent with a11y
|
||||
* labels, etc. That's why only some of these props are applied here.
|
||||
*/
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
|
||||
<Text
|
||||
selectable={selectable}
|
||||
{...native({
|
||||
accessibilityLabel: _(msg`Hashtag: ${tag}`),
|
||||
accessibilityHint: _(msg`Click here to open tag menu for ${tag}`),
|
||||
accessibilityRole: isNative ? 'button' : undefined,
|
||||
onPress: open,
|
||||
onPressIn: onPressIn,
|
||||
onPressOut: onPressOut,
|
||||
})}
|
||||
{...web({
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
})}
|
||||
// @ts-ignore
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
pointerEvents: 'auto',
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
web({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
(hovered || focused || pressed) && {
|
||||
...web({outline: 0}),
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
},
|
||||
]}>
|
||||
{tag}
|
||||
</Text>
|
||||
</TagMenu>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
279
src/components/TagMenu/index.tsx
Normal file
279
src/components/TagMenu/index.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Link} from '#/components/Link'
|
||||
import {makeSearchLink} from '#/lib/routes/links'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
|
||||
export function useTagMenuControl() {
|
||||
return Dialog.useDialogControl()
|
||||
}
|
||||
|
||||
export function TagMenu({
|
||||
children,
|
||||
control,
|
||||
tag,
|
||||
authorHandle,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
tag: string
|
||||
authorHandle?: string
|
||||
}>) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {isLoading: isPreferencesLoading, data: preferences} =
|
||||
usePreferencesQuery()
|
||||
const {
|
||||
mutateAsync: upsertMutedWord,
|
||||
variables: optimisticUpsert,
|
||||
reset: resetUpsert,
|
||||
} = useUpsertMutedWordsMutation()
|
||||
const {
|
||||
mutateAsync: removeMutedWord,
|
||||
variables: optimisticRemove,
|
||||
reset: resetRemove,
|
||||
} = useRemoveMutedWordMutation()
|
||||
|
||||
const sanitizedTag = tag.replace(/^#/, '')
|
||||
const isMuted = Boolean(
|
||||
(preferences?.mutedWords?.find(
|
||||
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
||||
) ??
|
||||
optimisticUpsert?.find(
|
||||
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
||||
)) &&
|
||||
!(optimisticRemove?.value === sanitizedTag),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.Inner label={_(msg`Tag menu: ${tag}`)}>
|
||||
{isPreferencesLoading ? (
|
||||
<View style={[a.w_full, a.align_center]}>
|
||||
<Loader size="lg" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.mb_md,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Link
|
||||
label={_(msg`Search for all posts with tag ${tag}`)}
|
||||
to={makeSearchLink({query: tag})}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
|
||||
control.close(() => {
|
||||
// @ts-ignore :ron_swanson: "I know more than you"
|
||||
navigation.navigate('SearchTab', {
|
||||
screen: 'Search',
|
||||
params: {
|
||||
q: tag,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return false
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Search size="lg" style={[t.atoms.text_contrast_medium]} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
native({top: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
See{' '}
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{tag}
|
||||
</Text>{' '}
|
||||
posts
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{authorHandle && !isInvalidHandle(authorHandle) && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<Link
|
||||
label={_(
|
||||
msg`Search for all posts by @${authorHandle} with tag ${tag}`,
|
||||
)}
|
||||
to={makeSearchLink({query: tag, from: authorHandle})}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
|
||||
control.close(() => {
|
||||
// @ts-ignore :ron_swanson: "I know more than you"
|
||||
navigation.navigate('SearchTab', {
|
||||
screen: 'Search',
|
||||
params: {
|
||||
q:
|
||||
tag +
|
||||
(authorHandle ? ` from:${authorHandle}` : ''),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return false
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Person
|
||||
size="lg"
|
||||
style={[t.atoms.text_contrast_medium]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
native({top: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
See{' '}
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{tag}
|
||||
</Text>{' '}
|
||||
posts by this user
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{preferences ? (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<Button
|
||||
label={
|
||||
isMuted
|
||||
? _(msg`Unmute all ${tag} posts`)
|
||||
: _(msg`Mute all ${tag} posts`)
|
||||
}
|
||||
onPress={() => {
|
||||
control.close(() => {
|
||||
if (isMuted) {
|
||||
resetUpsert()
|
||||
removeMutedWord({
|
||||
value: sanitizedTag,
|
||||
targets: ['tag'],
|
||||
})
|
||||
} else {
|
||||
resetRemove()
|
||||
upsertMutedWord([
|
||||
{value: sanitizedTag, targets: ['tag']},
|
||||
])
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Mute
|
||||
size="lg"
|
||||
style={[t.atoms.text_contrast_medium]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
native({top: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{tag}
|
||||
</Text>{' '}
|
||||
<Trans>posts</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={_(msg`Close this dialog`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Inner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
136
src/components/TagMenu/index.web.tsx
Normal file
136
src/components/TagMenu/index.web.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {web} from '#/alf'
|
||||
|
||||
export function useTagMenuControl() {}
|
||||
|
||||
export function TagMenu({
|
||||
children,
|
||||
tag,
|
||||
authorHandle,
|
||||
}: React.PropsWithChildren<{
|
||||
tag: string
|
||||
authorHandle?: string
|
||||
}>) {
|
||||
const sanitizedTag = tag.replace(/^#/, '')
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||
useUpsertMutedWordsMutation()
|
||||
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
||||
useRemoveMutedWordMutation()
|
||||
const isMuted = Boolean(
|
||||
(preferences?.mutedWords?.find(
|
||||
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
||||
) ??
|
||||
optimisticUpsert?.find(
|
||||
m => m.value === sanitizedTag && m.targets.includes('tag'),
|
||||
)) &&
|
||||
!(optimisticRemove?.value === sanitizedTag),
|
||||
)
|
||||
const truncatedTag = enforceLen(tag, 15, true, 'middle')
|
||||
|
||||
const dropdownItems = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: _(msg`See ${truncatedTag} posts`),
|
||||
onPress() {
|
||||
navigation.navigate('Search', {
|
||||
q: tag,
|
||||
})
|
||||
},
|
||||
testID: 'tagMenuSearch',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'magnifyingglass',
|
||||
},
|
||||
android: '',
|
||||
web: 'magnifying-glass',
|
||||
},
|
||||
},
|
||||
authorHandle &&
|
||||
!isInvalidHandle(authorHandle) && {
|
||||
label: _(msg`See ${truncatedTag} posts by user`),
|
||||
onPress() {
|
||||
navigation.navigate({
|
||||
name: 'Search',
|
||||
params: {
|
||||
q: tag + (authorHandle ? ` from:${authorHandle}` : ''),
|
||||
},
|
||||
})
|
||||
},
|
||||
testID: 'tagMenuSeachByUser',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'magnifyingglass',
|
||||
},
|
||||
android: '',
|
||||
web: ['far', 'user'],
|
||||
},
|
||||
},
|
||||
preferences && {
|
||||
label: 'separator',
|
||||
},
|
||||
preferences && {
|
||||
label: isMuted
|
||||
? _(msg`Unmute ${truncatedTag}`)
|
||||
: _(msg`Mute ${truncatedTag}`),
|
||||
onPress() {
|
||||
if (isMuted) {
|
||||
removeMutedWord({value: sanitizedTag, targets: ['tag']})
|
||||
} else {
|
||||
upsertMutedWord([{value: sanitizedTag, targets: ['tag']}])
|
||||
}
|
||||
},
|
||||
testID: 'tagMenuMute',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'speaker.slash',
|
||||
},
|
||||
android: 'ic_menu_sort_alphabetically',
|
||||
web: isMuted ? 'eye' : ['far', 'eye-slash'],
|
||||
},
|
||||
},
|
||||
].filter(Boolean)
|
||||
}, [
|
||||
_,
|
||||
authorHandle,
|
||||
isMuted,
|
||||
navigation,
|
||||
preferences,
|
||||
tag,
|
||||
truncatedTag,
|
||||
sanitizedTag,
|
||||
upsertMutedWord,
|
||||
removeMutedWord,
|
||||
])
|
||||
|
||||
return (
|
||||
<EventStopper>
|
||||
<NativeDropdown
|
||||
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
|
||||
accessibilityHint=""
|
||||
// @ts-ignore
|
||||
items={dropdownItems}
|
||||
triggerStyle={web({
|
||||
textAlign: 'left',
|
||||
})}>
|
||||
{children}
|
||||
</NativeDropdown>
|
||||
</EventStopper>
|
||||
)
|
||||
}
|
||||
29
src/components/dialogs/Context.tsx
Normal file
29
src/components/dialogs/Context.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
type Control = Dialog.DialogOuterProps['control']
|
||||
|
||||
type ControlsContext = {
|
||||
mutedWordsDialogControl: Control
|
||||
}
|
||||
|
||||
const ControlsContext = React.createContext({
|
||||
mutedWordsDialogControl: {} as Control,
|
||||
})
|
||||
|
||||
export function useGlobalDialogsControlContext() {
|
||||
return React.useContext(ControlsContext)
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const mutedWordsDialogControl = Dialog.useDialogControl()
|
||||
const ctx = React.useMemo(
|
||||
() => ({mutedWordsDialogControl}),
|
||||
[mutedWordsDialogControl],
|
||||
)
|
||||
|
||||
return (
|
||||
<ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider>
|
||||
)
|
||||
}
|
||||
340
src/components/dialogs/MutedWords.tsx
Normal file
340
src/components/dialogs/MutedWords.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, useTheme, useBreakpoints, ViewStyleProp, web} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {logger} from '#/logger'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
|
||||
export function MutedWordsDialog() {
|
||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<MutedWordsInner control={control} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {
|
||||
isLoading: isPreferencesLoading,
|
||||
data: preferences,
|
||||
error: preferencesError,
|
||||
} = usePreferencesQuery()
|
||||
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||
const [field, setField] = React.useState('')
|
||||
const [options, setOptions] = React.useState(['content'])
|
||||
const [_error, setError] = React.useState('')
|
||||
|
||||
const submit = React.useCallback(async () => {
|
||||
const value = field.trim()
|
||||
const targets = ['tag', options.includes('content') && 'content'].filter(
|
||||
Boolean,
|
||||
) as AppBskyActorDefs.MutedWord['targets']
|
||||
|
||||
if (!value || !targets.length) return
|
||||
|
||||
try {
|
||||
await addMutedWord([{value, targets}])
|
||||
setField('')
|
||||
} catch (e: any) {
|
||||
logger.error(`Failed to save muted word`, {message: e.message})
|
||||
setError(e.message)
|
||||
}
|
||||
}, [field, options, addMutedWord, setField])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||
<Trans>Add muted words and tags</Trans>
|
||||
</Text>
|
||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Posts can be muted based on their text, their tags, or both.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.pb_lg]}>
|
||||
<Dialog.Input
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
label={_(msg`Enter a word or tag`)}
|
||||
placeholder={_(msg`Enter a word or tag`)}
|
||||
value={field}
|
||||
onChangeText={setField}
|
||||
onSubmitEditing={submit}
|
||||
/>
|
||||
|
||||
<Toggle.Group
|
||||
label={_(msg`Toggle between muted word options.`)}
|
||||
type="radio"
|
||||
values={options}
|
||||
onChange={setOptions}>
|
||||
<View
|
||||
style={[
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.flex_wrap,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in post text and tags`)}
|
||||
name="content"
|
||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.Label>
|
||||
<Trans>Mute in text & tags</Trans>
|
||||
</Toggle.Label>
|
||||
</View>
|
||||
<PageText size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in tags only`)}
|
||||
name="tag"
|
||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.Label>
|
||||
<Trans>Mute in tags only</Trans>
|
||||
</Toggle.Label>
|
||||
</View>
|
||||
<Hashtag size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Button
|
||||
disabled={isPending || !field}
|
||||
label={_(msg`Add mute word for configured settings`)}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
||||
onPress={submit}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : Plus} />
|
||||
</Button>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
We recommend avoiding common words that appear in many posts, since
|
||||
it can result in no posts being shown.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.pt_2xl]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
|
||||
<Trans>Your muted words</Trans>
|
||||
</Text>
|
||||
|
||||
{isPreferencesLoading ? (
|
||||
<Loader />
|
||||
) : preferencesError || !preferences ? (
|
||||
<View
|
||||
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
We're sorry, but we weren't able to load your muted words at
|
||||
this time. Please try again.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : preferences.mutedWords.length ? (
|
||||
[...preferences.mutedWords]
|
||||
.reverse()
|
||||
.map((word, i) => (
|
||||
<MutedWordRow
|
||||
key={word.value + i}
|
||||
word={word}
|
||||
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View
|
||||
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||
<Trans>You haven't muted any words or tags yet</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isNative && <View style={{height: 20}} />}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function MutedWordRow({
|
||||
style,
|
||||
word,
|
||||
}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
||||
const control = Prompt.usePromptControl()
|
||||
|
||||
const remove = React.useCallback(async () => {
|
||||
control.close()
|
||||
removeMutedWord(word)
|
||||
}, [removeMutedWord, word, control])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.Title>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</Prompt.Title>
|
||||
<Prompt.Description>
|
||||
<Trans>
|
||||
This will delete {word.value} from your muted words. You can always
|
||||
add it back later.
|
||||
</Trans>
|
||||
</Prompt.Description>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Cancel>
|
||||
<ButtonText>
|
||||
<Trans>Nevermind</Trans>
|
||||
</ButtonText>
|
||||
</Prompt.Cancel>
|
||||
<Prompt.Action onPress={remove}>
|
||||
<ButtonText>
|
||||
<Trans>Remove</Trans>
|
||||
</ButtonText>
|
||||
</Prompt.Action>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.rounded_md,
|
||||
a.gap_md,
|
||||
style,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
a.w_full,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
web({
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}),
|
||||
]}>
|
||||
{word.value}
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
||||
{word.targets.map(target => (
|
||||
<View
|
||||
key={target}
|
||||
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
||||
<Text
|
||||
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Button
|
||||
label={_(msg`Remove mute word from your list`)}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.open()}
|
||||
style={[a.ml_sm]}>
|
||||
<ButtonIcon icon={isPending ? Loader : X} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetToggle({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
const ctx = Toggle.useItemContext()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_xs,
|
||||
a.flex_1,
|
||||
a.py_sm,
|
||||
a.px_sm,
|
||||
gtMobile && a.px_md,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_50,
|
||||
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
|
||||
ctx.selected && [
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
|
||||
},
|
||||
],
|
||||
ctx.disabled && {
|
||||
opacity: 0.8,
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) {
|
|||
return (
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
|
||||
style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
|
||||
{...web({
|
||||
onClick: () => inputRef.current?.focus(),
|
||||
onMouseOver: onHoverIn,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants'
|
|||
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
|
||||
|
||||
export type ItemState = {
|
||||
name: string
|
||||
|
|
@ -331,15 +332,14 @@ export function createSharedToggleStyles({
|
|||
export function Checkbox() {
|
||||
const t = useTheme()
|
||||
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
|
||||
const {baseStyles, baseHoverStyles, indicatorStyles} =
|
||||
createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
})
|
||||
const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
})
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
|
|
@ -355,21 +355,7 @@ export function Checkbox() {
|
|||
baseStyles,
|
||||
hovered || focused ? baseHoverStyles : {},
|
||||
]}>
|
||||
{selected ? (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_2xs,
|
||||
{height: 12, width: 12},
|
||||
selected
|
||||
? {
|
||||
backgroundColor: t.palette.primary_500,
|
||||
}
|
||||
: {},
|
||||
indicatorStyles,
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
|||
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
|
||||
})
|
||||
|
||||
export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z',
|
||||
})
|
||||
|
|
|
|||
5
src/components/icons/Clipboard.tsx
Normal file
5
src/components/icons/Clipboard.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z',
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z',
|
||||
path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z',
|
||||
})
|
||||
|
|
|
|||
5
src/components/icons/MagnifyingGlass2.tsx
Normal file
5
src/components/icons/MagnifyingGlass2.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
|
||||
})
|
||||
5
src/components/icons/Mute.tsx
Normal file
5
src/components/icons/Mute.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z',
|
||||
})
|
||||
5
src/components/icons/PageText.tsx
Normal file
5
src/components/icons/PageText.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z',
|
||||
})
|
||||
5
src/components/icons/Person.tsx
Normal file
5
src/components/icons/Person.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
|
||||
})
|
||||
692
src/lib/__tests__/moderatePost_wrapped.test.ts
Normal file
692
src/lib/__tests__/moderatePost_wrapped.test.ts
Normal file
|
|
@ -0,0 +1,692 @@
|
|||
import {describe, it, expect} from '@jest/globals'
|
||||
import {RichText} from '@atproto/api'
|
||||
|
||||
import {hasMutedWord} from '../moderatePost_wrapped'
|
||||
|
||||
describe(`hasMutedWord`, () => {
|
||||
describe(`tags`, () => {
|
||||
it(`match: outline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'outlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: inline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: content target matches inline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: only tag targets`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`early exits`, () => {
|
||||
it(`match: single character 希`, () => {
|
||||
/**
|
||||
* @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
|
||||
*/
|
||||
const rt = new RichText({
|
||||
text: `改善希望です`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: '希', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: long muted word, short post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `hey`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'politics', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: exact text`, () => {
|
||||
const rt = new RichText({
|
||||
text: `javascript`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`general content`, () => {
|
||||
it(`match: word within post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post about javascript`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: partial word`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Use your brain, Eric`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'ai', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: multiline`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Use your\n\tbrain, Eric`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'brain', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: :)`, () => {
|
||||
const rt = new RichText({
|
||||
text: `So happy :)`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `:)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`punctuation semi-fuzzy`, () => {
|
||||
describe(`yay!`, () => {
|
||||
const rt = new RichText({
|
||||
text: `We're federating, yay!`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: yay!`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'yay!', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: yay`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'yay', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`y!ppee!!`, () => {
|
||||
const rt = new RichText({
|
||||
text: `We're federating, y!ppee!!`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: y!ppee`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'y!ppee', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
// single exclamation point, source has double
|
||||
it(`no match: y!ppee!`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'y!ppee!', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`Why so S@assy?`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Why so S@assy?`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: S@assy`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'S@assy', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: s@assy`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 's@assy', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`New York Times`, () => {
|
||||
const rt = new RichText({
|
||||
text: `New York Times`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
// case insensitive
|
||||
it(`match: new york times`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'new york times', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`!command`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Idk maybe a bot !command`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: !command`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: command`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: !command`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Idk maybe a bot command`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`e/acc`, () => {
|
||||
const rt = new RichText({
|
||||
text: `I'm e/acc pilled`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: e/acc`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `e/acc`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: acc`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `acc`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`super-bad`, () => {
|
||||
const rt = new RichText({
|
||||
text: `I'm super-bad`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: super-bad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super-bad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: super`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: super bad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super bad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: superbad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `superbad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`idk_what_this_would_be`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Weird post with idk_what_this_would_be`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: idk what this would be`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idk what this would be`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: idk what this would be for`, () => {
|
||||
// extra word
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [
|
||||
{value: `idk what this would be for`, targets: ['content']},
|
||||
],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: idk`, () => {
|
||||
// extra word
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idk`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: idkwhatthiswouldbe`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`parentheses`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Post with context(iykyk)`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: context(iykyk)`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: context`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `context`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: iykyk`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `iykyk`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: (iykyk)`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `(iykyk)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`🦋`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Post with 🦋`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: 🦋`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `🦋`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`phrases`, () => {
|
||||
describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
|
||||
const rt = new RichText({
|
||||
text: `I like turtles, or how I learned to stop worrying and love the internet.`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: stop worrying`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'stop worrying', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: turtles, or how`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'turtles, or how', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`languages without spaces`, () => {
|
||||
// I love turtles, or how I learned to stop worrying and love the internet
|
||||
describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
|
||||
const rt = new RichText({
|
||||
text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
// internet
|
||||
it(`match: インターネット`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'インターネット', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
languages: ['ja'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`doesn't mute own post`, () => {
|
||||
it(`does mute if it isn't own post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`doesn't mute own post when muted word is in text`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: true,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`doesn't mute own post when muted word is in tags`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute #words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['tags']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: true,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||
|
||||
// add image embed if present
|
||||
if (opts.images?.length) {
|
||||
logger.info(`Uploading images`, {
|
||||
logger.debug(`Uploading images`, {
|
||||
count: opts.images.length,
|
||||
})
|
||||
|
||||
const images: AppBskyEmbedImages.Image[] = []
|
||||
for (const image of opts.images) {
|
||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||
logger.info(`Compressing image`)
|
||||
logger.debug(`Compressing image`)
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const {width, height} = image.compressed || image
|
||||
logger.info(`Uploading image`)
|
||||
logger.debug(`Uploading image`)
|
||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
|
|
|
|||
|
|
@ -75,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20)
|
|||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
export const BSKY_FEED_OWNER_DIDS = [
|
||||
'did:plc:z72i7hdynmk6r22z27h6tvur',
|
||||
'did:plc:vpkhqolt662uhesyj6nxm7ys',
|
||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||
]
|
||||
|
|
|
|||
91
src/lib/hooks/useIntentHandler.ts
Normal file
91
src/lib/hooks/useIntentHandler.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react'
|
||||
import * as Linking from 'expo-linking'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useComposerControls} from 'state/shell'
|
||||
import {useSession} from 'state/session'
|
||||
import {useCloseAllActiveElements} from 'state/util'
|
||||
|
||||
type IntentType = 'compose'
|
||||
|
||||
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
||||
|
||||
export function useIntentHandler() {
|
||||
const incomingUrl = Linking.useURL()
|
||||
const composeIntent = useComposeIntent()
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleIncomingURL = (url: string) => {
|
||||
const urlp = new URL(url)
|
||||
const [_, intentTypeNative, intentTypeWeb] = urlp.pathname.split('/')
|
||||
|
||||
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
||||
// intent check. On web, we have to check the first part of the path since we have an actual hostname
|
||||
const intentType = isNative ? intentTypeNative : intentTypeWeb
|
||||
const isIntent = isNative
|
||||
? urlp.hostname === 'intent'
|
||||
: intentTypeNative === 'intent'
|
||||
const params = urlp.searchParams
|
||||
|
||||
if (!isIntent) return
|
||||
|
||||
switch (intentType as IntentType) {
|
||||
case 'compose': {
|
||||
composeIntent({
|
||||
text: params.get('text'),
|
||||
imageUrisStr: params.get('imageUris'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingUrl) handleIncomingURL(incomingUrl)
|
||||
}, [incomingUrl, composeIntent])
|
||||
}
|
||||
|
||||
function useComposeIntent() {
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {openComposer} = useComposerControls()
|
||||
const {hasSession} = useSession()
|
||||
|
||||
return React.useCallback(
|
||||
({
|
||||
text,
|
||||
imageUrisStr,
|
||||
}: {
|
||||
text: string | null
|
||||
imageUrisStr: string | null // unused for right now, will be used later with intents
|
||||
}) => {
|
||||
if (!hasSession) return
|
||||
|
||||
closeAllActiveElements()
|
||||
|
||||
const imageUris = imageUrisStr
|
||||
?.split(',')
|
||||
.filter(part => {
|
||||
// For some security, we're going to filter out any image uri that is external. We don't want someone to
|
||||
// be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
|
||||
// and we load that image
|
||||
if (part.includes('https://') || part.includes('http://')) {
|
||||
return false
|
||||
}
|
||||
// We also should just filter out cases that don't have all the info we need
|
||||
if (!VALID_IMAGE_REGEX.test(part)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(part => {
|
||||
const [uri, width, height] = part.split('|')
|
||||
return {uri, width: Number(width), height: Number(height)}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
openComposer({
|
||||
text: text ?? undefined,
|
||||
imageUris: isNative ? imageUris : undefined,
|
||||
})
|
||||
}, 500)
|
||||
},
|
||||
[hasSession, closeAllActiveElements, openComposer],
|
||||
)
|
||||
}
|
||||
|
|
@ -2,19 +2,151 @@ import {
|
|||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
moderatePost,
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyRichtextFacet,
|
||||
AppBskyEmbedImages,
|
||||
} from '@atproto/api'
|
||||
|
||||
type ModeratePost = typeof moderatePost
|
||||
type Options = Parameters<ModeratePost>[1] & {
|
||||
hiddenPosts?: string[]
|
||||
mutedWords?: AppBskyActorDefs.MutedWord[]
|
||||
}
|
||||
|
||||
const REGEX = {
|
||||
LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
|
||||
ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
|
||||
SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
|
||||
WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
|
||||
}
|
||||
|
||||
/**
|
||||
* List of 2-letter lang codes for languages that either don't use spaces, or
|
||||
* don't use spaces in a way conducive to word-based filtering.
|
||||
*
|
||||
* For these, we use a simple `String.includes` to check for a match.
|
||||
*/
|
||||
const LANGUAGE_EXCEPTIONS = [
|
||||
'ja', // Japanese
|
||||
'zh', // Chinese
|
||||
'ko', // Korean
|
||||
'th', // Thai
|
||||
'vi', // Vietnamese
|
||||
]
|
||||
|
||||
export function hasMutedWord({
|
||||
mutedWords,
|
||||
text,
|
||||
facets,
|
||||
outlineTags,
|
||||
languages,
|
||||
isOwnPost,
|
||||
}: {
|
||||
mutedWords: AppBskyActorDefs.MutedWord[]
|
||||
text: string
|
||||
facets?: AppBskyRichtextFacet.Main[]
|
||||
outlineTags?: string[]
|
||||
languages?: string[]
|
||||
isOwnPost: boolean
|
||||
}) {
|
||||
if (isOwnPost) return false
|
||||
|
||||
const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
|
||||
const tags = ([] as string[])
|
||||
.concat(outlineTags || [])
|
||||
.concat(
|
||||
facets
|
||||
?.filter(facet => {
|
||||
return facet.features.find(feature =>
|
||||
AppBskyRichtextFacet.isTag(feature),
|
||||
)
|
||||
})
|
||||
.map(t => t.features[0].tag as string) || [],
|
||||
)
|
||||
.map(t => t.toLowerCase())
|
||||
|
||||
for (const mute of mutedWords) {
|
||||
const mutedWord = mute.value.toLowerCase()
|
||||
const postText = text.toLowerCase()
|
||||
|
||||
// `content` applies to tags as well
|
||||
if (tags.includes(mutedWord)) return true
|
||||
// rest of the checks are for `content` only
|
||||
if (!mute.targets.includes('content')) continue
|
||||
// single character or other exception, has to use includes
|
||||
if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
|
||||
return true
|
||||
// too long
|
||||
if (mutedWord.length > postText.length) continue
|
||||
// exact match
|
||||
if (mutedWord === postText) return true
|
||||
// any muted phrase with space or punctuation
|
||||
if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
|
||||
return true
|
||||
|
||||
// check individual character groups
|
||||
const words = postText.split(REGEX.WORD_BOUNDARY)
|
||||
for (const word of words) {
|
||||
if (word === mutedWord) return true
|
||||
|
||||
// compare word without leading/trailing punctuation, but allow internal
|
||||
// punctuation (such as `s@ssy`)
|
||||
const wordTrimmedPunctuation = word.replace(
|
||||
REGEX.LEADING_TRAILING_PUNCTUATION,
|
||||
'',
|
||||
)
|
||||
|
||||
if (mutedWord === wordTrimmedPunctuation) return true
|
||||
if (mutedWord.length > wordTrimmedPunctuation.length) continue
|
||||
|
||||
// handle hyphenated, slash separated words, etc
|
||||
if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
|
||||
// check against full normalized phrase
|
||||
const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
|
||||
REGEX.SEPARATORS,
|
||||
' ',
|
||||
)
|
||||
const mutedWordNormalizedSeparators = mutedWord.replace(
|
||||
REGEX.SEPARATORS,
|
||||
' ',
|
||||
)
|
||||
// hyphenated (or other sep) to spaced words
|
||||
if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
|
||||
return true
|
||||
|
||||
/* Disabled for now e.g. `super-cool` to `supercool`
|
||||
const wordNormalizedCompressed = wordNormalizedSeparators.replace(
|
||||
REGEX.WORD_BOUNDARY,
|
||||
'',
|
||||
)
|
||||
const mutedWordNormalizedCompressed =
|
||||
mutedWordNormalizedSeparators.replace(/\s+?/g, '')
|
||||
// hyphenated (or other sep) to non-hyphenated contiguous word
|
||||
if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
|
||||
return true
|
||||
*/
|
||||
|
||||
// then individual parts of separated phrases/words
|
||||
const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
|
||||
for (const wp of wordParts) {
|
||||
// still retain internal punctuation
|
||||
if (wp === mutedWord) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function moderatePost_wrapped(
|
||||
subject: Parameters<ModeratePost>[0],
|
||||
opts: Options,
|
||||
) {
|
||||
const {hiddenPosts = [], ...options} = opts
|
||||
const {hiddenPosts = [], mutedWords = [], ...options} = opts
|
||||
const moderations = moderatePost(subject, options)
|
||||
const isOwnPost = subject.author.did === opts.userDid
|
||||
|
||||
if (hiddenPosts.includes(subject.uri)) {
|
||||
moderations.content.filter = true
|
||||
|
|
@ -29,15 +161,86 @@ export function moderatePost_wrapped(
|
|||
}
|
||||
}
|
||||
|
||||
if (AppBskyFeedPost.isRecord(subject.record)) {
|
||||
let muted = hasMutedWord({
|
||||
mutedWords,
|
||||
text: subject.record.text,
|
||||
facets: subject.record.facets || [],
|
||||
outlineTags: subject.record.tags || [],
|
||||
languages: subject.record.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
|
||||
if (
|
||||
subject.record.embed &&
|
||||
AppBskyEmbedImages.isMain(subject.record.embed)
|
||||
) {
|
||||
for (const image of subject.record.embed.images) {
|
||||
muted =
|
||||
muted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: subject.record.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (muted) {
|
||||
moderations.content.filter = true
|
||||
moderations.content.blur = true
|
||||
if (!moderations.content.cause) {
|
||||
moderations.content.cause = {
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
type: 'muted-word',
|
||||
source: {type: 'user'},
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (subject.embed) {
|
||||
let embedHidden = false
|
||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
||||
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
|
||||
|
||||
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
|
||||
embedHidden =
|
||||
embedHidden ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: subject.embed.record.value.text,
|
||||
facets: subject.embed.record.value.facets,
|
||||
outlineTags: subject.embed.record.value.tags,
|
||||
languages: subject.embed.record.value.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
|
||||
if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) {
|
||||
for (const image of subject.embed.record.value.embed.images) {
|
||||
embedHidden =
|
||||
embedHidden ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: subject.embed.record.value.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
||||
) {
|
||||
// TODO what
|
||||
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
|
||||
}
|
||||
if (embedHidden) {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,13 @@ export function describeModerationCause(
|
|||
description: 'You have hidden this post',
|
||||
}
|
||||
}
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
if (cause.type === 'muted-word') {
|
||||
return {
|
||||
name: 'Post hidden by muted word',
|
||||
description: `You've chosen to hide a word or tag within this post.`,
|
||||
}
|
||||
}
|
||||
return cause.labelDef.strings[context].en
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,3 +25,13 @@ export function makeCustomFeedLink(
|
|||
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
||||
return [`/profile`, did, 'lists', rkey, ...segments].join('/')
|
||||
}
|
||||
|
||||
export function makeTagLink(did: string) {
|
||||
return `/search?q=${encodeURIComponent(did)}`
|
||||
}
|
||||
|
||||
export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
|
||||
return `/search?q=${encodeURIComponent(
|
||||
props.query + (props.from ? ` from:${props.from}` : ''),
|
||||
)}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export type CommonNavigatorParams = {
|
|||
PreferencesFollowingFeed: undefined
|
||||
PreferencesThreads: undefined
|
||||
PreferencesExternalEmbeds: undefined
|
||||
Search: {q?: string}
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
|
|
|||
|
|
@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
|
|||
return base + 's'
|
||||
}
|
||||
|
||||
export function enforceLen(str: string, len: number, ellipsis = false): string {
|
||||
export function enforceLen(
|
||||
str: string,
|
||||
len: number,
|
||||
ellipsis = false,
|
||||
mode: 'end' | 'middle' = 'end',
|
||||
): string {
|
||||
str = str || ''
|
||||
if (str.length > len) {
|
||||
return str.slice(0, len) + (ellipsis ? '...' : '')
|
||||
if (ellipsis) {
|
||||
if (mode === 'end') {
|
||||
return str.slice(0, len) + '…'
|
||||
} else if (mode === 'middle') {
|
||||
const half = Math.floor(len / 2)
|
||||
return str.slice(0, half) + '…' + str.slice(-half)
|
||||
} else {
|
||||
// fallback
|
||||
return str.slice(0, len)
|
||||
}
|
||||
} else {
|
||||
return str.slice(0, len)
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -114,7 +114,7 @@ export function AdultContentEnabledPref({
|
|||
</Trans>
|
||||
</Prompt.Description>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action onPress={prompt.close}>OK</Prompt.Action>
|
||||
<Prompt.Action onPress={() => prompt.close()}>OK</Prompt.Action>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ export function reducer(
|
|||
})
|
||||
|
||||
if (s.activeStep !== state.activeStep) {
|
||||
logger.info(`onboarding: step changed`, {activeStep: state.activeStep})
|
||||
logger.debug(`onboarding: step changed`, {activeStep: state.activeStep})
|
||||
}
|
||||
|
||||
return state
|
||||
|
|
|
|||
|
|
@ -1,20 +1,32 @@
|
|||
import React from 'react'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {DialogControlRefProps} from '#/components/Dialog'
|
||||
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
|
||||
|
||||
const DialogContext = React.createContext<{
|
||||
/**
|
||||
* The currently active `useDialogControl` hooks.
|
||||
*/
|
||||
activeDialogs: React.MutableRefObject<
|
||||
Map<string, React.MutableRefObject<DialogControlProps>>
|
||||
Map<string, React.MutableRefObject<DialogControlRefProps>>
|
||||
>
|
||||
/**
|
||||
* The currently open dialogs, referenced by their IDs, generated from
|
||||
* `useId`.
|
||||
*/
|
||||
openDialogs: React.MutableRefObject<Set<string>>
|
||||
}>({
|
||||
activeDialogs: {
|
||||
current: new Map(),
|
||||
},
|
||||
openDialogs: {
|
||||
current: new Set(),
|
||||
},
|
||||
})
|
||||
|
||||
const DialogControlContext = React.createContext<{
|
||||
closeAllDialogs(): void
|
||||
closeAllDialogs(): boolean
|
||||
}>({
|
||||
closeAllDialogs: () => {},
|
||||
closeAllDialogs: () => false,
|
||||
})
|
||||
|
||||
export function useDialogStateContext() {
|
||||
|
|
@ -27,17 +39,22 @@ export function useDialogStateControlContext() {
|
|||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const activeDialogs = React.useRef<
|
||||
Map<string, React.MutableRefObject<DialogControlProps>>
|
||||
Map<string, React.MutableRefObject<DialogControlRefProps>>
|
||||
>(new Map())
|
||||
const openDialogs = React.useRef<Set<string>>(new Set())
|
||||
|
||||
const closeAllDialogs = React.useCallback(() => {
|
||||
activeDialogs.current.forEach(dialog => dialog.current.close())
|
||||
return openDialogs.current.size > 0
|
||||
}, [])
|
||||
const context = React.useMemo(() => ({activeDialogs}), [])
|
||||
|
||||
const context = React.useMemo(() => ({activeDialogs, openDialogs}), [])
|
||||
const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs])
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={context}>
|
||||
<DialogControlContext.Provider value={controls}>
|
||||
{children}
|
||||
<GlobalDialogsProvider>{children}</GlobalDialogsProvider>
|
||||
</DialogControlContext.Provider>
|
||||
</DialogContext.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
|||
import {openPicker} from 'lib/media/picker'
|
||||
import {getImageDim} from 'lib/media/manip'
|
||||
|
||||
interface InitialImageUri {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export class GalleryModel {
|
||||
images: ImageModel[] = []
|
||||
|
||||
constructor() {
|
||||
constructor(uris?: {uri: string; width: number; height: number}[]) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
if (uris) {
|
||||
this.addFromUris(uris)
|
||||
}
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
|
|
@ -23,7 +33,7 @@ export class GalleryModel {
|
|||
return this.images.some(image => image.altText.trim() === '')
|
||||
}
|
||||
|
||||
async add(image_: Omit<RNImage, 'size'>) {
|
||||
*add(image_: Omit<RNImage, 'size'>) {
|
||||
if (this.size >= 4) {
|
||||
return
|
||||
}
|
||||
|
|
@ -86,4 +96,15 @@ export class GalleryModel {
|
|||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async addFromUris(uris: InitialImageUri[]) {
|
||||
for (const uriObj of uris) {
|
||||
this.add({
|
||||
mime: 'image/jpeg',
|
||||
height: uriObj.height,
|
||||
width: uriObj.width,
|
||||
path: uriObj.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ test('migrate: fresh install', async () => {
|
|||
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'persisted state: no migration needed',
|
||||
)
|
||||
})
|
||||
|
|
@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => {
|
|||
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'persisted state: no migration needed',
|
||||
)
|
||||
})
|
||||
|
|
@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => {
|
|||
await migrate()
|
||||
|
||||
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'persisted state: migrated legacy storage',
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const _emitter = new EventEmitter()
|
|||
* the Provider.
|
||||
*/
|
||||
export async function init() {
|
||||
logger.info('persisted state: initializing')
|
||||
logger.debug('persisted state: initializing')
|
||||
|
||||
broadcast.onmessage = onBroadcastMessage
|
||||
|
||||
|
|
@ -27,11 +27,11 @@ export async function init() {
|
|||
await migrate() // migrate old store
|
||||
const stored = await store.read() // check for new store
|
||||
if (!stored) {
|
||||
logger.info('persisted state: initializing default storage')
|
||||
logger.debug('persisted state: initializing default storage')
|
||||
await store.write(defaults) // opt: init new store
|
||||
}
|
||||
_state = stored || defaults // return new store
|
||||
logger.log('persisted state: initialized')
|
||||
logger.debug('persisted state: initialized')
|
||||
} catch (e) {
|
||||
logger.error('persisted state: failed to load root state from storage', {
|
||||
message: e,
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
|
|||
* local storage AND old storage exists.
|
||||
*/
|
||||
export async function migrate() {
|
||||
logger.info('persisted state: check need to migrate')
|
||||
logger.debug('persisted state: check need to migrate')
|
||||
|
||||
try {
|
||||
const rawLegacyData = await AsyncStorage.getItem(
|
||||
|
|
@ -131,7 +131,7 @@ export async function migrate() {
|
|||
const alreadyMigrated = Boolean(newData)
|
||||
|
||||
if (!alreadyMigrated && rawLegacyData) {
|
||||
logger.info('persisted state: migrating legacy storage')
|
||||
logger.debug('persisted state: migrating legacy storage')
|
||||
|
||||
const legacyData = JSON.parse(rawLegacyData)
|
||||
const newData = transform(legacyData)
|
||||
|
|
@ -139,14 +139,14 @@ export async function migrate() {
|
|||
|
||||
if (validate.success) {
|
||||
await write(newData)
|
||||
logger.info('persisted state: migrated legacy storage')
|
||||
logger.debug('persisted state: migrated legacy storage')
|
||||
} else {
|
||||
logger.error('persisted state: legacy data failed validation', {
|
||||
message: validate.error,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
logger.info('persisted state: no migration needed')
|
||||
logger.debug('persisted state: no migration needed')
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(e, {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
useQuery,
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import {
|
||||
AtUri,
|
||||
|
|
@ -15,7 +13,6 @@ import {
|
|||
AppBskyUnspeccedGetPopularFeedGenerators,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {router} from '#/routes'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
|
|
@ -219,83 +216,59 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
|
|||
likeUri: '',
|
||||
}
|
||||
|
||||
export function usePinnedFeedsInfos(): {
|
||||
feeds: FeedSourceInfo[]
|
||||
hasPinnedCustom: boolean
|
||||
isLoading: boolean
|
||||
} {
|
||||
const queryClient = useQueryClient()
|
||||
const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([
|
||||
FOLLOWING_FEED_STUB,
|
||||
])
|
||||
const [isLoading, setLoading] = React.useState(true)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
export function usePinnedFeedsInfos() {
|
||||
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
|
||||
const pinnedUris = preferences?.feeds?.pinned ?? []
|
||||
|
||||
const hasPinnedCustom = React.useMemo<boolean>(() => {
|
||||
return tabs.some(tab => tab !== FOLLOWING_FEED_STUB)
|
||||
}, [tabs])
|
||||
return useQuery({
|
||||
staleTime: STALE.INFINITY,
|
||||
enabled: !isLoadingPrefs,
|
||||
queryKey: ['pinnedFeedsInfos', pinnedUris.join(',')],
|
||||
queryFn: async () => {
|
||||
let resolved = new Map()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!preferences?.feeds?.pinned) return
|
||||
const uris = preferences.feeds.pinned
|
||||
|
||||
async function fetchFeedInfo() {
|
||||
const reqs = []
|
||||
|
||||
for (const uri of uris) {
|
||||
const cached = queryClient.getQueryData<FeedSourceInfo>(
|
||||
feedSourceInfoQueryKey({uri}),
|
||||
)
|
||||
|
||||
if (cached) {
|
||||
reqs.push(cached)
|
||||
} else {
|
||||
reqs.push(
|
||||
(async () => {
|
||||
// these requests can fail, need to filter those out
|
||||
try {
|
||||
return await queryClient.fetchQuery({
|
||||
staleTime: STALE.SECONDS.FIFTEEN,
|
||||
queryKey: feedSourceInfoQueryKey({uri}),
|
||||
queryFn: async () => {
|
||||
const type = getFeedTypeFromUri(uri)
|
||||
|
||||
if (type === 'feed') {
|
||||
const res =
|
||||
await getAgent().app.bsky.feed.getFeedGenerator({
|
||||
feed: uri,
|
||||
})
|
||||
return hydrateFeedGenerator(res.data.view)
|
||||
} else {
|
||||
const res = await getAgent().app.bsky.graph.getList({
|
||||
list: uri,
|
||||
limit: 1,
|
||||
})
|
||||
return hydrateList(res.data.list)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
// expected failure
|
||||
logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, {
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
})(),
|
||||
)
|
||||
}
|
||||
// Get all feeds. We can do this in a batch.
|
||||
const feedUris = pinnedUris.filter(
|
||||
uri => getFeedTypeFromUri(uri) === 'feed',
|
||||
)
|
||||
let feedsPromise = Promise.resolve()
|
||||
if (feedUris.length > 0) {
|
||||
feedsPromise = getAgent()
|
||||
.app.bsky.feed.getFeedGenerators({
|
||||
feeds: feedUris,
|
||||
})
|
||||
.then(res => {
|
||||
for (let feedView of res.data.feeds) {
|
||||
resolved.set(feedView.uri, hydrateFeedGenerator(feedView))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const views = (await Promise.all(reqs)).filter(
|
||||
Boolean,
|
||||
) as FeedSourceInfo[]
|
||||
// Get all lists. This currently has to be done individually.
|
||||
const listUris = pinnedUris.filter(
|
||||
uri => getFeedTypeFromUri(uri) === 'list',
|
||||
)
|
||||
const listsPromises = listUris.map(listUri =>
|
||||
getAgent()
|
||||
.app.bsky.graph.getList({
|
||||
list: listUri,
|
||||
limit: 1,
|
||||
})
|
||||
.then(res => {
|
||||
const listView = res.data.list
|
||||
resolved.set(listView.uri, hydrateList(listView))
|
||||
}),
|
||||
)
|
||||
|
||||
setTabs([FOLLOWING_FEED_STUB].concat(views))
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchFeedInfo()
|
||||
}, [queryClient, setTabs, preferences?.feeds?.pinned])
|
||||
|
||||
return {feeds: tabs, hasPinnedCustom, isLoading}
|
||||
// The returned result will have the original order.
|
||||
const result = [FOLLOWING_FEED_STUB]
|
||||
await Promise.allSettled([feedsPromise, ...listsPromises])
|
||||
for (let pinnedUri of pinnedUris) {
|
||||
if (resolved.has(pinnedUri)) {
|
||||
result.push(resolved.get(pinnedUri))
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import React, {useCallback, useEffect, useRef} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
PostModeration,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
InfiniteData,
|
||||
|
|
@ -29,6 +34,7 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
|||
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
|
||||
import {useModerationOpts} from './preferences'
|
||||
import {queryClient} from 'lib/react-query'
|
||||
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
||||
|
||||
type ActorDid = string
|
||||
type AuthorFilter =
|
||||
|
|
@ -137,24 +143,41 @@ export function usePostFeedQuery(
|
|||
cursor: undefined,
|
||||
}
|
||||
|
||||
const res = await api.fetch({cursor, limit: PAGE_SIZE})
|
||||
precacheFeedPostProfiles(queryClient, res.feed)
|
||||
try {
|
||||
const res = await api.fetch({cursor, limit: PAGE_SIZE})
|
||||
precacheFeedPostProfiles(queryClient, res.feed)
|
||||
|
||||
/*
|
||||
* If this is a public view, we need to check if posts fail moderation.
|
||||
* If all fail, we throw an error. If only some fail, we continue and let
|
||||
* moderations happen later, which results in some posts being shown and
|
||||
* some not.
|
||||
*/
|
||||
if (!getAgent().session) {
|
||||
assertSomePostsPassModeration(res.feed)
|
||||
}
|
||||
/*
|
||||
* If this is a public view, we need to check if posts fail moderation.
|
||||
* If all fail, we throw an error. If only some fail, we continue and let
|
||||
* moderations happen later, which results in some posts being shown and
|
||||
* some not.
|
||||
*/
|
||||
if (!getAgent().session) {
|
||||
assertSomePostsPassModeration(res.feed)
|
||||
}
|
||||
|
||||
return {
|
||||
api,
|
||||
cursor: res.cursor,
|
||||
feed: res.feed,
|
||||
fetchedAt: Date.now(),
|
||||
return {
|
||||
api,
|
||||
cursor: res.cursor,
|
||||
feed: res.feed,
|
||||
fetchedAt: Date.now(),
|
||||
}
|
||||
} catch (e) {
|
||||
const feedDescParts = feedDesc.split('|')
|
||||
const feedOwnerDid = new AtUri(feedDescParts[1]).hostname
|
||||
|
||||
if (
|
||||
feedDescParts[0] === 'feedgen' &&
|
||||
BSKY_FEED_OWNER_DIDS.includes(feedOwnerDid)
|
||||
) {
|
||||
logger.error(`Bluesky feed may be offline: ${feedOwnerDid}`, {
|
||||
feedDesc,
|
||||
jsError: e,
|
||||
})
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
},
|
||||
initialPageParam: undefined,
|
||||
|
|
@ -253,7 +276,7 @@ export function usePostFeedQuery(
|
|||
.success
|
||||
) {
|
||||
return {
|
||||
_reactKey: `${slice._reactKey}-${i}`,
|
||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||
uri: item.post.uri,
|
||||
post: item.post,
|
||||
record: item.post.record,
|
||||
|
|
|
|||
|
|
@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
|||
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
|
||||
userAge: 13, // TODO(pwi)
|
||||
interests: {tags: []},
|
||||
mutedWords: [],
|
||||
hiddenPosts: [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import {useMemo} from 'react'
|
||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
|
||||
import {
|
||||
LabelPreference,
|
||||
BskyFeedViewPreference,
|
||||
AppBskyActorDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
|
|
@ -108,6 +112,7 @@ export function useModerationOpts() {
|
|||
return {
|
||||
...moderationOpts,
|
||||
hiddenPosts,
|
||||
mutedWords: prefs.data.mutedWords || [],
|
||||
}
|
||||
}, [currentAccount?.did, prefs.data, hiddenPosts])
|
||||
return opts
|
||||
|
|
@ -278,3 +283,45 @@ export function useUnpinFeedMutation() {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpsertMutedWordsMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
|
||||
await getAgent().upsertMutedWords(mutedWords)
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateMutedWordMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
|
||||
await getAgent().updateMutedWord(mutedWord)
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveMutedWordMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
|
||||
await getAgent().removeMutedWord(mutedWord)
|
||||
// triggers a refetch
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: preferencesQueryKey,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ function createPersistSessionHandler(
|
|||
accessJwt: session?.accessJwt,
|
||||
}
|
||||
|
||||
logger.info(`session: persistSession`, {
|
||||
logger.debug(`session: persistSession`, {
|
||||
event,
|
||||
deactivated: refreshedAccount.deactivated,
|
||||
})
|
||||
|
|
@ -320,7 +320,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
)
|
||||
|
||||
const logout = React.useCallback<ApiContext['logout']>(async () => {
|
||||
logger.info(`session: logout`)
|
||||
logger.debug(`session: logout`)
|
||||
clearCurrentAccount()
|
||||
setStateAndPersist(s => {
|
||||
return {
|
||||
|
|
@ -374,7 +374,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
}
|
||||
|
||||
if (canReusePrevSession) {
|
||||
logger.info(`session: attempting to reuse previous session`)
|
||||
logger.debug(`session: attempting to reuse previous session`)
|
||||
|
||||
agent.session = prevSession
|
||||
__globalAgent = agent
|
||||
|
|
@ -384,7 +384,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
if (prevSession.deactivated) {
|
||||
// don't attempt to resume
|
||||
// use will be taken to the deactivated screen
|
||||
logger.info(`session: reusing session for deactivated account`)
|
||||
logger.debug(`session: reusing session for deactivated account`)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -410,7 +410,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
__globalAgent = PUBLIC_BSKY_AGENT
|
||||
})
|
||||
} else {
|
||||
logger.info(`session: attempting to resume using previous session`)
|
||||
logger.debug(`session: attempting to resume using previous session`)
|
||||
|
||||
try {
|
||||
const freshAccount = await resumeSessionWithFreshAccount()
|
||||
|
|
@ -431,7 +431,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
}
|
||||
|
||||
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
|
||||
logger.info(`session: resumeSessionWithFreshAccount`)
|
||||
logger.debug(`session: resumeSessionWithFreshAccount`)
|
||||
|
||||
await networkRetry(1, () => agent.resumeSession(prevSession))
|
||||
|
||||
|
|
@ -552,11 +552,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
return persisted.onUpdate(() => {
|
||||
const session = persisted.get('session')
|
||||
|
||||
logger.info(`session: persisted onUpdate`, {})
|
||||
logger.debug(`session: persisted onUpdate`, {})
|
||||
|
||||
if (session.currentAccount && session.currentAccount.refreshJwt) {
|
||||
if (session.currentAccount?.did !== state.currentAccount?.did) {
|
||||
logger.info(`session: persisted onUpdate, switching accounts`, {
|
||||
logger.debug(`session: persisted onUpdate, switching accounts`, {
|
||||
from: {
|
||||
did: state.currentAccount?.did,
|
||||
handle: state.currentAccount?.handle,
|
||||
|
|
@ -569,7 +569,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||
|
||||
initSession(session.currentAccount)
|
||||
} else {
|
||||
logger.info(`session: persisted onUpdate, updating session`, {})
|
||||
logger.debug(`session: persisted onUpdate, updating session`, {})
|
||||
|
||||
/*
|
||||
* Use updated session in this tab's agent. Do not call
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export interface ComposerOpts {
|
|||
quote?: ComposerOptsQuote
|
||||
mention?: string // handle of user to mention
|
||||
openPicker?: (pos: DOMRect | undefined) => void
|
||||
text?: string
|
||||
imageUris?: {uri: string; width: number; height: number}[]
|
||||
}
|
||||
|
||||
type StateContext = ComposerOpts | undefined
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {useLightboxControls} from './lightbox'
|
|||
import {useModalControls} from './modals'
|
||||
import {useComposerControls} from './shell/composer'
|
||||
import {useSetDrawerOpen} from './shell/drawer-open'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
|
||||
/**
|
||||
* returns true if something was closed
|
||||
|
|
@ -12,6 +13,7 @@ export function useCloseAnyActiveElement() {
|
|||
const {closeLightbox} = useLightboxControls()
|
||||
const {closeModal} = useModalControls()
|
||||
const {closeComposer} = useComposerControls()
|
||||
const {closeAllDialogs} = useDialogStateControlContext()
|
||||
const setDrawerOpen = useSetDrawerOpen()
|
||||
return useCallback(() => {
|
||||
if (closeLightbox()) {
|
||||
|
|
@ -23,9 +25,12 @@ export function useCloseAnyActiveElement() {
|
|||
if (closeComposer()) {
|
||||
return true
|
||||
}
|
||||
if (closeAllDialogs()) {
|
||||
return true
|
||||
}
|
||||
setDrawerOpen(false)
|
||||
return false
|
||||
}, [closeLightbox, closeModal, closeComposer, setDrawerOpen])
|
||||
}, [closeLightbox, closeModal, closeComposer, setDrawerOpen, closeAllDialogs])
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -35,11 +40,19 @@ export function useCloseAllActiveElements() {
|
|||
const {closeLightbox} = useLightboxControls()
|
||||
const {closeAllModals} = useModalControls()
|
||||
const {closeComposer} = useComposerControls()
|
||||
const {closeAllDialogs: closeAlfDialogs} = useDialogStateControlContext()
|
||||
const setDrawerOpen = useSetDrawerOpen()
|
||||
return useCallback(() => {
|
||||
closeLightbox()
|
||||
closeAllModals()
|
||||
closeComposer()
|
||||
closeAlfDialogs()
|
||||
setDrawerOpen(false)
|
||||
}, [closeLightbox, closeAllModals, closeComposer, setDrawerOpen])
|
||||
}, [
|
||||
closeLightbox,
|
||||
closeAllModals,
|
||||
closeComposer,
|
||||
closeAlfDialogs,
|
||||
setDrawerOpen,
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
83
src/view/com/composer/text-input/web/TagDecorator.ts
Normal file
83
src/view/com/composer/text-input/web/TagDecorator.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* 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'
|
||||
|
||||
function getDecorations(doc: ProsemirrorNode) {
|
||||
const decorations: Decoration[] = []
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g
|
||||
const textContent = node.textContent
|
||||
|
||||
let match
|
||||
while ((match = regex.exec(textContent))) {
|
||||
const [matchedString, tag] = match
|
||||
|
||||
if (tag.length > 66) continue
|
||||
|
||||
const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || []
|
||||
|
||||
const from = match.index + matchedString.indexOf(tag)
|
||||
const to = from + (tag.length - trailingPunc.length)
|
||||
|
||||
decorations.push(
|
||||
Decoration.inline(pos + from, pos + to, {
|
||||
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 ✨')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import React from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
|
||||
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Link, TextLink} from '../util/Link'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {CogIcon} from '#/lib/icons'
|
||||
|
||||
export function HomeHeaderLayout({children}: {children: React.ReactNode}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
|
|
@ -20,6 +29,7 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
|
|||
const pal = usePalette('default')
|
||||
const {headerMinimalShellTransform} = useMinimalShellMode()
|
||||
const {headerHeight} = useShellLayout()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
|
||||
|
|
@ -28,12 +38,44 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
|
|||
onLayout={e => {
|
||||
headerHeight.value = e.nativeEvent.layout.height
|
||||
}}>
|
||||
<View style={[pal.view, styles.topBar]}>
|
||||
<TextLink
|
||||
type="title-lg"
|
||||
href="/settings/following-feed"
|
||||
accessibilityLabel={_(msg`Following Feed Preferences`)}
|
||||
accessibilityHint=""
|
||||
text={
|
||||
<FontAwesomeIcon
|
||||
icon="sliders"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Logo width={28} />
|
||||
<Link
|
||||
href="/settings/saved-feeds"
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Edit Saved Feeds`)}
|
||||
accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
|
||||
<CogIcon size={22} strokeWidth={2} style={pal.textLight} />
|
||||
</Link>
|
||||
</View>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 8,
|
||||
marginTop: 8,
|
||||
width: '100%',
|
||||
},
|
||||
tabBar: {
|
||||
// @ts-ignore Web only
|
||||
position: 'sticky',
|
||||
|
|
@ -42,7 +84,7 @@ const styles = StyleSheet.create({
|
|||
left: 'calc(50% - 300px)',
|
||||
width: 600,
|
||||
top: 0,
|
||||
flexDirection: 'row',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ const styles = StyleSheet.create({
|
|||
right: 0,
|
||||
top: 0,
|
||||
flexDirection: 'column',
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -437,6 +437,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}
|
||||
|
|
@ -327,9 +329,11 @@ let PostThreadItemLoaded = ({
|
|||
styles.postTextLargeContainer,
|
||||
]}>
|
||||
<RichText
|
||||
enableTags
|
||||
selectable
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_xl]}
|
||||
selectable
|
||||
authorHandle={post.author.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
|
@ -521,9 +525,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}
|
||||
|
|
|
|||
|
|
@ -184,10 +184,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}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ export function FeedItem({
|
|||
if (richText && moderation) {
|
||||
return (
|
||||
<FeedItemInner
|
||||
// Safeguard from clobbering per-post state below:
|
||||
key={postShadowed.uri}
|
||||
post={postShadowed}
|
||||
record={record}
|
||||
reason={reason}
|
||||
|
|
@ -347,10 +349,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}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {useLingui} from '@lingui/react'
|
|||
import {useSession} from '#/state/session'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
|
||||
let PostDropdownBtn = ({
|
||||
testID,
|
||||
|
|
@ -67,6 +68,7 @@ let PostDropdownBtn = ({
|
|||
const {hidePost} = useHiddenPostsApi()
|
||||
const openLink = useOpenLink()
|
||||
const navigation = useNavigation()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
|
||||
const rootUri = record.reply?.root?.uri || postUri
|
||||
const isThreadMuted = mutedThreads.includes(rootUri)
|
||||
|
|
@ -210,6 +212,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 && {
|
||||
|
|
|
|||
|
|
@ -128,10 +128,12 @@ export function QuoteEmbed({
|
|||
) : null}
|
||||
{richText ? (
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
style={[a.text_md]}
|
||||
numberOfLines={20}
|
||||
disableLinks
|
||||
authorHandle={quote.author.handle}
|
||||
/>
|
||||
) : null}
|
||||
{embed && <PostEmbeds embed={embed} moderation={{}} />}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
@ -115,6 +119,21 @@ export function RichText({
|
|||
/>,
|
||||
)
|
||||
}
|
||||
} 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 +152,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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
|
|||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
||||
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
|
||||
import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
|
||||
|
||||
library.add(
|
||||
faAddressCard,
|
||||
|
|
@ -208,4 +209,5 @@ library.add(
|
|||
faX,
|
||||
faXmark,
|
||||
faChevronDown,
|
||||
faFilter,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,11 +17,12 @@ import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
|||
import {emitSoftReset} from '#/state/events'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||
|
||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
|
||||
export function HomeScreen(props: Props) {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {feeds: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} =
|
||||
const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} =
|
||||
usePinnedFeedsInfos()
|
||||
if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) {
|
||||
return (
|
||||
|
|
@ -66,6 +67,8 @@ function HomeScreenReady({
|
|||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||
const selectedFeed = allFeeds[selectedIndex]
|
||||
|
||||
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
||||
|
||||
const pagerRef = React.useRef<PagerRef>(null)
|
||||
const lastPagerReportedIndexRef = React.useRef(selectedIndex)
|
||||
React.useLayoutEffect(() => {
|
||||
|
|
@ -124,10 +127,11 @@ function HomeScreenReady({
|
|||
onSelect={props.onSelect}
|
||||
testID="homeScreenFeedTabs"
|
||||
onPressSelected={onPressSelected}
|
||||
feeds={pinnedFeedInfos}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[onPressSelected],
|
||||
[onPressSelected, pinnedFeedInfos],
|
||||
)
|
||||
|
||||
const renderFollowingEmptyState = React.useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
useProfileUpdateMutation,
|
||||
} from '#/state/queries/profile'
|
||||
import {ScrollView} from '../com/util/Views'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
|
||||
export function ModerationScreen({}: Props) {
|
||||
|
|
@ -40,6 +41,7 @@ export function ModerationScreen({}: Props) {
|
|||
const {screen, track} = useAnalytics()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const {openModal} = useModalControls()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
|
|
@ -69,8 +71,8 @@ export function ModerationScreen({}: Props) {
|
|||
style={[styles.linkCard, pal.view]}
|
||||
onPress={onPressContentFiltering}
|
||||
accessibilityRole="tab"
|
||||
accessibilityHint="Content filtering"
|
||||
accessibilityLabel="">
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={_(msg`Open content filtering settings`)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="eye"
|
||||
|
|
@ -81,6 +83,23 @@ export function ModerationScreen({}: Props) {
|
|||
<Trans>Content filtering</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="mutedWordsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
onPress={() => mutedWordsDialogControl.open()}
|
||||
accessibilityRole="tab"
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={_(msg`Open muted words settings`)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="filter"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Muted words & tags</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Link
|
||||
testID="moderationlistsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
|
|
@ -53,6 +53,7 @@ import {listenSoftReset} from '#/state/events'
|
|||
import {s} from '#/lib/styles'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
|
||||
function Loader() {
|
||||
const pal = usePalette('default')
|
||||
|
|
@ -448,6 +449,7 @@ export function SearchScreenInner({
|
|||
export function SearchScreen(
|
||||
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
|
||||
) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const theme = useTheme()
|
||||
const textInput = React.useRef<TextInput>(null)
|
||||
const {_} = useLingui()
|
||||
|
|
@ -472,6 +474,27 @@ export function SearchScreen(
|
|||
React.useState(false)
|
||||
const [searchHistory, setSearchHistory] = React.useState<string[]>([])
|
||||
|
||||
/**
|
||||
* The Search screen's `q` param
|
||||
*/
|
||||
const queryParam = props.route?.params?.q
|
||||
|
||||
/**
|
||||
* If `true`, this means we received new instructions from the router. This
|
||||
* is handled in a effect, and used to update the value of `query` locally
|
||||
* within this screen.
|
||||
*/
|
||||
const routeParamsMismatch = queryParam && queryParam !== query
|
||||
|
||||
React.useEffect(() => {
|
||||
if (queryParam && routeParamsMismatch) {
|
||||
// reset immediately and let local state take over
|
||||
navigation.setParams({q: ''})
|
||||
// update query for next search
|
||||
setQuery(queryParam)
|
||||
}
|
||||
}, [queryParam, routeParamsMismatch, navigation])
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadSearchHistory = async () => {
|
||||
try {
|
||||
|
|
@ -774,6 +797,8 @@ export function SearchScreen(
|
|||
)}
|
||||
</View>
|
||||
</CenteredView>
|
||||
) : routeParamsMismatch ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<SearchScreenInner query={query} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ export function Typography() {
|
|||
<Text style={[a.text_2xs]}>atoms.text_2xs</Text>
|
||||
|
||||
<RichText
|
||||
resolveFacets
|
||||
// TODO: This only supports already resolved facets.
|
||||
// Resolving them on read is bad anyway.
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
/>
|
||||
<RichText
|
||||
selectable
|
||||
resolveFacets
|
||||
// TODO: This only supports already resolved facets.
|
||||
// Resolving them on read is bad anyway.
|
||||
value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
|
||||
style={[a.text_xl]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ export const Composer = observer(function ComposerImpl({
|
|||
onPost={state.onPost}
|
||||
quote={state.quote}
|
||||
mention={state.mention}
|
||||
text={state.text}
|
||||
imageUris={state.imageUris}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
|||
import {
|
||||
EmojiPicker,
|
||||
EmojiPickerState,
|
||||
} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx'
|
||||
} from 'view/com/composer/text-input/web/EmojiPicker.web'
|
||||
|
||||
const BOTTOM_BAR_HEIGHT = 61
|
||||
|
||||
|
|
@ -69,6 +69,7 @@ export function Composer({}: {winHeight: number}) {
|
|||
onPost={state.onPost}
|
||||
mention={state.mention}
|
||||
openPicker={onOpenPicker}
|
||||
text={state.text}
|
||||
/>
|
||||
</Animated.View>
|
||||
<EmojiPicker state={pickerState} close={onClosePicker} />
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {emitSoftReset} from '#/state/events'
|
|||
export function DesktopFeeds() {
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {feeds: pinnedFeedInfos} = usePinnedFeedsInfos()
|
||||
const {data: pinnedFeedInfos} = usePinnedFeedsInfos()
|
||||
const selectedFeed = useSelectedFeed()
|
||||
const setSelectedFeed = useSetSelectedFeed()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
|
@ -25,7 +25,9 @@ export function DesktopFeeds() {
|
|||
}
|
||||
return getCurrentRoute(state)
|
||||
})
|
||||
|
||||
if (!pinnedFeedInfos) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<View style={[styles.container, pal.view]}>
|
||||
{pinnedFeedInfos.map(feedInfo => {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {useSession} from '#/state/session'
|
|||
import {useCloseAnyActiveElement} from '#/state/util'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||
|
||||
function ShellInner() {
|
||||
const isDrawerOpen = useIsDrawerOpen()
|
||||
|
|
@ -94,6 +95,7 @@ function ShellInner() {
|
|||
</View>
|
||||
<Composer winHeight={winDim.height} />
|
||||
<ModalsContainer />
|
||||
<MutedWordsDialog />
|
||||
<PortalOutlet />
|
||||
<Lightbox />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
|||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||
|
||||
function ShellInner() {
|
||||
const isDrawerOpen = useIsDrawerOpen()
|
||||
|
|
@ -40,6 +41,7 @@ function ShellInner() {
|
|||
</ErrorBoundary>
|
||||
<Composer winHeight={0} />
|
||||
<ModalsContainer />
|
||||
<MutedWordsDialog />
|
||||
<PortalOutlet />
|
||||
<Lightbox />
|
||||
{!isDesktop && isDrawerOpen && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue