about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-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/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
-rw-r--r--src/view/screens/PreferencesHomeFeed.tsx (renamed from src/view/com/modals/PreferencesHomeFeed.tsx)52
-rw-r--r--src/view/screens/ProfileList.tsx2
-rw-r--r--src/view/screens/Search.web.tsx20
-rw-r--r--src/view/screens/Settings.tsx8
-rw-r--r--src/view/shell/desktop/LeftNav.tsx7
15 files changed, 216 insertions, 75 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/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'}}>
diff --git a/src/view/com/modals/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx
index 15f7625b5..b04f274f7 100644
--- a/src/view/com/modals/PreferencesHomeFeed.tsx
+++ b/src/view/screens/PreferencesHomeFeed.tsx
@@ -1,16 +1,16 @@
 import React, {useState} from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import {ScrollView, 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 {Text} from '../com/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%']
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
 
 function RepliesThresholdInput({enabled}: {enabled: boolean}) {
   const store = useStores()
@@ -43,18 +43,25 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) {
   )
 }
 
-export const Component = observer(function Component() {
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'PreferencesHomeFeed'
+>
+export const PreferencesHomeFeed = observer(({navigation}: Props) => {
   const pal = usePalette('default')
   const store = useStores()
 
   return (
-    <View
-      testID="preferencesHomeFeedModal"
-      style={[pal.view, styles.container]}>
+    <CenteredView
+      testID="preferencesHomeFeedScreen"
+      style={[
+        pal.view,
+        pal.border,
+        styles.container,
+        isDesktopWeb && styles.desktopContainer,
+      ]}>
+      <ViewHeader title="Home Feed Preferences" showOnDesktop />
       <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>
@@ -119,27 +126,33 @@ export const Component = observer(function Component() {
         <TouchableOpacity
           testID="confirmBtn"
           onPress={() => {
-            store.shell.closeModal()
+            navigation.canGoBack()
+              ? navigation.goBack()
+              : navigation.navigate('Settings')
           }}
-          style={[styles.btn]}
+          style={[styles.btn, isDesktopWeb && styles.btnDesktop]}
           accessibilityRole="button"
           accessibilityLabel="Confirm"
           accessibilityHint="">
           <Text style={[s.white, s.bold, s.f18]}>Done</Text>
         </TouchableOpacity>
       </View>
-    </View>
+    </CenteredView>
   )
 })
 
 const styles = StyleSheet.create({
   container: {
     flex: 1,
-    paddingBottom: isDesktopWeb ? 0 : 60,
+    paddingBottom: isDesktopWeb ? 40 : 90,
+  },
+  desktopContainer: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
   },
   titleSection: {
-    padding: 20,
     paddingBottom: 30,
+    paddingTop: isDesktopWeb ? 20 : 0,
   },
   title: {
     textAlign: 'center',
@@ -165,9 +178,12 @@ const styles = StyleSheet.create({
     padding: 14,
     backgroundColor: colors.blue3,
   },
+  btnDesktop: {
+    marginHorizontal: 'auto',
+    paddingHorizontal: 80,
+  },
   btnContainer: {
     paddingTop: 20,
-    paddingHorizontal: 20,
     borderTopWidth: isDesktopWeb ? 0 : 1,
   },
   dimmed: {
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 651fac21f..3c50fdde0 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -74,7 +74,7 @@ export const ProfileListScreen = withAuthRequired(
       store.shell.openModal({
         name: 'confirm',
         title: 'Delete List',
-        message: 'Are you sure',
+        message: 'Are you sure?',
         async onPressConfirm() {
           await list.delete()
           if (navigation.canGoBack()) {
diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx
index 85e8c212e..3218b4579 100644
--- a/src/view/screens/Search.web.tsx
+++ b/src/view/screens/Search.web.tsx
@@ -1,4 +1,5 @@
 import React from 'react'
+import {View, StyleSheet} from 'react-native'
 import {SearchUIModel} from 'state/models/ui/search'
 import {FoafsModel} from 'state/models/discovery/foafs'
 import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors'
@@ -47,13 +48,28 @@ export const SearchScreen = withAuthRequired(
     const {isDesktop} = useWebMediaQueries()
 
     if (searchUIModel) {
-      return <SearchResults model={searchUIModel} />
+      return (
+        <View style={styles.scrollContainer}>
+          <SearchResults model={searchUIModel} />
+        </View>
+      )
     }
 
     if (!isDesktop) {
-      return <Mobile.SearchScreen navigation={navigation} route={route} />
+      return (
+        <View style={styles.scrollContainer}>
+          <Mobile.SearchScreen navigation={navigation} route={route} />
+        </View>
+      )
     }
 
     return <Suggestions foafs={foafs} suggestedActors={suggestedActors} />
   }),
 )
+
+const styles = StyleSheet.create({
+  scrollContainer: {
+    height: '100%',
+    overflowY: 'auto',
+  },
+})
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 4a2c1c16a..481d77086 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -175,10 +175,8 @@ export const SettingsScreen = withAuthRequired(
     }, [])
 
     const openPreferencesModal = React.useCallback(() => {
-      store.shell.openModal({
-        name: 'preferences-home-feed',
-      })
-    }, [store])
+      navigation.navigate('PreferencesHomeFeed')
+    }, [navigation])
 
     const onPressAppPasswords = React.useCallback(() => {
       navigation.navigate('AppPasswords')
@@ -391,7 +389,7 @@ export const SettingsScreen = withAuthRequired(
             Advanced
           </Text>
           <TouchableOpacity
-            testID="preferencesHomeFeedModalButton"
+            testID="preferencesHomeFeedButton"
             style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
             onPress={openPreferencesModal}
             accessibilityRole="button"
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index b37befba6..eec55ee46 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -14,6 +14,7 @@ import {
 import {Text} from 'view/com/util/text/Text'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {Link} from 'view/com/util/Link'
+import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
 import {s, colors} from 'lib/styles'
@@ -40,10 +41,14 @@ import {makeProfileLink} from 'lib/routes/links'
 
 const ProfileCard = observer(() => {
   const store = useStores()
-  return (
+  return store.me.handle ? (
     <Link href={makeProfileLink(store.me)} style={styles.profileCard} asAnchor>
       <UserAvatar avatar={store.me.avatar} size={64} />
     </Link>
+  ) : (
+    <View style={styles.profileCard}>
+      <LoadingPlaceholder width={64} height={64} style={{borderRadius: 64}} />
+    </View>
   )
 })