about summary refs log tree commit diff
diff options
context:
space:
mode:
authorMinseo Lee <itoupluk427@gmail.com>2024-03-02 13:04:51 +0900
committerGitHub <noreply@github.com>2024-03-02 13:04:51 +0900
commitab2b454be8f15ccd4176edce2d28abdce501274b (patch)
tree41e198f85a4372950ce39a6613d231b2d5932be1
parent537ae578d6501319e07132ea8b12c280e0755fca (diff)
parentb70c404d4b369d6fab0dfbafd6b31390ffd20014 (diff)
downloadvoidsky-ab2b454be8f15ccd4176edce2d28abdce501274b.tar.zst
Merge branch 'bluesky-social:main' into patch-3
-rw-r--r--bskyweb/cmd/bskyweb/server.go1
-rw-r--r--bskyweb/templates/base.html6
-rw-r--r--package.json4
-rw-r--r--src/Navigation.tsx17
-rw-r--r--src/alf/index.tsx2
-rw-r--r--src/alf/themes.ts108
-rw-r--r--src/alf/tokens.ts43
-rw-r--r--src/alf/util/colorGeneration.ts17
-rw-r--r--src/components/Button.tsx48
-rw-r--r--src/components/Dialog/index.tsx68
-rw-r--r--src/components/Link.tsx14
-rw-r--r--src/components/Lists.tsx246
-rw-r--r--src/components/RichText.tsx14
-rw-r--r--src/components/TagMenu/index.tsx60
-rw-r--r--src/components/TagMenu/index.web.tsx45
-rw-r--r--src/components/dialogs/MutedWords.tsx323
-rw-r--r--src/components/forms/TextField.tsx4
-rw-r--r--src/components/forms/Toggle.tsx4
-rw-r--r--src/lib/hooks/useIntentHandler.ts20
-rw-r--r--src/lib/moderatePost_wrapped.ts145
-rw-r--r--src/lib/routes/types.ts3
-rw-r--r--src/lib/strings/url-helpers.ts12
-rw-r--r--src/lib/themes.ts4
-rw-r--r--src/routes.ts1
-rw-r--r--src/screens/Hashtag.tsx164
-rw-r--r--src/view/com/composer/text-input/web/LinkDecorator.ts5
-rw-r--r--src/view/com/composer/text-input/web/TagDecorator.ts22
-rw-r--r--src/view/com/home/HomeHeader.tsx2
-rw-r--r--src/view/com/home/HomeHeaderLayout.web.tsx76
-rw-r--r--src/view/com/home/HomeHeaderLayoutMobile.tsx1
-rw-r--r--src/view/com/util/Link.tsx10
-rw-r--r--src/view/com/util/ViewHeader.tsx97
-rw-r--r--src/view/com/util/moderation/ContentHider.tsx2
-rw-r--r--src/view/com/util/moderation/PostHider.tsx2
-rw-r--r--src/view/com/util/text/RichText.tsx1
-rw-r--r--src/view/screens/Home.tsx3
-rw-r--r--src/view/screens/Profile.tsx2
-rw-r--r--src/view/screens/ProfileList.tsx15
-rw-r--r--src/view/screens/Storybook/Links.tsx28
-rw-r--r--src/view/screens/Storybook/Palette.tsx85
-rw-r--r--src/view/shell/desktop/LeftNav.tsx6
-rw-r--r--web/index.html6
-rw-r--r--yarn.lock40
43 files changed, 1274 insertions, 502 deletions
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index f13d568b7..6b76acc94 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -180,6 +180,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/", server.WebHome)
 
 	// generic routes
+	e.GET("/hashtag/:tag", server.WebGeneric)
 	e.GET("/search", server.WebGeneric)
 	e.GET("/feeds", server.WebGeneric)
 	e.GET("/notifications", server.WebGeneric)
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 50fb9a2fd..413d7ff69 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -44,6 +44,12 @@
       scrollbar-gutter: stable both-edges;
     }
 
+    /* Buttons and inputs have a font set by UA, so we'll have to reset that */
+    button, input, textarea {
+      font: inherit;
+      line-height: inherit;
+    }
+
     /* Color theming */
     /* Default will always be white */
     :root {
diff --git a/package.json b/package.json
index e9dd9202d..d694d26c3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "bsky.app",
-  "version": "1.70.0",
+  "version": "1.71.0",
   "private": true,
   "engines": {
     "node": ">=18"
@@ -44,7 +44,7 @@
     "update-extensions": "scripts/updateExtensions.sh"
   },
   "dependencies": {
-    "@atproto/api": "^0.10.0",
+    "@atproto/api": "^0.10.4",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@emoji-mart/react": "^1.1.1",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 0aeeeb6ad..b30f8f982 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -77,6 +77,7 @@ import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbed
 import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
 import {msg} from '@lingui/macro'
 import {i18n, MessageDescriptor} from '@lingui/core'
+import HashtagScreen from '#/screens/Hashtag'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -262,6 +263,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
           requireAuth: true,
         }}
       />
+      <Stack.Screen
+        name="Hashtag"
+        getComponent={() => HashtagScreen}
+        options={{title: title(msg`Hashtag`)}}
+      />
     </>
   )
 }
@@ -479,12 +485,19 @@ const LINKING = {
   },
 
   getStateFromPath(path: string) {
+    const [name, params] = router.matchPath(path)
+
     // Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the
     // intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
     // intent
-    if (path.includes('intent/')) return
+    // On web, there is no route state that's created by default, so we should initialize it as the home route. On
+    // native, since the home tab and the home screen are defined as initial routes, we don't need to return a state
+    // since it will be created by react-navigation.
+    if (path.includes('intent/')) {
+      if (isNative) return
+      return buildStateObject('Flat', 'Home', params)
+    }
 
-    const [name, params] = router.matchPath(path)
     if (isNative) {
       if (name === 'Search') {
         return buildStateObject('SearchTab', 'Search', params)
diff --git a/src/alf/index.tsx b/src/alf/index.tsx
index 06d6ebf01..27738e91d 100644
--- a/src/alf/index.tsx
+++ b/src/alf/index.tsx
@@ -17,7 +17,7 @@ const breakpoints: {
   [key: string]: number
 } = {
   gtMobile: 800,
-  gtTablet: 1200,
+  gtTablet: 1300,
 }
 function getActiveBreakpoints({width}: {width: number}) {
   const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
diff --git a/src/alf/themes.ts b/src/alf/themes.ts
index da96f6eff..0c95a459e 100644
--- a/src/alf/themes.ts
+++ b/src/alf/themes.ts
@@ -1,6 +1,7 @@
 import * as tokens from '#/alf/tokens'
 import type {Mutable} from '#/alf/types'
 import {atoms} from '#/alf/atoms'
+import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
 
 export type ThemeName = 'light' | 'dim' | 'dark'
 export type ReadonlyTheme = typeof light
@@ -73,19 +74,19 @@ export const darkPalette: Palette = {
   white: tokens.color.gray_0,
   black: tokens.color.trueBlack,
 
-  contrast_25: `hsl(211, 28%, 8%)`,
-  contrast_50: `hsl(211, 28%, 11%)`,
-  contrast_100: `hsl(211, 28%, 16%)`,
-  contrast_200: `hsl(211, 28%, 24%)`,
-  contrast_300: `hsl(211, 24%, 31%)`,
-  contrast_400: `hsl(211, 24%, 38%)`,
-  contrast_500: `hsl(211, 20%, 44%)`,
-  contrast_600: `hsl(211, 20%, 55%)`,
-  contrast_700: `hsl(211, 20%, 63%)`,
-  contrast_800: `hsl(211, 20%, 71%)`,
-  contrast_900: `hsl(211, 20%, 79%)`,
-  contrast_950: `hsl(211, 20%, 87%)`,
-  contrast_975: `hsl(211, 20%, 95%)`,
+  contrast_25: tokens.color.gray_1000,
+  contrast_50: tokens.color.gray_975,
+  contrast_100: tokens.color.gray_950,
+  contrast_200: tokens.color.gray_900,
+  contrast_300: tokens.color.gray_800,
+  contrast_400: tokens.color.gray_700,
+  contrast_500: tokens.color.gray_600,
+  contrast_600: tokens.color.gray_500,
+  contrast_700: tokens.color.gray_400,
+  contrast_800: tokens.color.gray_300,
+  contrast_900: tokens.color.gray_200,
+  contrast_950: tokens.color.gray_100,
+  contrast_975: tokens.color.gray_50,
 
   primary_25: tokens.color.blue_25,
   primary_50: tokens.color.blue_50,
@@ -132,28 +133,63 @@ export const darkPalette: Palette = {
 
 export const dimPalette: Palette = {
   ...darkPalette,
-  black: `hsl(211, 28%, 12%)`,
+  black: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[0]}%)`,
 
-  contrast_25: `hsl(211, 28%, 15%)`,
-  contrast_50: `hsl(211, 28%, 18%)`,
-  contrast_100: `hsl(211, 28%, 24%)`,
-  contrast_200: `hsl(211, 28%, 27%)`,
-  contrast_300: `hsl(211, 24%, 34%)`,
-  contrast_400: `hsl(211, 24%, 41%)`,
-  contrast_500: `hsl(211, 20%, 52%)`,
-  contrast_600: `hsl(211, 20%, 55%)`,
-  contrast_700: `hsl(211, 20%, 67%)`,
-  contrast_800: `hsl(211, 20%, 71%)`,
-  contrast_900: `hsl(211, 20%, 79%)`,
-  contrast_950: `hsl(211, 20%, 87%)`,
-  contrast_975: `hsl(211, 20%, 95%)`,
+  contrast_25: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[1]}%)`,
+  contrast_50: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[2]}%)`,
+  contrast_100: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[3]}%)`,
+  contrast_200: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[4]}%)`,
+  contrast_300: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[5]}%)`,
+  contrast_400: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[6]}%)`,
+  contrast_500: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[7]}%)`,
+  contrast_600: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[8]}%)`,
+  contrast_700: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[9]}%)`,
+  contrast_800: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[10]}%)`,
+  contrast_900: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[11]}%)`,
+  contrast_950: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[12]}%)`,
+  contrast_975: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[13]}%)`,
 
-  primary_600: `hsl(211, 95%, 39%)`,
-  primary_700: `hsl(211, 90%, 30%)`,
-  primary_800: `hsl(211, 90%, 23%)`,
-  primary_900: `hsl(211, 80%, 16%)`,
-  primary_950: `hsl(211, 80%, 13%)`,
-  primary_975: `hsl(211, 80%, 10%)`,
+  primary_25: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[13]}%)`,
+  primary_50: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[12]}%)`,
+  primary_100: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[11]}%)`,
+  primary_200: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[10]}%)`,
+  primary_300: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[9]}%)`,
+  primary_400: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[8]}%)`,
+  primary_500: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[7]}%)`,
+  primary_600: `hsl(${BLUE_HUE}, 95%, ${tokens.dimScale[6]}%)`,
+  primary_700: `hsl(${BLUE_HUE}, 90%, ${tokens.dimScale[5]}%)`,
+  primary_800: `hsl(${BLUE_HUE}, 82%, ${tokens.dimScale[4]}%)`,
+  primary_900: `hsl(${BLUE_HUE}, 70%, ${tokens.dimScale[3]}%)`,
+  primary_950: `hsl(${BLUE_HUE}, 60%, ${tokens.dimScale[2]}%)`,
+  primary_975: `hsl(${BLUE_HUE}, 50%, ${tokens.dimScale[1]}%)`,
+
+  positive_25: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[13]}%)`,
+  positive_50: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[12]}%)`,
+  positive_100: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[11]}%)`,
+  positive_200: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[10]}%)`,
+  positive_300: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[9]}%)`,
+  positive_400: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[8]}%)`,
+  positive_500: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[7]}%)`,
+  positive_600: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[6]}%)`,
+  positive_700: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[5]}%)`,
+  positive_800: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[4]}%)`,
+  positive_900: `hsl(${GREEN_HUE}, 70%, ${tokens.dimScale[3]}%)`,
+  positive_950: `hsl(${GREEN_HUE}, 60%, ${tokens.dimScale[2]}%)`,
+  positive_975: `hsl(${GREEN_HUE}, 50%, ${tokens.dimScale[1]}%)`,
+
+  negative_25: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[13]}%)`,
+  negative_50: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[12]}%)`,
+  negative_100: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[11]}%)`,
+  negative_200: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[10]}%)`,
+  negative_300: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[9]}%)`,
+  negative_400: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[8]}%)`,
+  negative_500: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[7]}%)`,
+  negative_600: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[6]}%)`,
+  negative_700: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[5]}%)`,
+  negative_800: `hsl(${RED_HUE}, 88%, ${tokens.dimScale[4]}%)`,
+  negative_900: `hsl(${RED_HUE}, 84%, ${tokens.dimScale[3]}%)`,
+  negative_950: `hsl(${RED_HUE}, 80%, ${tokens.dimScale[2]}%)`,
+  negative_975: `hsl(${RED_HUE}, 70%, ${tokens.dimScale[1]}%)`,
 } as const
 
 export const light = {
@@ -404,17 +440,17 @@ export const dim: Theme = {
     shadow_sm: {
       ...atoms.shadow_sm,
       shadowOpacity: 0.7,
-      shadowColor: `hsl(211, 28%, 3%)`,
+      shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
     },
     shadow_md: {
       ...atoms.shadow_md,
       shadowOpacity: 0.7,
-      shadowColor: `hsl(211, 28%, 3%)`,
+      shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
     },
     shadow_lg: {
       ...atoms.shadow_lg,
       shadowOpacity: 0.7,
-      shadowColor: `hsl(211, 28%, 3%)`,
+      shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
     },
   },
 }
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index f0b8c7c69..b1468f461 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -1,25 +1,32 @@
-const BLUE_HUE = 211
-const RED_HUE = 346
-const GREEN_HUE = 152
+import {
+  BLUE_HUE,
+  RED_HUE,
+  GREEN_HUE,
+  generateScale,
+} from '#/alf/util/colorGeneration'
+
+export const scale = generateScale(6, 100)
+// dim shifted 6% lighter
+export const dimScale = generateScale(12, 100)
 
 export const color = {
   trueBlack: '#000000',
 
-  gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`,
-  gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`,
-  gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`,
-  gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`,
-  gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`,
-  gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`,
-  gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`,
-  gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`,
-  gray_600: `hsl(${BLUE_HUE}, 24%, 42%)`,
-  gray_700: `hsl(${BLUE_HUE}, 24%, 34%)`,
-  gray_800: `hsl(${BLUE_HUE}, 28%, 26%)`,
-  gray_900: `hsl(${BLUE_HUE}, 28%, 18%)`,
-  gray_950: `hsl(${BLUE_HUE}, 28%, 10%)`,
-  gray_975: `hsl(${BLUE_HUE}, 28%, 7%)`,
-  gray_1000: `hsl(${BLUE_HUE}, 28%, 4%)`,
+  gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
+  gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
+  gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
+  gray_100: `hsl(${BLUE_HUE}, 20%, ${scale[11]}%)`,
+  gray_200: `hsl(${BLUE_HUE}, 20%, ${scale[10]}%)`,
+  gray_300: `hsl(${BLUE_HUE}, 20%, ${scale[9]}%)`,
+  gray_400: `hsl(${BLUE_HUE}, 20%, ${scale[8]}%)`,
+  gray_500: `hsl(${BLUE_HUE}, 20%, ${scale[7]}%)`,
+  gray_600: `hsl(${BLUE_HUE}, 24%, ${scale[6]}%)`,
+  gray_700: `hsl(${BLUE_HUE}, 24%, ${scale[5]}%)`,
+  gray_800: `hsl(${BLUE_HUE}, 28%, ${scale[4]}%)`,
+  gray_900: `hsl(${BLUE_HUE}, 28%, ${scale[3]}%)`,
+  gray_950: `hsl(${BLUE_HUE}, 28%, ${scale[2]}%)`,
+  gray_975: `hsl(${BLUE_HUE}, 28%, ${scale[1]}%)`,
+  gray_1000: `hsl(${BLUE_HUE}, 28%, ${scale[0]}%)`,
 
   blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
   blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts
new file mode 100644
index 000000000..929a01d3a
--- /dev/null
+++ b/src/alf/util/colorGeneration.ts
@@ -0,0 +1,17 @@
+export const BLUE_HUE = 211
+export const RED_HUE = 346
+export const GREEN_HUE = 152
+
+/**
+ * Smooth progression of lightness "stops" for generating HSL colors.
+ */
+export const COLOR_STOPS = [
+  0, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 1,
+]
+
+export function generateScale(start: number, end: number) {
+  const range = end - start
+  return COLOR_STOPS.map(stop => {
+    return start + range * stop
+  })
+}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index e401bda2a..5361be963 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -165,7 +165,7 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: tokens.color.blue_500,
+            borderColor: t.palette.primary_500,
           })
           hoverStyles.push(a.border, {
             backgroundColor: light
@@ -174,7 +174,7 @@ export function Button({
           })
         } else {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
+            borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
           })
         }
       } else if (variant === 'ghost') {
@@ -191,20 +191,14 @@ export function Button({
       if (variant === 'solid') {
         if (!disabled) {
           baseStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_50
-              : tokens.color.gray_900,
+            backgroundColor: t.palette.contrast_50,
           })
           hoverStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_100
-              : tokens.color.gray_950,
+            backgroundColor: t.palette.contrast_100,
           })
         } else {
           baseStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_200
-              : tokens.color.gray_950,
+            backgroundColor: t.palette.contrast_200,
           })
         }
       } else if (variant === 'outline') {
@@ -214,21 +208,19 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
+            borderColor: t.palette.contrast_300,
           })
-          hoverStyles.push(a.border, t.atoms.bg_contrast_50)
+          hoverStyles.push(t.atoms.bg_contrast_50)
         } else {
           baseStyles.push(a.border, {
-            borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
+            borderColor: t.palette.contrast_200,
           })
         }
       } else if (variant === 'ghost') {
         if (!disabled) {
           baseStyles.push(t.atoms.bg)
           hoverStyles.push({
-            backgroundColor: light
-              ? tokens.color.gray_100
-              : tokens.color.gray_900,
+            backgroundColor: t.palette.contrast_100,
           })
         }
       }
@@ -236,14 +228,14 @@ export function Button({
       if (variant === 'solid') {
         if (!disabled) {
           baseStyles.push({
-            backgroundColor: t.palette.negative_400,
+            backgroundColor: t.palette.negative_500,
           })
           hoverStyles.push({
-            backgroundColor: t.palette.negative_500,
+            backgroundColor: t.palette.negative_600,
           })
         } else {
           baseStyles.push({
-            backgroundColor: t.palette.negative_600,
+            backgroundColor: t.palette.negative_700,
           })
         }
       } else if (variant === 'outline') {
@@ -253,7 +245,7 @@ export function Button({
 
         if (!disabled) {
           baseStyles.push(a.border, {
-            borderColor: t.palette.negative_400,
+            borderColor: t.palette.negative_500,
           })
           hoverStyles.push(a.border, {
             backgroundColor: light
@@ -273,7 +265,7 @@ export function Button({
           hoverStyles.push({
             backgroundColor: light
               ? t.palette.negative_100
-              : t.palette.negative_950,
+              : t.palette.negative_975,
           })
         }
       }
@@ -461,31 +453,31 @@ export function useSharedButtonTextStyles() {
       if (variant === 'solid' || variant === 'gradient') {
         if (!disabled) {
           baseStyles.push({
-            color: light ? tokens.color.gray_700 : tokens.color.gray_100,
+            color: t.palette.contrast_700,
           })
         } else {
           baseStyles.push({
-            color: light ? tokens.color.gray_400 : tokens.color.gray_700,
+            color: t.palette.contrast_400,
           })
         }
       } else if (variant === 'outline') {
         if (!disabled) {
           baseStyles.push({
-            color: light ? tokens.color.gray_600 : tokens.color.gray_300,
+            color: t.palette.contrast_600,
           })
         } else {
           baseStyles.push({
-            color: light ? tokens.color.gray_400 : tokens.color.gray_700,
+            color: t.palette.contrast_300,
           })
         }
       } else if (variant === 'ghost') {
         if (!disabled) {
           baseStyles.push({
-            color: light ? tokens.color.gray_600 : tokens.color.gray_300,
+            color: t.palette.contrast_600,
           })
         } else {
           baseStyles.push({
-            color: light ? tokens.color.gray_400 : tokens.color.gray_600,
+            color: t.palette.contrast_300,
           })
         }
       }
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index 6dfc24f3b..ef4f4741b 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -1,12 +1,15 @@
 import React, {useImperativeHandle} from 'react'
-import {View, Dimensions} from 'react-native'
+import {View, Dimensions, Keyboard, Pressable} from 'react-native'
 import BottomSheet, {
-  BottomSheetBackdrop,
+  BottomSheetBackdropProps,
   BottomSheetScrollView,
   BottomSheetTextInput,
   BottomSheetView,
+  useBottomSheet,
+  WINDOW_HEIGHT,
 } from '@gorhom/bottom-sheet'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import Animated, {useAnimatedStyle} from 'react-native-reanimated'
 
 import {useTheme, atoms as a, flatten} from '#/alf'
 import {Portal} from '#/components/Portal'
@@ -26,6 +29,47 @@ export * from '#/components/Dialog/types'
 // @ts-ignore
 export const Input = createInput(BottomSheetTextInput)
 
+function Backdrop(props: BottomSheetBackdropProps) {
+  const t = useTheme()
+  const bottomSheet = useBottomSheet()
+
+  const animatedStyle = useAnimatedStyle(() => {
+    const opacity =
+      (Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
+
+    return {
+      opacity: Math.min(Math.max(opacity, 0), 0.55),
+    }
+  })
+
+  const onPress = React.useCallback(() => {
+    bottomSheet.close()
+  }, [bottomSheet])
+
+  return (
+    <Animated.View
+      style={[
+        t.atoms.bg_contrast_300,
+        {
+          top: 0,
+          left: 0,
+          right: 0,
+          bottom: 0,
+          position: 'absolute',
+        },
+        animatedStyle,
+      ]}>
+      <Pressable
+        accessibilityRole="button"
+        accessibilityLabel="Dialog backdrop"
+        accessibilityHint="Press the backdrop to close the dialog"
+        style={{flex: 1}}
+        onPress={onPress}
+      />
+    </Animated.View>
+  )
+}
+
 export function Outer({
   children,
   control,
@@ -78,6 +122,7 @@ export function Outer({
   const onChange = React.useCallback(
     (index: number) => {
       if (index === -1) {
+        Keyboard.dismiss()
         try {
           closeCallback.current?.()
         } catch (e: any) {
@@ -113,15 +158,7 @@ export function Outer({
           ref={sheet}
           index={openIndex}
           backgroundStyle={{backgroundColor: 'transparent'}}
-          backdropComponent={props => (
-            <BottomSheetBackdrop
-              opacity={0.4}
-              appearsOnIndex={0}
-              disappearsOnIndex={-1}
-              {...props}
-              style={[flatten(props.style), t.atoms.bg_contrast_300]}
-            />
-          )}
+          backdropComponent={Backdrop}
           handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
           handleStyle={{display: 'none'}}
           onChange={onChange}>
@@ -190,8 +227,15 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
 
 export function Handle() {
   const t = useTheme()
+
+  const onTouchStart = React.useCallback(() => {
+    Keyboard.dismiss()
+  }, [])
+
   return (
-    <View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
+    <View
+      style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}
+      onTouchStart={onTouchStart}>
       <View
         style={[
           a.rounded_sm,
diff --git a/src/components/Link.tsx b/src/components/Link.tsx
index 0a654fed2..8c963909b 100644
--- a/src/components/Link.tsx
+++ b/src/components/Link.tsx
@@ -49,7 +49,7 @@ type BaseLinkProps = Pick<
    *
    * Note: atm this only works for `InlineLink`s with a string child.
    */
-  warnOnMismatchingTextChild?: boolean
+  disableMismatchWarning?: boolean
 
   /**
    * Callback for when the link is pressed. Prevent default and return `false`
@@ -69,7 +69,7 @@ export function useLink({
   to,
   displayText,
   action = 'push',
-  warnOnMismatchingTextChild,
+  disableMismatchWarning,
   onPress: outerOnPress,
 }: BaseLinkProps & {
   displayText: string
@@ -90,7 +90,7 @@ export function useLink({
       if (exitEarlyIfFalse === false) return
 
       const requiresWarning = Boolean(
-        warnOnMismatchingTextChild &&
+        !disableMismatchWarning &&
           displayText &&
           isExternal &&
           linkRequiresWarning(href, displayText),
@@ -148,7 +148,7 @@ export function useLink({
     },
     [
       outerOnPress,
-      warnOnMismatchingTextChild,
+      disableMismatchWarning,
       displayText,
       isExternal,
       href,
@@ -167,7 +167,7 @@ export function useLink({
   }
 }
 
-export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
+export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
   Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
 
 /**
@@ -226,7 +226,7 @@ export function InlineLink({
   children,
   to,
   action = 'push',
-  warnOnMismatchingTextChild,
+  disableMismatchWarning,
   style,
   onPress: outerOnPress,
   download,
@@ -239,7 +239,7 @@ export function InlineLink({
     to,
     displayText: stringChildren ? children : '',
     action,
-    warnOnMismatchingTextChild,
+    disableMismatchWarning,
     onPress: outerOnPress,
   })
   const {
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
new file mode 100644
index 000000000..12a935807
--- /dev/null
+++ b/src/components/Lists.tsx
@@ -0,0 +1,246 @@
+import React from 'react'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {View} from 'react-native'
+import {Loader} from '#/components/Loader'
+import {Trans} from '@lingui/macro'
+import {cleanError} from 'lib/strings/errors'
+import {Button} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {StackActions} from '@react-navigation/native'
+import {useNavigation} from '@react-navigation/core'
+import {NavigationProp} from 'lib/routes/types'
+import {router} from '#/routes'
+
+export function ListFooter({
+  isFetching,
+  isError,
+  error,
+  onRetry,
+}: {
+  isFetching: boolean
+  isError: boolean
+  error?: string
+  onRetry?: () => Promise<unknown>
+}) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.align_center,
+        a.justify_center,
+        a.border_t,
+        a.pb_lg,
+        t.atoms.border_contrast_low,
+        {height: 100},
+      ]}>
+      {isFetching ? (
+        <Loader size="xl" />
+      ) : (
+        <ListFooterMaybeError
+          isError={isError}
+          error={error}
+          onRetry={onRetry}
+        />
+      )}
+    </View>
+  )
+}
+
+function ListFooterMaybeError({
+  isError,
+  error,
+  onRetry,
+}: {
+  isError: boolean
+  error?: string
+  onRetry?: () => Promise<unknown>
+}) {
+  const t = useTheme()
+
+  if (!isError) return null
+
+  return (
+    <View style={[a.w_full, a.px_lg]}>
+      <View
+        style={[
+          a.flex_row,
+          a.gap_md,
+          a.p_md,
+          a.rounded_sm,
+          a.align_center,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <Text
+          style={[a.flex_1, a.text_sm, t.atoms.text_contrast_medium]}
+          numberOfLines={2}>
+          {error ? (
+            cleanError(error)
+          ) : (
+            <Trans>Oops, something went wrong!</Trans>
+          )}
+        </Text>
+        <Button
+          variant="gradient"
+          label="Press to retry"
+          style={[
+            a.align_center,
+            a.justify_center,
+            a.rounded_sm,
+            a.overflow_hidden,
+            a.px_md,
+            a.py_sm,
+          ]}
+          onPress={onRetry}>
+          Retry
+        </Button>
+      </View>
+    </View>
+  )
+}
+
+export function ListHeaderDesktop({
+  title,
+  subtitle,
+}: {
+  title: string
+  subtitle?: string
+}) {
+  const {gtTablet} = useBreakpoints()
+  const t = useTheme()
+
+  if (!gtTablet) return null
+
+  return (
+    <View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
+      <Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
+      {subtitle ? (
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          {subtitle}
+        </Text>
+      ) : undefined}
+    </View>
+  )
+}
+
+export function ListMaybePlaceholder({
+  isLoading,
+  isEmpty,
+  isError,
+  empty,
+  error,
+  notFoundType = 'page',
+  onRetry,
+}: {
+  isLoading: boolean
+  isEmpty: boolean
+  isError: boolean
+  empty?: string
+  error?: string
+  notFoundType?: 'page' | 'results'
+  onRetry?: () => Promise<unknown>
+}) {
+  const navigation = useNavigation<NavigationProp>()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+
+  const canGoBack = navigation.canGoBack()
+  const onGoBack = React.useCallback(() => {
+    if (canGoBack) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('HomeTab')
+
+      // Checking the state for routes ensures that web doesn't encounter errors while going back
+      if (navigation.getState()?.routes) {
+        navigation.dispatch(StackActions.push(...router.matchPath('/')))
+      } else {
+        navigation.navigate('HomeTab')
+        navigation.dispatch(StackActions.popToTop())
+      }
+    }
+  }, [navigation, canGoBack])
+
+  if (!isEmpty) return null
+
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.align_center,
+        !gtMobile ? [a.justify_between, a.border_t] : a.gap_5xl,
+        t.atoms.border_contrast_low,
+        {paddingTop: 175, paddingBottom: 110},
+      ]}>
+      {isLoading ? (
+        <View style={[a.w_full, a.align_center, {top: 100}]}>
+          <Loader size="xl" />
+        </View>
+      ) : (
+        <>
+          <View style={[a.w_full, a.align_center, a.gap_lg]}>
+            <Text style={[a.font_bold, a.text_3xl]}>
+              {isError ? (
+                <Trans>Oops!</Trans>
+              ) : isEmpty ? (
+                <>
+                  {notFoundType === 'results' ? (
+                    <Trans>No results found</Trans>
+                  ) : (
+                    <Trans>Page not found</Trans>
+                  )}
+                </>
+              ) : undefined}
+            </Text>
+
+            {isError ? (
+              <Text
+                style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
+                {error ? error : <Trans>Something went wrong!</Trans>}
+              </Text>
+            ) : isEmpty ? (
+              <Text
+                style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
+                {empty ? (
+                  empty
+                ) : (
+                  <Trans>
+                    We're sorry! We can't find the page you were looking for.
+                  </Trans>
+                )}
+              </Text>
+            ) : undefined}
+          </View>
+          <View
+            style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}>
+            {isError && onRetry && (
+              <Button
+                variant="solid"
+                color="primary"
+                label="Click here"
+                onPress={onRetry}
+                size="large"
+                style={[
+                  a.rounded_sm,
+                  a.overflow_hidden,
+                  {paddingVertical: 10},
+                ]}>
+                Retry
+              </Button>
+            )}
+            <Button
+              variant="solid"
+              color={isError && onRetry ? 'secondary' : 'primary'}
+              label="Click here"
+              onPress={onGoBack}
+              size="large"
+              style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
+              Go Back
+            </Button>
+          </View>
+        </>
+      )}
+    </View>
+  )
+}
diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx
index 3d5f08026..1a14415cf 100644
--- a/src/components/RichText.tsx
+++ b/src/components/RichText.tsx
@@ -105,8 +105,7 @@ export function RichText({
             to={link.uri}
             style={[...styles, {pointerEvents: 'auto'}]}
             // @ts-ignore TODO
-            dataSet={WORD_WRAP}
-            warnOnMismatchingLabel>
+            dataSet={WORD_WRAP}>
             {toShortUrl(segment.text)}
           </InlineLink>,
         )
@@ -121,6 +120,7 @@ export function RichText({
         <RichTextTag
           key={key}
           text={segment.text}
+          tag={tag.tag}
           style={styles}
           selectable={selectable}
           authorHandle={authorHandle}
@@ -146,12 +146,14 @@ export function RichText({
 }
 
 function RichTextTag({
-  text: tag,
+  text,
+  tag,
   style,
   selectable,
   authorHandle,
 }: {
   text: string
+  tag: string
   selectable?: boolean
   authorHandle?: string
 } & TextStyleProp) {
@@ -185,8 +187,8 @@ function RichTextTag({
         <Text
           selectable={selectable}
           {...native({
-            accessibilityLabel: _(msg`Hashtag: ${tag}`),
-            accessibilityHint: _(msg`Click here to open tag menu for ${tag}`),
+            accessibilityLabel: _(msg`Hashtag: #${tag}`),
+            accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
             accessibilityRole: isNative ? 'button' : undefined,
             onPress: open,
             onPressIn: onPressIn,
@@ -214,7 +216,7 @@ function RichTextTag({
               textDecorationColor: t.palette.primary_500,
             },
           ]}>
-          {tag}
+          {text}
         </Text>
       </TagMenu>
     </React.Fragment>
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index 2fec7a188..c9ced9a54 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -34,6 +34,10 @@ export function TagMenu({
   authorHandle,
 }: React.PropsWithChildren<{
   control: Dialog.DialogOuterProps['control']
+  /**
+   * This should be the sanitized tag value from the facet itself, not the
+   * "display" value with a leading `#`.
+   */
   tag: string
   authorHandle?: string
 }>) {
@@ -52,16 +56,16 @@ export function TagMenu({
     variables: optimisticRemove,
     reset: resetRemove,
   } = useRemoveMutedWordMutation()
+  const displayTag = '#' + tag
 
-  const sanitizedTag = tag.replace(/^#/, '')
   const isMuted = Boolean(
     (preferences?.mutedWords?.find(
-      m => m.value === sanitizedTag && m.targets.includes('tag'),
+      m => m.value === tag && m.targets.includes('tag'),
     ) ??
       optimisticUpsert?.find(
-        m => m.value === sanitizedTag && m.targets.includes('tag'),
+        m => m.value === tag && m.targets.includes('tag'),
       )) &&
-      !(optimisticRemove?.value === sanitizedTag),
+      !(optimisticRemove?.value === tag),
   )
 
   return (
@@ -71,7 +75,7 @@ export function TagMenu({
       <Dialog.Outer control={control}>
         <Dialog.Handle />
 
-        <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}>
+        <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
           {isPreferencesLoading ? (
             <View style={[a.w_full, a.align_center]}>
               <Loader size="lg" />
@@ -87,18 +91,14 @@ export function TagMenu({
                   t.atoms.bg_contrast_25,
                 ]}>
                 <Link
-                  label={_(msg`Search for all posts with tag ${tag}`)}
-                  to={makeSearchLink({query: tag})}
+                  label={_(msg`Search for all posts with tag ${displayTag}`)}
+                  to={makeSearchLink({query: displayTag})}
                   onPress={e => {
                     e.preventDefault()
 
                     control.close(() => {
-                      // @ts-ignore :ron_swanson: "I know more than you"
-                      navigation.navigate('SearchTab', {
-                        screen: 'Search',
-                        params: {
-                          q: tag,
-                        },
+                      navigation.push('Hashtag', {
+                        tag: tag.replaceAll('#', '%23'),
                       })
                     })
 
@@ -128,7 +128,7 @@ export function TagMenu({
                       <Trans>
                         See{' '}
                         <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                          {tag}
+                          {displayTag}
                         </Text>{' '}
                         posts
                       </Trans>
@@ -142,21 +142,19 @@ export function TagMenu({
 
                     <Link
                       label={_(
-                        msg`Search for all posts by @${authorHandle} with tag ${tag}`,
+                        msg`Search for all posts by @${authorHandle} with tag ${displayTag}`,
                       )}
-                      to={makeSearchLink({query: tag, from: authorHandle})}
+                      to={makeSearchLink({
+                        query: displayTag,
+                        from: authorHandle,
+                      })}
                       onPress={e => {
                         e.preventDefault()
 
                         control.close(() => {
-                          // @ts-ignore :ron_swanson: "I know more than you"
-                          navigation.navigate('SearchTab', {
-                            screen: 'Search',
-                            params: {
-                              q:
-                                tag +
-                                (authorHandle ? ` from:${authorHandle}` : ''),
-                            },
+                          navigation.push('Hashtag', {
+                            tag: tag.replaceAll('#', '%23'),
+                            author: authorHandle,
                           })
                         })
 
@@ -190,7 +188,7 @@ export function TagMenu({
                             See{' '}
                             <Text
                               style={[a.text_md, a.font_bold, t.atoms.text]}>
-                              {tag}
+                              {displayTag}
                             </Text>{' '}
                             posts by this user
                           </Trans>
@@ -207,22 +205,20 @@ export function TagMenu({
                     <Button
                       label={
                         isMuted
-                          ? _(msg`Unmute all ${tag} posts`)
-                          : _(msg`Mute all ${tag} posts`)
+                          ? _(msg`Unmute all ${displayTag} posts`)
+                          : _(msg`Mute all ${displayTag} posts`)
                       }
                       onPress={() => {
                         control.close(() => {
                           if (isMuted) {
                             resetUpsert()
                             removeMutedWord({
-                              value: sanitizedTag,
+                              value: tag,
                               targets: ['tag'],
                             })
                           } else {
                             resetRemove()
-                            upsertMutedWord([
-                              {value: sanitizedTag, targets: ['tag']},
-                            ])
+                            upsertMutedWord([{value: tag, targets: ['tag']}])
                           }
                         })
                       }}>
@@ -252,7 +248,7 @@ export function TagMenu({
                           ]}>
                           {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
                           <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
-                            {tag}
+                            {displayTag}
                           </Text>{' '}
                           <Trans>posts</Trans>
                         </Text>
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
index 31187112f..a0dc2bce6 100644
--- a/src/components/TagMenu/index.web.tsx
+++ b/src/components/TagMenu/index.web.tsx
@@ -14,18 +14,34 @@ import {
 } from '#/state/queries/preferences'
 import {enforceLen} from '#/lib/strings/helpers'
 import {web} from '#/alf'
+import * as Dialog from '#/components/Dialog'
 
-export function useTagMenuControl() {}
+export function useTagMenuControl(): Dialog.DialogControlProps {
+  return {
+    id: '',
+    // @ts-ignore
+    ref: null,
+    open: () => {
+      throw new Error(`TagMenu controls are only available on native platforms`)
+    },
+    close: () => {
+      throw new Error(`TagMenu controls are only available on native platforms`)
+    },
+  }
+}
 
 export function TagMenu({
   children,
   tag,
   authorHandle,
 }: React.PropsWithChildren<{
+  /**
+   * This should be the sanitized tag value from the facet itself, not the
+   * "display" value with a leading `#`.
+   */
   tag: string
   authorHandle?: string
 }>) {
-  const sanitizedTag = tag.replace(/^#/, '')
   const {_} = useLingui()
   const navigation = useNavigation<NavigationProp>()
   const {data: preferences} = usePreferencesQuery()
@@ -35,22 +51,22 @@ export function TagMenu({
     useRemoveMutedWordMutation()
   const isMuted = Boolean(
     (preferences?.mutedWords?.find(
-      m => m.value === sanitizedTag && m.targets.includes('tag'),
+      m => m.value === tag && m.targets.includes('tag'),
     ) ??
       optimisticUpsert?.find(
-        m => m.value === sanitizedTag && m.targets.includes('tag'),
+        m => m.value === tag && m.targets.includes('tag'),
       )) &&
-      !(optimisticRemove?.value === sanitizedTag),
+      !(optimisticRemove?.value === tag),
   )
-  const truncatedTag = enforceLen(tag, 15, true, 'middle')
+  const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
 
   const dropdownItems = React.useMemo(() => {
     return [
       {
         label: _(msg`See ${truncatedTag} posts`),
         onPress() {
-          navigation.navigate('Search', {
-            q: tag,
+          navigation.push('Hashtag', {
+            tag: tag.replaceAll('#', '%23'),
           })
         },
         testID: 'tagMenuSearch',
@@ -66,11 +82,9 @@ export function TagMenu({
         !isInvalidHandle(authorHandle) && {
           label: _(msg`See ${truncatedTag} posts by user`),
           onPress() {
-            navigation.navigate({
-              name: 'Search',
-              params: {
-                q: tag + (authorHandle ? ` from:${authorHandle}` : ''),
-              },
+            navigation.push('Hashtag', {
+              tag: tag.replaceAll('#', '%23'),
+              author: authorHandle,
             })
           },
           testID: 'tagMenuSeachByUser',
@@ -91,9 +105,9 @@ export function TagMenu({
           : _(msg`Mute ${truncatedTag}`),
         onPress() {
           if (isMuted) {
-            removeMutedWord({value: sanitizedTag, targets: ['tag']})
+            removeMutedWord({value: tag, targets: ['tag']})
           } else {
-            upsertMutedWord([{value: sanitizedTag, targets: ['tag']}])
+            upsertMutedWord([{value: tag, targets: ['tag']}])
           }
         },
         testID: 'tagMenuMute',
@@ -114,7 +128,6 @@ export function TagMenu({
     preferences,
     tag,
     truncatedTag,
-    sanitizedTag,
     upsertMutedWord,
     removeMutedWord,
   ])
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index 7c0d4fbca..658ba2aae 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -1,8 +1,8 @@
 import React from 'react'
-import {View} from 'react-native'
+import {Keyboard, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {AppBskyActorDefs} from '@atproto/api'
+import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
 
 import {
   usePreferencesQuery,
@@ -10,7 +10,14 @@ import {
   useRemoveMutedWordMutation,
 } from '#/state/queries/preferences'
 import {isNative} from '#/platform/detection'
-import {atoms as a, useTheme, useBreakpoints, ViewStyleProp, web} from '#/alf'
+import {
+  atoms as a,
+  useTheme,
+  useBreakpoints,
+  ViewStyleProp,
+  web,
+  native,
+} from '#/alf'
 import {Text} from '#/components/Typography'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
@@ -48,166 +55,208 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
   const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
   const [field, setField] = React.useState('')
   const [options, setOptions] = React.useState(['content'])
-  const [_error, setError] = React.useState('')
+  const [error, setError] = React.useState('')
 
   const submit = React.useCallback(async () => {
-    const value = field.trim()
+    const sanitizedValue = sanitizeMutedWordValue(field)
     const targets = ['tag', options.includes('content') && 'content'].filter(
       Boolean,
     ) as AppBskyActorDefs.MutedWord['targets']
 
-    if (!value || !targets.length) return
+    if (!sanitizedValue || !targets.length) {
+      setField('')
+      setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
+      return
+    }
 
     try {
-      await addMutedWord([{value, targets}])
+      // send raw value and rely on SDK as sanitization source of truth
+      await addMutedWord([{value: field, targets}])
       setField('')
     } catch (e: any) {
       logger.error(`Failed to save muted word`, {message: e.message})
       setError(e.message)
     }
-  }, [field, options, addMutedWord, setField])
+  }, [_, field, options, addMutedWord, setField])
 
   return (
     <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
-      <Text
-        style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
-        <Trans>Add muted words and tags</Trans>
-      </Text>
-      <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
-        <Trans>
-          Posts can be muted based on their text, their tags, or both.
-        </Trans>
-      </Text>
+      <View onTouchStart={Keyboard.dismiss}>
+        <Text
+          style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
+          <Trans>Add muted words and tags</Trans>
+        </Text>
+        <Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
+          <Trans>
+            Posts can be muted based on their text, their tags, or both.
+          </Trans>
+        </Text>
 
-      <View style={[a.pb_lg]}>
-        <Dialog.Input
-          autoCorrect={false}
-          autoCapitalize="none"
-          autoComplete="off"
-          label={_(msg`Enter a word or tag`)}
-          placeholder={_(msg`Enter a word or tag`)}
-          value={field}
-          onChangeText={setField}
-          onSubmitEditing={submit}
-        />
+        <View style={[a.pb_lg]}>
+          <Dialog.Input
+            autoCorrect={false}
+            autoCapitalize="none"
+            autoComplete="off"
+            label={_(msg`Enter a word or tag`)}
+            placeholder={_(msg`Enter a word or tag`)}
+            value={field}
+            onChangeText={value => {
+              if (error) {
+                setError('')
+              }
+              setField(value)
+            }}
+            onSubmitEditing={submit}
+          />
 
-        <Toggle.Group
-          label={_(msg`Toggle between muted word options.`)}
-          type="radio"
-          values={options}
-          onChange={setOptions}>
-          <View
-            style={[
-              a.pt_sm,
-              a.pb_md,
-              a.flex_row,
-              a.align_center,
-              a.gap_sm,
-              a.flex_wrap,
-            ]}>
-            <Toggle.Item
-              label={_(msg`Mute this word in post text and tags`)}
-              name="content"
-              style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
-              <TargetToggle>
-                <View style={[a.flex_row, a.align_center, a.gap_sm]}>
-                  <Toggle.Radio />
-                  <Toggle.Label>
-                    <Trans>Mute in text & tags</Trans>
-                  </Toggle.Label>
-                </View>
-                <PageText size="sm" />
-              </TargetToggle>
-            </Toggle.Item>
+          <Toggle.Group
+            label={_(msg`Toggle between muted word options.`)}
+            type="radio"
+            values={options}
+            onChange={setOptions}>
+            <View
+              style={[
+                a.pt_sm,
+                a.py_sm,
+                a.flex_row,
+                a.align_center,
+                a.gap_sm,
+                a.flex_wrap,
+              ]}>
+              <Toggle.Item
+                label={_(msg`Mute this word in post text and tags`)}
+                name="content"
+                style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
+                <TargetToggle>
+                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                    <Toggle.Radio />
+                    <Toggle.Label>
+                      <Trans>Mute in text & tags</Trans>
+                    </Toggle.Label>
+                  </View>
+                  <PageText size="sm" />
+                </TargetToggle>
+              </Toggle.Item>
 
-            <Toggle.Item
-              label={_(msg`Mute this word in tags only`)}
-              name="tag"
-              style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
-              <TargetToggle>
-                <View style={[a.flex_row, a.align_center, a.gap_sm]}>
-                  <Toggle.Radio />
-                  <Toggle.Label>
-                    <Trans>Mute in tags only</Trans>
-                  </Toggle.Label>
-                </View>
-                <Hashtag size="sm" />
-              </TargetToggle>
-            </Toggle.Item>
+              <Toggle.Item
+                label={_(msg`Mute this word in tags only`)}
+                name="tag"
+                style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
+                <TargetToggle>
+                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                    <Toggle.Radio />
+                    <Toggle.Label>
+                      <Trans>Mute in tags only</Trans>
+                    </Toggle.Label>
+                  </View>
+                  <Hashtag size="sm" />
+                </TargetToggle>
+              </Toggle.Item>
 
-            <Button
-              disabled={isPending || !field}
-              label={_(msg`Add mute word for configured settings`)}
-              size="small"
-              color="primary"
-              variant="solid"
-              style={[!gtMobile && [a.w_full, a.flex_0]]}
-              onPress={submit}>
-              <ButtonText>
-                <Trans>Add</Trans>
-              </ButtonText>
-              <ButtonIcon icon={isPending ? Loader : Plus} />
-            </Button>
-          </View>
-        </Toggle.Group>
+              <Button
+                disabled={isPending || !field}
+                label={_(msg`Add mute word for configured settings`)}
+                size="small"
+                color="primary"
+                variant="solid"
+                style={[!gtMobile && [a.w_full, a.flex_0]]}
+                onPress={submit}>
+                <ButtonText>
+                  <Trans>Add</Trans>
+                </ButtonText>
+                <ButtonIcon icon={isPending ? Loader : Plus} />
+              </Button>
+            </View>
+          </Toggle.Group>
 
-        <Text
-          style={[
-            a.text_sm,
-            a.italic,
-            a.leading_snug,
-            t.atoms.text_contrast_medium,
-          ]}>
-          <Trans>
-            We recommend avoiding common words that appear in many posts, since
-            it can result in no posts being shown.
-          </Trans>
-        </Text>
-      </View>
+          {error && (
+            <View
+              style={[
+                a.mb_lg,
+                a.flex_row,
+                a.rounded_sm,
+                a.p_md,
+                a.mb_xs,
+                t.atoms.bg_contrast_25,
+                {
+                  backgroundColor: t.palette.negative_400,
+                },
+              ]}>
+              <Text
+                style={[
+                  a.italic,
+                  {color: t.palette.white},
+                  native({marginTop: 2}),
+                ]}>
+                {error}
+              </Text>
+            </View>
+          )}
 
-      <Divider />
+          <Text
+            style={[
+              a.pt_xs,
+              a.text_sm,
+              a.italic,
+              a.leading_snug,
+              t.atoms.text_contrast_medium,
+            ]}>
+            <Trans>
+              We recommend avoiding common words that appear in many posts,
+              since it can result in no posts being shown.
+            </Trans>
+          </Text>
+        </View>
 
-      <View style={[a.pt_2xl]}>
-        <Text
-          style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
-          <Trans>Your muted words</Trans>
-        </Text>
+        <Divider />
 
-        {isPreferencesLoading ? (
-          <Loader />
-        ) : preferencesError || !preferences ? (
-          <View
-            style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
-            <Text style={[a.italic, t.atoms.text_contrast_high]}>
-              <Trans>
-                We're sorry, but we weren't able to load your muted words at
-                this time. Please try again.
-              </Trans>
-            </Text>
-          </View>
-        ) : preferences.mutedWords.length ? (
-          [...preferences.mutedWords]
-            .reverse()
-            .map((word, i) => (
-              <MutedWordRow
-                key={word.value + i}
-                word={word}
-                style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
-              />
-            ))
-        ) : (
-          <View
-            style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
-            <Text style={[a.italic, t.atoms.text_contrast_high]}>
-              <Trans>You haven't muted any words or tags yet</Trans>
-            </Text>
-          </View>
-        )}
-      </View>
+        <View style={[a.pt_2xl]}>
+          <Text
+            style={[
+              a.text_md,
+              a.font_bold,
+              a.pb_md,
+              t.atoms.text_contrast_high,
+            ]}>
+            <Trans>Your muted words</Trans>
+          </Text>
+
+          {isPreferencesLoading ? (
+            <Loader />
+          ) : preferencesError || !preferences ? (
+            <View
+              style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
+              <Text style={[a.italic, t.atoms.text_contrast_high]}>
+                <Trans>
+                  We're sorry, but we weren't able to load your muted words at
+                  this time. Please try again.
+                </Trans>
+              </Text>
+            </View>
+          ) : preferences.mutedWords.length ? (
+            [...preferences.mutedWords]
+              .reverse()
+              .map((word, i) => (
+                <MutedWordRow
+                  key={word.value + i}
+                  word={word}
+                  style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
+                />
+              ))
+          ) : (
+            <View
+              style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
+              <Text style={[a.italic, t.atoms.text_contrast_high]}>
+                <Trans>You haven't muted any words or tags yet</Trans>
+              </Text>
+            </View>
+          )}
+        </View>
 
-      {isNative && <View style={{height: 20}} />}
+        {isNative && <View style={{height: 20}} />}
 
-      <Dialog.Close />
+        <Dialog.Close />
+      </View>
     </Dialog.ScrollableInner>
   )
 }
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index a781bdd18..b37f4bfae 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -10,7 +10,7 @@ import {
 } from 'react-native'
 
 import {HITSLOP_20} from 'lib/constants'
-import {useTheme, atoms as a, web, tokens, android} from '#/alf'
+import {useTheme, atoms as a, web, android} from '#/alf'
 import {Text} from '#/components/Typography'
 import {useInteractionState} from '#/components/hooks/useInteractionState'
 import {Props as SVGIconProps} from '#/components/icons/common'
@@ -110,7 +110,7 @@ export function useSharedInputStyles() {
       {
         backgroundColor:
           t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
-        borderColor: tokens.color.red_500,
+        borderColor: t.palette.negative_500,
       },
     ]
 
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index 140740f70..a83f92a2a 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -301,7 +301,7 @@ export function createSharedToggleStyles({
   if (isInvalid) {
     base.push({
       backgroundColor:
-        t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
+        t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
       borderColor:
         t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
     })
@@ -310,7 +310,7 @@ export function createSharedToggleStyles({
       baseHover.push({
         backgroundColor:
           t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
-        borderColor: t.palette.negative_500,
+        borderColor: t.palette.negative_600,
       })
     }
   }
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
index d1e2de31d..8741530b5 100644
--- a/src/lib/hooks/useIntentHandler.ts
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -15,15 +15,20 @@ export function useIntentHandler() {
 
   React.useEffect(() => {
     const handleIncomingURL = (url: string) => {
+      // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
+      // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
+      // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
+      // path parameter is in pathname rather than in hostname.
+      if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
+        url = url.replace('bluesky://', 'bluesky:///')
+      }
+
       const urlp = new URL(url)
-      const [_, intentTypeNative, intentTypeWeb] = urlp.pathname.split('/')
+      const [_, intent, intentType] = urlp.pathname.split('/')
 
       // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
       // intent check. On web, we have to check the first part of the path since we have an actual hostname
-      const intentType = isNative ? intentTypeNative : intentTypeWeb
-      const isIntent = isNative
-        ? urlp.hostname === 'intent'
-        : intentTypeNative === 'intent'
+      const isIntent = intent === 'intent'
       const params = urlp.searchParams
 
       if (!isIntent) return
@@ -69,10 +74,7 @@ function useComposeIntent() {
             return false
           }
           // We also should just filter out cases that don't have all the info we need
-          if (!VALID_IMAGE_REGEX.test(part)) {
-            return false
-          }
-          return true
+          return VALID_IMAGE_REGEX.test(part)
         })
         .map(part => {
           const [uri, width, height] = part.split('|')
diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts
index 92543b42c..9f6fa9c07 100644
--- a/src/lib/moderatePost_wrapped.ts
+++ b/src/lib/moderatePost_wrapped.ts
@@ -6,6 +6,7 @@ import {
   AppBskyFeedPost,
   AppBskyRichtextFacet,
   AppBskyEmbedImages,
+  AppBskyEmbedExternal,
 } from '@atproto/api'
 
 type ModeratePost = typeof moderatePost
@@ -205,44 +206,151 @@ export function moderatePost_wrapped(
 
   if (subject.embed) {
     let embedHidden = false
+    let embedMuted = false
+    let externalMuted = false
+
     if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
       embedHidden = hiddenPosts.includes(subject.embed.record.uri)
+    }
+    if (
+      AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
+      AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
+    ) {
+      embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
+    }
 
+    if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
       if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
-        embedHidden =
-          embedHidden ||
+        const embeddedPost = subject.embed.record.value
+
+        embedMuted =
+          embedMuted ||
           hasMutedWord({
             mutedWords,
-            text: subject.embed.record.value.text,
-            facets: subject.embed.record.value.facets,
-            outlineTags: subject.embed.record.value.tags,
-            languages: subject.embed.record.value.langs,
+            text: embeddedPost.text,
+            facets: embeddedPost.facets,
+            outlineTags: embeddedPost.tags,
+            languages: embeddedPost.langs,
             isOwnPost,
           })
 
-        if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) {
-          for (const image of subject.embed.record.value.embed.images) {
-            embedHidden =
-              embedHidden ||
+        if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
+          for (const image of embeddedPost.embed.images) {
+            embedMuted =
+              embedMuted ||
               hasMutedWord({
                 mutedWords,
                 text: image.alt,
                 facets: [],
                 outlineTags: [],
-                languages: subject.embed.record.value.langs,
+                languages: embeddedPost.langs,
                 isOwnPost,
               })
           }
         }
+
+        if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
+          const {external} = embeddedPost.embed
+
+          embedMuted =
+            embedMuted ||
+            hasMutedWord({
+              mutedWords,
+              text: external.title + ' ' + external.description,
+              facets: [],
+              outlineTags: [],
+              languages: [],
+              isOwnPost,
+            })
+        }
+
+        if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
+          if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
+            const {external} = embeddedPost.embed.media
+
+            embedMuted =
+              embedMuted ||
+              hasMutedWord({
+                mutedWords,
+                text: external.title + ' ' + external.description,
+                facets: [],
+                outlineTags: [],
+                languages: [],
+                isOwnPost,
+              })
+          }
+
+          if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
+            for (const image of embeddedPost.embed.media.images) {
+              embedMuted =
+                embedMuted ||
+                hasMutedWord({
+                  mutedWords,
+                  text: image.alt,
+                  facets: [],
+                  outlineTags: [],
+                  languages: AppBskyFeedPost.isRecord(embeddedPost.record)
+                    ? embeddedPost.langs
+                    : [],
+                  isOwnPost,
+                })
+            }
+          }
+        }
       }
     }
+
+    if (AppBskyEmbedExternal.isView(subject.embed)) {
+      const {external} = subject.embed
+
+      externalMuted =
+        externalMuted ||
+        hasMutedWord({
+          mutedWords,
+          text: external.title + ' ' + external.description,
+          facets: [],
+          outlineTags: [],
+          languages: [],
+          isOwnPost,
+        })
+    }
+
     if (
       AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
       AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
     ) {
-      // TODO what
-      embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
+      if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
+        const post = subject.embed.record.record.value
+        embedMuted =
+          embedMuted ||
+          hasMutedWord({
+            mutedWords,
+            text: post.text,
+            facets: post.facets,
+            outlineTags: post.tags,
+            languages: post.langs,
+            isOwnPost,
+          })
+      }
+
+      if (AppBskyEmbedImages.isView(subject.embed.media)) {
+        for (const image of subject.embed.media.images) {
+          embedMuted =
+            embedMuted ||
+            hasMutedWord({
+              mutedWords,
+              text: image.alt,
+              facets: [],
+              outlineTags: [],
+              languages: AppBskyFeedPost.isRecord(subject.record)
+                ? subject.record.langs
+                : [],
+              isOwnPost,
+            })
+        }
+      }
     }
+
     if (embedHidden) {
       moderations.embed.filter = true
       moderations.embed.blur = true
@@ -254,6 +362,17 @@ export function moderatePost_wrapped(
           priority: 1,
         }
       }
+    } else if (externalMuted || embedMuted) {
+      moderations.content.filter = true
+      moderations.content.blur = true
+      if (!moderations.content.cause) {
+        moderations.content.cause = {
+          // @ts-ignore Temporary extension to the moderation system -prf
+          type: 'muted-word',
+          source: {type: 'user'},
+          priority: 1,
+        }
+      }
     }
   }
 
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 0ec09f610..6756a62a6 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -34,6 +34,7 @@ export type CommonNavigatorParams = {
   PreferencesThreads: undefined
   PreferencesExternalEmbeds: undefined
   Search: {q?: string}
+  Hashtag: {tag: string; author?: string}
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -69,6 +70,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
   Search: {q?: string}
   Feeds: undefined
   Notifications: undefined
+  Hashtag: {tag: string; author?: string}
 }
 
 export type AllNavigatorParams = CommonNavigatorParams & {
@@ -81,6 +83,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   NotificationsTab: undefined
   Notifications: undefined
   MyProfileTab: undefined
+  Hashtag: {tag: string; author?: string}
 }
 
 // NOTE
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index ef341154d..ba2cdb39b 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -157,17 +157,11 @@ export function linkRequiresWarning(uri: string, label: string) {
 
   const host = urip.hostname.toLowerCase()
 
-  if (host === 'bsky.app') {
+  // Hosts that end with bsky.app or bsky.social should be trusted by default.
+  if (host.endsWith('bsky.app') || host.endsWith('bsky.social')) {
     // if this is a link to internal content,
     // warn if it represents itself as a URL to another app
-    if (
-      labelDomain &&
-      labelDomain !== 'bsky.app' &&
-      isPossiblyAUrl(labelDomain)
-    ) {
-      return true
-    }
-    return false
+    return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
   } else {
     // if this is a link to external content,
     // warn if the label doesnt match the target
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index 135d50ab6..bd75aabea 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -357,8 +357,8 @@ export const dimTheme: Theme = {
       textVeryLight: dimPalette.contrast_400,
       replyLine: dimPalette.contrast_200,
       replyLineDot: dimPalette.contrast_200,
-      unreadNotifBg: `hsl(211, 48%, 17%)`,
-      unreadNotifBorder: `hsl(211, 48%, 30%)`,
+      unreadNotifBg: dimPalette.primary_975,
+      unreadNotifBorder: dimPalette.primary_900,
       postCtrl: dimPalette.contrast_500,
       brandText: dimPalette.primary_500,
       emptyStateIcon: dimPalette.contrast_300,
diff --git a/src/routes.ts b/src/routes.ts
index d17f15912..3fc908b48 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -33,4 +33,5 @@ export const router = new Router({
   TermsOfService: '/support/tos',
   CommunityGuidelines: '/support/community-guidelines',
   CopyrightPolicy: '/support/copyright',
+  Hashtag: '/hashtag/:tag',
 })
diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx
new file mode 100644
index 000000000..09a1f2824
--- /dev/null
+++ b/src/screens/Hashtag.tsx
@@ -0,0 +1,164 @@
+import React from 'react'
+import {ListRenderItemInfo, Pressable} from 'react-native'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {useFocusEffect} from '@react-navigation/native'
+import {useSetMinimalShellMode} from 'state/shell'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {useSearchPostsQuery} from 'state/queries/search-posts'
+import {Post} from 'view/com/post/Post'
+import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {enforceLen} from 'lib/strings/helpers'
+import {
+  ListFooter,
+  ListHeaderDesktop,
+  ListMaybePlaceholder,
+} from '#/components/Lists'
+import {List} from 'view/com/util/List'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {CenteredView} from 'view/com/util/Views'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
+import {shareUrl} from 'lib/sharing'
+import {HITSLOP_10} from 'lib/constants'
+import {isNative} from 'platform/detection'
+
+const renderItem = ({item}: ListRenderItemInfo<PostView>) => {
+  return <Post post={item} />
+}
+
+const keyExtractor = (item: PostView, index: number) => {
+  return `${item.uri}-${index}`
+}
+
+export default function HashtagScreen({
+  route,
+}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
+  const {tag, author} = route.params
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {gtMobile} = useBreakpoints()
+  const {_} = useLingui()
+  const [isPTR, setIsPTR] = React.useState(false)
+
+  const fullTag = React.useMemo(() => {
+    return `#${tag.replaceAll('%23', '#')}`
+  }, [tag])
+
+  const queryParam = React.useMemo(() => {
+    if (!author) return fullTag
+    return `${fullTag} from:${sanitizeHandle(author)}`
+  }, [fullTag, author])
+
+  const headerTitle = React.useMemo(() => {
+    return enforceLen(fullTag.toLowerCase(), 24, true, 'middle')
+  }, [fullTag])
+
+  const sanitizedAuthor = React.useMemo(() => {
+    if (!author) return
+    return sanitizeHandle(author)
+  }, [author])
+
+  const {
+    data,
+    isFetching,
+    isLoading,
+    isRefetching,
+    isError,
+    error,
+    refetch,
+    fetchNextPage,
+    hasNextPage,
+  } = useSearchPostsQuery({query: queryParam})
+
+  const posts = React.useMemo(() => {
+    return data?.pages.flatMap(page => page.posts) || []
+  }, [data])
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
+
+  const onShare = React.useCallback(() => {
+    const url = new URL('https://bsky.app')
+    url.pathname = `/hashtag/${tag}`
+    if (author) {
+      url.searchParams.set('author', author)
+    }
+    shareUrl(url.toString())
+  }, [tag, author])
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTR(true)
+    await refetch()
+    setIsPTR(false)
+  }, [refetch])
+
+  const onEndReached = React.useCallback(() => {
+    if (isFetching || !hasNextPage || error) return
+    fetchNextPage()
+  }, [isFetching, hasNextPage, error, fetchNextPage])
+
+  return (
+    <CenteredView style={a.flex_1} sideBorders={gtMobile}>
+      <ViewHeader
+        title={headerTitle}
+        subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined}
+        canGoBack
+        renderButton={
+          isNative
+            ? () => (
+                <Pressable
+                  accessibilityRole="button"
+                  onPress={onShare}
+                  hitSlop={HITSLOP_10}>
+                  <ArrowOutOfBox_Stroke2_Corner0_Rounded
+                    size="lg"
+                    onPress={onShare}
+                  />
+                </Pressable>
+              )
+            : undefined
+        }
+      />
+      <ListMaybePlaceholder
+        isLoading={isLoading || isRefetching}
+        isError={isError}
+        isEmpty={posts.length < 1}
+        onRetry={refetch}
+        notFoundType="results"
+        empty={_(msg`We couldn't find any results for that hashtag.`)}
+      />
+      {!isLoading && posts.length > 0 && (
+        <List<PostView>
+          data={posts}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          refreshing={isPTR}
+          onRefresh={onRefresh}
+          onEndReached={onEndReached}
+          onEndReachedThreshold={4}
+          // @ts-ignore web only -prf
+          desktopFixedHeight
+          ListHeaderComponent={
+            <ListHeaderDesktop
+              title={headerTitle}
+              subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined}
+            />
+          }
+          ListFooterComponent={
+            <ListFooter
+              isFetching={isFetching && !isRefetching}
+              isError={isError}
+              error={error?.name}
+              onRetry={fetchNextPage}
+            />
+          }
+        />
+      )}
+    </CenteredView>
+  )
+}
diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts
index 19945de08..e36ac80e4 100644
--- a/src/view/com/composer/text-input/web/LinkDecorator.ts
+++ b/src/view/com/composer/text-input/web/LinkDecorator.ts
@@ -18,6 +18,8 @@ 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'
+import {URL_REGEX} from '@atproto/api'
+
 import {isValidDomain} from 'lib/strings/url-helpers'
 
 export const LinkDecorator = Mark.create({
@@ -78,8 +80,7 @@ function linkDecorator() {
 
 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
+  const re = URL_REGEX
   while ((match = re.exec(str))) {
     let uri = match[2]
     if (!uri.startsWith('http')) {
diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts
index d820ec3f0..2bf3184a8 100644
--- a/src/view/com/composer/text-input/web/TagDecorator.ts
+++ b/src/view/com/composer/text-input/web/TagDecorator.ts
@@ -18,28 +18,36 @@ 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'
+import {TAG_REGEX, TRAILING_PUNCTUATION_REGEX} from '@atproto/api'
 
 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 regex = TAG_REGEX
       const textContent = node.textContent
 
       let match
       while ((match = regex.exec(textContent))) {
-        const [matchedString, tag] = match
+        const [matchedString, _, tag] = match
 
-        if (tag.length > 66) continue
+        if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64)
+          continue
 
-        const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || []
+        const [trailingPunc = ''] = tag.match(TRAILING_PUNCTUATION_REGEX) || []
+        const matchedFrom = match.index + matchedString.indexOf(tag)
+        const matchedTo = matchedFrom + (tag.length - trailingPunc.length)
 
-        const from = match.index + matchedString.indexOf(tag)
-        const to = from + (tag.length - trailingPunc.length)
+        /*
+         * The match is exclusive of `#` so we need to adjust the start of the
+         * highlight by -1 to include the `#`
+         */
+        const start = pos + matchedFrom - 1
+        const end = pos + matchedTo
 
         decorations.push(
-          Decoration.inline(pos + from, pos + to, {
+          Decoration.inline(start, end, {
             class: 'autolink',
           }),
         )
diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx
index bbd16465a..aa3ecb7fc 100644
--- a/src/view/com/home/HomeHeader.tsx
+++ b/src/view/com/home/HomeHeader.tsx
@@ -52,7 +52,7 @@ export function HomeHeader(
   )
 
   return (
-    <HomeHeaderLayout>
+    <HomeHeaderLayout tabBarAnchor={props.tabBarAnchor}>
       <TabBar
         key={items.join(',')}
         onPressSelected={props.onPressSelected}
diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx
index fbb55e6bc..6145081af 100644
--- a/src/view/com/home/HomeHeaderLayout.web.tsx
+++ b/src/view/com/home/HomeHeaderLayout.web.tsx
@@ -1,13 +1,10 @@
 import React from 'react'
 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 {Link} from '../util/Link'
 import {
   FontAwesomeIcon,
   FontAwesomeIconStyle,
@@ -16,41 +13,42 @@ import {useLingui} from '@lingui/react'
 import {msg} from '@lingui/macro'
 import {CogIcon} from '#/lib/icons'
 
-export function HomeHeaderLayout({children}: {children: React.ReactNode}) {
+export function HomeHeaderLayout(props: {
+  children: React.ReactNode
+  tabBarAnchor: JSX.Element | null | undefined
+}) {
   const {isMobile} = useWebMediaQueries()
   if (isMobile) {
-    return <HomeHeaderLayoutMobile>{children}</HomeHeaderLayoutMobile>
+    return <HomeHeaderLayoutMobile {...props} />
   } else {
-    return <HomeHeaderLayoutTablet>{children}</HomeHeaderLayoutTablet>
+    return <HomeHeaderLayoutDesktopAndTablet {...props} />
   }
 }
 
-function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
+function HomeHeaderLayoutDesktopAndTablet({
+  children,
+  tabBarAnchor,
+}: {
+  children: React.ReactNode
+  tabBarAnchor: JSX.Element | null | undefined
+}) {
   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
-    <Animated.View
-      style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
-      onLayout={e => {
-        headerHeight.value = e.nativeEvent.layout.height
-      }}>
-      <View style={[pal.view, styles.topBar]}>
-        <TextLink
-          type="title-lg"
+    <>
+      <View style={[pal.view, pal.border, styles.bar, styles.topBar]}>
+        <Link
           href="/settings/following-feed"
+          hitSlop={10}
+          accessibilityRole="button"
           accessibilityLabel={_(msg`Following Feed Preferences`)}
-          accessibilityHint=""
-          text={
-            <FontAwesomeIcon
-              icon="sliders"
-              style={pal.textLight as FontAwesomeIconStyle}
-            />
-          }
-        />
+          accessibilityHint="">
+          <FontAwesomeIcon
+            icon="sliders"
+            style={pal.textLight as FontAwesomeIconStyle}
+          />
+        </Link>
         <Logo width={28} />
         <Link
           href="/settings/saved-feeds"
@@ -61,32 +59,38 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
           <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
         </Link>
       </View>
-      {children}
-    </Animated.View>
+      {tabBarAnchor}
+      <View style={[pal.view, pal.border, styles.bar, styles.tabBar]}>
+        {children}
+      </View>
+    </>
   )
 }
 
 const styles = StyleSheet.create({
+  bar: {
+    // @ts-ignore Web only
+    left: 'calc(50% - 300px)',
+    width: 600,
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
   topBar: {
     flexDirection: 'row',
     justifyContent: 'space-between',
     alignItems: 'center',
     paddingHorizontal: 18,
-    paddingVertical: 8,
-    marginTop: 8,
-    width: '100%',
+    paddingTop: 16,
+    paddingBottom: 8,
   },
   tabBar: {
     // @ts-ignore Web only
     position: 'sticky',
-    zIndex: 1,
-    // @ts-ignore Web only -prf
-    left: 'calc(50% - 300px)',
-    width: 600,
     top: 0,
     flexDirection: 'column',
     alignItems: 'center',
     borderLeftWidth: 1,
     borderRightWidth: 1,
+    zIndex: 1,
   },
 })
diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx
index f51efb7b4..d7b7231c6 100644
--- a/src/view/com/home/HomeHeaderLayoutMobile.tsx
+++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx
@@ -23,6 +23,7 @@ export function HomeHeaderLayoutMobile({
   children,
 }: {
   children: React.ReactNode
+  tabBarAnchor: JSX.Element | null | undefined
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index d52d3c0e6..e50fb7f09 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -159,7 +159,7 @@ export const TextLink = memo(function TextLink({
   dataSet,
   title,
   onPress,
-  warnOnMismatchingLabel,
+  disableMismatchWarning,
   navigationAction,
   ...orgProps
 }: {
@@ -172,7 +172,7 @@ export const TextLink = memo(function TextLink({
   lineHeight?: number
   dataSet?: any
   title?: string
-  warnOnMismatchingLabel?: boolean
+  disableMismatchWarning?: boolean
   navigationAction?: 'push' | 'replace' | 'navigate'
 } & TextProps) {
   const {...props} = useLinkProps({to: sanitizeUrl(href)})
@@ -180,14 +180,14 @@ export const TextLink = memo(function TextLink({
   const {openModal, closeModal} = useModalControls()
   const openLink = useOpenLink()
 
-  if (warnOnMismatchingLabel && typeof text !== 'string') {
+  if (!disableMismatchWarning && typeof text !== 'string') {
     console.error('Unable to detect mismatching label')
   }
 
   props.onPress = React.useCallback(
     (e?: Event) => {
       const requiresWarning =
-        warnOnMismatchingLabel &&
+        !disableMismatchWarning &&
         linkRequiresWarning(href, typeof text === 'string' ? text : '')
       if (requiresWarning) {
         e?.preventDefault?.()
@@ -227,7 +227,7 @@ export const TextLink = memo(function TextLink({
       navigation,
       href,
       text,
-      warnOnMismatchingLabel,
+      disableMismatchWarning,
       navigationAction,
       openLink,
     ],
diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx
index 1ccfcf56c..872e10eef 100644
--- a/src/view/com/util/ViewHeader.tsx
+++ b/src/view/com/util/ViewHeader.tsx
@@ -13,11 +13,13 @@ import Animated from 'react-native-reanimated'
 import {useSetDrawerOpen} from '#/state/shell'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useTheme} from '#/alf'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
 export function ViewHeader({
   title,
+  subtitle,
   canGoBack,
   showBackButton = true,
   hideOnScroll,
@@ -26,6 +28,7 @@ export function ViewHeader({
   renderButton,
 }: {
   title: string
+  subtitle?: string
   canGoBack?: boolean
   showBackButton?: boolean
   hideOnScroll?: boolean
@@ -39,6 +42,7 @@ export function ViewHeader({
   const navigation = useNavigation<NavigationProp>()
   const {track} = useAnalytics()
   const {isDesktop, isTablet} = useWebMediaQueries()
+  const t = useTheme()
 
   const onPressBack = React.useCallback(() => {
     if (navigation.canGoBack()) {
@@ -71,42 +75,60 @@ export function ViewHeader({
 
     return (
       <Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}>
-        {showBackButton ? (
-          <TouchableOpacity
-            testID="viewHeaderDrawerBtn"
-            onPress={canGoBack ? onPressBack : onPressMenu}
-            hitSlop={BACK_HITSLOP}
-            style={canGoBack ? styles.backBtn : styles.backBtnWide}
-            accessibilityRole="button"
-            accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
-            accessibilityHint={
-              canGoBack ? '' : _(msg`Access navigation links and settings`)
-            }>
-            {canGoBack ? (
-              <FontAwesomeIcon
-                size={18}
-                icon="angle-left"
-                style={[styles.backIcon, pal.text]}
-              />
-            ) : !isTablet ? (
-              <FontAwesomeIcon
-                size={18}
-                icon="bars"
-                style={[styles.backIcon, pal.textLight]}
-              />
+        <View style={{flex: 1}}>
+          <View style={{flexDirection: 'row', alignItems: 'center'}}>
+            {showBackButton ? (
+              <TouchableOpacity
+                testID="viewHeaderDrawerBtn"
+                onPress={canGoBack ? onPressBack : onPressMenu}
+                hitSlop={BACK_HITSLOP}
+                style={canGoBack ? styles.backBtn : styles.backBtnWide}
+                accessibilityRole="button"
+                accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
+                accessibilityHint={
+                  canGoBack ? '' : _(msg`Access navigation links and settings`)
+                }>
+                {canGoBack ? (
+                  <FontAwesomeIcon
+                    size={18}
+                    icon="angle-left"
+                    style={[styles.backIcon, pal.text]}
+                  />
+                ) : !isTablet ? (
+                  <FontAwesomeIcon
+                    size={18}
+                    icon="bars"
+                    style={[styles.backIcon, pal.textLight]}
+                  />
+                ) : null}
+              </TouchableOpacity>
             ) : null}
-          </TouchableOpacity>
-        ) : null}
-        <View style={styles.titleContainer} pointerEvents="none">
-          <Text type="title" style={[pal.text, styles.title]}>
-            {title}
-          </Text>
+            <View style={styles.titleContainer} pointerEvents="none">
+              <Text type="title" style={[pal.text, styles.title]}>
+                {title}
+              </Text>
+            </View>
+            {renderButton ? (
+              renderButton()
+            ) : showBackButton ? (
+              <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
+            ) : null}
+          </View>
+          {subtitle ? (
+            <View
+              style={[styles.titleContainer, {marginTop: -3}]}
+              pointerEvents="none">
+              <Text
+                style={[
+                  pal.text,
+                  styles.subtitle,
+                  t.atoms.text_contrast_medium,
+                ]}>
+                {subtitle}
+              </Text>
+            </View>
+          ) : undefined}
         </View>
-        {renderButton ? (
-          renderButton()
-        ) : showBackButton ? (
-          <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
-        ) : null}
       </Container>
     )
   }
@@ -185,7 +207,6 @@ function Container({
 const styles = StyleSheet.create({
   header: {
     flexDirection: 'row',
-    alignItems: 'center',
     paddingHorizontal: 12,
     paddingVertical: 6,
     width: '100%',
@@ -207,12 +228,14 @@ const styles = StyleSheet.create({
   titleContainer: {
     marginLeft: 'auto',
     marginRight: 'auto',
-    paddingRight: 10,
+    alignItems: 'center',
   },
   title: {
     fontWeight: 'bold',
   },
-
+  subtitle: {
+    fontSize: 13,
+  },
   backBtn: {
     width: 30,
     height: 30,
diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx
index b3a563116..cd2545290 100644
--- a/src/view/com/util/moderation/ContentHider.tsx
+++ b/src/view/com/util/moderation/ContentHider.tsx
@@ -46,7 +46,7 @@ export function ContentHider({
     )
   }
 
-  const isMute = moderation.cause?.type === 'muted'
+  const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
   const desc = describeModerationCause(moderation.cause, 'content')
   return (
     <View testID={testID} style={[styles.outer, style]}>
diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx
index b1fa71d4a..ede62e988 100644
--- a/src/view/com/util/moderation/PostHider.tsx
+++ b/src/view/com/util/moderation/PostHider.tsx
@@ -47,7 +47,7 @@ export function PostHider({
     )
   }
 
-  const isMute = moderation.cause?.type === 'muted'
+  const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '')
   const desc = describeModerationCause(moderation.cause, 'content')
   return !override ? (
     <Pressable
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index 0ec3f3181..f4ade30e5 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -114,7 +114,6 @@ export function RichText({
             href={link.uri}
             style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
             dataSet={WORD_WRAP}
-            warnOnMismatchingLabel
             selectable={selectable}
           />,
         )
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 7ad9beb56..99ac8c44a 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -123,8 +123,7 @@ function HomeScreenReady({
       return (
         <HomeHeader
           key="FEEDS_TAB_BAR"
-          selectedPage={props.selectedPage}
-          onSelect={props.onSelect}
+          {...props}
           testID="homeScreenFeedTabs"
           onPressSelected={onPressSelected}
           feeds={pinnedFeedInfos}
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 64e067593..b30b4491b 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -491,6 +491,8 @@ const styles = StyleSheet.create({
   container: {
     flexDirection: 'column',
     height: '100%',
+    // @ts-ignore Web-only.
+    overflowAnchor: 'none', // Fixes jumps when switching tabs while scrolled down.
   },
   loading: {
     paddingVertical: 10,
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index b3072c397..3c675ee0a 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -60,7 +60,7 @@ import {
 import {logger} from '#/logger'
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {listenSoftReset} from '#/state/events'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
 
 const SECTION_TITLES_CURATE = ['Posts', 'About']
 const SECTION_TITLES_MOD = ['About']
@@ -699,6 +699,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
     ref,
   ) {
     const pal = usePalette('default')
+    const t = useTheme()
     const {_} = useLingui()
     const {isMobile} = useWebMediaQueries()
     const {currentAccount} = useSession()
@@ -792,7 +793,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
                 paddingBottom: isMobile ? 14 : 18,
               },
             ]}>
-            <Text type="lg-bold">
+            <Text type="lg-bold" style={t.atoms.text}>
               <Trans>Users</Trans>
             </Text>
             {isOwner && (
@@ -817,14 +818,18 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
         </View>
       )
     }, [
-      pal,
-      list,
       isMobile,
+      pal.border,
+      pal.textLight,
+      pal.colors.link,
+      pal.link,
       descriptionRT,
       isCurateList,
       isOwner,
-      onPressAddUser,
+      list.creator,
+      t.atoms.text,
       _,
+      onPressAddUser,
     ])
 
     const renderEmptyState = useCallback(() => {
diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx
index 3f1806906..f9ecfba55 100644
--- a/src/view/screens/Storybook/Links.tsx
+++ b/src/view/screens/Storybook/Links.tsx
@@ -4,7 +4,7 @@ import {View} from 'react-native'
 import {useTheme, atoms as a} from '#/alf'
 import {ButtonText} from '#/components/Button'
 import {InlineLink, Link} from '#/components/Link'
-import {H1, H3, Text} from '#/components/Typography'
+import {H1, Text} from '#/components/Typography'
 
 export function Links() {
   const t = useTheme()
@@ -13,31 +13,19 @@ export function Links() {
       <H1>Links</H1>
 
       <View style={[a.gap_md, a.align_start]}>
-        <InlineLink
-          to="https://bsky.social"
-          warnOnMismatchingTextChild
-          style={[a.text_md]}>
-          External
+        <InlineLink to="https://google.com" style={[a.text_lg]}>
+          https://google.com
         </InlineLink>
-        <InlineLink to="https://bsky.social" style={[a.text_md, t.atoms.text]}>
-          <H3>External with custom children</H3>
+        <InlineLink to="https://google.com" style={[a.text_lg]}>
+          External with custom children (google.com)
         </InlineLink>
         <InlineLink
           to="https://bsky.social"
           style={[a.text_md, t.atoms.text_contrast_low]}>
-          External with custom children
-        </InlineLink>
-        <InlineLink
-          to="https://bsky.social"
-          warnOnMismatchingTextChild
-          style={[a.text_lg]}>
-          https://bsky.social
+          Internal (bsky.social)
         </InlineLink>
-        <InlineLink
-          to="https://bsky.app/profile/bsky.app"
-          warnOnMismatchingTextChild
-          style={[a.text_md]}>
-          Internal
+        <InlineLink to="https://bsky.app/profile/bsky.app" style={[a.text_md]}>
+          Internal (bsky.app)
         </InlineLink>
 
         <Link
diff --git a/src/view/screens/Storybook/Palette.tsx b/src/view/screens/Storybook/Palette.tsx
index 900754681..42000aa81 100644
--- a/src/view/screens/Storybook/Palette.tsx
+++ b/src/view/screens/Storybook/Palette.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {View} from 'react-native'
 
-import * as tokens from '#/alf/tokens'
 import {atoms as a, useTheme} from '#/alf'
 
 export function Palette() {
@@ -28,79 +27,79 @@ export function Palette() {
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_25},
+            {height: 60, backgroundColor: t.palette.primary_25},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_50},
+            {height: 60, backgroundColor: t.palette.primary_50},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_100},
+            {height: 60, backgroundColor: t.palette.primary_100},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_200},
+            {height: 60, backgroundColor: t.palette.primary_200},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_300},
+            {height: 60, backgroundColor: t.palette.primary_300},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_400},
+            {height: 60, backgroundColor: t.palette.primary_400},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_500},
+            {height: 60, backgroundColor: t.palette.primary_500},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_600},
+            {height: 60, backgroundColor: t.palette.primary_600},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_700},
+            {height: 60, backgroundColor: t.palette.primary_700},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_800},
+            {height: 60, backgroundColor: t.palette.primary_800},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_900},
+            {height: 60, backgroundColor: t.palette.primary_900},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_950},
+            {height: 60, backgroundColor: t.palette.primary_950},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.blue_975},
+            {height: 60, backgroundColor: t.palette.primary_975},
           ]}
         />
       </View>
@@ -108,153 +107,159 @@ export function Palette() {
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_25},
+            {height: 60, backgroundColor: t.palette.positive_25},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_50},
+            {height: 60, backgroundColor: t.palette.positive_50},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_100},
+            {height: 60, backgroundColor: t.palette.positive_100},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_200},
+            {height: 60, backgroundColor: t.palette.positive_200},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_300},
+            {height: 60, backgroundColor: t.palette.positive_300},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_400},
+            {height: 60, backgroundColor: t.palette.positive_400},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_500},
+            {height: 60, backgroundColor: t.palette.positive_500},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_600},
+            {height: 60, backgroundColor: t.palette.positive_600},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_700},
+            {height: 60, backgroundColor: t.palette.positive_700},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_800},
+            {height: 60, backgroundColor: t.palette.positive_800},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_900},
+            {height: 60, backgroundColor: t.palette.positive_900},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_950},
+            {height: 60, backgroundColor: t.palette.positive_950},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.green_975},
+            {height: 60, backgroundColor: t.palette.positive_975},
           ]}
         />
       </View>
       <View style={[a.flex_row, a.gap_md]}>
         <View
-          style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_25}]}
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: t.palette.negative_25},
+          ]}
         />
         <View
-          style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_50}]}
+          style={[
+            a.flex_1,
+            {height: 60, backgroundColor: t.palette.negative_50},
+          ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_100},
+            {height: 60, backgroundColor: t.palette.negative_100},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_200},
+            {height: 60, backgroundColor: t.palette.negative_200},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_300},
+            {height: 60, backgroundColor: t.palette.negative_300},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_400},
+            {height: 60, backgroundColor: t.palette.negative_400},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_500},
+            {height: 60, backgroundColor: t.palette.negative_500},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_600},
+            {height: 60, backgroundColor: t.palette.negative_600},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_700},
+            {height: 60, backgroundColor: t.palette.negative_700},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_800},
+            {height: 60, backgroundColor: t.palette.negative_800},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_900},
+            {height: 60, backgroundColor: t.palette.negative_900},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_950},
+            {height: 60, backgroundColor: t.palette.negative_950},
           ]}
         />
         <View
           style={[
             a.flex_1,
-            {height: 60, backgroundColor: tokens.color.red_975},
+            {height: 60, backgroundColor: t.palette.negative_975},
           ]}
         />
       </View>
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index fbc90bfc6..def0333c7 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -200,10 +200,10 @@ function ComposeBtn() {
   const fetchHandle = useFetchHandle()
 
   const getProfileHandle = async () => {
-    const {routes} = getState()
-    const currentRoute = routes[routes.length - 1]
+    const routes = getState()?.routes
+    const currentRoute = routes?.[routes?.length - 1]
 
-    if (currentRoute.name === 'Profile') {
+    if (currentRoute?.name === 'Profile') {
       let handle: string | undefined = (
         currentRoute.params as CommonNavigatorParams['Profile']
       ).name
diff --git a/web/index.html b/web/index.html
index 78090591c..8f2275a7f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -48,6 +48,12 @@
         scrollbar-gutter: stable both-edges;
       }
 
+      /* Buttons and inputs have a font set by UA, so we'll have to reset that */
+      button, input, textarea {
+        font: inherit;
+        line-height: inherit;
+      }
+
       /* Color theming */
       /* Default will always be white */
       :root {
diff --git a/yarn.lock b/yarn.lock
index a62ff2f83..ceb712ce2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,15 +34,15 @@
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
-"@atproto/api@^0.10.0":
-  version "0.10.0"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044"
-  integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ==
+"@atproto/api@^0.10.4":
+  version "0.10.4"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.4.tgz#b73446f2344783c42c6040082756449443f15750"
+  integrity sha512-9gwZt4v4pngfD4mgsET9i9Ym0PpMSzftTzqBjCbFpObx15zMkFemYnLUnyT/NEww2u/aRxjAe2TeBnU0dIbbuQ==
   dependencies:
     "@atproto/common-web" "^0.2.3"
-    "@atproto/lexicon" "^0.3.1"
-    "@atproto/syntax" "^0.1.5"
-    "@atproto/xrpc" "^0.4.1"
+    "@atproto/lexicon" "^0.3.2"
+    "@atproto/syntax" "^0.2.0"
+    "@atproto/xrpc" "^0.4.2"
     multiformats "^9.9.0"
     tlds "^1.234.0"
     typed-emitter "^2.1.0"
@@ -245,6 +245,17 @@
     multiformats "^9.9.0"
     zod "^3.21.4"
 
+"@atproto/lexicon@^0.3.2":
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.3.2.tgz#0085a3acd3a77867b8efe188297a1bbacc55ce5c"
+  integrity sha512-kmGCkrRwpWIqmn/KO4BZwUf8Nmfndk3XvFC06V0ygCWc42g6+t4QP/6ywNW4PgqfZY0Q5aW4EuDfD7KjAFkFtQ==
+  dependencies:
+    "@atproto/common-web" "^0.2.3"
+    "@atproto/syntax" "^0.2.0"
+    iso-datestring-validator "^2.2.2"
+    multiformats "^9.9.0"
+    zod "^3.21.4"
+
 "@atproto/ozone@^0.0.7":
   version "0.0.7"
   resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.0.7.tgz#bfad82bc1d0900e79401a82f13581f707415505a"
@@ -340,6 +351,13 @@
   dependencies:
     "@atproto/common-web" "^0.2.3"
 
+"@atproto/syntax@^0.2.0":
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.2.0.tgz#4bab724c02e11f8943b8ec101251082cf55067e9"
+  integrity sha512-K+9jl6mtxC9ytlR7msSiP9jVNqtdxEBSt0kOfsC924lqGwuD8nlUAMi1GSMgAZJGg/Rd+0MKXh789heTdeL3HQ==
+  dependencies:
+    "@atproto/common-web" "^0.2.3"
+
 "@atproto/xrpc-server@^0.4.2":
   version "0.4.2"
   resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.4.2.tgz#23efd89086b85933f1b0cc00c86e895adcaac315"
@@ -365,6 +383,14 @@
     "@atproto/lexicon" "^0.3.1"
     zod "^3.21.4"
 
+"@atproto/xrpc@^0.4.2":
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.4.2.tgz#57812e0624be597b85f21471acf336513f35ccda"
+  integrity sha512-x4x2QB4nWmLjIpz2Ue9n/QVbVyJkk6tQMhvmDQaVFF89E3FcVI4rxF4uhzSxaLpbNtyVQBNEEmNHOr5EJLeHVA==
+  dependencies:
+    "@atproto/lexicon" "^0.3.2"
+    zod "^3.21.4"
+
 "@aws-crypto/crc32@3.0.0":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"