Merge branch 'bluesky-social:main' into main

This commit is contained in:
Jan-Olof Eriksson 2024-02-29 11:55:03 +02:00 committed by GitHub
commit 963a44ab87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
131 changed files with 7094 additions and 1712 deletions

View file

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

View file

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

View file

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

View file

@ -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,
},
]}
/>

View file

@ -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,
},

View file

@ -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
}
}

View file

@ -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)
},
}
}

View file

@ -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])

View file

@ -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>

View file

@ -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'>

View file

@ -89,7 +89,7 @@ export function Cancel({
color="secondary"
size="small"
label={_(msg`Cancel`)}
onPress={close}>
onPress={() => close()}>
{children}
</Button>
)

View file

@ -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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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,

View file

@ -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>
)
}

View file

@ -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',
})

View 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',
})

View file

@ -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',
})

View 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',
})

View 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',
})

View 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',
})

View 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',
})

View 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)
})
})
})

View file

@ -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,

View file

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

View file

@ -0,0 +1,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],
)
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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}` : ''),
)}`
}

View file

@ -33,6 +33,7 @@ export type CommonNavigatorParams = {
PreferencesFollowingFeed: undefined
PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined
Search: {q?: string}
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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>
</>

View file

@ -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

View file

@ -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>
)

View file

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

View file

@ -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',
)
})

View file

@ -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,

View file

@ -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, {

View file

@ -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
},
})
}

View file

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

View file

@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
userAge: 13, // TODO(pwi)
interests: {tags: []},
mutedWords: [],
hiddenPosts: [],
}

View file

@ -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,
})
},
})
}

View file

@ -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

View file

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

View file

@ -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,
])
}

View file

@ -133,8 +133,8 @@ function IsValidIcon({valid}: {valid: boolean}) {
const t = useTheme()
if (!valid) {
return <Check size="md" style={{color: t.palette.negative_500}} />
return <Times size="md" style={{color: t.palette.negative_500}} />
}
return <Times size="md" style={{color: t.palette.positive_700}} />
return <Check size="md" style={{color: t.palette.positive_700}} />
}

View file

@ -107,7 +107,7 @@ export const LoginForm = ({
const errMsg = e.toString()
setIsProcessing(false)
if (errMsg.includes('Authentication Required')) {
logger.info('Failed to login due to invalid credentials', {
logger.debug('Failed to login due to invalid credentials', {
error: errMsg,
})
setError(_(msg`Invalid username or password`))

View file

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

View file

@ -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}

View file

@ -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',

View 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]
},
})

View file

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

View file

@ -1,8 +1,7 @@
import React from 'react'
import {RenderTabBarFnProps} from 'view/com/pager/Pager'
import {HomeHeaderLayout} from './HomeHeaderLayout'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {usePinnedFeedsInfos} from '#/state/queries/feed'
import {FeedSourceInfo} from '#/state/queries/feed'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {isWeb} from 'platform/detection'
@ -10,25 +9,22 @@ import {TabBar} from '../pager/TabBar'
import {usePalette} from '#/lib/hooks/usePalette'
export function HomeHeader(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
) {
const {isDesktop} = useWebMediaQueries()
if (isDesktop) {
return null
}
return <HomeHeaderInner {...props} />
}
export function HomeHeaderInner(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
props: RenderTabBarFnProps & {
testID?: string
onPressSelected: () => void
feeds: FeedSourceInfo[]
},
) {
const {feeds} = props
const navigation = useNavigation<NavigationProp>()
const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
const pal = usePalette('default')
const hasPinnedCustom = React.useMemo<boolean>(() => {
return feeds.some(tab => tab.uri !== '')
}, [feeds])
const items = React.useMemo(() => {
const pinnedNames = feeds.map(f => f.displayName)
if (!hasPinnedCustom) {
return pinnedNames.concat('Feeds ✨')
}

View file

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

View file

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

View file

@ -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,
},
})

View file

@ -437,6 +437,7 @@ function PostThreadLoaded({
// @ts-ignore our .web version only -prf
desktopFixedHeight
removeClippedSubviews={isAndroid ? false : undefined}
windowSize={11}
/>
)
}

View file

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

View file

@ -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}

View file

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

View file

@ -172,7 +172,7 @@ function ListImpl<ItemT>(
<View
ref={containerRef}
style={[
styles.contentContainer,
!isMobile && styles.sideBorders,
contentContainerStyle,
desktopFixedHeight ? styles.minHeightViewport : null,
pal.border,
@ -304,7 +304,7 @@ export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
const styles = StyleSheet.create({
contentContainer: {
sideBorders: {
borderLeftWidth: 1,
borderRightWidth: 1,
},

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import React from 'react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {Pressable, StyleSheet, View, Text} from 'react-native'
import {Pressable, StyleSheet, View, Text, ViewStyle} from 'react-native'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
import {usePalette} from 'lib/hooks/usePalette'
@ -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,

View file

@ -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 && {

View file

@ -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={{}} />}

View file

@ -7,6 +7,9 @@ import {lh} from 'lib/styles'
import {toShortUrl} from 'lib/strings/url-helpers'
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {makeTagLink} from 'lib/routes/links'
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
import {isNative} from '#/platform/detection'
const WORD_WRAP = {wordWrap: 1}
@ -82,6 +85,7 @@ export function RichText({
for (const segment of richText.segments()) {
const link = segment.link
const mention = segment.mention
const tag = segment.tag
if (
!noLinks &&
mention &&
@ -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>
)
}

View file

@ -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,
)

View file

@ -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(() => {

View file

@ -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]}

View file

@ -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} />
)}

View file

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

View file

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

View file

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

View file

@ -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 => {

View file

@ -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 />
</>

View file

@ -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 && (