Add tags and mute words (#2968)
* Add bare minimum hashtags support (#2804) * Add bare minimum hashtags support As atproto/api already parses hashtags, this is as simple as hooking it up like link segments. This is "bare minimum" because: - Opening hashtag "#foo" is actually just a search for "foo" right now to work around #2491. - There is no integration in the composer. This hasn't stopped people from using hashtags already, and can be added later. - This change itself only had to hook things up - thank you for having already put the hashtag parsing in place. * Remove workaround for hash search not working now that it's fixed * Add RichTextTag and TagMenu * Sketch * Remove hackfix * Some cleanup * Sketch web * Mobile design * Mobile handling of tags search * Web only * Fix navigation woes * Use new callback * Hook it up * Integrate muted tags * Fix dropdown styles * Type error * Use close callback * Fix styles * Cleanup, install latest sdk * Quick muted words screen * Targets * Dir structure * Icons, list view * Move to dialog * Add removal confirmation * Swap copy * Improve checkboxees * Update matching, add tests * Moderate embeds * Create global dialogs concept again to prevent flashing * Add access from moderation screen * Highlight tags on native * Add web highlighting * Add close to web modal * Adjust close color * Rename toggles and adjust logic * Icon update * Load states * Improve regex * Improve regex * Improve regex * Revert link test * Hyphenated words * Improve matching * Enhance * Some tweaks * Muted words modal changes * Handle invalid handles, handle long tags * Remove main regex * Better test * Space/punct check drop to includes * Lowercase post text before comparison * Add better real world test case --------- Co-authored-by: Kisaragi Hiu <mail@kisaragi-hiu.com>zio/stable
parent
c8582924e2
commit
58aaad704a
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 300 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 422 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 269 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 465 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 356 B |
|
@ -205,6 +205,11 @@
|
||||||
[data-tooltip]:hover::before {
|
[data-tooltip]:hover::before {
|
||||||
display:block;
|
display:block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* NativeDropdown component */
|
||||||
|
.nativeDropdown-item:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% include "scripts.html" %}
|
{% include "scripts.html" %}
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||||
|
|
|
@ -43,7 +43,7 @@
|
||||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
|
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.9.5",
|
"@atproto/api": "^0.10.0",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
|
|
@ -497,7 +497,8 @@ const LINKING = {
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
return buildStateObject('Flat', name, params)
|
const res = buildStateObject('Flat', name, params)
|
||||||
|
return res
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import {web, native} from '#/alf/util/platform'
|
||||||
import * as tokens from '#/alf/tokens'
|
import * as tokens from '#/alf/tokens'
|
||||||
|
|
||||||
export const atoms = {
|
export const atoms = {
|
||||||
|
@ -113,6 +114,9 @@ export const atoms = {
|
||||||
flex_wrap: {
|
flex_wrap: {
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
},
|
},
|
||||||
|
flex_0: {
|
||||||
|
flex: web('0 0 auto') || (native(0) as number),
|
||||||
|
},
|
||||||
flex_1: {
|
flex_1: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|
|
@ -188,7 +188,7 @@ export function Close() {
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="primary"
|
color="secondary"
|
||||||
shape="round"
|
shape="round"
|
||||||
onPress={close}
|
onPress={close}
|
||||||
label={_(msg`Close active dialog`)}>
|
label={_(msg`Close active dialog`)}>
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
|
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 {InlineLink} from '#/components/Link'
|
||||||
import {Text, TextProps} from '#/components/Typography'
|
import {Text, TextProps} from '#/components/Typography'
|
||||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||||
import {getAgent} from '#/state/session'
|
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}
|
const WORD_WRAP = {wordWrap: 1}
|
||||||
|
|
||||||
|
@ -17,6 +22,8 @@ export function RichText({
|
||||||
disableLinks,
|
disableLinks,
|
||||||
resolveFacets = false,
|
resolveFacets = false,
|
||||||
selectable,
|
selectable,
|
||||||
|
enableTags = false,
|
||||||
|
authorHandle,
|
||||||
}: TextStyleProp &
|
}: TextStyleProp &
|
||||||
Pick<TextProps, 'selectable'> & {
|
Pick<TextProps, 'selectable'> & {
|
||||||
value: RichTextAPI | string
|
value: RichTextAPI | string
|
||||||
|
@ -24,6 +31,8 @@ export function RichText({
|
||||||
numberOfLines?: number
|
numberOfLines?: number
|
||||||
disableLinks?: boolean
|
disableLinks?: boolean
|
||||||
resolveFacets?: boolean
|
resolveFacets?: boolean
|
||||||
|
enableTags?: boolean
|
||||||
|
authorHandle?: string
|
||||||
}) {
|
}) {
|
||||||
const detected = React.useRef(false)
|
const detected = React.useRef(false)
|
||||||
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
|
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
|
||||||
|
@ -85,6 +94,7 @@ export function RichText({
|
||||||
for (const segment of richText.segments()) {
|
for (const segment of richText.segments()) {
|
||||||
const link = segment.link
|
const link = segment.link
|
||||||
const mention = segment.mention
|
const mention = segment.mention
|
||||||
|
const tag = segment.tag
|
||||||
if (
|
if (
|
||||||
mention &&
|
mention &&
|
||||||
AppBskyRichtextFacet.validateMention(mention).success &&
|
AppBskyRichtextFacet.validateMention(mention).success &&
|
||||||
|
@ -118,6 +128,21 @@ export function RichText({
|
||||||
</InlineLink>,
|
</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 {
|
} else {
|
||||||
els.push(segment.text)
|
els.push(segment.text)
|
||||||
}
|
}
|
||||||
|
@ -136,3 +161,79 @@ export function RichText({
|
||||||
</Text>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
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 dropdownItems = React.useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: _(msg`See ${tag} posts`),
|
||||||
|
onPress() {
|
||||||
|
navigation.navigate('Search', {
|
||||||
|
q: tag,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
testID: 'tagMenuSearch',
|
||||||
|
icon: {
|
||||||
|
ios: {
|
||||||
|
name: 'magnifyingglass',
|
||||||
|
},
|
||||||
|
android: '',
|
||||||
|
web: 'magnifying-glass',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authorHandle &&
|
||||||
|
!isInvalidHandle(authorHandle) && {
|
||||||
|
label: _(msg`See ${tag} posts by this 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 ${tag}`) : _(msg`Mute ${tag}`),
|
||||||
|
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,
|
||||||
|
sanitizedTag,
|
||||||
|
upsertMutedWord,
|
||||||
|
removeMutedWord,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EventStopper>
|
||||||
|
<NativeDropdown
|
||||||
|
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
|
||||||
|
accessibilityHint=""
|
||||||
|
// @ts-ignore
|
||||||
|
items={dropdownItems}>
|
||||||
|
{children}
|
||||||
|
</NativeDropdown>
|
||||||
|
</EventStopper>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,328 @@
|
||||||
|
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} 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,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_high]}>
|
||||||
|
{word.value}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
||||||
|
{word.targets.map(target => (
|
||||||
|
<View
|
||||||
|
key={target}
|
||||||
|
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
||||||
|
<Text
|
||||||
|
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={_(msg`Remove mute word from your list`)}
|
||||||
|
size="tiny"
|
||||||
|
shape="round"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => control.open()}
|
||||||
|
style={[a.ml_sm]}>
|
||||||
|
<ButtonIcon icon={isPending ? Loader : X} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetToggle({children}: React.PropsWithChildren<{}>) {
|
||||||
|
const t = useTheme()
|
||||||
|
const ctx = Toggle.useItemContext()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_between,
|
||||||
|
a.gap_xs,
|
||||||
|
a.flex_1,
|
||||||
|
a.py_sm,
|
||||||
|
a.px_sm,
|
||||||
|
gtMobile && a.px_md,
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.bg_contrast_50,
|
||||||
|
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
|
||||||
|
ctx.selected && [
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ctx.disabled && {
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) {
|
||||||
return (
|
return (
|
||||||
<Context.Provider value={context}>
|
<Context.Provider value={context}>
|
||||||
<View
|
<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({
|
{...web({
|
||||||
onClick: () => inputRef.current?.focus(),
|
onClick: () => inputRef.current?.focus(),
|
||||||
onMouseOver: onHoverIn,
|
onMouseOver: onHoverIn,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants'
|
||||||
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
|
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
|
||||||
|
|
||||||
export type ItemState = {
|
export type ItemState = {
|
||||||
name: string
|
name: string
|
||||||
|
@ -331,15 +332,14 @@ export function createSharedToggleStyles({
|
||||||
export function Checkbox() {
|
export function Checkbox() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
|
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
|
||||||
const {baseStyles, baseHoverStyles, indicatorStyles} =
|
const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
|
||||||
createSharedToggleStyles({
|
theme: t,
|
||||||
theme: t,
|
hovered,
|
||||||
hovered,
|
focused,
|
||||||
focused,
|
selected,
|
||||||
selected,
|
disabled,
|
||||||
disabled,
|
isInvalid,
|
||||||
isInvalid,
|
})
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@ -355,21 +355,7 @@ export function Checkbox() {
|
||||||
baseStyles,
|
baseStyles,
|
||||||
hovered || focused ? baseHoverStyles : {},
|
hovered || focused ? baseHoverStyles : {},
|
||||||
]}>
|
]}>
|
||||||
{selected ? (
|
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.rounded_2xs,
|
|
||||||
{height: 12, width: 12},
|
|
||||||
selected
|
|
||||||
? {
|
|
||||||
backgroundColor: t.palette.primary_500,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
indicatorStyles,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||||
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
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',
|
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',
|
||||||
|
})
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z',
|
||||||
|
})
|
|
@ -1,5 +1,5 @@
|
||||||
import {createSinglePathSVG} from './TEMPLATE'
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
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',
|
||||||
})
|
})
|
||||||
|
|
|
@ -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',
|
||||||
|
})
|
|
@ -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',
|
||||||
|
})
|
|
@ -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',
|
||||||
|
})
|
|
@ -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',
|
||||||
|
})
|
|
@ -0,0 +1,578 @@
|
||||||
|
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(
|
||||||
|
[{value: 'outlineTag', targets: ['tag']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
['outlineTag'],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: inline tag`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `This is a post #inlineTag`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'inlineTag', targets: ['tag']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
['outlineTag'],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: 'inlineTag', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
['outlineTag'],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: only tag targets`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `This is a post`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'inlineTag', targets: ['tag']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: '希', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: long muted word, short post`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `hey`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'politics', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: exact text`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `javascript`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'javascript', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: 'javascript', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: partial word`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Use your brain, Eric`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'ai', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: multiline`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Use your\n\tbrain, Eric`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'brain', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: :)`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `So happy :)`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `:)`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: 'yay!', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: yay`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'yay', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: 'y!ppee', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// single exclamation point, source has double
|
||||||
|
it(`no match: y!ppee!`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'y!ppee!', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: 'S@assy', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: s@assy`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 's@assy', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: 'new york times', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`!command`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Idk maybe a bot !command`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: !command`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `!command`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: command`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `command`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: !command`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Idk maybe a bot command`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `!command`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: `e/acc`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: acc`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `acc`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: `super-bad`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: super`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `super`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: super bad`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `super bad`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: superbad`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `superbad`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: `idk what this would be`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`no match: idk what this would be for`, () => {
|
||||||
|
// extra word
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `idk what this would be for`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: idk`, () => {
|
||||||
|
// extra word
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `idk`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: idkwhatthiswouldbe`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `idkwhatthiswouldbe`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`parentheses`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Post with context(iykyk)`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: context(iykyk)`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `context(iykyk)`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: context`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `context`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: iykyk`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `iykyk`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: (iykyk)`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `(iykyk)`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe(`🦋`, () => {
|
||||||
|
const rt = new RichText({
|
||||||
|
text: `Post with 🦋`,
|
||||||
|
})
|
||||||
|
rt.detectFacetsWithoutResolution()
|
||||||
|
|
||||||
|
it(`match: 🦋`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: `🦋`, targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
[{value: 'stop worrying', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it(`match: turtles, or how`, () => {
|
||||||
|
const match = hasMutedWord(
|
||||||
|
[{value: 'turtles, or how', targets: ['content']}],
|
||||||
|
rt.text,
|
||||||
|
rt.facets,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(match).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -2,18 +2,122 @@ import {
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
moderatePost,
|
moderatePost,
|
||||||
|
AppBskyActorDefs,
|
||||||
|
AppBskyFeedPost,
|
||||||
|
AppBskyRichtextFacet,
|
||||||
|
AppBskyEmbedImages,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
type ModeratePost = typeof moderatePost
|
type ModeratePost = typeof moderatePost
|
||||||
type Options = Parameters<ModeratePost>[1] & {
|
type Options = Parameters<ModeratePost>[1] & {
|
||||||
hiddenPosts?: string[]
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMutedWord(
|
||||||
|
mutedWords: AppBskyActorDefs.MutedWord[],
|
||||||
|
text: string,
|
||||||
|
facets?: AppBskyRichtextFacet.Main[],
|
||||||
|
outlineTags?: string[],
|
||||||
|
) {
|
||||||
|
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, has to use includes
|
||||||
|
if (mutedWord.length === 1 && 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(
|
export function moderatePost_wrapped(
|
||||||
subject: Parameters<ModeratePost>[0],
|
subject: Parameters<ModeratePost>[0],
|
||||||
opts: Options,
|
opts: Options,
|
||||||
) {
|
) {
|
||||||
const {hiddenPosts = [], ...options} = opts
|
const {hiddenPosts = [], mutedWords = [], ...options} = opts
|
||||||
const moderations = moderatePost(subject, options)
|
const moderations = moderatePost(subject, options)
|
||||||
|
|
||||||
if (hiddenPosts.includes(subject.uri)) {
|
if (hiddenPosts.includes(subject.uri)) {
|
||||||
|
@ -29,15 +133,65 @@ export function moderatePost_wrapped(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (AppBskyFeedPost.isRecord(subject.record)) {
|
||||||
|
let muted = hasMutedWord(
|
||||||
|
mutedWords,
|
||||||
|
subject.record.text,
|
||||||
|
subject.record.facets || [],
|
||||||
|
subject.record.tags || [],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
subject.record.embed &&
|
||||||
|
AppBskyEmbedImages.isMain(subject.record.embed)
|
||||||
|
) {
|
||||||
|
for (const image of subject.record.embed.images) {
|
||||||
|
muted = muted || hasMutedWord(mutedWords, image.alt, [], [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (subject.embed) {
|
||||||
let embedHidden = false
|
let embedHidden = false
|
||||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
||||||
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
|
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
|
||||||
|
|
||||||
|
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
|
||||||
|
embedHidden =
|
||||||
|
embedHidden ||
|
||||||
|
hasMutedWord(
|
||||||
|
mutedWords,
|
||||||
|
subject.embed.record.value.text,
|
||||||
|
subject.embed.record.value.facets,
|
||||||
|
subject.embed.record.value.tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) {
|
||||||
|
for (const image of subject.embed.record.value.embed.images) {
|
||||||
|
embedHidden =
|
||||||
|
embedHidden || hasMutedWord(mutedWords, image.alt, [], [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
||||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
||||||
) {
|
) {
|
||||||
|
// TODO what
|
||||||
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
|
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
|
||||||
}
|
}
|
||||||
if (embedHidden) {
|
if (embedHidden) {
|
||||||
|
|
|
@ -67,6 +67,13 @@ export function describeModerationCause(
|
||||||
description: 'You have hidden this post',
|
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
|
return cause.labelDef.strings[context].en
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,3 +25,13 @@ export function makeCustomFeedLink(
|
||||||
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
|
||||||
return [`/profile`, did, 'lists', rkey, ...segments].join('/')
|
return [`/profile`, did, 'lists', rkey, ...segments].join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function makeTagLink(did: string) {
|
||||||
|
return `/search?q=${encodeURIComponent(did)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
|
||||||
|
return `/search?q=${encodeURIComponent(
|
||||||
|
props.query + (props.from ? ` from:${props.from}` : ''),
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ export type CommonNavigatorParams = {
|
||||||
PreferencesFollowingFeed: undefined
|
PreferencesFollowingFeed: undefined
|
||||||
PreferencesThreads: undefined
|
PreferencesThreads: undefined
|
||||||
PreferencesExternalEmbeds: undefined
|
PreferencesExternalEmbeds: undefined
|
||||||
|
Search: {q?: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {DialogControlProps} from '#/components/Dialog'
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
|
||||||
|
|
||||||
const DialogContext = React.createContext<{
|
const DialogContext = React.createContext<{
|
||||||
activeDialogs: React.MutableRefObject<
|
activeDialogs: React.MutableRefObject<
|
||||||
|
@ -37,7 +38,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={context}>
|
<DialogContext.Provider value={context}>
|
||||||
<DialogControlContext.Provider value={controls}>
|
<DialogControlContext.Provider value={controls}>
|
||||||
{children}
|
<GlobalDialogsProvider>{children}</GlobalDialogsProvider>
|
||||||
</DialogControlContext.Provider>
|
</DialogControlContext.Provider>
|
||||||
</DialogContext.Provider>
|
</DialogContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
||||||
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
|
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
|
||||||
userAge: 13, // TODO(pwi)
|
userAge: 13, // TODO(pwi)
|
||||||
interests: {tags: []},
|
interests: {tags: []},
|
||||||
|
mutedWords: [],
|
||||||
|
hiddenPosts: [],
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
|
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 {track} from '#/lib/analytics/analytics'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
|
@ -108,6 +112,7 @@ export function useModerationOpts() {
|
||||||
return {
|
return {
|
||||||
...moderationOpts,
|
...moderationOpts,
|
||||||
hiddenPosts,
|
hiddenPosts,
|
||||||
|
mutedWords: prefs.data.mutedWords || [],
|
||||||
}
|
}
|
||||||
}, [currentAccount?.did, prefs.data, hiddenPosts])
|
}, [currentAccount?.did, prefs.data, hiddenPosts])
|
||||||
return opts
|
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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||||
let i = 0
|
let i = 0
|
||||||
|
|
||||||
return Array.from(richtext.segments()).map(segment => {
|
return Array.from(richtext.segments()).map(segment => {
|
||||||
const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
|
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={i++}
|
key={i++}
|
||||||
style={[
|
style={[
|
||||||
segment.facet && !isTag ? pal.link : pal.text,
|
segment.facet ? pal.link : pal.text,
|
||||||
styles.textInputFormatting,
|
styles.textInputFormatting,
|
||||||
]}>
|
]}>
|
||||||
{segment.text}
|
{segment.text}
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {Portal} from '#/components/Portal'
|
||||||
import {Text} from '../../util/text/Text'
|
import {Text} from '../../util/text/Text'
|
||||||
import {Trans} from '@lingui/macro'
|
import {Trans} from '@lingui/macro'
|
||||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||||
|
import {TagDecorator} from './web/TagDecorator'
|
||||||
|
|
||||||
export interface TextInputRef {
|
export interface TextInputRef {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
|
@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
||||||
() => [
|
() => [
|
||||||
Document,
|
Document,
|
||||||
LinkDecorator,
|
LinkDecorator,
|
||||||
|
TagDecorator,
|
||||||
Mention.configure({
|
Mention.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'mention',
|
class: 'mention',
|
||||||
|
|
|
@ -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]
|
||||||
|
},
|
||||||
|
})
|
|
@ -327,9 +327,11 @@ let PostThreadItemLoaded = ({
|
||||||
styles.postTextLargeContainer,
|
styles.postTextLargeContainer,
|
||||||
]}>
|
]}>
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
|
selectable
|
||||||
value={richText}
|
value={richText}
|
||||||
style={[a.flex_1, a.text_xl]}
|
style={[a.flex_1, a.text_xl]}
|
||||||
selectable
|
authorHandle={post.author.handle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
@ -521,9 +523,11 @@ let PostThreadItemLoaded = ({
|
||||||
{richText?.text ? (
|
{richText?.text ? (
|
||||||
<View style={styles.postTextContainer}>
|
<View style={styles.postTextContainer}>
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
value={richText}
|
value={richText}
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
|
authorHandle={post.author.handle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
|
@ -184,10 +184,12 @@ function PostInner({
|
||||||
{richText.text ? (
|
{richText.text ? (
|
||||||
<View style={styles.postTextContainer}>
|
<View style={styles.postTextContainer}>
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
testID="postText"
|
testID="postText"
|
||||||
value={richText}
|
value={richText}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
|
authorHandle={post.author.handle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
|
@ -347,10 +347,12 @@ let PostContent = ({
|
||||||
{richText.text ? (
|
{richText.text ? (
|
||||||
<View style={styles.postTextContainer}>
|
<View style={styles.postTextContainer}>
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
testID="postText"
|
testID="postText"
|
||||||
value={richText}
|
value={richText}
|
||||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||||
style={[a.flex_1, a.text_md]}
|
style={[a.flex_1, a.text_md]}
|
||||||
|
authorHandle={postAuthor.handle}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
|
@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
|
className="nativeDropdown-item"
|
||||||
{...props}
|
{...props}
|
||||||
style={StyleSheet.flatten([
|
style={StyleSheet.flatten([
|
||||||
styles.item,
|
styles.item,
|
||||||
|
@ -232,6 +233,10 @@ const styles = StyleSheet.create({
|
||||||
paddingLeft: 12,
|
paddingLeft: 12,
|
||||||
paddingRight: 12,
|
paddingRight: 12,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||||
|
outline: 0,
|
||||||
|
border: 0,
|
||||||
},
|
},
|
||||||
itemTitle: {
|
itemTitle: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|
|
@ -34,6 +34,7 @@ import {useLingui} from '@lingui/react'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
|
|
||||||
let PostDropdownBtn = ({
|
let PostDropdownBtn = ({
|
||||||
testID,
|
testID,
|
||||||
|
@ -67,6 +68,7 @@ let PostDropdownBtn = ({
|
||||||
const {hidePost} = useHiddenPostsApi()
|
const {hidePost} = useHiddenPostsApi()
|
||||||
const openLink = useOpenLink()
|
const openLink = useOpenLink()
|
||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
|
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||||
|
|
||||||
const rootUri = record.reply?.root?.uri || postUri
|
const rootUri = record.reply?.root?.uri || postUri
|
||||||
const isThreadMuted = mutedThreads.includes(rootUri)
|
const isThreadMuted = mutedThreads.includes(rootUri)
|
||||||
|
@ -210,6 +212,20 @@ let PostDropdownBtn = ({
|
||||||
web: 'comment-slash',
|
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 &&
|
hasSession &&
|
||||||
!isAuthor &&
|
!isAuthor &&
|
||||||
!isPostHidden && {
|
!isPostHidden && {
|
||||||
|
|
|
@ -128,10 +128,12 @@ export function QuoteEmbed({
|
||||||
) : null}
|
) : null}
|
||||||
{richText ? (
|
{richText ? (
|
||||||
<RichText
|
<RichText
|
||||||
|
enableTags
|
||||||
value={richText}
|
value={richText}
|
||||||
style={[a.text_md]}
|
style={[a.text_md]}
|
||||||
numberOfLines={20}
|
numberOfLines={20}
|
||||||
disableLinks
|
disableLinks
|
||||||
|
authorHandle={quote.author.handle}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{embed && <PostEmbeds embed={embed} moderation={{}} />}
|
{embed && <PostEmbeds embed={embed} moderation={{}} />}
|
||||||
|
|
|
@ -7,6 +7,9 @@ import {lh} from 'lib/styles'
|
||||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
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}
|
const WORD_WRAP = {wordWrap: 1}
|
||||||
|
|
||||||
|
@ -82,6 +85,7 @@ export function RichText({
|
||||||
for (const segment of richText.segments()) {
|
for (const segment of richText.segments()) {
|
||||||
const link = segment.link
|
const link = segment.link
|
||||||
const mention = segment.mention
|
const mention = segment.mention
|
||||||
|
const tag = segment.tag
|
||||||
if (
|
if (
|
||||||
!noLinks &&
|
!noLinks &&
|
||||||
mention &&
|
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 {
|
} else {
|
||||||
els.push(segment.text)
|
els.push(segment.text)
|
||||||
}
|
}
|
||||||
|
@ -133,3 +152,50 @@ export function RichText({
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RichTextTag({
|
||||||
|
text: tag,
|
||||||
|
type,
|
||||||
|
style,
|
||||||
|
lineHeightStyle,
|
||||||
|
selectable,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
type?: TypographyVariant
|
||||||
|
style?: StyleProp<TextStyle>
|
||||||
|
lineHeightStyle?: TextStyle
|
||||||
|
selectable?: boolean
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const control = useTagMenuControl()
|
||||||
|
|
||||||
|
const open = React.useCallback(() => {
|
||||||
|
control.open()
|
||||||
|
}, [control])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<TagMenu control={control} tag={tag}>
|
||||||
|
{isNative ? (
|
||||||
|
<TextLink
|
||||||
|
type={type}
|
||||||
|
text={tag}
|
||||||
|
// segment.text has the leading "#" while tag.tag does not
|
||||||
|
href={makeTagLink(tag)}
|
||||||
|
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
|
||||||
|
dataSet={WORD_WRAP}
|
||||||
|
selectable={selectable}
|
||||||
|
onPress={open}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
selectable={selectable}
|
||||||
|
type={type}
|
||||||
|
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}>
|
||||||
|
{tag}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TagMenu>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -103,6 +103,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
|
||||||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||||
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
||||||
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
|
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
|
||||||
|
import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
|
||||||
|
|
||||||
library.add(
|
library.add(
|
||||||
faAddressCard,
|
faAddressCard,
|
||||||
|
@ -208,4 +209,5 @@ library.add(
|
||||||
faX,
|
faX,
|
||||||
faXmark,
|
faXmark,
|
||||||
faChevronDown,
|
faChevronDown,
|
||||||
|
faFilter,
|
||||||
)
|
)
|
||||||
|
|
|
@ -31,6 +31,7 @@ import {
|
||||||
useProfileUpdateMutation,
|
useProfileUpdateMutation,
|
||||||
} from '#/state/queries/profile'
|
} from '#/state/queries/profile'
|
||||||
import {ScrollView} from '../com/util/Views'
|
import {ScrollView} from '../com/util/Views'
|
||||||
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
|
||||||
export function ModerationScreen({}: Props) {
|
export function ModerationScreen({}: Props) {
|
||||||
|
@ -40,6 +41,7 @@ export function ModerationScreen({}: Props) {
|
||||||
const {screen, track} = useAnalytics()
|
const {screen, track} = useAnalytics()
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
const {openModal} = useModalControls()
|
const {openModal} = useModalControls()
|
||||||
|
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
|
@ -69,8 +71,8 @@ export function ModerationScreen({}: Props) {
|
||||||
style={[styles.linkCard, pal.view]}
|
style={[styles.linkCard, pal.view]}
|
||||||
onPress={onPressContentFiltering}
|
onPress={onPressContentFiltering}
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
accessibilityHint="Content filtering"
|
accessibilityHint=""
|
||||||
accessibilityLabel="">
|
accessibilityLabel={_(msg`Open content filtering settings`)}>
|
||||||
<View style={[styles.iconContainer, pal.btn]}>
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="eye"
|
icon="eye"
|
||||||
|
@ -81,6 +83,23 @@ export function ModerationScreen({}: Props) {
|
||||||
<Trans>Content filtering</Trans>
|
<Trans>Content filtering</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</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
|
<Link
|
||||||
testID="moderationlistsBtn"
|
testID="moderationlistsBtn"
|
||||||
style={[styles.linkCard, pal.view]}
|
style={[styles.linkCard, pal.view]}
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
FontAwesomeIcon,
|
FontAwesomeIcon,
|
||||||
FontAwesomeIconStyle,
|
FontAwesomeIconStyle,
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {
|
import {
|
||||||
|
@ -53,6 +53,7 @@ import {listenSoftReset} from '#/state/events'
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||||
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
|
||||||
function Loader() {
|
function Loader() {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -448,6 +449,7 @@ export function SearchScreenInner({
|
||||||
export function SearchScreen(
|
export function SearchScreen(
|
||||||
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
|
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
|
||||||
) {
|
) {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const textInput = React.useRef<TextInput>(null)
|
const textInput = React.useRef<TextInput>(null)
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
@ -472,6 +474,27 @@ export function SearchScreen(
|
||||||
React.useState(false)
|
React.useState(false)
|
||||||
const [searchHistory, setSearchHistory] = React.useState<string[]>([])
|
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(() => {
|
React.useEffect(() => {
|
||||||
const loadSearchHistory = async () => {
|
const loadSearchHistory = async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -774,6 +797,8 @@ export function SearchScreen(
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
|
) : routeParamsMismatch ? (
|
||||||
|
<ActivityIndicator />
|
||||||
) : (
|
) : (
|
||||||
<SearchScreenInner query={query} />
|
<SearchScreenInner query={query} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {useSession} from '#/state/session'
|
||||||
import {useCloseAnyActiveElement} from '#/state/util'
|
import {useCloseAnyActiveElement} from '#/state/util'
|
||||||
import * as notifications from 'lib/notifications/notifications'
|
import * as notifications from 'lib/notifications/notifications'
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
|
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||||
|
|
||||||
function ShellInner() {
|
function ShellInner() {
|
||||||
const isDrawerOpen = useIsDrawerOpen()
|
const isDrawerOpen = useIsDrawerOpen()
|
||||||
|
@ -94,6 +95,7 @@ function ShellInner() {
|
||||||
</View>
|
</View>
|
||||||
<Composer winHeight={winDim.height} />
|
<Composer winHeight={winDim.height} />
|
||||||
<ModalsContainer />
|
<ModalsContainer />
|
||||||
|
<MutedWordsDialog />
|
||||||
<PortalOutlet />
|
<PortalOutlet />
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
|
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||||
|
|
||||||
function ShellInner() {
|
function ShellInner() {
|
||||||
const isDrawerOpen = useIsDrawerOpen()
|
const isDrawerOpen = useIsDrawerOpen()
|
||||||
|
@ -40,6 +41,7 @@ function ShellInner() {
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<Composer winHeight={0} />
|
<Composer winHeight={0} />
|
||||||
<ModalsContainer />
|
<ModalsContainer />
|
||||||
|
<MutedWordsDialog />
|
||||||
<PortalOutlet />
|
<PortalOutlet />
|
||||||
<Lightbox />
|
<Lightbox />
|
||||||
{!isDesktop && isDrawerOpen && (
|
{!isDesktop && isDrawerOpen && (
|
||||||
|
|
|
@ -209,6 +209,11 @@
|
||||||
[data-tooltip]:hover::before {
|
[data-tooltip]:hover::before {
|
||||||
display:block;
|
display:block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* NativeDropdown component */
|
||||||
|
.nativeDropdown-item:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
14
yarn.lock
14
yarn.lock
|
@ -34,6 +34,20 @@
|
||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
|
"@atproto/api@^0.10.0":
|
||||||
|
version "0.10.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044"
|
||||||
|
integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ==
|
||||||
|
dependencies:
|
||||||
|
"@atproto/common-web" "^0.2.3"
|
||||||
|
"@atproto/lexicon" "^0.3.1"
|
||||||
|
"@atproto/syntax" "^0.1.5"
|
||||||
|
"@atproto/xrpc" "^0.4.1"
|
||||||
|
multiformats "^9.9.0"
|
||||||
|
tlds "^1.234.0"
|
||||||
|
typed-emitter "^2.1.0"
|
||||||
|
zod "^3.21.4"
|
||||||
|
|
||||||
"@atproto/api@^0.9.5":
|
"@atproto/api@^0.9.5":
|
||||||
version "0.9.5"
|
version "0.9.5"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.9.5.tgz#630e5d9520bba38d0cd348c8028ddbb73bd074f8"
|
||||||
|
|
Loading…
Reference in New Issue