about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
authorJan-Olof Eriksson <jan-olof.eriksson@iki.fi>2024-02-29 11:55:03 +0200
committerGitHub <noreply@github.com>2024-02-29 11:55:03 +0200
commit963a44ab872a1044d6997a8fcf7b2fc754ac618a (patch)
treebbd64f464a8f14e55cbb06e28811cdc43f059d29 /src/view
parent1f9562847512bb41cd8bb381b735a388be4db59b (diff)
parenta35976cdc9b6467ad8b6e0c4ff46ba684fee9064 (diff)
downloadvoidsky-963a44ab872a1044d6997a8fcf7b2fc754ac618a.tar.zst
Merge branch 'bluesky-social:main' into main
Diffstat (limited to 'src/view')
-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/Composer.tsx11
-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.tsx89
-rw-r--r--src/view/com/home/HomeHeader.tsx26
-rw-r--r--src/view/com/home/HomeHeaderLayout.web.tsx46
-rw-r--r--src/view/com/home/HomeHeaderLayoutMobile.tsx1
-rw-r--r--src/view/com/pager/TabBar.tsx119
-rw-r--r--src/view/com/post-thread/PostThread.tsx1
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx8
-rw-r--r--src/view/com/post/Post.tsx2
-rw-r--r--src/view/com/posts/FeedItem.tsx4
-rw-r--r--src/view/com/util/List.web.tsx4
-rw-r--r--src/view/com/util/MainScrollProvider.tsx16
-rw-r--r--src/view/com/util/forms/NativeDropdown.tsx3
-rw-r--r--src/view/com/util/forms/NativeDropdown.web.tsx12
-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
-rw-r--r--src/view/icons/index.tsx2
-rw-r--r--src/view/screens/Home.tsx8
-rw-r--r--src/view/screens/Moderation.tsx23
-rw-r--r--src/view/screens/Search/Search.tsx27
-rw-r--r--src/view/screens/Storybook/Typography.tsx6
-rw-r--r--src/view/shell/Composer.tsx2
-rw-r--r--src/view/shell/Composer.web.tsx3
-rw-r--r--src/view/shell/desktop/Feeds.tsx6
-rw-r--r--src/view/shell/index.tsx2
-rw-r--r--src/view/shell/index.web.tsx2
32 files changed, 452 insertions, 149 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 e480de7a4..fdba9f203 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/Composer.tsx b/src/view/com/composer/Composer.tsx
index 1ed6b98a5..2855d4232 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -71,6 +71,8 @@ export const ComposePost = observer(function ComposePost({
   quote: initQuote,
   mention: initMention,
   openPicker,
+  text: initText,
+  imageUris: initImageUris,
 }: Props) {
   const {currentAccount} = useSession()
   const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -91,7 +93,9 @@ export const ComposePost = observer(function ComposePost({
   const [error, setError] = useState('')
   const [richtext, setRichText] = useState(
     new RichText({
-      text: initMention
+      text: initText
+        ? initText
+        : initMention
         ? insertMentionAt(
             `@${initMention}`,
             initMention.length + 1,
@@ -110,7 +114,10 @@ export const ComposePost = observer(function ComposePost({
   const [labels, setLabels] = useState<string[]>([])
   const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
   const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
-  const gallery = useMemo(() => new GalleryModel(), [])
+  const gallery = useMemo(
+    () => new GalleryModel(initImageUris),
+    [initImageUris],
+  )
   const onClose = useCallback(() => {
     closeComposer()
   }, [closeComposer])
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..e6b5d1fb6 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -1,30 +1,24 @@
 import React from 'react'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {useQueryClient} from '@tanstack/react-query'
 import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
 import {MainScrollProvider} from '../util/MainScrollProvider'
-import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
 import {ComposeIcon2} from 'lib/icons'
-import {colors, s} from 'lib/styles'
+import {s} from 'lib/styles'
 import {View, useWindowDimensions} from 'react-native'
 import {ListMethods} from '../util/List'
 import {Feed} from '../posts/Feed'
-import {TextLink} from '../util/Link'
 import {FAB} from '../util/fab/FAB'
 import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
-import {listenSoftReset, emitSoftReset} from '#/state/events'
+import {listenSoftReset} from '#/state/events'
 import {truncateAndInvalidate} from '#/state/queries/util'
 import {TabState, getTabState, getRootNavigation} from '#/lib/routes/helpers'
 import {isNative} from '#/platform/detection'
@@ -47,10 +41,8 @@ export function FeedPage({
   renderEndOfFeed?: () => JSX.Element
 }) {
   const {hasSession} = useSession()
-  const pal = usePalette('default')
   const {_} = useLingui()
   const navigation = useNavigation()
-  const {isDesktop} = useWebMediaQueries()
   const queryClient = useQueryClient()
   const {openComposer} = useComposerControls()
   const [isScrolledDown, setIsScrolledDown] = React.useState(false)
@@ -99,63 +91,6 @@ export function FeedPage({
     setHasNew(false)
   }, [scrollToTop, feed, queryClient, setHasNew])
 
-  const ListHeaderComponent = React.useCallback(() => {
-    if (isDesktop) {
-      return (
-        <View
-          style={[
-            pal.view,
-            {
-              flexDirection: 'row',
-              alignItems: 'center',
-              justifyContent: 'space-between',
-              paddingHorizontal: 18,
-              paddingVertical: 12,
-            },
-          ]}>
-          <TextLink
-            type="title-lg"
-            href="/"
-            style={[pal.text, {fontWeight: 'bold'}]}
-            text={
-              <>
-                Bluesky{' '}
-                {hasNew && (
-                  <View
-                    style={{
-                      top: -8,
-                      backgroundColor: colors.blue3,
-                      width: 8,
-                      height: 8,
-                      borderRadius: 4,
-                    }}
-                  />
-                )}
-              </>
-            }
-            onPress={emitSoftReset}
-          />
-          {hasSession && (
-            <TextLink
-              type="title-lg"
-              href="/settings/following-feed"
-              style={{fontWeight: 'bold'}}
-              accessibilityLabel={_(msg`Feed Preferences`)}
-              accessibilityHint=""
-              text={
-                <FontAwesomeIcon
-                  icon="sliders"
-                  style={pal.textLight as FontAwesomeIconStyle}
-                />
-              }
-            />
-          )}
-        </View>
-      )
-    }
-    return <></>
-  }, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession])
-
   return (
     <View testID={testID} style={s.h100pct}>
       <MainScrollProvider>
@@ -171,7 +106,6 @@ export function FeedPage({
           onHasNew={setHasNew}
           renderEmptyState={renderEmptyState}
           renderEndOfFeed={renderEndOfFeed}
-          ListHeaderComponent={ListHeaderComponent}
           headerOffset={headerOffset}
         />
       </MainScrollProvider>
@@ -200,21 +134,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/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx
index 5ffa31f39..bbd16465a 100644
--- a/src/view/com/home/HomeHeader.tsx
+++ b/src/view/com/home/HomeHeader.tsx
@@ -1,8 +1,7 @@
 import React from 'react'
 import {RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {HomeHeaderLayout} from './HomeHeaderLayout'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {usePinnedFeedsInfos} from '#/state/queries/feed'
+import {FeedSourceInfo} from '#/state/queries/feed'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
 import {isWeb} from 'platform/detection'
@@ -10,25 +9,22 @@ import {TabBar} from '../pager/TabBar'
 import {usePalette} from '#/lib/hooks/usePalette'
 
 export function HomeHeader(
-  props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
-) {
-  const {isDesktop} = useWebMediaQueries()
-  if (isDesktop) {
-    return null
-  }
-  return <HomeHeaderInner {...props} />
-}
-
-export function HomeHeaderInner(
-  props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
+  props: RenderTabBarFnProps & {
+    testID?: string
+    onPressSelected: () => void
+    feeds: FeedSourceInfo[]
+  },
 ) {
+  const {feeds} = props
   const navigation = useNavigation<NavigationProp>()
-  const {feeds, hasPinnedCustom} = usePinnedFeedsInfos()
   const pal = usePalette('default')
 
+  const hasPinnedCustom = React.useMemo<boolean>(() => {
+    return feeds.some(tab => tab.uri !== '')
+  }, [feeds])
+
   const items = React.useMemo(() => {
     const pinnedNames = feeds.map(f => f.displayName)
-
     if (!hasPinnedCustom) {
       return pinnedNames.concat('Feeds ✨')
     }
diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx
index 47cb00235..fbb55e6bc 100644
--- a/src/view/com/home/HomeHeaderLayout.web.tsx
+++ b/src/view/com/home/HomeHeaderLayout.web.tsx
@@ -1,11 +1,20 @@
 import React from 'react'
-import {StyleSheet} from 'react-native'
+import {StyleSheet, View} from 'react-native'
 import Animated from 'react-native-reanimated'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
 import {useShellLayout} from '#/state/shell/shell-layout'
+import {Logo} from '#/view/icons/Logo'
+import {Link, TextLink} from '../util/Link'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {CogIcon} from '#/lib/icons'
 
 export function HomeHeaderLayout({children}: {children: React.ReactNode}) {
   const {isMobile} = useWebMediaQueries()
@@ -20,6 +29,7 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
   const pal = usePalette('default')
   const {headerMinimalShellTransform} = useMinimalShellMode()
   const {headerHeight} = useShellLayout()
+  const {_} = useLingui()
 
   return (
     // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
@@ -28,12 +38,44 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
       onLayout={e => {
         headerHeight.value = e.nativeEvent.layout.height
       }}>
+      <View style={[pal.view, styles.topBar]}>
+        <TextLink
+          type="title-lg"
+          href="/settings/following-feed"
+          accessibilityLabel={_(msg`Following Feed Preferences`)}
+          accessibilityHint=""
+          text={
+            <FontAwesomeIcon
+              icon="sliders"
+              style={pal.textLight as FontAwesomeIconStyle}
+            />
+          }
+        />
+        <Logo width={28} />
+        <Link
+          href="/settings/saved-feeds"
+          hitSlop={10}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Edit Saved Feeds`)}
+          accessibilityHint={_(msg`Opens screen to edit Saved Feeds`)}>
+          <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
+        </Link>
+      </View>
       {children}
     </Animated.View>
   )
 }
 
 const styles = StyleSheet.create({
+  topBar: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    paddingHorizontal: 18,
+    paddingVertical: 8,
+    marginTop: 8,
+    width: '100%',
+  },
   tabBar: {
     // @ts-ignore Web only
     position: 'sticky',
@@ -42,7 +84,7 @@ const styles = StyleSheet.create({
     left: 'calc(50% - 300px)',
     width: 600,
     top: 0,
-    flexDirection: 'row',
+    flexDirection: 'column',
     alignItems: 'center',
     borderLeftWidth: 1,
     borderRightWidth: 1,
diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx
index 6c4b911f0..f51efb7b4 100644
--- a/src/view/com/home/HomeHeaderLayoutMobile.tsx
+++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx
@@ -103,7 +103,6 @@ const styles = StyleSheet.create({
     right: 0,
     top: 0,
     flexDirection: 'column',
-    borderBottomWidth: 1,
   },
   topBar: {
     flexDirection: 'row',
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index dadcfcebd..ff8acd60c 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -4,8 +4,8 @@ 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'
+import {isNative} from '#/platform/detection'
 
 export interface TabBarProps {
   testID?: string
@@ -16,6 +16,10 @@ export interface TabBarProps {
   onPressSelected?: (index: number) => void
 }
 
+// How much of the previous/next item we're showing
+// to give the user a hint there's more to scroll.
+const OFFSCREEN_ITEM_WIDTH = 20
+
 export function TabBar({
   testID,
   selectedPage,
@@ -26,19 +30,68 @@ export function TabBar({
 }: TabBarProps) {
   const pal = usePalette('default')
   const scrollElRef = useRef<ScrollView>(null)
+  const itemRefs = useRef<Array<Element>>([])
   const [itemXs, setItemXs] = useState<number[]>([])
   const indicatorStyle = useMemo(
     () => ({borderBottomColor: indicatorColor || pal.colors.link}),
     [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,
-    })
-  }, [scrollElRef, itemXs, selectedPage])
+    if (isNative) {
+      // On native, the primary interaction is swiping.
+      // We adjust the scroll little by little on every tab change.
+      // Scroll into view but keep the end of the previous item visible.
+      let x = itemXs[selectedPage] || 0
+      x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH)
+      scrollElRef.current?.scrollTo({x})
+    } else {
+      // On the web, the primary interaction is tapping.
+      // Scrolling under tap feels disorienting so only adjust the scroll offset
+      // when tapping on an item out of view--and we adjust by almost an entire page.
+      const parent = scrollElRef?.current?.getScrollableNode?.()
+      if (!parent) {
+        return
+      }
+      const parentRect = parent.getBoundingClientRect()
+      if (!parentRect) {
+        return
+      }
+      const {
+        left: parentLeft,
+        right: parentRight,
+        width: parentWidth,
+      } = parentRect
+      const child = itemRefs.current[selectedPage]
+      if (!child) {
+        return
+      }
+      const childRect = child.getBoundingClientRect?.()
+      if (!childRect) {
+        return
+      }
+      const {left: childLeft, right: childRight, width: childWidth} = childRect
+      let dx = 0
+      if (childRight >= parentRight) {
+        dx += childRight - parentRight
+        dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
+      } else if (childLeft <= parentLeft) {
+        dx -= parentLeft - childLeft
+        dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
+      }
+      let x = parent.scrollLeft + dx
+      x = Math.max(0, x)
+      x = Math.min(x, parent.scrollWidth - parentWidth)
+      if (dx !== 0) {
+        parent.scroll({
+          left: x,
+          behavior: 'smooth',
+        })
+      }
+    }
+  }, [scrollElRef, itemXs, selectedPage, styles])
 
   const onPressItem = useCallback(
     (index: number) => {
@@ -63,8 +116,6 @@ export function TabBar({
     [],
   )
 
-  const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
-
   return (
     <View testID={testID} style={[pal.view, styles.outer]}>
       <DraggableScrollView
@@ -79,20 +130,24 @@ export function TabBar({
             <PressableWithHover
               testID={`${testID}-selector-${i}`}
               key={`${item}-${i}`}
+              ref={node => (itemRefs.current[i] = node)}
               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>
           )
         })}
       </DraggableScrollView>
+      <View style={[pal.border, styles.outerBottomBorder]} />
     </View>
   )
 }
@@ -103,18 +158,25 @@ 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',
+  },
+  outerBottomBorder: {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    bottom: -1,
+    borderBottomWidth: 1,
   },
 })
 
@@ -123,17 +185,24 @@ 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',
+  },
+  outerBottomBorder: {
+    position: 'absolute',
+    left: 0,
+    right: 0,
+    bottom: -1,
+    borderBottomWidth: 1,
   },
 })
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 ebd739839..9522ea6a0 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -94,6 +94,8 @@ export function PostThreadItem({
   if (richText && moderation) {
     return (
       <PostThreadItemLoaded
+        // Safeguard from clobbering per-post state below:
+        key={postShadowed.uri}
         post={postShadowed}
         prevPost={prevPost}
         nextPost={nextPost}
@@ -327,9 +329,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 +525,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..7d29703e2 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -70,6 +70,8 @@ export function FeedItem({
   if (richText && moderation) {
     return (
       <FeedItemInner
+        // Safeguard from clobbering per-post state below:
+        key={postShadowed.uri}
         post={postShadowed}
         record={record}
         reason={reason}
@@ -347,10 +349,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/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 2c90e33ff..01b8a954d 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -20,12 +20,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const setMode = useSetMinimalShellMode()
   const startDragOffset = useSharedValue<number | null>(null)
   const startMode = useSharedValue<number | null>(null)
+  const didJustRestoreScroll = useSharedValue<boolean>(false)
 
   useEffect(() => {
     if (isWeb) {
       return listenToForcedWindowScroll(() => {
         startDragOffset.value = null
         startMode.value = null
+        didJustRestoreScroll.value = true
       })
     }
   })
@@ -86,6 +88,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
           mode.value = newValue
         }
       } else {
+        if (didJustRestoreScroll.value) {
+          didJustRestoreScroll.value = false
+          // Don't hide/show navbar based on scroll restoratoin.
+          return
+        }
         // On the web, we don't try to follow the drag because we don't know when it ends.
         // Instead, show/hide immediately based on whether we're scrolling up or down.
         const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
@@ -98,7 +105,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
         }
       }
     },
-    [headerHeight, mode, setMode, startDragOffset, startMode],
+    [
+      headerHeight,
+      mode,
+      setMode,
+      startDragOffset,
+      startMode,
+      didJustRestoreScroll,
+    ],
   )
 
   return (
diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx
index 082285064..0a47569f2 100644
--- a/src/view/com/util/forms/NativeDropdown.tsx
+++ b/src/view/com/util/forms/NativeDropdown.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import * as DropdownMenu from 'zeego/dropdown-menu'
-import {Pressable, StyleSheet, Platform, View} from 'react-native'
+import {Pressable, StyleSheet, Platform, View, ViewStyle} from 'react-native'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -151,6 +151,7 @@ type Props = {
   testID?: string
   accessibilityLabel?: string
   accessibilityHint?: string
+  triggerStyle?: ViewStyle
 }
 
 /* The `NativeDropdown` function uses native iOS and Android dropdown menus.
diff --git a/src/view/com/util/forms/NativeDropdown.web.tsx b/src/view/com/util/forms/NativeDropdown.web.tsx
index 9e9888ad8..6abeb16cc 100644
--- a/src/view/com/util/forms/NativeDropdown.web.tsx
+++ b/src/view/com/util/forms/NativeDropdown.web.tsx
@@ -1,7 +1,7 @@
 import React from 'react'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
-import {Pressable, StyleSheet, View, Text} from 'react-native'
+import {Pressable, StyleSheet, View, Text, ViewStyle} from 'react-native'
 import {IconProp} from '@fortawesome/fontawesome-svg-core'
 import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -21,6 +21,7 @@ export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
 
   return (
     <DropdownMenu.Item
+      className="nativeDropdown-item"
       {...props}
       style={StyleSheet.flatten([
         styles.item,
@@ -52,6 +53,7 @@ type Props = {
   testID?: string
   accessibilityLabel?: string
   accessibilityHint?: string
+  triggerStyle?: ViewStyle
 }
 
 export function NativeDropdown({
@@ -60,6 +62,7 @@ export function NativeDropdown({
   testID,
   accessibilityLabel,
   accessibilityHint,
+  triggerStyle,
 }: React.PropsWithChildren<Props>) {
   const pal = usePalette('default')
   const theme = useTheme()
@@ -119,7 +122,8 @@ export function NativeDropdown({
           accessibilityLabel={accessibilityLabel}
           accessibilityHint={accessibilityHint}
           onPress={() => setOpen(o => !o)}
-          hitSlop={HITSLOP_10}>
+          hitSlop={HITSLOP_10}
+          style={triggerStyle}>
           {children}
         </Pressable>
       </DropdownMenu.Trigger>
@@ -232,6 +236,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/Home.tsx b/src/view/screens/Home.tsx
index 856c237f6..7ad9beb56 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -17,11 +17,12 @@ import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
 import {emitSoftReset} from '#/state/events'
 import {useSession} from '#/state/session'
 import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
+import {useSetTitle} from '#/lib/hooks/useSetTitle'
 
 type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'>
 export function HomeScreen(props: Props) {
   const {data: preferences} = usePreferencesQuery()
-  const {feeds: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} =
+  const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} =
     usePinnedFeedsInfos()
   if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) {
     return (
@@ -66,6 +67,8 @@ function HomeScreenReady({
   const selectedIndex = Math.max(0, maybeFoundIndex)
   const selectedFeed = allFeeds[selectedIndex]
 
+  useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
+
   const pagerRef = React.useRef<PagerRef>(null)
   const lastPagerReportedIndexRef = React.useRef(selectedIndex)
   React.useLayoutEffect(() => {
@@ -124,10 +127,11 @@ function HomeScreenReady({
           onSelect={props.onSelect}
           testID="homeScreenFeedTabs"
           onPressSelected={onPressSelected}
+          feeds={pinnedFeedInfos}
         />
       )
     },
-    [onPressSelected],
+    [onPressSelected, pinnedFeedInfos],
   )
 
   const renderFollowingEmptyState = React.useCallback(() => {
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/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx
index 8ee4270b2..f0d67c528 100644
--- a/src/view/screens/Storybook/Typography.tsx
+++ b/src/view/screens/Storybook/Typography.tsx
@@ -22,12 +22,14 @@ export function Typography() {
       <Text style={[a.text_2xs]}>atoms.text_2xs</Text>
 
       <RichText
-        resolveFacets
+        // TODO: This only supports already resolved facets.
+        // Resolving them on read is bad anyway.
         value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
       />
       <RichText
         selectable
-        resolveFacets
+        // TODO: This only supports already resolved facets.
+        // Resolving them on read is bad anyway.
         value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`}
         style={[a.text_xl]}
       />
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index d37ff4fb7..1937fcb6e 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -55,6 +55,8 @@ export const Composer = observer(function ComposerImpl({
         onPost={state.onPost}
         quote={state.quote}
         mention={state.mention}
+        text={state.text}
+        imageUris={state.imageUris}
       />
     </Animated.View>
   )
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 99e659d62..00233f66a 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -9,7 +9,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 import {
   EmojiPicker,
   EmojiPickerState,
-} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx'
+} from 'view/com/composer/text-input/web/EmojiPicker.web'
 
 const BOTTOM_BAR_HEIGHT = 61
 
@@ -69,6 +69,7 @@ export function Composer({}: {winHeight: number}) {
           onPost={state.onPost}
           mention={state.mention}
           openPicker={onOpenPicker}
+          text={state.text}
         />
       </Animated.View>
       <EmojiPicker state={pickerState} close={onClosePicker} />
diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx
index c3b1caa35..f447490b3 100644
--- a/src/view/shell/desktop/Feeds.tsx
+++ b/src/view/shell/desktop/Feeds.tsx
@@ -15,7 +15,7 @@ import {emitSoftReset} from '#/state/events'
 export function DesktopFeeds() {
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {feeds: pinnedFeedInfos} = usePinnedFeedsInfos()
+  const {data: pinnedFeedInfos} = usePinnedFeedsInfos()
   const selectedFeed = useSelectedFeed()
   const setSelectedFeed = useSetSelectedFeed()
   const navigation = useNavigation<NavigationProp>()
@@ -25,7 +25,9 @@ export function DesktopFeeds() {
     }
     return getCurrentRoute(state)
   })
-
+  if (!pinnedFeedInfos) {
+    return null
+  }
   return (
     <View style={[styles.container, pal.view]}>
       {pinnedFeedInfos.map(feedInfo => {
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 && (