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>
This commit is contained in:
parent
c8582924e2
commit
58aaad704a
49 changed files with 1983 additions and 39 deletions
|
@ -190,12 +190,11 @@ export const TextInput = forwardRef(function TextInputImpl(
|
|||
let i = 0
|
||||
|
||||
return Array.from(richtext.segments()).map(segment => {
|
||||
const isTag = AppBskyRichtextFacet.isTag(segment.facet?.features?.[0])
|
||||
return (
|
||||
<Text
|
||||
key={i++}
|
||||
style={[
|
||||
segment.facet && !isTag ? pal.link : pal.text,
|
||||
segment.facet ? pal.link : pal.text,
|
||||
styles.textInputFormatting,
|
||||
]}>
|
||||
{segment.text}
|
||||
|
|
|
@ -23,6 +23,7 @@ import {Portal} from '#/components/Portal'
|
|||
import {Text} from '../../util/text/Text'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {TagDecorator} from './web/TagDecorator'
|
||||
|
||||
export interface TextInputRef {
|
||||
focus: () => void
|
||||
|
@ -67,6 +68,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||
() => [
|
||||
Document,
|
||||
LinkDecorator,
|
||||
TagDecorator,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
|
|
83
src/view/com/composer/text-input/web/TagDecorator.ts
Normal file
83
src/view/com/composer/text-input/web/TagDecorator.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* TipTap is a stateful rich-text editor, which is extremely useful
|
||||
* when you _want_ it to be stateful formatting such as bold and italics.
|
||||
*
|
||||
* However we also use "stateless" behaviors, specifically for URLs
|
||||
* where the text itself drives the formatting.
|
||||
*
|
||||
* This plugin uses a regex to detect URIs and then applies
|
||||
* link decorations (a <span> with the "autolink") class. That avoids
|
||||
* adding any stateful formatting to TipTap's document model.
|
||||
*
|
||||
* We then run the URI detection again when constructing the
|
||||
* RichText object from TipTap's output and merge their features into
|
||||
* the facet-set.
|
||||
*/
|
||||
|
||||
import {Mark} from '@tiptap/core'
|
||||
import {Plugin, PluginKey} from '@tiptap/pm/state'
|
||||
import {Node as ProsemirrorNode} from '@tiptap/pm/model'
|
||||
import {Decoration, DecorationSet} from '@tiptap/pm/view'
|
||||
|
||||
function getDecorations(doc: ProsemirrorNode) {
|
||||
const decorations: Decoration[] = []
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g
|
||||
const textContent = node.textContent
|
||||
|
||||
let match
|
||||
while ((match = regex.exec(textContent))) {
|
||||
const [matchedString, tag] = match
|
||||
|
||||
if (tag.length > 66) continue
|
||||
|
||||
const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || []
|
||||
|
||||
const from = match.index + matchedString.indexOf(tag)
|
||||
const to = from + (tag.length - trailingPunc.length)
|
||||
|
||||
decorations.push(
|
||||
Decoration.inline(pos + from, pos + to, {
|
||||
class: 'autolink',
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
const tagDecoratorPlugin: Plugin = new Plugin({
|
||||
key: new PluginKey('link-decorator'),
|
||||
|
||||
state: {
|
||||
init: (_, {doc}) => getDecorations(doc),
|
||||
apply: (transaction, decorationSet) => {
|
||||
if (transaction.docChanged) {
|
||||
return getDecorations(transaction.doc)
|
||||
}
|
||||
return decorationSet.map(transaction.mapping, transaction.doc)
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
return tagDecoratorPlugin.getState(state)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const TagDecorator = Mark.create({
|
||||
name: 'tag-decorator',
|
||||
priority: 1000,
|
||||
keepOnSplit: false,
|
||||
inclusive() {
|
||||
return true
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [tagDecoratorPlugin]
|
||||
},
|
||||
})
|
|
@ -327,9 +327,11 @@ let PostThreadItemLoaded = ({
|
|||
styles.postTextLargeContainer,
|
||||
]}>
|
||||
<RichText
|
||||
enableTags
|
||||
selectable
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_xl]}
|
||||
selectable
|
||||
authorHandle={post.author.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
@ -521,9 +523,11 @@ let PostThreadItemLoaded = ({
|
|||
{richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
authorHandle={post.author.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
|
|
@ -184,10 +184,12 @@ function PostInner({
|
|||
{richText.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
authorHandle={post.author.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
|
|
@ -347,10 +347,12 @@ let PostContent = ({
|
|||
{richText.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
enableTags
|
||||
testID="postText"
|
||||
value={richText}
|
||||
numberOfLines={limitLines ? MAX_POST_LINES : undefined}
|
||||
style={[a.flex_1, a.text_md]}
|
||||
authorHandle={postAuthor.handle}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
|
|
@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
|
|||
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
className="nativeDropdown-item"
|
||||
{...props}
|
||||
style={StyleSheet.flatten([
|
||||
styles.item,
|
||||
|
@ -232,6 +233,10 @@ const styles = StyleSheet.create({
|
|||
paddingLeft: 12,
|
||||
paddingRight: 12,
|
||||
borderRadius: 8,
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
outline: 0,
|
||||
border: 0,
|
||||
},
|
||||
itemTitle: {
|
||||
fontSize: 16,
|
||||
|
|
|
@ -34,6 +34,7 @@ import {useLingui} from '@lingui/react'
|
|||
import {useSession} from '#/state/session'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
|
||||
let PostDropdownBtn = ({
|
||||
testID,
|
||||
|
@ -67,6 +68,7 @@ let PostDropdownBtn = ({
|
|||
const {hidePost} = useHiddenPostsApi()
|
||||
const openLink = useOpenLink()
|
||||
const navigation = useNavigation()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
|
||||
const rootUri = record.reply?.root?.uri || postUri
|
||||
const isThreadMuted = mutedThreads.includes(rootUri)
|
||||
|
@ -210,6 +212,20 @@ let PostDropdownBtn = ({
|
|||
web: 'comment-slash',
|
||||
},
|
||||
},
|
||||
hasSession && {
|
||||
label: _(msg`Mute words & tags`),
|
||||
onPress() {
|
||||
mutedWordsDialogControl.open()
|
||||
},
|
||||
testID: 'postDropdownMuteWordsBtn',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'speaker.slash',
|
||||
},
|
||||
android: 'ic_lock_silent_mode',
|
||||
web: 'filter',
|
||||
},
|
||||
},
|
||||
hasSession &&
|
||||
!isAuthor &&
|
||||
!isPostHidden && {
|
||||
|
|
|
@ -128,10 +128,12 @@ export function QuoteEmbed({
|
|||
) : null}
|
||||
{richText ? (
|
||||
<RichText
|
||||
enableTags
|
||||
value={richText}
|
||||
style={[a.text_md]}
|
||||
numberOfLines={20}
|
||||
disableLinks
|
||||
authorHandle={quote.author.handle}
|
||||
/>
|
||||
) : null}
|
||||
{embed && <PostEmbeds embed={embed} moderation={{}} />}
|
||||
|
|
|
@ -7,6 +7,9 @@ import {lh} from 'lib/styles'
|
|||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {useTheme, TypographyVariant} from 'lib/ThemeContext'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeTagLink} from 'lib/routes/links'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
|
@ -82,6 +85,7 @@ export function RichText({
|
|||
for (const segment of richText.segments()) {
|
||||
const link = segment.link
|
||||
const mention = segment.mention
|
||||
const tag = segment.tag
|
||||
if (
|
||||
!noLinks &&
|
||||
mention &&
|
||||
|
@ -115,6 +119,21 @@ export function RichText({
|
|||
/>,
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
!noLinks &&
|
||||
tag &&
|
||||
AppBskyRichtextFacet.validateTag(tag).success
|
||||
) {
|
||||
els.push(
|
||||
<RichTextTag
|
||||
key={key}
|
||||
text={segment.text}
|
||||
type={type}
|
||||
style={style}
|
||||
lineHeightStyle={lineHeightStyle}
|
||||
selectable={selectable}
|
||||
/>,
|
||||
)
|
||||
} else {
|
||||
els.push(segment.text)
|
||||
}
|
||||
|
@ -133,3 +152,50 @@ export function RichText({
|
|||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function RichTextTag({
|
||||
text: tag,
|
||||
type,
|
||||
style,
|
||||
lineHeightStyle,
|
||||
selectable,
|
||||
}: {
|
||||
text: string
|
||||
type?: TypographyVariant
|
||||
style?: StyleProp<TextStyle>
|
||||
lineHeightStyle?: TextStyle
|
||||
selectable?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const control = useTagMenuControl()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
control.open()
|
||||
}, [control])
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TagMenu control={control} tag={tag}>
|
||||
{isNative ? (
|
||||
<TextLink
|
||||
type={type}
|
||||
text={tag}
|
||||
// segment.text has the leading "#" while tag.tag does not
|
||||
href={makeTagLink(tag)}
|
||||
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
|
||||
dataSet={WORD_WRAP}
|
||||
selectable={selectable}
|
||||
onPress={open}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
type={type}
|
||||
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}>
|
||||
{tag}
|
||||
</Text>
|
||||
)}
|
||||
</TagMenu>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -103,6 +103,7 @@ import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash'
|
|||
import {faX} from '@fortawesome/free-solid-svg-icons/faX'
|
||||
import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
|
||||
import {faChevronDown} from '@fortawesome/free-solid-svg-icons/faChevronDown'
|
||||
import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
|
||||
|
||||
library.add(
|
||||
faAddressCard,
|
||||
|
@ -208,4 +209,5 @@ library.add(
|
|||
faX,
|
||||
faXmark,
|
||||
faChevronDown,
|
||||
faFilter,
|
||||
)
|
||||
|
|
|
@ -31,6 +31,7 @@ import {
|
|||
useProfileUpdateMutation,
|
||||
} from '#/state/queries/profile'
|
||||
import {ScrollView} from '../com/util/Views'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>
|
||||
export function ModerationScreen({}: Props) {
|
||||
|
@ -40,6 +41,7 @@ export function ModerationScreen({}: Props) {
|
|||
const {screen, track} = useAnalytics()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
const {openModal} = useModalControls()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
|
@ -69,8 +71,8 @@ export function ModerationScreen({}: Props) {
|
|||
style={[styles.linkCard, pal.view]}
|
||||
onPress={onPressContentFiltering}
|
||||
accessibilityRole="tab"
|
||||
accessibilityHint="Content filtering"
|
||||
accessibilityLabel="">
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={_(msg`Open content filtering settings`)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="eye"
|
||||
|
@ -81,6 +83,23 @@ export function ModerationScreen({}: Props) {
|
|||
<Trans>Content filtering</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="mutedWordsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
onPress={() => mutedWordsDialogControl.open()}
|
||||
accessibilityRole="tab"
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={_(msg`Open muted words settings`)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="filter"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Muted words & tags</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Link
|
||||
testID="moderationlistsBtn"
|
||||
style={[styles.linkCard, pal.view]}
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {useFocusEffect, useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
|
@ -53,6 +53,7 @@ import {listenSoftReset} from '#/state/events'
|
|||
import {s} from '#/lib/styles'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {augmentSearchQuery} from '#/lib/strings/helpers'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
|
||||
function Loader() {
|
||||
const pal = usePalette('default')
|
||||
|
@ -448,6 +449,7 @@ export function SearchScreenInner({
|
|||
export function SearchScreen(
|
||||
props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>,
|
||||
) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const theme = useTheme()
|
||||
const textInput = React.useRef<TextInput>(null)
|
||||
const {_} = useLingui()
|
||||
|
@ -472,6 +474,27 @@ export function SearchScreen(
|
|||
React.useState(false)
|
||||
const [searchHistory, setSearchHistory] = React.useState<string[]>([])
|
||||
|
||||
/**
|
||||
* The Search screen's `q` param
|
||||
*/
|
||||
const queryParam = props.route?.params?.q
|
||||
|
||||
/**
|
||||
* If `true`, this means we received new instructions from the router. This
|
||||
* is handled in a effect, and used to update the value of `query` locally
|
||||
* within this screen.
|
||||
*/
|
||||
const routeParamsMismatch = queryParam && queryParam !== query
|
||||
|
||||
React.useEffect(() => {
|
||||
if (queryParam && routeParamsMismatch) {
|
||||
// reset immediately and let local state take over
|
||||
navigation.setParams({q: ''})
|
||||
// update query for next search
|
||||
setQuery(queryParam)
|
||||
}
|
||||
}, [queryParam, routeParamsMismatch, navigation])
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadSearchHistory = async () => {
|
||||
try {
|
||||
|
@ -774,6 +797,8 @@ export function SearchScreen(
|
|||
)}
|
||||
</View>
|
||||
</CenteredView>
|
||||
) : routeParamsMismatch ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<SearchScreenInner query={query} />
|
||||
)}
|
||||
|
|
|
@ -29,6 +29,7 @@ import {useSession} from '#/state/session'
|
|||
import {useCloseAnyActiveElement} from '#/state/util'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||
|
||||
function ShellInner() {
|
||||
const isDrawerOpen = useIsDrawerOpen()
|
||||
|
@ -94,6 +95,7 @@ function ShellInner() {
|
|||
</View>
|
||||
<Composer winHeight={winDim.height} />
|
||||
<ModalsContainer />
|
||||
<MutedWordsDialog />
|
||||
<PortalOutlet />
|
||||
<Lightbox />
|
||||
</>
|
||||
|
|
|
@ -16,6 +16,7 @@ import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
|||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||
|
||||
function ShellInner() {
|
||||
const isDrawerOpen = useIsDrawerOpen()
|
||||
|
@ -40,6 +41,7 @@ function ShellInner() {
|
|||
</ErrorBoundary>
|
||||
<Composer winHeight={0} />
|
||||
<ModalsContainer />
|
||||
<MutedWordsDialog />
|
||||
<PortalOutlet />
|
||||
<Lightbox />
|
||||
{!isDesktop && isDrawerOpen && (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue