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/auth/create/Step2.tsx4
-rw-r--r--src/view/com/auth/login/LoginForm.tsx2
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx3
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx2
-rw-r--r--src/view/com/composer/text-input/web/TagDecorator.ts83
-rw-r--r--src/view/com/feeds/FeedPage.tsx19
-rw-r--r--src/view/com/pager/TabBar.tsx45
-rw-r--r--src/view/com/post-thread/PostThread.tsx1
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx6
-rw-r--r--src/view/com/post/Post.tsx2
-rw-r--r--src/view/com/posts/FeedItem.tsx2
-rw-r--r--src/view/com/util/List.web.tsx4
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx5
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx16
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx2
-rw-r--r--src/view/com/util/text/RichText.tsx66
16 files changed, 218 insertions, 44 deletions
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index a38920309..5c262977f 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -133,8 +133,8 @@ function IsValidIcon({valid}: {valid: boolean}) {
   const t = useTheme()
 
   if (!valid) {
-    return <Check size="md" style={{color: t.palette.negative_500}} />
+    return <Times size="md" style={{color: t.palette.negative_500}} />
   }
 
-  return <Times size="md" style={{color: t.palette.positive_700}} />
+  return <Check size="md" style={{color: t.palette.positive_700}} />
 }
diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx
index 821660b7b..3202d69c5 100644
--- a/src/view/com/auth/login/LoginForm.tsx
+++ b/src/view/com/auth/login/LoginForm.tsx
@@ -107,7 +107,7 @@ export const LoginForm = ({
       const errMsg = e.toString()
       setIsProcessing(false)
       if (errMsg.includes('Authentication Required')) {
-        logger.info('Failed to login due to invalid credentials', {
+        logger.debug('Failed to login due to invalid credentials', {
           error: errMsg,
         })
         setError(_(msg`Invalid username or password`))
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/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 60814e837..2aacdb89d 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -200,21 +200,12 @@ export function FeedPage({
 function useHeaderOffset() {
   const {isDesktop, isTablet} = useWebMediaQueries()
   const {fontScale} = useWindowDimensions()
-  const {hasSession} = useSession()
   if (isDesktop || isTablet) {
     return 0
   }
-  if (hasSession) {
-    const navBarPad = 16
-    const navBarText = 21 * fontScale
-    const tabBarPad = 20 + 3 // nav bar padding + border
-    const tabBarText = 16 * fontScale
-    const magic = 7 * fontScale
-    return navBarPad + navBarText + tabBarPad + tabBarText + magic
-  } else {
-    const navBarPad = 16
-    const navBarText = 21 * fontScale
-    const magic = 4 * fontScale
-    return navBarPad + navBarText + magic
-  }
+  const navBarHeight = 42
+  const tabBarPad = 10 + 10 + 3 // padding + border
+  const normalLineHeight = 1.2
+  const tabBarText = 16 * normalLineHeight * fontScale
+  return navBarHeight + tabBarPad + tabBarText
 }
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index dadcfcebd..3204bb23e 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -4,7 +4,6 @@ import {Text} from '../util/text/Text'
 import {PressableWithHover} from '../util/PressableWithHover'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {isWeb} from 'platform/detection'
 import {DraggableScrollView} from './DraggableScrollView'
 
 export interface TabBarProps {
@@ -32,13 +31,15 @@ export function TabBar({
     [indicatorColor, pal],
   )
   const {isDesktop, isTablet} = useWebMediaQueries()
+  const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
 
   // scrolls to the selected item when the page changes
   useEffect(() => {
     scrollElRef.current?.scrollTo({
-      x: itemXs[selectedPage] || 0,
+      x:
+        (itemXs[selectedPage] || 0) - styles.contentContainer.paddingHorizontal,
     })
-  }, [scrollElRef, itemXs, selectedPage])
+  }, [scrollElRef, itemXs, selectedPage, styles])
 
   const onPressItem = useCallback(
     (index: number) => {
@@ -63,8 +64,6 @@ export function TabBar({
     [],
   )
 
-  const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
-
   return (
     <View testID={testID} style={[pal.view, styles.outer]}>
       <DraggableScrollView
@@ -80,15 +79,17 @@ export function TabBar({
               testID={`${testID}-selector-${i}`}
               key={`${item}-${i}`}
               onLayout={e => onItemLayout(e, i)}
-              style={[styles.item, selected && indicatorStyle]}
+              style={styles.item}
               hoverStyle={pal.viewLight}
               onPress={() => onPressItem(i)}>
-              <Text
-                type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
-                testID={testID ? `${testID}-${item}` : undefined}
-                style={selected ? pal.text : pal.textLight}>
-                {item}
-              </Text>
+              <View style={[styles.itemInner, selected && indicatorStyle]}>
+                <Text
+                  type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
+                  testID={testID ? `${testID}-${item}` : undefined}
+                  style={selected ? pal.text : pal.textLight}>
+                  {item}
+                </Text>
+              </View>
             </PressableWithHover>
           )
         })}
@@ -103,18 +104,18 @@ const desktopStyles = StyleSheet.create({
     width: 598,
   },
   contentContainer: {
-    columnGap: 8,
-    marginLeft: 14,
-    paddingRight: 14,
+    paddingHorizontal: 0,
     backgroundColor: 'transparent',
   },
   item: {
     paddingTop: 14,
+    paddingHorizontal: 14,
+    justifyContent: 'center',
+  },
+  itemInner: {
     paddingBottom: 12,
-    paddingHorizontal: 10,
     borderBottomWidth: 3,
     borderBottomColor: 'transparent',
-    justifyContent: 'center',
   },
 })
 
@@ -123,17 +124,17 @@ const mobileStyles = StyleSheet.create({
     flexDirection: 'row',
   },
   contentContainer: {
-    columnGap: isWeb ? 0 : 20,
-    marginLeft: isWeb ? 0 : 18,
-    paddingRight: isWeb ? 0 : 36,
     backgroundColor: 'transparent',
+    paddingHorizontal: 8,
   },
   item: {
     paddingTop: 10,
+    paddingHorizontal: 10,
+    justifyContent: 'center',
+  },
+  itemInner: {
     paddingBottom: 10,
-    paddingHorizontal: isWeb ? 8 : 0,
     borderBottomWidth: 3,
     borderBottomColor: 'transparent',
-    justifyContent: 'center',
   },
 })
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 434f018fc..a7ee42a94 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -437,6 +437,7 @@ function PostThreadLoaded({
       // @ts-ignore our .web version only -prf
       desktopFixedHeight
       removeClippedSubviews={isAndroid ? false : undefined}
+      windowSize={11}
     />
   )
 }
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 392a427d6..0d50104cd 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/List.web.tsx b/src/view/com/util/List.web.tsx
index 29bad2db8..936bac198 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -172,7 +172,7 @@ function ListImpl<ItemT>(
       <View
         ref={containerRef}
         style={[
-          styles.contentContainer,
+          !isMobile && styles.sideBorders,
           contentContainerStyle,
           desktopFixedHeight ? styles.minHeightViewport : null,
           pal.border,
@@ -304,7 +304,7 @@ export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
 const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
 
 const styles = StyleSheet.create({
-  contentContainer: {
+  sideBorders: {
     borderLeftWidth: 1,
     borderRightWidth: 1,
   },
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>
+  )
+}