diff options
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/composer/text-input/TextInput.web.tsx | 43 | ||||
-rw-r--r-- | src/view/com/composer/text-input/web/LinkDecorator.ts | 106 | ||||
-rw-r--r-- | src/view/com/modals/ContentFilteringSettings.tsx | 22 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 3 | ||||
-rw-r--r-- | src/view/com/modals/PreferencesHomeFeed.tsx | 176 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 5 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 1 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 1 | ||||
-rw-r--r-- | src/view/com/util/Link.tsx | 16 | ||||
-rw-r--r-- | src/view/com/util/ViewSelector.tsx | 1 |
11 files changed, 154 insertions, 224 deletions
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index dfe1e26a1..395263af8 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,12 +1,11 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {RichText} from '@atproto/api' +import {RichText, AppBskyRichtextFacet} from '@atproto/api' import EventEmitter from 'eventemitter3' import {useEditor, EditorContent, JSONContent} from '@tiptap/react' import {Document} from '@tiptap/extension-document' import History from '@tiptap/extension-history' import Hardbreak from '@tiptap/extension-hard-break' -import {Link} from '@tiptap/extension-link' import {Mention} from '@tiptap/extension-mention' import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' @@ -17,6 +16,7 @@ import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {isUriImage, blobToDataUri} from 'lib/media/util' import {Emoji} from './web/EmojiPicker.web' +import {LinkDecorator} from './web/LinkDecorator' export interface TextInputRef { focus: () => void @@ -74,11 +74,7 @@ export const TextInput = React.forwardRef( { extensions: [ Document, - Link.configure({ - protocols: ['http', 'https'], - autolink: true, - linkOnPaste: false, - }), + LinkDecorator, Mention.configure({ HTMLAttributes: { class: 'mention', @@ -128,9 +124,20 @@ export const TextInput = React.forwardRef( newRt.detectFacetsWithoutResolution() setRichText(newRt) - const newSuggestedLinks = new Set(editorJsonToLinks(json)) - if (!isEqual(newSuggestedLinks, suggestedLinks)) { - onSuggestedLinksChanged(newSuggestedLinks) + const set: Set<string> = new Set() + + if (newRt.facets) { + for (const facet of newRt.facets) { + for (const feature of facet.features) { + if (AppBskyRichtextFacet.isLink(feature)) { + set.add(feature.uri) + } + } + } + } + + if (!isEqual(set, suggestedLinks)) { + onSuggestedLinksChanged(set) } }, }, @@ -237,22 +244,6 @@ function textToEditorJson(text: string): JSONContent { } } -function editorJsonToLinks(json: JSONContent): string[] { - let links: string[] = [] - if (json.content?.length) { - for (const node of json.content) { - links = links.concat(editorJsonToLinks(node)) - } - } - - const link = json.marks?.find(m => m.type === 'link') - if (link?.attrs?.href) { - links.push(link.attrs.href) - } - - return links -} - const styles = StyleSheet.create({ container: { flex: 1, diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts new file mode 100644 index 000000000..531e8d5a0 --- /dev/null +++ b/src/view/com/composer/text-input/web/LinkDecorator.ts @@ -0,0 +1,106 @@ +/** + * 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 {findChildren} from '@tiptap/core' +import {Node as ProsemirrorNode} from '@tiptap/pm/model' +import {Decoration, DecorationSet} from '@tiptap/pm/view' +import {isValidDomain} from 'lib/strings/url-helpers' + +export const LinkDecorator = Mark.create({ + name: 'link-decorator', + priority: 1000, + keepOnSplit: false, + inclusive() { + return true + }, + addProseMirrorPlugins() { + return [linkDecorator()] + }, +}) + +function getDecorations(doc: ProsemirrorNode) { + const decorations: Decoration[] = [] + + findChildren(doc, node => node.type.name === 'paragraph').forEach( + paragraphNode => { + const textContent = paragraphNode.node.textContent + + // links + iterateUris(textContent, (from, to) => { + decorations.push( + Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, { + class: 'autolink', + }), + ) + }) + }, + ) + + return DecorationSet.create(doc, decorations) +} + +function linkDecorator() { + const linkDecoratorPlugin: 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 linkDecoratorPlugin.getState(state) + }, + }, + }) + return linkDecoratorPlugin +} + +function iterateUris(str: string, cb: (from: number, to: number) => void) { + let match + const re = + /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim + while ((match = re.exec(str))) { + let uri = match[2] + if (!uri.startsWith('http')) { + const domain = match.groups?.domain + if (!domain || !isValidDomain(domain)) { + continue + } + uri = `https://${uri}` + } + let from = str.indexOf(match[2], match.index) + let to = from + match[2].length + 1 + // strip ending puncuation + if (/[.,;!?]$/.test(uri)) { + uri = uri.slice(0, -1) + to-- + } + if (/[)]$/.test(uri) && !uri.includes('(')) { + uri = uri.slice(0, -1) + to-- + } + cb(from, to) + } +} diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 5215c9cb4..f39351feb 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -48,15 +48,17 @@ export const Component = observer(({}: {}) => { <ScrollView style={styles.scrollContainer}> <View style={s.mb10}> {isIOS ? ( - <Text type="md" style={pal.textLight}> - Adult content can only be enabled via the Web at{' '} - <TextLink - style={pal.link} - href="https://bsky.app" - text="bsky.app" - /> - . - </Text> + store.preferences.adultContentEnabled ? null : ( + <Text type="md" style={pal.textLight}> + Adult content can only be enabled via the Web at{' '} + <TextLink + style={pal.link} + href="https://bsky.app" + text="bsky.app" + /> + . + </Text> + ) ) : ( <ToggleButton type="default-light" @@ -188,7 +190,7 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) { /> <SelectableBtn current={current} - value="show" + value="ignore" label="Show" right onChange={onChange} diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index dd45262be..4a5a7c504 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -28,7 +28,6 @@ import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' -import * as PreferencesHomeFeed from './PreferencesHomeFeed' import * as ModerationDetailsModal from './ModerationDetails' const DEFAULT_SNAPPOINTS = ['90%'] @@ -130,9 +129,6 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'post-languages-settings') { snapPoints = PostLanguagesSettingsModal.snapPoints element = <PostLanguagesSettingsModal.Component /> - } else if (activeModal?.name === 'preferences-home-feed') { - snapPoints = PreferencesHomeFeed.snapPoints - element = <PreferencesHomeFeed.Component /> } else if (activeModal?.name === 'moderation-details') { snapPoints = ModerationDetailsModal.snapPoints element = <ModerationDetailsModal.Component {...activeModal} /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 3aeddeb6b..5cfdd6bb3 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -27,7 +27,6 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as ModerationDetailsModal from './ModerationDetails' -import * as PreferencesHomeFeed from './PreferencesHomeFeed' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <AltTextImageModal.Component {...modal} /> } else if (modal.name === 'edit-image') { element = <EditImageModal.Component {...modal} /> - } else if (modal.name === 'preferences-home-feed') { - element = <PreferencesHomeFeed.Component /> } else if (modal.name === 'moderation-details') { element = <ModerationDetailsModal.Component {...modal} /> } else { diff --git a/src/view/com/modals/PreferencesHomeFeed.tsx b/src/view/com/modals/PreferencesHomeFeed.tsx deleted file mode 100644 index 15f7625b5..000000000 --- a/src/view/com/modals/PreferencesHomeFeed.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React, {useState} from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {Slider} from '@miblanchard/react-native-slider' -import {Text} from '../util/text/Text' -import {useStores} from 'state/index' -import {s, colors} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {isWeb, isDesktopWeb} from 'platform/detection' -import {ToggleButton} from 'view/com/util/forms/ToggleButton' -import {ScrollView} from 'view/com/modals/util' - -export const snapPoints = ['90%'] - -function RepliesThresholdInput({enabled}: {enabled: boolean}) { - const store = useStores() - const pal = usePalette('default') - const [value, setValue] = useState(store.preferences.homeFeedRepliesThreshold) - - return ( - <View style={[s.mt10, !enabled && styles.dimmed]}> - <Text type="xs" style={pal.text}> - {value === 0 - ? `Show all replies` - : `Show replies with at least ${value} ${ - value > 1 ? `likes` : `like` - }`} - </Text> - <Slider - value={value} - onValueChange={(v: number | number[]) => { - const threshold = Math.floor(Array.isArray(v) ? v[0] : v) - setValue(threshold) - store.preferences.setHomeFeedRepliesThreshold(threshold) - }} - minimumValue={0} - maximumValue={25} - containerStyle={isWeb ? undefined : s.flex1} - disabled={!enabled} - thumbTintColor={colors.blue3} - /> - </View> - ) -} - -export const Component = observer(function Component() { - const pal = usePalette('default') - const store = useStores() - - return ( - <View - testID="preferencesHomeFeedModal" - style={[pal.view, styles.container]}> - <View style={styles.titleSection}> - <Text type="title-lg" style={[pal.text, styles.title]}> - Home Feed Preferences - </Text> - <Text type="xl" style={[pal.textLight, styles.description]}> - Fine-tune the content you see on your home screen. - </Text> - </View> - - <ScrollView> - <View style={styles.cardsContainer}> - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Replies - </Text> - <Text style={[pal.text, s.pb10]}> - Adjust the number of likes a reply must have to be shown in your - feed. - </Text> - <ToggleButton - type="default-light" - label={store.preferences.homeFeedRepliesEnabled ? 'Yes' : 'No'} - isSelected={store.preferences.homeFeedRepliesEnabled} - onPress={store.preferences.toggleHomeFeedRepliesEnabled} - /> - - <RepliesThresholdInput - enabled={store.preferences.homeFeedRepliesEnabled} - /> - </View> - - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Reposts - </Text> - <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all reposts from your feed. - </Text> - <ToggleButton - type="default-light" - label={store.preferences.homeFeedRepostsEnabled ? 'Yes' : 'No'} - isSelected={store.preferences.homeFeedRepostsEnabled} - onPress={store.preferences.toggleHomeFeedRepostsEnabled} - /> - </View> - - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Quote Posts - </Text> - <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all quote posts from your feed. - Reposts will still be visible. - </Text> - <ToggleButton - type="default-light" - label={store.preferences.homeFeedQuotePostsEnabled ? 'Yes' : 'No'} - isSelected={store.preferences.homeFeedQuotePostsEnabled} - onPress={store.preferences.toggleHomeFeedQuotePostsEnabled} - /> - </View> - </View> - </ScrollView> - - <View style={[styles.btnContainer, pal.borderDark]}> - <TouchableOpacity - testID="confirmBtn" - onPress={() => { - store.shell.closeModal() - }} - style={[styles.btn]} - accessibilityRole="button" - accessibilityLabel="Confirm" - accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Done</Text> - </TouchableOpacity> - </View> - </View> - ) -}) - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: isDesktopWeb ? 0 : 60, - }, - titleSection: { - padding: 20, - paddingBottom: 30, - }, - title: { - textAlign: 'center', - marginBottom: 5, - }, - description: { - textAlign: 'center', - paddingHorizontal: 32, - }, - cardsContainer: { - paddingHorizontal: 20, - }, - card: { - padding: 16, - borderRadius: 10, - marginBottom: 20, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - backgroundColor: colors.blue3, - }, - btnContainer: { - paddingTop: 20, - paddingHorizontal: 20, - borderTopWidth: isDesktopWeb ? 0 : 1, - }, - dimmed: { - opacity: 0.3, - }, -}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 088be6a90..8b556cea3 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -367,6 +367,7 @@ export const PostThreadItem = observer(function PostThreadItem({ pal.border, pal.view, item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder, + styles.cursor, ]} moderation={item.moderation.content}> <PostSandboxWarning /> @@ -616,4 +617,8 @@ const styles = StyleSheet.create({ marginLeft: 'auto', marginRight: 'auto', }, + cursor: { + // @ts-ignore web only + cursor: 'pointer', + }, }) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 94dfe6e8b..661b3a899 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -304,6 +304,7 @@ const styles = StyleSheet.create({ paddingBottom: 5, paddingLeft: 10, borderTopWidth: 1, + cursor: 'pointer', }, layout: { flexDirection: 'row', diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index e1212f32c..c46411f0f 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -343,6 +343,7 @@ const styles = StyleSheet.create({ borderTopWidth: 1, paddingLeft: 10, paddingRight: 15, + cursor: 'pointer', }, outerSmallTop: { borderTopWidth: 0, diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index ead85d0b5..321b6ab63 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -259,15 +259,21 @@ function onPressInner( e?: Event, ) { let shouldHandle = false + const isLeftClick = + // @ts-ignore Web only -prf + Platform.OS === 'web' && (e.button == null || e.button === 0) + // @ts-ignore Web only -prf + const isMiddleClick = Platform.OS === 'web' && e.button === 1 + const isMetaKey = + // @ts-ignore Web only -prf + Platform.OS === 'web' && (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) + const newTab = isMetaKey || isMiddleClick if (Platform.OS !== 'web' || !e) { shouldHandle = e ? !e.defaultPrevented : true } else if ( !e.defaultPrevented && // onPress prevented default - // @ts-ignore Web only -prf - !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && // ignore clicks with modifier keys - // @ts-ignore Web only -prf - (e.button == null || e.button === 0) && // ignore everything but left clicks + (isLeftClick || isMiddleClick) && // ignore everything but left and middle clicks // @ts-ignore Web only -prf [undefined, null, '', 'self'].includes(e.currentTarget?.target) // let browser handle "target=_blank" etc. ) { @@ -277,7 +283,7 @@ function onPressInner( if (shouldHandle) { href = convertBskyAppUrlIfNeeded(href) - if (href.startsWith('http') || href.startsWith('mailto')) { + if (newTab || href.startsWith('http') || href.startsWith('mailto')) { Linking.openURL(href) } else { store.shell.closeModal() // close any active modals diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index cd3299284..8d2a30506 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -168,6 +168,7 @@ export function Selector({ backgroundColor: pal.colors.background, }}> <ScrollView + testID="selector" horizontal showsHorizontalScrollIndicator={false} style={{position: 'absolute'}}> |