From 1cf49517b517e8bee90bf937d033dff35cc9f690 Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Thu, 7 Mar 2024 09:04:02 -0800
Subject: [PATCH 01/13] Allow all encoding for hashtags in URL (#3131)

---
 src/components/TagMenu/index.tsx     | 4 ++--
 src/components/TagMenu/index.web.tsx | 4 ++--
 src/screens/Hashtag.tsx              | 4 ++--
 3 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index c9ced9a5..849a3f42 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -98,7 +98,7 @@ export function TagMenu({
 
                     control.close(() => {
                       navigation.push('Hashtag', {
-                        tag: tag.replaceAll('#', '%23'),
+                        tag: encodeURIComponent(tag),
                       })
                     })
 
@@ -153,7 +153,7 @@ export function TagMenu({
 
                         control.close(() => {
                           navigation.push('Hashtag', {
-                            tag: tag.replaceAll('#', '%23'),
+                            tag: encodeURIComponent(tag),
                             author: authorHandle,
                           })
                         })
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
index a0dc2bce..8245bd01 100644
--- a/src/components/TagMenu/index.web.tsx
+++ b/src/components/TagMenu/index.web.tsx
@@ -66,7 +66,7 @@ export function TagMenu({
         label: _(msg`See ${truncatedTag} posts`),
         onPress() {
           navigation.push('Hashtag', {
-            tag: tag.replaceAll('#', '%23'),
+            tag: encodeURIComponent(tag),
           })
         },
         testID: 'tagMenuSearch',
@@ -83,7 +83,7 @@ export function TagMenu({
           label: _(msg`See ${truncatedTag} posts by user`),
           onPress() {
             navigation.push('Hashtag', {
-              tag: tag.replaceAll('#', '%23'),
+              tag: encodeURIComponent(tag),
               author: authorHandle,
             })
           },
diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx
index f1b81737..776cc585 100644
--- a/src/screens/Hashtag.tsx
+++ b/src/screens/Hashtag.tsx
@@ -42,7 +42,7 @@ export default function HashtagScreen({
   const [isPTR, setIsPTR] = React.useState(false)
 
   const fullTag = React.useMemo(() => {
-    return `#${tag.replaceAll('%23', '#')}`
+    return `#${decodeURIComponent(tag)}`
   }, [tag])
 
   const queryParam = React.useMemo(() => {
@@ -83,7 +83,7 @@ export default function HashtagScreen({
 
   const onShare = React.useCallback(() => {
     const url = new URL('https://bsky.app')
-    url.pathname = `/hashtag/${tag}`
+    url.pathname = `/hashtag/${decodeURIComponent(tag)}`
     if (author) {
       url.searchParams.set('author', author)
     }

From c8e0fa9c97d276a917996627f163c267783e9411 Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Fri, 8 Mar 2024 04:13:36 +0000
Subject: [PATCH 02/13] Mark bundle start time on web (#3147)

* Mark bundle start time on web

* TS
---
 index.web.js                            | 2 ++
 src/platform/markBundleStartTime.web.ts | 2 ++
 2 files changed, 4 insertions(+)
 create mode 100644 src/platform/markBundleStartTime.web.ts

diff --git a/index.web.js b/index.web.js
index 4dee831c..96237345 100644
--- a/index.web.js
+++ b/index.web.js
@@ -1,3 +1,5 @@
+import '#/platform/markBundleStartTime'
+
 import '#/platform/polyfills'
 import {registerRootComponent} from 'expo'
 import {doPolyfill} from '#/lib/api/api-polyfill'
diff --git a/src/platform/markBundleStartTime.web.ts b/src/platform/markBundleStartTime.web.ts
new file mode 100644
index 00000000..cd64c9f1
--- /dev/null
+++ b/src/platform/markBundleStartTime.web.ts
@@ -0,0 +1,2 @@
+// @ts-ignore Web-only. On RN, this is set by Metro.
+window.__BUNDLE_START_TIME__ = performance.now()

From 31826633cb1d1180875b20e218a39ce341ab2ec0 Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Thu, 7 Mar 2024 20:14:24 -0800
Subject: [PATCH 03/13] rm waitlist modal, button during sign up (#3148)

---
 src/view/com/auth/create/Step1.tsx |  27 +---
 src/view/com/modals/Modal.tsx      |   4 -
 src/view/com/modals/Modal.web.tsx  |   3 -
 src/view/com/modals/Waitlist.tsx   | 190 -----------------------------
 4 files changed, 2 insertions(+), 222 deletions(-)
 delete mode 100644 src/view/com/modals/Waitlist.tsx

diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index 4c701848..c1bec535 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -4,7 +4,6 @@ import {
   Keyboard,
   StyleSheet,
   TouchableOpacity,
-  TouchableWithoutFeedback,
   View,
 } from 'react-native'
 import {CreateAccountState, CreateAccountDispatch, is18} from './state'
@@ -19,7 +18,6 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
 import {isWeb} from 'platform/detection'
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
 import {logger} from '#/logger'
 import {
   FontAwesomeIcon,
@@ -49,7 +47,6 @@ export function Step1({
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
-  const {openModal} = useModalControls()
   const serverInputControl = useDialogControl()
 
   const onPressSelectService = React.useCallback(() => {
@@ -57,10 +54,6 @@ export function Step1({
     Keyboard.dismiss()
   }, [serverInputControl])
 
-  const onPressWaitlist = React.useCallback(() => {
-    openModal({name: 'waitlist'})
-  }, [openModal])
-
   const birthDate = React.useMemo(() => {
     return sanitizeDate(uiState.birthDate)
   }, [uiState.birthDate])
@@ -164,23 +157,7 @@ export function Step1({
             </View>
           )}
 
-          {!uiState.inviteCode && uiState.isInviteCodeRequired ? (
-            <View style={[s.flexRow, s.alignCenter]}>
-              <Text style={pal.text}>
-                <Trans>Don't have an invite code?</Trans>{' '}
-              </Text>
-              <TouchableWithoutFeedback
-                onPress={onPressWaitlist}
-                accessibilityLabel={_(msg`Join the waitlist.`)}
-                accessibilityHint="">
-                <View style={styles.touchable}>
-                  <Text style={pal.link}>
-                    <Trans>Join the waitlist.</Trans>
-                  </Text>
-                </View>
-              </TouchableWithoutFeedback>
-            </View>
-          ) : (
+          {uiState.inviteCode ? (
             <>
               <View style={s.pb20}>
                 <Text
@@ -260,7 +237,7 @@ export function Step1({
                 />
               )}
             </>
-          )}
+          ) : undefined}
         </>
       )}
     </View>
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 8da91c75..100444ff 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -20,7 +20,6 @@ import * as ReportModal from './report/Modal'
 import * as AppealLabelModal from './AppealLabel'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as ChangeHandleModal from './ChangeHandle'
-import * as WaitlistModal from './Waitlist'
 import * as InviteCodesModal from './InviteCodes'
 import * as AddAppPassword from './AddAppPasswords'
 import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
@@ -109,9 +108,6 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'change-handle') {
     snapPoints = ChangeHandleModal.snapPoints
     element = <ChangeHandleModal.Component {...activeModal} />
-  } else if (activeModal?.name === 'waitlist') {
-    snapPoints = WaitlistModal.snapPoints
-    element = <WaitlistModal.Component />
   } else if (activeModal?.name === 'invite-codes') {
     snapPoints = InviteCodesModal.snapPoints
     element = <InviteCodesModal.Component />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 97a60be9..0ced894a 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -22,7 +22,6 @@ import * as CropImageModal from './crop-image/CropImage.web'
 import * as AltTextImageModal from './AltImage'
 import * as EditImageModal from './EditImage'
 import * as ChangeHandleModal from './ChangeHandle'
-import * as WaitlistModal from './Waitlist'
 import * as InviteCodesModal from './InviteCodes'
 import * as AddAppPassword from './AddAppPasswords'
 import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
@@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ThreadgateModal.Component {...modal} />
   } else if (modal.name === 'change-handle') {
     element = <ChangeHandleModal.Component {...modal} />
-  } else if (modal.name === 'waitlist') {
-    element = <WaitlistModal.Component />
   } else if (modal.name === 'invite-codes') {
     element = <InviteCodesModal.Component />
   } else if (modal.name === 'add-app-password') {
diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx
deleted file mode 100644
index 263dd27a..00000000
--- a/src/view/com/modals/Waitlist.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import React from 'react'
-import {
-  ActivityIndicator,
-  StyleSheet,
-  TouchableOpacity,
-  View,
-} from 'react-native'
-import {TextInput} from './util'
-import {
-  FontAwesomeIcon,
-  FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
-import LinearGradient from 'react-native-linear-gradient'
-import {Text} from '../util/text/Text'
-import {s, gradients} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useTheme} from 'lib/ThemeContext'
-import {ErrorMessage} from '../util/error/ErrorMessage'
-import {cleanError} from 'lib/strings/errors'
-import {Trans, msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useModalControls} from '#/state/modals'
-
-export const snapPoints = ['80%']
-
-export function Component({}: {}) {
-  const pal = usePalette('default')
-  const theme = useTheme()
-  const {_} = useLingui()
-  const {closeModal} = useModalControls()
-  const [email, setEmail] = React.useState<string>('')
-  const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false)
-  const [isProcessing, setIsProcessing] = React.useState<boolean>(false)
-  const [error, setError] = React.useState<string>('')
-
-  const onPressSignup = async () => {
-    setError('')
-    setIsProcessing(true)
-    try {
-      const res = await fetch('https://bsky.app/api/waitlist', {
-        method: 'POST',
-        headers: {'Content-Type': 'application/json'},
-        body: JSON.stringify({email}),
-      })
-      const resBody = await res.json()
-      if (resBody.success) {
-        setIsEmailSent(true)
-      } else {
-        setError(
-          resBody.error ||
-            _(msg`Something went wrong. Check your email and try again.`),
-        )
-      }
-    } catch (e: any) {
-      setError(cleanError(e))
-    }
-    setIsProcessing(false)
-  }
-  const onCancel = () => {
-    closeModal()
-  }
-
-  return (
-    <View style={[styles.container, pal.view]}>
-      <View style={[styles.innerContainer, pal.view]}>
-        <Text type="title-xl" style={[styles.title, pal.text]}>
-          <Trans>Join the waitlist</Trans>
-        </Text>
-        <Text type="lg" style={[styles.description, pal.text]}>
-          <Trans>
-            Bluesky uses invites to build a healthier community. If you don't
-            know anybody with an invite, you can sign up for the waitlist and
-            we'll send one soon.
-          </Trans>
-        </Text>
-        <TextInput
-          style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]}
-          placeholder={_(msg`Enter your email`)}
-          placeholderTextColor={pal.textLight.color}
-          autoCapitalize="none"
-          autoCorrect={false}
-          keyboardAppearance={theme.colorScheme}
-          value={email}
-          onChangeText={setEmail}
-          onSubmitEditing={onPressSignup}
-          enterKeyHint="done"
-          accessible={true}
-          accessibilityLabel={_(msg`Email`)}
-          accessibilityHint={_(
-            msg`Input your email to get on the Bluesky waitlist`,
-          )}
-        />
-        {error ? (
-          <View style={s.mt10}>
-            <ErrorMessage message={error} style={styles.error} />
-          </View>
-        ) : undefined}
-        {isProcessing ? (
-          <View style={[styles.btn, s.mt10]}>
-            <ActivityIndicator />
-          </View>
-        ) : isEmailSent ? (
-          <View style={[styles.btn, s.mt10]}>
-            <FontAwesomeIcon
-              icon="check"
-              style={pal.text as FontAwesomeIconStyle}
-            />
-            <Text style={[s.ml10, pal.text]}>
-              <Trans>
-                Your email has been saved! We&apos;ll be in touch soon.
-              </Trans>
-            </Text>
-          </View>
-        ) : (
-          <>
-            <TouchableOpacity
-              onPress={onPressSignup}
-              accessibilityRole="button"
-              accessibilityHint={_(
-                msg`Confirms signing up ${email} to the waitlist`,
-              )}>
-              <LinearGradient
-                colors={[gradients.blueLight.start, gradients.blueLight.end]}
-                start={{x: 0, y: 0}}
-                end={{x: 1, y: 1}}
-                style={[styles.btn]}>
-                <Text type="button-lg" style={[s.white, s.bold]}>
-                  <Trans>Join Waitlist</Trans>
-                </Text>
-              </LinearGradient>
-            </TouchableOpacity>
-            <TouchableOpacity
-              style={[styles.btn, s.mt10]}
-              onPress={onCancel}
-              accessibilityRole="button"
-              accessibilityLabel={_(msg`Cancel waitlist signup`)}
-              accessibilityHint={_(
-                msg`Exits signing up for waitlist with ${email}`,
-              )}
-              onAccessibilityEscape={onCancel}>
-              <Text type="button-lg" style={pal.textLight}>
-                <Trans>Cancel</Trans>
-              </Text>
-            </TouchableOpacity>
-          </>
-        )}
-      </View>
-    </View>
-  )
-}
-
-const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-  innerContainer: {
-    paddingBottom: 20,
-  },
-  title: {
-    textAlign: 'center',
-    marginTop: 12,
-    marginBottom: 12,
-  },
-  description: {
-    textAlign: 'center',
-    paddingHorizontal: 22,
-    marginBottom: 10,
-  },
-  textInput: {
-    borderWidth: 1,
-    borderRadius: 6,
-    paddingHorizontal: 16,
-    paddingVertical: 12,
-    fontSize: 20,
-    marginHorizontal: 20,
-  },
-  btn: {
-    flexDirection: 'row',
-    alignItems: 'center',
-    justifyContent: 'center',
-    borderRadius: 32,
-    padding: 14,
-    marginHorizontal: 20,
-  },
-  error: {
-    borderRadius: 6,
-    marginHorizontal: 20,
-    marginBottom: 20,
-  },
-})

From dd86d0964d391e9748843aa1a6400d73c3a6d9f9 Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Fri, 8 Mar 2024 04:33:42 +0000
Subject: [PATCH 04/13] Enable gating and experimentation on native, send init
 event (#3149)

* Add the mobile fork

* Add init event
---
 src/Navigation.tsx              |  4 +++
 src/lib/statsig/statsig.tsx     | 62 ++++++++++++++++++++++++++++++---
 src/lib/statsig/statsig.web.tsx | 14 +++++++-
 3 files changed, 74 insertions(+), 6 deletions(-)

diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index b30f8f98..8a9f69b5 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -78,6 +78,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack
 import {msg} from '@lingui/macro'
 import {i18n, MessageDescriptor} from '@lingui/core'
 import HashtagScreen from '#/screens/Hashtag'
+import {logEvent} from './lib/statsig/statsig'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
 
@@ -649,11 +650,14 @@ function logModuleInitTime() {
     return
   }
   didInit = true
+
   const initMs = Math.round(
     // @ts-ignore Emitted by Metro in the bundle prelude
     performance.now() - global.__BUNDLE_START_TIME__,
   )
   console.log(`Time to first paint: ${initMs} ms`)
+  logEvent('init', initMs)
+
   if (__DEV__) {
     // This log is noisy, so keep false committed
     const shouldLog = false
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index 88a57c3f..43822dda 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -1,11 +1,63 @@
 import React from 'react'
+import {
+  Statsig,
+  StatsigProvider,
+  useGate as useStatsigGate,
+} from 'statsig-react-native-expo'
+import {useSession} from '../../state/session'
+import {sha256} from 'js-sha256'
 
-export function useGate(_gateName: string) {
-  // Not enabled for native yet.
-  return false
+const statsigOptions = {
+  environment: {
+    tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
+  },
+  // Don't block on waiting for network. The fetched config will kick in on next load.
+  // This ensures the UI is always consistent and doesn't update mid-session.
+  // Note this makes cold load (no local storage) and private mode return `false` for all gates.
+  initTimeoutMs: 1,
+}
+
+export function logEvent(
+  eventName: string,
+  value?: string | number | null,
+  metadata?: Record<string, string> | null,
+) {
+  Statsig.logEvent(eventName, value, metadata)
+}
+
+export function useGate(gateName: string) {
+  const {isLoading, value} = useStatsigGate(gateName)
+  if (isLoading) {
+    // This should not happen because of waitForInitialization={true}.
+    console.error('Did not expected isLoading to ever be true.')
+  }
+  return value
+}
+
+function toStatsigUser(did: string | undefined) {
+  let userID: string | undefined
+  if (did) {
+    userID = sha256(did)
+  }
+  return {userID}
 }
 
 export function Provider({children}: {children: React.ReactNode}) {
-  // Not enabled for native yet.
-  return children
+  const {currentAccount} = useSession()
+  const currentStatsigUser = React.useMemo(
+    () => toStatsigUser(currentAccount?.did),
+    [currentAccount?.did],
+  )
+  return (
+    <StatsigProvider
+      sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
+      mountKey={currentStatsigUser.userID}
+      user={currentStatsigUser}
+      // This isn't really blocking due to short initTimeoutMs above.
+      // However, it ensures `isLoading` is always `false`.
+      waitForInitialization={true}
+      options={statsigOptions}>
+      {children}
+    </StatsigProvider>
+  )
 }
diff --git a/src/lib/statsig/statsig.web.tsx b/src/lib/statsig/statsig.web.tsx
index 6508131c..fc66e8d9 100644
--- a/src/lib/statsig/statsig.web.tsx
+++ b/src/lib/statsig/statsig.web.tsx
@@ -1,5 +1,9 @@
 import React from 'react'
-import {StatsigProvider, useGate as useStatsigGate} from 'statsig-react'
+import {
+  Statsig,
+  StatsigProvider,
+  useGate as useStatsigGate,
+} from 'statsig-react'
 import {useSession} from '../../state/session'
 import {sha256} from 'js-sha256'
 
@@ -13,6 +17,14 @@ const statsigOptions = {
   initTimeoutMs: 1,
 }
 
+export function logEvent(
+  eventName: string,
+  value?: string | number | null,
+  metadata?: Record<string, string> | null,
+) {
+  Statsig.logEvent(eventName, value, metadata)
+}
+
 export function useGate(gateName: string) {
   const {isLoading, value} = useStatsigGate(gateName)
   if (isLoading) {

From 8f623c3bdf8dbbdc4c4f10f19b0b2c134b4160cb Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Fri, 8 Mar 2024 14:45:59 -0600
Subject: [PATCH 05/13] Refactor `PostDropdownBtn` to use new `Menu` (#3112)

* Refactor PostDropdownBtn

(cherry picked from commit 0adf6cb75e3d4b7c1630cf6153c0d7e289e1b859)

* Update icons

(cherry picked from commit ac89ef9b28721c00736b1388455f3f5f092de0ad)

* Port over fixes

* fix scrollbar disappearing

* Try CSS solution

* Disable arrow for now

---------

Co-authored-by: Hailey <me@haileyok.com>
---
 ...bubbleQuestion_stroke2_corner0_rounded.svg |   1 +
 .../icons/filter_stroke2_corner0_rounded.svg  |   1 +
 ...akerVolumeFull_stroke2_corner0_rounded.svg |   1 +
 .../icons/trash_stroke2_corner0_rounded.svg   |   1 +
 .../icons/warning_stroke2_corner0_rounded.svg |   1 +
 src/components/Menu/index.web.tsx             |   8 +-
 src/components/icons/Bubble.tsx               |   5 +
 src/components/icons/Filter.tsx               |   5 +
 src/components/icons/Speaker.tsx              |   5 +
 src/components/icons/Trash.tsx                |   5 +
 src/components/icons/Warning.tsx              |   5 +
 src/view/com/util/EventStopper.tsx            |  11 +-
 src/view/com/util/forms/PostDropdownBtn.tsx   | 376 ++++++++++--------
 src/view/com/util/post-ctrls/PostCtrls.tsx    |   1 +
 14 files changed, 249 insertions(+), 177 deletions(-)
 create mode 100644 assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/filter_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/trash_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/warning_stroke2_corner0_rounded.svg
 create mode 100644 src/components/icons/Bubble.tsx
 create mode 100644 src/components/icons/Filter.tsx
 create mode 100644 src/components/icons/Speaker.tsx
 create mode 100644 src/components/icons/Trash.tsx
 create mode 100644 src/components/icons/Warning.tsx

diff --git a/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..0bfcc48a
--- /dev/null
+++ b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/filter_stroke2_corner0_rounded.svg b/assets/icons/filter_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..1fbcfc57
--- /dev/null
+++ b/assets/icons/filter_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-.293.707L15 14.414V20a1 1 0 0 1-.758.97l-4 1A1 1 0 0 1 9 21v-6.586L3.293 8.707A1 1 0 0 1 3 8V4Zm2 1v2.586l5.707 5.707A1 1 0 0 1 11 14v5.72l2-.5V14a1 1 0 0 1 .293-.707L19 7.586V5H5Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..81357a12
--- /dev/null
+++ b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.472 3.118A1 1 0 0 1 13 4v16a1 1 0 0 1-1.555.832L5.697 17H2a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3.697l5.748-3.832a1 1 0 0 1 1.027-.05ZM11 5.868 6.555 8.833A1 1 0 0 1 6 9H3v6h3a1 1 0 0 1 .555.168L11 18.131V5.87Zm7.364-1.645a1 1 0 0 1 1.414 0A10.969 10.969 0 0 1 23 12c0 3.037-1.232 5.788-3.222 7.778a1 1 0 1 1-1.414-1.414A8.969 8.969 0 0 0 21 12a8.969 8.969 0 0 0-2.636-6.364 1 1 0 0 1 0-1.414Zm-3.182 3.181a1 1 0 0 1 1.414 0A6.483 6.483 0 0 1 18.5 12a6.483 6.483 0 0 1-1.904 4.597 1 1 0 0 1-1.414-1.415A4.483 4.483 0 0 0 16.5 12a4.483 4.483 0 0 0-1.318-3.182 1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/trash_stroke2_corner0_rounded.svg b/assets/icons/trash_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..d4b32f81
--- /dev/null
+++ b/assets/icons/trash_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
diff --git a/assets/icons/warning_stroke2_corner0_rounded.svg b/assets/icons/warning_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..d5b6f13d
--- /dev/null
+++ b/assets/icons/warning_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z" clip-rule="evenodd"/></svg>
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index ca2e4056..054e51b0 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -92,10 +92,8 @@ export function Trigger({children, label, style}: TriggerProps) {
         accessibilityLabel={label}
         onFocus={onFocus}
         onBlur={onBlur}
-        style={flatten([style, web({outline: 0})])}
-        onPointerDown={() => {
-          control.open()
-        }}
+        style={flatten([style, focused && web({outline: 0})])}
+        onPointerDown={() => control.open()}
         {...web({
           onMouseEnter,
           onMouseLeave,
@@ -131,6 +129,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
           {children}
         </View>
 
+        {/* Disabled until we can fix positioning
         <DropdownMenu.Arrow
           className="DropdownMenuArrow"
           fill={
@@ -138,6 +137,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
               .backgroundColor
           }
         />
+          */}
       </DropdownMenu.Content>
     </DropdownMenu.Portal>
   )
diff --git a/src/components/icons/Bubble.tsx b/src/components/icons/Bubble.tsx
new file mode 100644
index 00000000..d4e08f6d
--- /dev/null
+++ b/src/components/icons/Bubble.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const BubbleQuestion_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
+})
diff --git a/src/components/icons/Filter.tsx b/src/components/icons/Filter.tsx
new file mode 100644
index 00000000..02ac1c71
--- /dev/null
+++ b/src/components/icons/Filter.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Filter_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-.293.707L15 14.414V20a1 1 0 0 1-.758.97l-4 1A1 1 0 0 1 9 21v-6.586L3.293 8.707A1 1 0 0 1 3 8V4Zm2 1v2.586l5.707 5.707A1 1 0 0 1 11 14v5.72l2-.5V14a1 1 0 0 1 .293-.707L19 7.586V5H5Z',
+})
diff --git a/src/components/icons/Speaker.tsx b/src/components/icons/Speaker.tsx
new file mode 100644
index 00000000..365d5e11
--- /dev/null
+++ b/src/components/icons/Speaker.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SpeakerVolumeFull_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12.472 3.118A1 1 0 0 1 13 4v16a1 1 0 0 1-1.555.832L5.697 17H2a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3.697l5.748-3.832a1 1 0 0 1 1.027-.05ZM11 5.868 6.555 8.833A1 1 0 0 1 6 9H3v6h3a1 1 0 0 1 .555.168L11 18.131V5.87Zm7.364-1.645a1 1 0 0 1 1.414 0A10.969 10.969 0 0 1 23 12c0 3.037-1.232 5.788-3.222 7.778a1 1 0 1 1-1.414-1.414A8.969 8.969 0 0 0 21 12a8.969 8.969 0 0 0-2.636-6.364 1 1 0 0 1 0-1.414Zm-3.182 3.181a1 1 0 0 1 1.414 0A6.483 6.483 0 0 1 18.5 12a6.483 6.483 0 0 1-1.904 4.597 1 1 0 0 1-1.414-1.415A4.483 4.483 0 0 0 16.5 12a4.483 4.483 0 0 0-1.318-3.182 1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/Trash.tsx b/src/components/icons/Trash.tsx
new file mode 100644
index 00000000..d09a3311
--- /dev/null
+++ b/src/components/icons/Trash.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Trash_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/Warning.tsx b/src/components/icons/Warning.tsx
new file mode 100644
index 00000000..fc84b289
--- /dev/null
+++ b/src/components/icons/Warning.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Warning_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z',
+})
diff --git a/src/view/com/util/EventStopper.tsx b/src/view/com/util/EventStopper.tsx
index e743e89b..8f5f5cf5 100644
--- a/src/view/com/util/EventStopper.tsx
+++ b/src/view/com/util/EventStopper.tsx
@@ -8,7 +8,14 @@ import {View, ViewStyle} from 'react-native'
 export function EventStopper({
   children,
   style,
-}: React.PropsWithChildren<{style?: ViewStyle | ViewStyle[]}>) {
+  onKeyDown = true,
+}: React.PropsWithChildren<{
+  style?: ViewStyle | ViewStyle[]
+  /**
+   * Default `true`. Set to `false` to allow onKeyDown to propagate
+   */
+  onKeyDown?: boolean
+}>) {
   const stop = (e: any) => {
     e.stopPropagation()
   }
@@ -18,7 +25,7 @@ export function EventStopper({
       onTouchEnd={stop}
       // @ts-ignore web only -prf
       onClick={stop}
-      onKeyDown={stop}
+      onKeyDown={onKeyDown ? stop : undefined}
       style={style}>
       {children}
     </View>
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 09850a7f..6f2ae55b 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,5 +1,11 @@
 import React, {memo} from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
+import {
+  StyleProp,
+  ViewStyle,
+  Pressable,
+  View,
+  PressableProps,
+} from 'react-native'
 import Clipboard from '@react-native-clipboard/clipboard'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
@@ -12,10 +18,6 @@ import {
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {useTheme} from 'lib/ThemeContext'
 import {shareUrl} from 'lib/sharing'
-import {
-  NativeDropdown,
-  DropdownItem as NativeDropdownItem,
-} from './NativeDropdown'
 import * as Toast from '../Toast'
 import {EventStopper} from '../EventStopper'
 import {useModalControls} from '#/state/modals'
@@ -36,6 +38,19 @@ import {isWeb} from '#/platform/detection'
 import {richTextToString} from '#/lib/strings/rich-text-helpers'
 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
 
+import {atoms as a, useTheme as useAlf, web} from '#/alf'
+import * as Menu from '#/components/Menu'
+import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
+import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
+import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+
 let PostDropdownBtn = ({
   testID,
   postAuthor,
@@ -45,6 +60,7 @@ let PostDropdownBtn = ({
   richText,
   style,
   showAppealLabelItem,
+  hitSlop,
 }: {
   testID: string
   postAuthor: AppBskyActorDefs.ProfileViewBasic
@@ -54,9 +70,11 @@ let PostDropdownBtn = ({
   richText: RichTextAPI
   style?: StyleProp<ViewStyle>
   showAppealLabelItem?: boolean
+  hitSlop?: PressableProps['hitSlop']
 }): React.ReactNode => {
   const {hasSession, currentAccount} = useSession()
   const theme = useTheme()
+  const alf = useAlf()
   const {_} = useLingui()
   const defaultCtrlColor = theme.palette.default.postCtrl
   const {openModal} = useModalControls()
@@ -151,173 +169,189 @@ let PostDropdownBtn = ({
     hidePost({uri: postUri})
   }, [postUri, hidePost])
 
-  const dropdownItems: NativeDropdownItem[] = [
-    {
-      label: _(msg`Translate`),
-      onPress() {
-        onOpenTranslate()
-      },
-      testID: 'postDropdownTranslateBtn',
-      icon: {
-        ios: {
-          name: 'character.book.closed',
-        },
-        android: 'ic_menu_sort_alphabetically',
-        web: 'language',
-      },
-    },
-    {
-      label: _(msg`Copy post text`),
-      onPress() {
-        onCopyPostText()
-      },
-      testID: 'postDropdownCopyTextBtn',
-      icon: {
-        ios: {
-          name: 'doc.on.doc',
-        },
-        android: 'ic_menu_edit',
-        web: ['far', 'paste'],
-      },
-    },
-    {
-      label: isWeb ? _(msg`Copy link to post`) : _(msg`Share`),
-      onPress() {
-        const url = toShareUrl(href)
-        shareUrl(url)
-      },
-      testID: 'postDropdownShareBtn',
-      icon: {
-        ios: {
-          name: 'square.and.arrow.up',
-        },
-        android: 'ic_menu_share',
-        web: 'share',
-      },
-    },
-    hasSession && {
-      label: 'separator',
-    },
-    hasSession && {
-      label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`),
-      onPress() {
-        onToggleThreadMute()
-      },
-      testID: 'postDropdownMuteThreadBtn',
-      icon: {
-        ios: {
-          name: 'speaker.slash',
-        },
-        android: 'ic_lock_silent_mode',
-        web: 'comment-slash',
-      },
-    },
-    hasSession && {
-      label: _(msg`Mute words & tags`),
-      onPress() {
-        mutedWordsDialogControl.open()
-      },
-      testID: 'postDropdownMuteWordsBtn',
-      icon: {
-        ios: {
-          name: 'speaker.slash',
-        },
-        android: 'ic_lock_silent_mode',
-        web: 'filter',
-      },
-    },
-    hasSession &&
-      !isAuthor &&
-      !isPostHidden && {
-        label: _(msg`Hide post`),
-        onPress() {
-          openModal({
-            name: 'confirm',
-            title: _(msg`Hide this post?`),
-            message: _(msg`This will hide this post from your feeds.`),
-            onPressConfirm: onHidePost,
-          })
-        },
-        testID: 'postDropdownHideBtn',
-        icon: {
-          ios: {
-            name: 'eye.slash',
-          },
-          android: 'ic_menu_delete',
-          web: ['far', 'eye-slash'],
-        },
-      },
-    {
-      label: 'separator',
-    },
-    !isAuthor &&
-      hasSession && {
-        label: _(msg`Report post`),
-        onPress() {
-          openModal({
-            name: 'report',
-            uri: postUri,
-            cid: postCid,
-          })
-        },
-        testID: 'postDropdownReportBtn',
-        icon: {
-          ios: {
-            name: 'exclamationmark.triangle',
-          },
-          android: 'ic_menu_report_image',
-          web: 'circle-exclamation',
-        },
-      },
-    isAuthor && {
-      label: _(msg`Delete post`),
-      onPress() {
-        openModal({
-          name: 'confirm',
-          title: _(msg`Delete this post?`),
-          message: _(msg`Are you sure? This cannot be undone.`),
-          onPressConfirm: onDeletePost,
-        })
-      },
-      testID: 'postDropdownDeleteBtn',
-      icon: {
-        ios: {
-          name: 'trash',
-        },
-        android: 'ic_menu_delete',
-        web: ['far', 'trash-can'],
-      },
-    },
-    showAppealLabelItem && {
-      label: 'separator',
-    },
-    showAppealLabelItem && {
-      label: _(msg`Appeal content warning`),
-      onPress() {
-        openModal({name: 'appeal-label', uri: postUri, cid: postCid})
-      },
-      testID: 'postDropdownAppealBtn',
-      icon: {
-        ios: {
-          name: 'exclamationmark.triangle',
-        },
-        android: 'ic_menu_report_image',
-        web: 'circle-exclamation',
-      },
-    },
-  ].filter(Boolean) as NativeDropdownItem[]
-
   return (
-    <EventStopper>
-      <NativeDropdown
-        testID={testID}
-        items={dropdownItems}
-        accessibilityLabel={_(msg`More post options`)}
-        accessibilityHint="">
-        <View style={style}>
-          <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
-        </View>
-      </NativeDropdown>
+    <EventStopper onKeyDown={false}>
+      <Menu.Root>
+        <Menu.Trigger label={_(msg`Open post options menu`)}>
+          {({props, state}) => {
+            const styles = [
+              style,
+              a.rounded_full,
+              (state.hovered || state.focused || state.pressed) && [
+                web({outline: 0}),
+                alf.atoms.bg_contrast_25,
+              ],
+            ]
+            return isWeb ? (
+              <View {...props} testID={testID} style={styles}>
+                <FontAwesomeIcon
+                  icon="ellipsis"
+                  size={20}
+                  color={defaultCtrlColor}
+                  style={{pointerEvents: 'none'}}
+                />
+              </View>
+            ) : (
+              <Pressable
+                {...props}
+                hitSlop={hitSlop}
+                testID={testID}
+                style={styles}>
+                <FontAwesomeIcon
+                  icon="ellipsis"
+                  size={20}
+                  color={defaultCtrlColor}
+                  style={{pointerEvents: 'none'}}
+                />
+              </Pressable>
+            )
+          }}
+        </Menu.Trigger>
+
+        <Menu.Outer>
+          <Menu.Group>
+            <Menu.Item
+              testID="postDropdownTranslateBtn"
+              label={_(msg`Translate`)}
+              onPress={onOpenTranslate}>
+              <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={Translate} position="right" />
+            </Menu.Item>
+
+            <Menu.Item
+              testID="postDropdownCopyTextBtn"
+              label={_(msg`Copy post text`)}
+              onPress={onCopyPostText}>
+              <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
+              <Menu.ItemIcon icon={ClipboardIcon} position="right" />
+            </Menu.Item>
+
+            <Menu.Item
+              testID="postDropdownShareBtn"
+              label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
+              onPress={() => {
+                const url = toShareUrl(href)
+                shareUrl(url)
+              }}>
+              <Menu.ItemText>
+                {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={Share} position="right" />
+            </Menu.Item>
+          </Menu.Group>
+
+          {hasSession && (
+            <>
+              <Menu.Divider />
+
+              <Menu.Group>
+                <Menu.Item
+                  testID="postDropdownMuteThreadBtn"
+                  label={
+                    isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
+                  }
+                  onPress={onToggleThreadMute}>
+                  <Menu.ItemText>
+                    {isThreadMuted
+                      ? _(msg`Unmute thread`)
+                      : _(msg`Mute thread`)}
+                  </Menu.ItemText>
+                  <Menu.ItemIcon
+                    icon={isThreadMuted ? Unmute : Mute}
+                    position="right"
+                  />
+                </Menu.Item>
+
+                <Menu.Item
+                  testID="postDropdownMuteWordsBtn"
+                  label={_(msg`Mute words & tags`)}
+                  onPress={() => mutedWordsDialogControl.open()}>
+                  <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
+                  <Menu.ItemIcon icon={Filter} position="right" />
+                </Menu.Item>
+
+                {!isAuthor && !isPostHidden && (
+                  <Menu.Item
+                    testID="postDropdownHideBtn"
+                    label={_(msg`Hide post`)}
+                    onPress={() => {
+                      openModal({
+                        name: 'confirm',
+                        title: _(msg`Hide this post?`),
+                        message: _(
+                          msg`This will hide this post from your feeds.`,
+                        ),
+                        onPressConfirm: onHidePost,
+                      })
+                    }}>
+                    <Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText>
+                    <Menu.ItemIcon icon={EyeSlash} position="right" />
+                  </Menu.Item>
+                )}
+              </Menu.Group>
+            </>
+          )}
+
+          <Menu.Divider />
+
+          <Menu.Group>
+            {!isAuthor && (
+              <Menu.Item
+                testID="postDropdownReportBtn"
+                label={_(msg`Report post`)}
+                onPress={() => {
+                  openModal({
+                    name: 'report',
+                    uri: postUri,
+                    cid: postCid,
+                  })
+                }}>
+                <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={Warning} position="right" />
+              </Menu.Item>
+            )}
+
+            {isAuthor && (
+              <Menu.Item
+                testID="postDropdownDeleteBtn"
+                label={_(msg`Delete post`)}
+                onPress={() => {
+                  openModal({
+                    name: 'confirm',
+                    title: _(msg`Delete this post?`),
+                    message: _(msg`Are you sure? This cannot be undone.`),
+                    onPressConfirm: onDeletePost,
+                  })
+                }}>
+                <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
+                <Menu.ItemIcon icon={Trash} position="right" />
+              </Menu.Item>
+            )}
+
+            {showAppealLabelItem && (
+              <>
+                <Menu.Divider />
+
+                <Menu.Item
+                  testID="postDropdownAppealBtn"
+                  label={_(msg`Appeal content warning`)}
+                  onPress={() => {
+                    openModal({
+                      name: 'appeal-label',
+                      uri: postUri,
+                      cid: postCid,
+                    })
+                  }}>
+                  <Menu.ItemText>
+                    {_(msg`Appeal content warning`)}
+                  </Menu.ItemText>
+                  <Menu.ItemIcon icon={CircleInfo} position="right" />
+                </Menu.Item>
+              </>
+            )}
+          </Menu.Group>
+        </Menu.Outer>
+      </Menu.Root>
     </EventStopper>
   )
 }
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index bd21ddda..b1ec32b3 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -231,6 +231,7 @@ let PostCtrls = ({
           richText={richText}
           showAppealLabelItem={showAppealLabelItem}
           style={styles.btnPad}
+          hitSlop={big ? HITSLOP_20 : HITSLOP_10}
         />
       </View>
     </View>

From 0f9f08b1ef795215975c7b041d0e94a992d22124 Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Fri, 8 Mar 2024 14:31:24 -0800
Subject: [PATCH 06/13] Fix reactivity of dialogs (Dialogs Pt. 1) (#3146)

* Improve a11y on ios

* Format

* Remove android

* Fix android

* Revert some changes

* use sharedvalue for `importantForAccessibility`

* add back `isOpen`

* fix some more types

---------

Co-authored-by: Eric Bailey <git@esb.lol>
---
 src/components/Dialog/context.ts |  6 ++--
 src/components/Dialog/types.ts   |  2 +-
 src/state/dialogs/index.tsx      | 60 ++++++++++++++++++++++----------
 src/view/shell/index.tsx         | 19 +++-------
 4 files changed, 49 insertions(+), 38 deletions(-)

diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index 9b571e8e..859f8edd 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -21,8 +21,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
     open: () => {},
     close: () => {},
   })
-  const {activeDialogs, openDialogs} = useDialogStateContext()
-  const isOpen = openDialogs.includes(id)
+  const {activeDialogs} = useDialogStateContext()
 
   React.useEffect(() => {
     activeDialogs.current.set(id, control)
@@ -36,7 +35,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
     () => ({
       id,
       ref: control,
-      isOpen,
       open: () => {
         control.current.open()
       },
@@ -44,6 +42,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
         control.current.close(cb)
       },
     }),
-    [id, control, isOpen],
+    [id, control],
   )
 }
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index fa9398fe..4fc60ec3 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -22,7 +22,7 @@ export type DialogControlRefProps = {
 export type DialogControlProps = DialogControlRefProps & {
   id: string
   ref: React.RefObject<DialogControlRefProps>
-  isOpen: boolean
+  isOpen?: boolean
 }
 
 export type DialogContextProps = {
diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx
index 90aaca4f..951105a5 100644
--- a/src/state/dialogs/index.tsx
+++ b/src/state/dialogs/index.tsx
@@ -1,8 +1,9 @@
 import React from 'react'
+import {SharedValue, useSharedValue} from 'react-native-reanimated'
 import {DialogControlRefProps} from '#/components/Dialog'
 import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
 
-const DialogContext = React.createContext<{
+interface IDialogContext {
   /**
    * The currently active `useDialogControl` hooks.
    */
@@ -13,13 +14,18 @@ const DialogContext = React.createContext<{
    * The currently open dialogs, referenced by their IDs, generated from
    * `useId`.
    */
-  openDialogs: string[]
-}>({
-  activeDialogs: {
-    current: new Map(),
-  },
-  openDialogs: [],
-})
+  openDialogs: React.MutableRefObject<Set<string>>
+  /**
+   * The counterpart to `accessibilityViewIsModal` for Android. This property
+   * applies to the parent of all non-modal views, and prevents TalkBack from
+   * navigating within content beneath an open dialog.
+   *
+   * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
+   */
+  importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'>
+}
+
+const DialogContext = React.createContext<IDialogContext>({} as IDialogContext)
 
 const DialogControlContext = React.createContext<{
   closeAllDialogs(): boolean
@@ -41,26 +47,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   const activeDialogs = React.useRef<
     Map<string, React.MutableRefObject<DialogControlRefProps>>
   >(new Map())
-  const [openDialogs, setOpenDialogs] = React.useState<string[]>([])
+  const openDialogs = React.useRef<Set<string>>(new Set())
+  const importantForAccessibility = useSharedValue<
+    'auto' | 'no-hide-descendants'
+  >('auto')
 
   const closeAllDialogs = React.useCallback(() => {
     activeDialogs.current.forEach(dialog => dialog.current.close())
-    return openDialogs.length > 0
-  }, [openDialogs])
+    return openDialogs.current.size > 0
+  }, [])
 
   const setDialogIsOpen = React.useCallback(
     (id: string, isOpen: boolean) => {
-      setOpenDialogs(prev => {
-        const filtered = prev.filter(dialogId => dialogId !== id) as string[]
-        return isOpen ? [...filtered, id] : filtered
-      })
+      if (isOpen) {
+        openDialogs.current.add(id)
+        importantForAccessibility.value = 'no-hide-descendants'
+      } else {
+        openDialogs.current.delete(id)
+        if (openDialogs.current.size < 1) {
+          importantForAccessibility.value = 'auto'
+        }
+      }
     },
-    [setOpenDialogs],
+    [importantForAccessibility],
   )
 
-  const context = React.useMemo(
-    () => ({activeDialogs, openDialogs}),
-    [openDialogs],
+  const context = React.useMemo<IDialogContext>(
+    () => ({
+      activeDialogs: {
+        current: new Map(),
+      },
+      openDialogs: {
+        current: new Set(),
+      },
+      importantForAccessibility,
+    }),
+    [importantForAccessibility],
   )
   const controls = React.useMemo(
     () => ({closeAllDialogs, setDialogIsOpen}),
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index bdba7917..76a7f8fb 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -30,7 +30,8 @@ import {useCloseAnyActiveElement} from '#/state/util'
 import * as notifications from 'lib/notifications/notifications'
 import {Outlet as PortalOutlet} from '#/components/Portal'
 import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
-import {useDialogStateContext} from '#/state/dialogs'
+import {useDialogStateContext} from 'state/dialogs'
+import Animated from 'react-native-reanimated'
 
 function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
@@ -54,9 +55,9 @@ function ShellInner() {
   const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
   const {hasSession, currentAccount} = useSession()
   const closeAnyActiveElement = useCloseAnyActiveElement()
+  const {importantForAccessibility} = useDialogStateContext()
   // start undefined
   const currentAccountDid = React.useRef<string | undefined>(undefined)
-  const {openDialogs} = useDialogStateContext()
 
   React.useEffect(() => {
     let listener = {remove() {}}
@@ -80,19 +81,9 @@ function ShellInner() {
     }
   }, [currentAccount])
 
-  /**
-   * The counterpart to `accessibilityViewIsModal` for Android. This property
-   * applies to the parent of all non-modal views, and prevents TalkBack from
-   * navigating within content beneath an open dialog.
-   *
-   * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
-   */
-  const importantForAccessibility =
-    openDialogs.length > 0 ? 'no-hide-descendants' : undefined
-
   return (
     <>
-      <View
+      <Animated.View
         style={containerPadding}
         importantForAccessibility={importantForAccessibility}>
         <ErrorBoundary>
@@ -106,7 +97,7 @@ function ShellInner() {
             <TabsNavigator />
           </Drawer>
         </ErrorBoundary>
-      </View>
+      </Animated.View>
       <Composer winHeight={winDim.height} />
       <ModalsContainer />
       <MutedWordsDialog />

From 8ee325e73d927a34ef23a776c5404e9556f0d94a Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Fri, 8 Mar 2024 14:31:50 -0800
Subject: [PATCH 07/13] Make ALF prompt scrollable for accessibility (#3150)

* make alf prompt scrollable

* padding
---
 src/components/Prompt.tsx | 20 +++++++++++++++-----
 1 file changed, 15 insertions(+), 5 deletions(-)

diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 8e55bd83..28ec2d03 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -3,7 +3,7 @@ import {View, PressableProps} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useTheme, atoms as a} from '#/alf'
+import {useTheme, atoms as a, useBreakpoints} from '#/alf'
 import {Text} from '#/components/Typography'
 import {Button} from '#/components/Button'
 
@@ -25,6 +25,7 @@ export function Outer({
 }: React.PropsWithChildren<{
   control: Dialog.DialogOuterProps['control']
 }>) {
+  const {gtMobile} = useBreakpoints()
   const titleId = React.useId()
   const descriptionId = React.useId()
 
@@ -38,12 +39,12 @@ export function Outer({
       <Context.Provider value={context}>
         <Dialog.Handle />
 
-        <Dialog.Inner
+        <Dialog.ScrollableInner
           accessibilityLabelledBy={titleId}
           accessibilityDescribedBy={descriptionId}
-          style={[{width: 'auto', maxWidth: 400}]}>
+          style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
           {children}
-        </Dialog.Inner>
+        </Dialog.ScrollableInner>
       </Context.Provider>
     </Dialog.Outer>
   )
@@ -71,8 +72,17 @@ export function Description({children}: React.PropsWithChildren<{}>) {
 }
 
 export function Actions({children}: React.PropsWithChildren<{}>) {
+  const {gtMobile} = useBreakpoints()
+
   return (
-    <View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}>
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.gap_sm,
+        a.justify_end,
+        gtMobile && [a.pb_4xl],
+      ]}>
       {children}
     </View>
   )

From 62e57c3b08020e17b3266876de342996c8bd12db Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Fri, 8 Mar 2024 14:43:28 -0800
Subject: [PATCH 08/13] Adjustments to ALF prompt buttons (Dialogs Pt. 2)
 (#3144)

* Improve a11y on ios

* Format

* Remove android

* Fix android

* small adjustment to buttons in prompt

* full width below gtmobile

* Revert some changes

* use sharedvalue for `importantForAccessibility`

* add back `isOpen`

* fix some more types

* small adjustment to buttons in prompt

* full width below gtmobile

---------

Co-authored-by: Eric Bailey <git@esb.lol>
---
 src/components/Button.tsx | 4 +++-
 src/components/Prompt.tsx | 9 +++++----
 2 files changed, 8 insertions(+), 5 deletions(-)

diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 5361be96..d3bf73cc 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -27,7 +27,7 @@ export type ButtonColor =
   | 'gradient_sunset'
   | 'gradient_nordic'
   | 'gradient_bonfire'
-export type ButtonSize = 'tiny' | 'small' | 'large'
+export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
 export type ButtonShape = 'round' | 'square' | 'default'
 export type VariantProps = {
   /**
@@ -274,6 +274,8 @@ export function Button({
     if (shape === 'default') {
       if (size === 'large') {
         baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
+      } else if (size === 'medium') {
+        baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
       } else if (size === 'small') {
         baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
       } else if (size === 'tiny') {
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 28ec2d03..3b245c44 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -78,10 +78,9 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
     <View
       style={[
         a.w_full,
-        a.flex_row,
         a.gap_sm,
         a.justify_end,
-        gtMobile && [a.pb_4xl],
+        gtMobile ? [a.flex_row] : [a.flex_col, a.pt_md, a.pb_4xl],
       ]}>
       {children}
     </View>
@@ -92,12 +91,13 @@ export function Cancel({
   children,
 }: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
   const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
   const {close} = Dialog.useDialogContext()
   return (
     <Button
       variant="solid"
       color="secondary"
-      size="small"
+      size={gtMobile ? 'small' : 'medium'}
       label={_(msg`Cancel`)}
       onPress={() => close()}>
       {children}
@@ -110,6 +110,7 @@ export function Action({
   onPress,
 }: React.PropsWithChildren<{onPress?: () => void}>) {
   const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
   const {close} = Dialog.useDialogContext()
   const handleOnPress = React.useCallback(() => {
     close()
@@ -119,7 +120,7 @@ export function Action({
     <Button
       variant="solid"
       color="primary"
-      size="small"
+      size={gtMobile ? 'small' : 'medium'}
       label={_(msg`Confirm`)}
       onPress={handleOnPress}>
       {children}

From 7899a6fd09b1c4981480bec20fa3b4aca9252a5f Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Fri, 8 Mar 2024 15:25:33 -0800
Subject: [PATCH 09/13] Fix missing check for invite code (#3157)

---
 src/view/com/auth/create/Step1.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index c1bec535..1f6852f8 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -157,7 +157,7 @@ export function Step1({
             </View>
           )}
 
-          {uiState.inviteCode ? (
+          {!uiState.isInviteCodeRequired || uiState.inviteCode ? (
             <>
               <View style={s.pb20}>
                 <Text

From aad8c080eda81ad96875c817420d719a8c80874f Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Sat, 9 Mar 2024 00:29:31 +0000
Subject: [PATCH 10/13] Poll Statsig for config changes (#3158)

---
 src/lib/statsig/statsig.tsx     | 12 ++++++++++++
 src/lib/statsig/statsig.web.tsx | 12 ++++++++++++
 2 files changed, 24 insertions(+)

diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index 43822dda..6d9ebeb0 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -48,6 +48,18 @@ export function Provider({children}: {children: React.ReactNode}) {
     () => toStatsigUser(currentAccount?.did),
     [currentAccount?.did],
   )
+
+  React.useEffect(() => {
+    function refresh() {
+      // Intentionally refetching the config using the JS SDK rather than React SDK
+      // so that the new config is stored in cache but isn't used during this session.
+      // It will kick in for the next reload.
+      Statsig.updateUser(currentStatsigUser)
+    }
+    const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
+    return () => clearInterval(id)
+  }, [currentStatsigUser])
+
   return (
     <StatsigProvider
       sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
diff --git a/src/lib/statsig/statsig.web.tsx b/src/lib/statsig/statsig.web.tsx
index fc66e8d9..d1c91201 100644
--- a/src/lib/statsig/statsig.web.tsx
+++ b/src/lib/statsig/statsig.web.tsx
@@ -48,6 +48,18 @@ export function Provider({children}: {children: React.ReactNode}) {
     () => toStatsigUser(currentAccount?.did),
     [currentAccount?.did],
   )
+
+  React.useEffect(() => {
+    function refresh() {
+      // Intentionally refetching the config using the JS SDK rather than React SDK
+      // so that the new config is stored in cache but isn't used during this session.
+      // It will kick in for the next reload.
+      Statsig.updateUser(currentStatsigUser)
+    }
+    const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
+    return () => clearInterval(id)
+  }, [currentStatsigUser])
+
   return (
     <StatsigProvider
       sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"

From 594958c6dc2a69155c19bcd108f19fe9c64f98be Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Sat, 9 Mar 2024 10:35:23 -0600
Subject: [PATCH 11/13] Fix RSS URLs treated as internal (#3156)

* Fix RSS URLs treated as internal

* Add utils to patch relative RSS external links

* modify router to support multiple paths

---------

Co-authored-by: Hailey <me@haileyok.com>
---
 src/lib/routes/router.ts                 | 10 ++++++++--
 src/lib/strings/url-helpers.ts           | 24 ++++++++++++++++++++++--
 src/routes.ts                            |  2 +-
 src/state/preferences/in-app-browser.tsx |  9 +++++++++
 4 files changed, 40 insertions(+), 5 deletions(-)

diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts
index 00defaed..8c8be373 100644
--- a/src/lib/routes/router.ts
+++ b/src/lib/routes/router.ts
@@ -2,9 +2,15 @@ import {RouteParams, Route} from './types'
 
 export class Router {
   routes: [string, Route][] = []
-  constructor(description: Record<string, string>) {
+  constructor(description: Record<string, string | string[]>) {
     for (const [screen, pattern] of Object.entries(description)) {
-      this.routes.push([screen, createRoute(pattern)])
+      if (typeof pattern === 'string') {
+        this.routes.push([screen, createRoute(pattern)])
+      } else {
+        pattern.forEach(subPattern => {
+          this.routes.push([screen, createRoute(subPattern)])
+        })
+      }
     }
   }
 
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 7729e4a3..820311e4 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -3,6 +3,8 @@ import {BSKY_SERVICE} from 'lib/constants'
 import TLDs from 'tlds'
 import psl from 'psl'
 
+export const BSKY_APP_HOST = 'https://bsky.app'
+
 export function isValidDomain(str: string): boolean {
   return !!TLDs.find(tld => {
     let i = str.lastIndexOf(tld)
@@ -67,8 +69,21 @@ export function isBskyAppUrl(url: string): boolean {
   return url.startsWith('https://bsky.app/')
 }
 
+export function isRelativeUrl(url: string): boolean {
+  return /^\/[^/]/.test(url)
+}
+
+export function isBskyRSSUrl(url: string): boolean {
+  return (
+    (url.startsWith('https://bsky.app/') || isRelativeUrl(url)) &&
+    /\/rss\/?$/.test(url)
+  )
+}
+
 export function isExternalUrl(url: string): boolean {
-  return !isBskyAppUrl(url) && url.startsWith('http')
+  const external = !isBskyAppUrl(url) && url.startsWith('http')
+  const rss = isBskyRSSUrl(url)
+  return external || rss
 }
 
 export function isBskyPostUrl(url: string): boolean {
@@ -149,7 +164,7 @@ export function linkRequiresWarning(uri: string, label: string) {
   const labelDomain = labelToDomain(label)
 
   // If the uri started with a / we know it is internal.
-  if (uri.startsWith('/')) {
+  if (isRelativeUrl(uri)) {
     return false
   }
 
@@ -222,3 +237,8 @@ export function splitApexDomain(hostname: string): [string, string] {
     hostnamep.domain,
   ]
 }
+
+export function createBskyAppAbsoluteUrl(path: string): string {
+  const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
+  return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
+}
diff --git a/src/routes.ts b/src/routes.ts
index 3fc908b4..5c263fd6 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -12,7 +12,7 @@ export const router = new Router({
   ModerationModlists: '/moderation/modlists',
   ModerationMutedAccounts: '/moderation/muted-accounts',
   ModerationBlockedAccounts: '/moderation/blocked-accounts',
-  Profile: '/profile/:name',
+  Profile: ['/profile/:name', '/profile/:name/rss'],
   ProfileFollowers: '/profile/:name/followers',
   ProfileFollows: '/profile/:name/follows',
   ProfileList: '/profile/:name/lists/:rkey',
diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx
index 4f033db6..2398f1f8 100644
--- a/src/state/preferences/in-app-browser.tsx
+++ b/src/state/preferences/in-app-browser.tsx
@@ -5,6 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
 import {isNative} from '#/platform/detection'
 import {useModalControls} from '../modals'
 import {usePalette} from 'lib/hooks/usePalette'
+import {
+  isBskyRSSUrl,
+  isRelativeUrl,
+  createBskyAppAbsoluteUrl,
+} from 'lib/strings/url-helpers'
 
 type StateContext = persisted.Schema['useInAppBrowser']
 type SetContext = (v: persisted.Schema['useInAppBrowser']) => void
@@ -57,6 +62,10 @@ export function useOpenLink() {
 
   const openLink = React.useCallback(
     (url: string, override?: boolean) => {
+      if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
+        url = createBskyAppAbsoluteUrl(url)
+      }
+
       if (isNative && !url.startsWith('mailto:')) {
         if (override === undefined && enabled === undefined) {
           openModal({

From f6685419d8ec29823cdd2bd965a97099830b8240 Mon Sep 17 00:00:00 2001
From: Alice <81575558+aliceisjustplaying@users.noreply.github.com>
Date: Sat, 9 Mar 2024 21:07:56 +0000
Subject: [PATCH 12/13] Fix `aria-label` on the Share button (#3159)

It had the same one as the Like button for some reason
---
 src/view/com/util/post-ctrls/PostCtrls.tsx | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index b1ec32b3..1e26eecc 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -212,9 +212,7 @@ let PostCtrls = ({
             style={[styles.btn]}
             onPress={onShare}
             accessibilityRole="button"
-            accessibilityLabel={`${
-              post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`)
-            } (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`}
+            accessibilityLabel={`${_(msg`Share`)}`}
             accessibilityHint=""
             hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
             <ArrowOutOfBox style={[defaultCtrlColor, styles.mt1]} width={22} />

From 1aaed1cc0d59ecafa1bb3c7f57f0a323940e6317 Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Sat, 9 Mar 2024 17:42:42 -0600
Subject: [PATCH 13/13] Fix font styles on web (#3162)

---
 bskyweb/templates/base.html | 3 +++
 web/index.html              | 3 +++
 2 files changed, 6 insertions(+)

diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 55447552..c7c5ec0f 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -43,6 +43,9 @@
       height: calc(100% + env(safe-area-inset-top));
       scrollbar-gutter: stable both-edges;
     }
+    html, body {
+      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+    }
 
     /* Buttons and inputs have a font set by UA, so we'll have to reset that */
     button, input, textarea {
diff --git a/web/index.html b/web/index.html
index b6e01ba4..de0abfc9 100644
--- a/web/index.html
+++ b/web/index.html
@@ -47,6 +47,9 @@
         height: calc(100% + env(safe-area-inset-top));
         scrollbar-gutter: stable both-edges;
       }
+      html, body {
+        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+      }
 
       /* Buttons and inputs have a font set by UA, so we'll have to reset that */
       button, input, textarea {