about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx43
-rw-r--r--src/view/com/composer/text-input/web/LinkDecorator.ts106
-rw-r--r--src/view/com/modals/ContentFilteringSettings.tsx22
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/modals/PreferencesHomeFeed.tsx176
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx5
-rw-r--r--src/view/com/post/Post.tsx1
-rw-r--r--src/view/com/posts/FeedItem.tsx1
-rw-r--r--src/view/com/util/Link.tsx16
-rw-r--r--src/view/com/util/ViewSelector.tsx1
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'}}>