diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/composer/text-input/TextInput.tsx | 3 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/TagDecorator.ts | 83 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 6 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 2 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/forms/NativeDropdown.web.tsx | 5 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 16 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/QuoteEmbed.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/text/RichText.tsx | 66 | ||||
-rw-r--r-- | src/view/icons/index.tsx | 2 | ||||
-rw-r--r-- | src/view/screens/Moderation.tsx | 23 | ||||
-rw-r--r-- | src/view/screens/Search/Search.tsx | 27 | ||||
-rw-r--r-- | src/view/shell/index.tsx | 2 | ||||
-rw-r--r-- | src/view/shell/index.web.tsx | 2 |
15 files changed, 237 insertions, 6 deletions
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 17f9513b7..20be585c2 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -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} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 199f1f749..c62d11201 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -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', diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts new file mode 100644 index 000000000..d820ec3f0 --- /dev/null +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -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] + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index ebd739839..949fcfea0 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -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} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index aec916adb..5fa4da84e 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -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} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 6f64de181..47a964ab1 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -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} diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx index 9e9888ad8..052e7ca13 100644 --- a/src/view/com/util/forms/NativeDropdown.web.tsx +++ b/src/view/com/util/forms/NativeDropdown.web.tsx @@ -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, diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 1dfb687df..09850a7f5 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -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 && { diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index c128a6f00..35b091269 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -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={{}} />} diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index b6d461224..0ec3f3181 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -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> + ) +} diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx index b7bbf1600..ede1e6335 100644 --- a/src/view/icons/index.tsx +++ b/src/view/icons/index.tsx @@ -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, ) diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 8f1fe75b6..928766c30 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -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]} diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 142726701..42eec53d3 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -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} /> )} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 6b0cc6808..d895d8851 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -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 /> </> diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 97c065502..71dccb8c4 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -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 && ( |