about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/auth/create/Step2.tsx9
-rw-r--r--src/view/com/auth/create/Step3.tsx2
-rw-r--r--src/view/com/auth/login/ForgotPasswordForm.tsx23
-rw-r--r--src/view/com/auth/login/SetNewPasswordForm.tsx36
-rw-r--r--src/view/com/modals/AddAppPasswords.tsx15
-rw-r--r--src/view/com/modals/ChangePassword.tsx336
-rw-r--r--src/view/com/modals/DeleteAccount.tsx22
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/pager/Pager.tsx16
-rw-r--r--src/view/com/pager/Pager.web.tsx18
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx3
-rw-r--r--src/view/com/pager/PagerWithHeader.web.tsx3
-rw-r--r--src/view/com/util/Link.tsx25
-rw-r--r--src/view/com/util/WebAuxClickWrapper.tsx30
-rw-r--r--src/view/com/util/forms/Button.tsx12
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx40
-rw-r--r--src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx8
-rw-r--r--src/view/com/util/post-embeds/QuoteEmbed.tsx16
-rw-r--r--src/view/com/util/post-embeds/index.tsx17
-rw-r--r--src/view/screens/Home.tsx6
-rw-r--r--src/view/screens/ProfileList.tsx26
-rw-r--r--src/view/screens/Search/Search.tsx18
-rw-r--r--src/view/screens/Settings.tsx65
-rw-r--r--src/view/screens/Storybook/index.tsx24
-rw-r--r--src/view/shell/index.tsx11
-rw-r--r--src/view/shell/index.web.tsx2
27 files changed, 660 insertions, 130 deletions
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index 6005ee3a5..2e16b13bb 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -42,10 +42,11 @@ export function Step2({
   const {isMobile} = useWebMediaQueries()
 
   const onPressRequest = React.useCallback(() => {
-    if (
-      uiState.verificationPhone.length >= 9 &&
-      parsePhoneNumber(uiState.verificationPhone, uiState.phoneCountry)
-    ) {
+    const phoneNumber = parsePhoneNumber(
+      uiState.verificationPhone,
+      uiState.phoneCountry,
+    )
+    if (phoneNumber && phoneNumber.isValid()) {
       requestVerificationCode({uiState, uiDispatch, _})
     } else {
       uiDispatch({
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
index 2fd265535..3a52abf80 100644
--- a/src/view/com/auth/create/Step3.tsx
+++ b/src/view/com/auth/create/Step3.tsx
@@ -43,7 +43,7 @@ export function Step3({
         />
         <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
           <Trans>Your full handle will be</Trans>{' '}
-          <Text type="lg-bold" style={[pal.text, s.ml5]}>
+          <Text type="lg-bold" style={pal.text}>
             @{createFullHandle(uiState.handle, uiState.userDomain)}
           </Text>
         </Text>
diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx
index f9bb64f98..79399d85d 100644
--- a/src/view/com/auth/login/ForgotPasswordForm.tsx
+++ b/src/view/com/auth/login/ForgotPasswordForm.tsx
@@ -195,6 +195,29 @@ export const ForgotPasswordForm = ({
             </Text>
           ) : undefined}
         </View>
+        <View
+          style={[
+            s.flexRow,
+            s.alignCenter,
+            s.mt20,
+            s.mb20,
+            pal.border,
+            s.borderBottom1,
+            {alignSelf: 'center', width: '90%'},
+          ]}
+        />
+        <View style={[s.flexRow, s.justifyCenter]}>
+          <TouchableOpacity
+            testID="skipSendEmailButton"
+            onPress={onEmailSent}
+            accessibilityRole="button"
+            accessibilityLabel={_(msg`Go to next`)}
+            accessibilityHint={_(msg`Navigates to the next screen`)}>
+            <Text type="xl" style={[pal.link, s.pr5]}>
+              <Trans>Already have a code?</Trans>
+            </Text>
+          </TouchableOpacity>
+        </View>
       </View>
     </>
   )
diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx
index 630c6afde..6d1584c86 100644
--- a/src/view/com/auth/login/SetNewPasswordForm.tsx
+++ b/src/view/com/auth/login/SetNewPasswordForm.tsx
@@ -14,6 +14,7 @@ import {isNetworkError} from 'lib/strings/errors'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useTheme} from 'lib/ThemeContext'
 import {cleanError} from 'lib/strings/errors'
+import {checkAndFormatResetCode} from 'lib/strings/password'
 import {logger} from '#/logger'
 import {styles} from './styles'
 import {Trans, msg} from '@lingui/macro'
@@ -46,14 +47,26 @@ export const SetNewPasswordForm = ({
   const [password, setPassword] = useState<string>('')
 
   const onPressNext = async () => {
+    // Check that the code is correct. We do this again just incase the user enters the code after their pw and we
+    // don't get to call onBlur first
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    // TODO Better password strength check
+    if (!formattedCode || !password) {
+      setError(
+        _(
+          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+        ),
+      )
+      return
+    }
+
     setError('')
     setIsProcessing(true)
 
     try {
       const agent = new BskyAgent({service: serviceUrl})
-      const token = resetCode.replace(/\s/g, '')
       await agent.com.atproto.server.resetPassword({
-        token,
+        token: formattedCode,
         password,
       })
       onPasswordSet()
@@ -71,6 +84,19 @@ export const SetNewPasswordForm = ({
     }
   }
 
+  const onBlur = () => {
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    if (!formattedCode) {
+      setError(
+        _(
+          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+        ),
+      )
+      return
+    }
+    setResetCode(formattedCode)
+  }
+
   return (
     <>
       <View>
@@ -100,9 +126,11 @@ export const SetNewPasswordForm = ({
               autoCapitalize="none"
               autoCorrect={false}
               keyboardAppearance={theme.colorScheme}
-              autoFocus
+              autoComplete="off"
               value={resetCode}
               onChangeText={setResetCode}
+              onFocus={() => setError('')}
+              onBlur={onBlur}
               editable={!isProcessing}
               accessible={true}
               accessibilityLabel={_(msg`Reset code`)}
@@ -123,6 +151,7 @@ export const SetNewPasswordForm = ({
               placeholderTextColor={pal.colors.textLight}
               autoCapitalize="none"
               autoCorrect={false}
+              autoComplete="new-password"
               keyboardAppearance={theme.colorScheme}
               secureTextEntry
               value={password}
@@ -160,6 +189,7 @@ export const SetNewPasswordForm = ({
           ) : (
             <TouchableOpacity
               testID="setNewPasswordButton"
+              // Check the code before running the callback
               onPress={onPressNext}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Go to next`)}
diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx
index 5048a0041..a8913dd54 100644
--- a/src/view/com/modals/AddAppPasswords.tsx
+++ b/src/view/com/modals/AddAppPasswords.tsx
@@ -62,7 +62,8 @@ export function Component({}: {}) {
   const {_} = useLingui()
   const {closeModal} = useModalControls()
   const {data: passwords} = useAppPasswordsQuery()
-  const createMutation = useAppPasswordCreateMutation()
+  const {mutateAsync: mutateAppPassword, isPending} =
+    useAppPasswordCreateMutation()
   const [name, setName] = useState(
     shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)],
   )
@@ -107,7 +108,7 @@ export function Component({}: {}) {
     }
 
     try {
-      const newPassword = await createMutation.mutateAsync({name})
+      const newPassword = await mutateAppPassword({name})
       if (newPassword) {
         setAppPassword(newPassword.password)
       } else {
@@ -170,13 +171,10 @@ export function Component({}: {}) {
               autoFocus={true}
               maxLength={32}
               selectTextOnFocus={true}
-              multiline={true} // need this to be true otherwise selectTextOnFocus doesn't work
-              numberOfLines={1} // hack for multiline so only one line shows (android)
-              scrollEnabled={false} // hack for multiline so only one line shows (ios)
-              blurOnSubmit={true} // hack for multiline so it submits
-              editable={!appPassword}
+              blurOnSubmit={true}
+              editable={!isPending}
               returnKeyType="done"
-              onEndEditing={createAppPassword}
+              onSubmitEditing={createAppPassword}
               accessible={true}
               accessibilityLabel={_(msg`Name`)}
               accessibilityHint={_(msg`Input name for app password`)}
@@ -253,7 +251,6 @@ const styles = StyleSheet.create({
     width: '100%',
     paddingVertical: 10,
     paddingHorizontal: 8,
-    marginTop: 6,
     fontSize: 17,
     letterSpacing: 0.25,
     fontWeight: '400',
diff --git a/src/view/com/modals/ChangePassword.tsx b/src/view/com/modals/ChangePassword.tsx
new file mode 100644
index 000000000..d8add9794
--- /dev/null
+++ b/src/view/com/modals/ChangePassword.tsx
@@ -0,0 +1,336 @@
+import React, {useState} from 'react'
+import {
+  ActivityIndicator,
+  SafeAreaView,
+  StyleSheet,
+  TouchableOpacity,
+  View,
+} from 'react-native'
+import {ScrollView} from './util'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {TextInput} from './util'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isAndroid, isWeb} from 'platform/detection'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {cleanError, isNetworkError} from 'lib/strings/errors'
+import {checkAndFormatResetCode} from 'lib/strings/password'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useSession, getAgent} from '#/state/session'
+import * as EmailValidator from 'email-validator'
+import {logger} from '#/logger'
+
+enum Stages {
+  RequestCode,
+  ChangePassword,
+  Done,
+}
+
+export const snapPoints = isAndroid ? ['90%'] : ['45%']
+
+export function Component() {
+  const pal = usePalette('default')
+  const {currentAccount} = useSession()
+  const {_} = useLingui()
+  const [stage, setStage] = useState<Stages>(Stages.RequestCode)
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [resetCode, setResetCode] = useState<string>('')
+  const [newPassword, setNewPassword] = useState<string>('')
+  const [error, setError] = useState<string>('')
+  const {isMobile} = useWebMediaQueries()
+  const {closeModal} = useModalControls()
+  const agent = getAgent()
+
+  const onRequestCode = async () => {
+    if (
+      !currentAccount?.email ||
+      !EmailValidator.validate(currentAccount.email)
+    ) {
+      return setError(_(msg`Your email appears to be invalid.`))
+    }
+
+    setError('')
+    setIsProcessing(true)
+    try {
+      await agent.com.atproto.server.requestPasswordReset({
+        email: currentAccount.email,
+      })
+      setStage(Stages.ChangePassword)
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to request password reset', {error: e})
+      if (isNetworkError(e)) {
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onChangePassword = async () => {
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    // TODO Better password strength check
+    if (!formattedCode || !newPassword) {
+      setError(
+        _(
+          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+        ),
+      )
+      return
+    }
+
+    setError('')
+    setIsProcessing(true)
+    try {
+      await agent.com.atproto.server.resetPassword({
+        token: formattedCode,
+        password: newPassword,
+      })
+      setStage(Stages.Done)
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to set new password', {error: e})
+      if (isNetworkError(e)) {
+        setError(
+          'Unable to contact your service. Please check your Internet connection.',
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onBlur = () => {
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    if (!formattedCode) {
+      setError(
+        _(
+          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+        ),
+      )
+      return
+    }
+    setResetCode(formattedCode)
+  }
+
+  return (
+    <SafeAreaView style={[pal.view, s.flex1]}>
+      <ScrollView
+        contentContainerStyle={[
+          styles.container,
+          isMobile && styles.containerMobile,
+        ]}
+        keyboardShouldPersistTaps="handled">
+        <View>
+          <View style={styles.titleSection}>
+            <Text type="title-lg" style={[pal.text, styles.title]}>
+              {stage !== Stages.Done ? 'Change Password' : 'Password Changed'}
+            </Text>
+          </View>
+
+          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
+            {stage === Stages.RequestCode ? (
+              <Trans>
+                If you want to change your password, we will send you a code to
+                verify that this is your account.
+              </Trans>
+            ) : stage === Stages.ChangePassword ? (
+              <Trans>
+                Enter the code you received to change your password.
+              </Trans>
+            ) : (
+              <Trans>Your password has been changed successfully!</Trans>
+            )}
+          </Text>
+
+          {stage === Stages.RequestCode && (
+            <View style={[s.flexRow, s.justifyCenter, s.mt10]}>
+              <TouchableOpacity
+                testID="skipSendEmailButton"
+                onPress={() => setStage(Stages.ChangePassword)}
+                accessibilityRole="button"
+                accessibilityLabel={_(msg`Go to next`)}
+                accessibilityHint={_(msg`Navigates to the next screen`)}>
+                <Text type="xl" style={[pal.link, s.pr5]}>
+                  <Trans>Already have a code?</Trans>
+                </Text>
+              </TouchableOpacity>
+            </View>
+          )}
+          {stage === Stages.ChangePassword && (
+            <View style={[pal.border, styles.group]}>
+              <View style={[styles.groupContent]}>
+                <FontAwesomeIcon
+                  icon="ticket"
+                  style={[pal.textLight, styles.groupContentIcon]}
+                />
+                <TextInput
+                  testID="codeInput"
+                  style={[pal.text, styles.textInput]}
+                  placeholder="Reset code"
+                  placeholderTextColor={pal.colors.textLight}
+                  value={resetCode}
+                  onChangeText={setResetCode}
+                  onFocus={() => setError('')}
+                  onBlur={onBlur}
+                  accessible={true}
+                  accessibilityLabel={_(msg`Reset Code`)}
+                  accessibilityHint=""
+                  autoCapitalize="none"
+                  autoCorrect={false}
+                  autoComplete="off"
+                />
+              </View>
+              <View
+                style={[
+                  pal.borderDark,
+                  styles.groupContent,
+                  styles.groupBottom,
+                ]}>
+                <FontAwesomeIcon
+                  icon="lock"
+                  style={[pal.textLight, styles.groupContentIcon]}
+                />
+                <TextInput
+                  testID="codeInput"
+                  style={[pal.text, styles.textInput]}
+                  placeholder="New password"
+                  placeholderTextColor={pal.colors.textLight}
+                  onChangeText={setNewPassword}
+                  secureTextEntry
+                  accessible={true}
+                  accessibilityLabel={_(msg`New Password`)}
+                  accessibilityHint=""
+                  autoCapitalize="none"
+                  autoComplete="new-password"
+                />
+              </View>
+            </View>
+          )}
+          {error ? (
+            <ErrorMessage message={error} style={styles.error} />
+          ) : undefined}
+        </View>
+        <View style={[styles.btnContainer]}>
+          {isProcessing ? (
+            <View style={styles.btn}>
+              <ActivityIndicator color="#fff" />
+            </View>
+          ) : (
+            <View style={{gap: 6}}>
+              {stage === Stages.RequestCode && (
+                <Button
+                  testID="requestChangeBtn"
+                  type="primary"
+                  onPress={onRequestCode}
+                  accessibilityLabel={_(msg`Request Code`)}
+                  accessibilityHint=""
+                  label={_(msg`Request Code`)}
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              )}
+              {stage === Stages.ChangePassword && (
+                <Button
+                  testID="confirmBtn"
+                  type="primary"
+                  onPress={onChangePassword}
+                  accessibilityLabel={_(msg`Next`)}
+                  accessibilityHint=""
+                  label={_(msg`Next`)}
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              )}
+              <Button
+                testID="cancelBtn"
+                type={stage !== Stages.Done ? 'default' : 'primary'}
+                onPress={() => {
+                  closeModal()
+                }}
+                accessibilityLabel={
+                  stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`)
+                }
+                accessibilityHint=""
+                label={stage !== Stages.Done ? _(msg`Cancel`) : _(msg`Close`)}
+                labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                labelStyle={[s.f18]}
+              />
+            </View>
+          )}
+        </View>
+      </ScrollView>
+    </SafeAreaView>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    justifyContent: 'space-between',
+  },
+  containerMobile: {
+    paddingHorizontal: 18,
+    paddingBottom: 35,
+  },
+  titleSection: {
+    paddingTop: isWeb ? 0 : 4,
+    paddingBottom: isWeb ? 14 : 10,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: '600',
+    marginBottom: 5,
+  },
+  error: {
+    borderRadius: 6,
+  },
+  textInput: {
+    width: '100%',
+    paddingHorizontal: 14,
+    paddingVertical: 10,
+    fontSize: 16,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnContainer: {
+    paddingTop: 20,
+  },
+  group: {
+    borderWidth: 1,
+    borderRadius: 10,
+    marginVertical: 20,
+  },
+  groupLabel: {
+    paddingHorizontal: 20,
+    paddingBottom: 5,
+  },
+  groupContent: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  groupBottom: {
+    borderTopWidth: 1,
+  },
+  groupContentIcon: {
+    marginLeft: 10,
+  },
+})
diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx
index 945d7bc89..40d78cfe0 100644
--- a/src/view/com/modals/DeleteAccount.tsx
+++ b/src/view/com/modals/DeleteAccount.tsx
@@ -1,11 +1,12 @@
 import React from 'react'
 import {
+  SafeAreaView,
   ActivityIndicator,
   StyleSheet,
   TouchableOpacity,
   View,
 } from 'react-native'
-import {TextInput} from './util'
+import {TextInput, ScrollView} from './util'
 import LinearGradient from 'react-native-linear-gradient'
 import * as Toast from '../util/Toast'
 import {Text} from '../util/text/Text'
@@ -20,8 +21,9 @@ import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useModalControls} from '#/state/modals'
 import {useSession, useSessionApi, getAgent} from '#/state/session'
+import {isAndroid} from 'platform/detection'
 
-export const snapPoints = ['60%']
+export const snapPoints = isAndroid ? ['90%'] : ['55%']
 
 export function Component({}: {}) {
   const pal = usePalette('default')
@@ -76,8 +78,10 @@ export function Component({}: {}) {
     closeModal()
   }
   return (
-    <View style={[styles.container, pal.view]}>
-      <View style={[styles.innerContainer, pal.view]}>
+    <SafeAreaView style={[s.flex1]}>
+      <ScrollView
+        contentContainerStyle={[pal.view]}
+        keyboardShouldPersistTaps="handled">
         <View style={[styles.titleContainer, pal.view]}>
           <Text type="title-xl" style={[s.textCenter, pal.text]}>
             <Trans>Delete Account</Trans>
@@ -234,18 +238,12 @@ export function Component({}: {}) {
             )}
           </>
         )}
-      </View>
-    </View>
+      </ScrollView>
+    </SafeAreaView>
   )
 }
 
 const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-  },
-  innerContainer: {
-    paddingBottom: 20,
-  },
   titleContainer: {
     display: 'flex',
     flexDirection: 'row',
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 7f814d971..4aa10d75b 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -36,6 +36,7 @@ import * as ModerationDetailsModal from './ModerationDetails'
 import * as BirthDateSettingsModal from './BirthDateSettings'
 import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
+import * as ChangePasswordModal from './ChangePassword'
 import * as SwitchAccountModal from './SwitchAccount'
 import * as LinkWarningModal from './LinkWarning'
 import * as EmbedConsentModal from './EmbedConsent'
@@ -172,6 +173,9 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'change-email') {
     snapPoints = ChangeEmailModal.snapPoints
     element = <ChangeEmailModal.Component />
+  } else if (activeModal?.name === 'change-password') {
+    snapPoints = ChangePasswordModal.snapPoints
+    element = <ChangePasswordModal.Component />
   } else if (activeModal?.name === 'switch-account') {
     snapPoints = SwitchAccountModal.snapPoints
     element = <SwitchAccountModal.Component />
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index d79663746..384a4772a 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -34,6 +34,7 @@ import * as ModerationDetailsModal from './ModerationDetails'
 import * as BirthDateSettingsModal from './BirthDateSettings'
 import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
+import * as ChangePasswordModal from './ChangePassword'
 import * as LinkWarningModal from './LinkWarning'
 import * as EmbedConsentModal from './EmbedConsent'
 
@@ -134,6 +135,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <VerifyEmailModal.Component {...modal} />
   } else if (modal.name === 'change-email') {
     element = <ChangeEmailModal.Component />
+  } else if (modal.name === 'change-password') {
+    element = <ChangePasswordModal.Component />
   } else if (modal.name === 'link-warning') {
     element = <LinkWarningModal.Component {...modal} />
   } else if (modal.name === 'embed-consent') {
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index 834b1c0d0..06ec2e450 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -22,7 +22,6 @@ export interface RenderTabBarFnProps {
 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
 
 interface Props {
-  tabBarPosition?: 'top' | 'bottom'
   initialPage?: number
   renderTabBar: RenderTabBarFn
   onPageSelected?: (index: number) => void
@@ -36,7 +35,6 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
   function PagerImpl(
     {
       children,
-      tabBarPosition = 'top',
       initialPage = 0,
       renderTabBar,
       onPageScrollStateChanged,
@@ -122,11 +120,10 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
 
     return (
       <View testID={testID} style={s.flex1}>
-        {tabBarPosition === 'top' &&
-          renderTabBar({
-            selectedPage,
-            onSelect: onTabBarSelect,
-          })}
+        {renderTabBar({
+          selectedPage,
+          onSelect: onTabBarSelect,
+        })}
         <AnimatedPagerView
           ref={pagerView}
           style={s.flex1}
@@ -136,11 +133,6 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
           onPageScroll={onPageScroll}>
           {children}
         </AnimatedPagerView>
-        {tabBarPosition === 'bottom' &&
-          renderTabBar({
-            selectedPage,
-            onSelect: onTabBarSelect,
-          })}
       </View>
     )
   },
diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx
index dde799e42..d7113bb05 100644
--- a/src/view/com/pager/Pager.web.tsx
+++ b/src/view/com/pager/Pager.web.tsx
@@ -11,7 +11,6 @@ export interface RenderTabBarFnProps {
 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
 
 interface Props {
-  tabBarPosition?: 'top' | 'bottom'
   initialPage?: number
   renderTabBar: RenderTabBarFn
   onPageSelected?: (index: number) => void
@@ -20,7 +19,6 @@ interface Props {
 export const Pager = React.forwardRef(function PagerImpl(
   {
     children,
-    tabBarPosition = 'top',
     initialPage = 0,
     renderTabBar,
     onPageSelected,
@@ -72,22 +70,16 @@ export const Pager = React.forwardRef(function PagerImpl(
 
   return (
     <View style={s.hContentRegion}>
-      {tabBarPosition === 'top' &&
-        renderTabBar({
-          selectedPage,
-          tabBarAnchor: <View ref={anchorRef} />,
-          onSelect: onTabBarSelect,
-        })}
+      {renderTabBar({
+        selectedPage,
+        tabBarAnchor: <View ref={anchorRef} />,
+        onSelect: onTabBarSelect,
+      })}
       {React.Children.map(children, (child, i) => (
         <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
           {child}
         </View>
       ))}
-      {tabBarPosition === 'bottom' &&
-        renderTabBar({
-          selectedPage,
-          onSelect: onTabBarSelect,
-        })}
     </View>
   )
 })
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 279b607ad..7e9ed24db 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -183,8 +183,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
         initialPage={initialPage}
         onPageSelected={onPageSelectedInner}
         onPageSelecting={onPageSelecting}
-        renderTabBar={renderTabBar}
-        tabBarPosition="top">
+        renderTabBar={renderTabBar}>
         {toArray(children)
           .filter(Boolean)
           .map((child, i) => {
diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx
index 0a18a9e7d..4f959d548 100644
--- a/src/view/com/pager/PagerWithHeader.web.tsx
+++ b/src/view/com/pager/PagerWithHeader.web.tsx
@@ -76,8 +76,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
         initialPage={initialPage}
         onPageSelected={onPageSelectedInner}
         onPageSelecting={onPageSelecting}
-        renderTabBar={renderTabBar}
-        tabBarPosition="top">
+        renderTabBar={renderTabBar}>
         {toArray(children)
           .filter(Boolean)
           .map((child, i) => {
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index a517ba430..afbdeb8f4 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -31,6 +31,7 @@ import {PressableWithHover} from './PressableWithHover'
 import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
 import {useModalControls} from '#/state/modals'
 import {useOpenLink} from '#/state/preferences/in-app-browser'
+import {WebAuxClickWrapper} from 'view/com/util/WebAuxClickWrapper'
 
 type Event =
   | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@@ -104,17 +105,19 @@ export const Link = memo(function Link({
       )
     }
     return (
-      <TouchableWithoutFeedback
-        testID={testID}
-        onPress={onPress}
-        accessible={accessible}
-        accessibilityRole="link"
-        {...props}>
-        {/* @ts-ignore web only -prf */}
-        <View style={style} href={anchorHref}>
-          {children ? children : <Text>{title || 'link'}</Text>}
-        </View>
-      </TouchableWithoutFeedback>
+      <WebAuxClickWrapper>
+        <TouchableWithoutFeedback
+          testID={testID}
+          onPress={onPress}
+          accessible={accessible}
+          accessibilityRole="link"
+          {...props}>
+          {/* @ts-ignore web only -prf */}
+          <View style={style} href={anchorHref}>
+            {children ? children : <Text>{title || 'link'}</Text>}
+          </View>
+        </TouchableWithoutFeedback>
+      </WebAuxClickWrapper>
     )
   }
 
diff --git a/src/view/com/util/WebAuxClickWrapper.tsx b/src/view/com/util/WebAuxClickWrapper.tsx
new file mode 100644
index 000000000..8105a8518
--- /dev/null
+++ b/src/view/com/util/WebAuxClickWrapper.tsx
@@ -0,0 +1,30 @@
+import React from 'react'
+import {Platform} from 'react-native'
+
+const onMouseUp = (e: React.MouseEvent & {target: HTMLElement}) => {
+  // Only handle whenever it is the middle button
+  if (e.button !== 1 || e.target.closest('a') || e.target.tagName === 'A') {
+    return
+  }
+
+  e.target.dispatchEvent(
+    new MouseEvent('click', {metaKey: true, bubbles: true}),
+  )
+}
+
+const onMouseDown = (e: React.MouseEvent) => {
+  // Prevents the middle click scroll from enabling
+  if (e.button !== 1) return
+  e.preventDefault()
+}
+
+export function WebAuxClickWrapper({children}: React.PropsWithChildren<{}>) {
+  if (Platform.OS !== 'web') return children
+
+  return (
+    // @ts-ignore web only
+    <div onMouseDown={onMouseDown} onMouseUp={onMouseUp}>
+      {children}
+    </div>
+  )
+}
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 8f24f8288..e6e05bb04 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -9,15 +9,13 @@ import {
   PressableStateCallbackType,
   ActivityIndicator,
   View,
+  NativeSyntheticEvent,
+  NativeTouchEvent,
 } from 'react-native'
 import {Text} from '../text/Text'
 import {useTheme} from 'lib/ThemeContext'
 import {choose} from 'lib/functions'
 
-type Event =
-  | React.MouseEvent<HTMLAnchorElement, MouseEvent>
-  | GestureResponderEvent
-
 export type ButtonType =
   | 'primary'
   | 'secondary'
@@ -59,7 +57,7 @@ export function Button({
   style?: StyleProp<ViewStyle>
   labelContainerStyle?: StyleProp<ViewStyle>
   labelStyle?: StyleProp<TextStyle>
-  onPress?: () => void | Promise<void>
+  onPress?: (e: NativeSyntheticEvent<NativeTouchEvent>) => void | Promise<void>
   testID?: string
   accessibilityLabel?: string
   accessibilityHint?: string
@@ -148,11 +146,11 @@ export function Button({
 
   const [isLoading, setIsLoading] = React.useState(false)
   const onPressWrapped = React.useCallback(
-    async (event: Event) => {
+    async (event: GestureResponderEvent) => {
       event.stopPropagation()
       event.preventDefault()
       withLoading && setIsLoading(true)
-      await onPress?.()
+      await onPress?.(event)
       withLoading && setIsLoading(false)
     },
     [onPress, withLoading],
diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx
index 411b77484..2285b0615 100644
--- a/src/view/com/util/forms/DropdownButton.tsx
+++ b/src/view/com/util/forms/DropdownButton.tsx
@@ -1,10 +1,12 @@
 import React, {PropsWithChildren, useMemo, useRef} from 'react'
 import {
   Dimensions,
+  GestureResponderEvent,
   StyleProp,
   StyleSheet,
   TouchableOpacity,
   TouchableWithoutFeedback,
+  useWindowDimensions,
   View,
   ViewStyle,
 } from 'react-native'
@@ -19,6 +21,7 @@ import {useTheme} from 'lib/ThemeContext'
 import {HITSLOP_10} from 'lib/constants'
 import {useLingui} from '@lingui/react'
 import {msg} from '@lingui/macro'
+import {isWeb} from 'platform/detection'
 
 const ESTIMATED_BTN_HEIGHT = 50
 const ESTIMATED_SEP_HEIGHT = 16
@@ -80,21 +83,22 @@ export function DropdownButton({
   const ref1 = useRef<TouchableOpacity>(null)
   const ref2 = useRef<View>(null)
 
-  const onPress = () => {
+  const onPress = (e: GestureResponderEvent) => {
     const ref = ref1.current || ref2.current
+    const {height: winHeight} = Dimensions.get('window')
+    const pressY = e.nativeEvent.pageY
     ref?.measure(
       (
         _x: number,
         _y: number,
         width: number,
-        height: number,
+        _height: number,
         pageX: number,
         pageY: number,
       ) => {
         if (!menuWidth) {
           menuWidth = 200
         }
-        const winHeight = Dimensions.get('window').height
         let estimatedMenuHeight = 0
         for (const item of items) {
           if (item && isSep(item)) {
@@ -108,13 +112,16 @@ export function DropdownButton({
         const newX = openToRight
           ? pageX + width + rightOffset
           : pageX + width - menuWidth
-        let newY = pageY + height + bottomOffset
+
+        // Add a bit of additional room
+        let newY = pressY + bottomOffset + 20
         if (openUpwards || newY + estimatedMenuHeight > winHeight) {
           newY -= estimatedMenuHeight
         }
         createDropdownMenu(
           newX,
           newY,
+          pageY,
           menuWidth,
           items.filter(v => !!v) as DropdownItem[],
         )
@@ -168,6 +175,7 @@ export function DropdownButton({
 function createDropdownMenu(
   x: number,
   y: number,
+  pageY: number,
   width: number,
   items: DropdownItem[],
 ): RootSiblings {
@@ -185,6 +193,7 @@ function createDropdownMenu(
         onOuterPress={onOuterPress}
         x={x}
         y={y}
+        pageY={pageY}
         width={width}
         items={items}
         onPressItem={onPressItem}
@@ -198,6 +207,7 @@ type DropDownItemProps = {
   onOuterPress: () => void
   x: number
   y: number
+  pageY: number
   width: number
   items: DropdownItem[]
   onPressItem: (index: number) => void
@@ -207,6 +217,7 @@ const DropdownItems = ({
   onOuterPress,
   x,
   y,
+  pageY,
   width,
   items,
   onPressItem,
@@ -214,6 +225,7 @@ const DropdownItems = ({
   const pal = usePalette('default')
   const theme = useTheme()
   const {_} = useLingui()
+  const {height: screenHeight} = useWindowDimensions()
   const dropDownBackgroundColor =
     theme.colorScheme === 'dark' ? pal.btn : pal.view
   const separatorColor =
@@ -233,7 +245,21 @@ const DropdownItems = ({
         onPress={onOuterPress}
         accessibilityLabel={_(msg`Toggle dropdown`)}
         accessibilityHint="">
-        <View style={[styles.bg]} />
+        <View
+          style={[
+            styles.bg,
+            // On web we need to adjust the top and bottom relative to the scroll position
+            isWeb
+              ? {
+                  top: -pageY,
+                  bottom: pageY - screenHeight,
+                }
+              : {
+                  top: 0,
+                  bottom: 0,
+                },
+          ]}
+        />
       </TouchableWithoutFeedback>
       <View
         style={[
@@ -295,10 +321,8 @@ function isBtn(item: DropdownItem): item is DropdownItemButton {
 const styles = StyleSheet.create({
   bg: {
     position: 'absolute',
-    top: 0,
-    right: 0,
-    bottom: 0,
     left: 0,
+    width: '100%',
     backgroundColor: '#000',
     opacity: 0.1,
   },
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
index 8b0858b69..d556e7669 100644
--- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -78,9 +78,13 @@ function Player({
   onLoad: () => void
 }) {
   // ensures we only load what's requested
+  // when it's a youtube video, we need to allow both bsky.app and youtube.com
   const onShouldStartLoadWithRequest = React.useCallback(
-    (event: ShouldStartLoadRequest) => event.url === params.playerUri,
-    [params.playerUri],
+    (event: ShouldStartLoadRequest) =>
+      event.url === params.playerUri ||
+      (params.source.startsWith('youtube') &&
+        event.url.includes('www.youtube.com')),
+    [params.playerUri, params.source],
   )
 
   // Don't show the player until it is active
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 256817bba..d9d84feb4 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -113,13 +113,15 @@ export function QuoteEmbed({
       hoverStyle={{borderColor: pal.colors.borderLinkHover}}
       href={itemHref}
       title={itemTitle}>
-      <PostMeta
-        author={quote.author}
-        showAvatar
-        authorHasWarning={false}
-        postHref={itemHref}
-        timestamp={quote.indexedAt}
-      />
+      <View pointerEvents="none">
+        <PostMeta
+          author={quote.author}
+          showAvatar
+          authorHasWarning={false}
+          postHref={itemHref}
+          timestamp={quote.indexedAt}
+        />
+      </View>
       {moderation ? (
         <PostAlerts moderation={moderation} style={styles.alert} />
       ) : null}
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 6f168a293..7e235babb 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useCallback} from 'react'
 import {
   StyleSheet,
   StyleProp,
@@ -29,6 +29,8 @@ import {ListEmbed} from './ListEmbed'
 import {isCauseALabelOnUri, isQuoteBlurred} from 'lib/moderation'
 import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
 import {ContentHider} from '../moderation/ContentHider'
+import {isNative} from '#/platform/detection'
+import {shareUrl} from '#/lib/sharing'
 
 type Embed =
   | AppBskyEmbedRecord.View
@@ -51,6 +53,16 @@ export function PostEmbeds({
   const pal = usePalette('default')
   const {openLightbox} = useLightboxControls()
 
+  const externalUri = AppBskyEmbedExternal.isView(embed)
+    ? embed.external.uri
+    : null
+
+  const onShareExternal = useCallback(() => {
+    if (externalUri && isNative) {
+      shareUrl(externalUri)
+    }
+  }, [externalUri])
+
   // quote post with media
   // =
   if (AppBskyEmbedRecordWithMedia.isView(embed)) {
@@ -164,7 +176,8 @@ export function PostEmbeds({
         anchorNoUnderline
         href={link.uri}
         style={[styles.extOuter, pal.view, pal.borderDark, style]}
-        hoverStyle={{borderColor: pal.colors.borderLinkHover}}>
+        hoverStyle={{borderColor: pal.colors.borderLinkHover}}
+        onLongPress={onShareExternal}>
         <ExternalLinkEmbed link={link} />
       </Link>
     )
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 7d6a40f02..1da276488 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -184,8 +184,7 @@ function HomeScreenReady({
       initialPage={clamp(selectedPageIndex, 0, customFeeds.length)}
       onPageSelected={onPageSelected}
       onPageScrollStateChanged={onPageScrollStateChanged}
-      renderTabBar={renderTabBar}
-      tabBarPosition="top">
+      renderTabBar={renderTabBar}>
       <FeedPage
         key="1"
         testID="followingFeedPage"
@@ -212,8 +211,7 @@ function HomeScreenReady({
       testID="homeScreen"
       onPageSelected={onPageSelected}
       onPageScrollStateChanged={onPageScrollStateChanged}
-      renderTabBar={renderTabBar}
-      tabBarPosition="top">
+      renderTabBar={renderTabBar}>
       <HomeLoggedOutCTA />
     </Pager>
   )
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 17c93b037..796464883 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -55,6 +55,7 @@ import {
   usePreferencesQuery,
   usePinFeedMutation,
   useUnpinFeedMutation,
+  useSetSaveFeedsMutation,
 } from '#/state/queries/preferences'
 import {logger} from '#/logger'
 import {useAnalytics} from '#/lib/analytics/analytics'
@@ -246,9 +247,11 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
     useUnpinFeedMutation()
   const isPending = isPinPending || isUnpinPending
   const {data: preferences} = usePreferencesQuery()
+  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
   const {track} = useAnalytics()
 
   const isPinned = preferences?.feeds?.pinned?.includes(list.uri)
+  const isSaved = preferences?.feeds?.saved?.includes(list.uri)
 
   const onTogglePinned = React.useCallback(async () => {
     Haptics.default()
@@ -361,6 +364,16 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
       message: _(msg`Are you sure?`),
       async onPressConfirm() {
         await listDeleteMutation.mutateAsync({uri: list.uri})
+
+        if (isSaved || isPinned) {
+          const {saved, pinned} = preferences!.feeds
+
+          setSavedFeeds({
+            saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved,
+            pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned,
+          })
+        }
+
         Toast.show(_(msg`List deleted`))
         track('Lists:Delete')
         if (navigation.canGoBack()) {
@@ -370,7 +383,18 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
         }
       },
     })
-  }, [openModal, list, listDeleteMutation, navigation, track, _])
+  }, [
+    openModal,
+    list,
+    listDeleteMutation,
+    navigation,
+    track,
+    _,
+    preferences,
+    isPinned,
+    isSaved,
+    setSavedFeeds,
+  ])
 
   const onPressReport = useCallback(() => {
     openModal({
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 4703899a2..142726701 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -190,7 +190,13 @@ type SearchResultSlice =
 
 function SearchScreenPostResults({query}: {query: string}) {
   const {_} = useLingui()
+  const {currentAccount} = useSession()
   const [isPTR, setIsPTR] = React.useState(false)
+
+  const augmentedQuery = React.useMemo(() => {
+    return augmentSearchQuery(query || '', {did: currentAccount?.did})
+  }, [query, currentAccount])
+
   const {
     isFetched,
     data: results,
@@ -200,7 +206,7 @@ function SearchScreenPostResults({query}: {query: string}) {
     fetchNextPage,
     isFetchingNextPage,
     hasNextPage,
-  } = useSearchPostsQuery({query})
+  } = useSearchPostsQuery({query: augmentedQuery})
 
   const onPullToRefresh = React.useCallback(async () => {
     setIsPTR(true)
@@ -319,13 +325,9 @@ export function SearchScreenInner({
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
-  const {hasSession, currentAccount} = useSession()
+  const {hasSession} = useSession()
   const {isDesktop} = useWebMediaQueries()
 
-  const augmentedQuery = React.useMemo(() => {
-    return augmentSearchQuery(query || '', {did: currentAccount?.did})
-  }, [query, currentAccount])
-
   const onPageSelected = React.useCallback(
     (index: number) => {
       setMinimalShellMode(false)
@@ -337,7 +339,6 @@ export function SearchScreenInner({
   if (hasSession) {
     return query ? (
       <Pager
-        tabBarPosition="top"
         onPageSelected={onPageSelected}
         renderTabBar={props => (
           <CenteredView
@@ -348,7 +349,7 @@ export function SearchScreenInner({
         )}
         initialPage={0}>
         <View>
-          <SearchScreenPostResults query={augmentedQuery} />
+          <SearchScreenPostResults query={query} />
         </View>
         <View>
           <SearchScreenUserResults query={query} />
@@ -380,7 +381,6 @@ export function SearchScreenInner({
 
   return query ? (
     <Pager
-      tabBarPosition="top"
       onPageSelected={onPageSelected}
       renderTabBar={props => (
         <CenteredView
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 3b50c5449..104506576 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -40,8 +40,8 @@ import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
 import {useModalControls} from '#/state/modals'
 import {
   useSetMinimalShellMode,
-  useColorMode,
-  useSetColorMode,
+  useThemePrefs,
+  useSetThemePrefs,
   useOnboardingDispatch,
 } from '#/state/shell'
 import {
@@ -144,8 +144,8 @@ function SettingsAccountCard({account}: {account: SessionAccount}) {
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
 export function SettingsScreen({}: Props) {
   const queryClient = useQueryClient()
-  const colorMode = useColorMode()
-  const setColorMode = useSetColorMode()
+  const {colorMode, darkTheme} = useThemePrefs()
+  const {setColorMode, setDarkTheme} = useSetThemePrefs()
   const pal = usePalette('default')
   const {_} = useLingui()
   const setMinimalShellMode = useSetMinimalShellMode()
@@ -483,8 +483,36 @@ export function SettingsScreen({}: Props) {
             />
           </View>
         </View>
+
         <View style={styles.spacer20} />
 
+        {colorMode !== 'light' && (
+          <>
+            <Text type="xl-bold" style={[pal.text, styles.heading]}>
+              <Trans>Dark Theme</Trans>
+            </Text>
+            <View>
+              <View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
+                <SelectableBtn
+                  selected={!darkTheme || darkTheme === 'dim'}
+                  label={_(msg`Dim`)}
+                  left
+                  onSelect={() => setDarkTheme('dim')}
+                  accessibilityHint={_(msg`Set dark theme to the dim theme`)}
+                />
+                <SelectableBtn
+                  selected={darkTheme === 'dark'}
+                  label={_(msg`Dark`)}
+                  right
+                  onSelect={() => setDarkTheme('dark')}
+                  accessibilityHint={_(msg`Set dark theme to the dark theme`)}
+                />
+              </View>
+            </View>
+            <View style={styles.spacer20} />
+          </>
+        )}
+
         <Text type="xl-bold" style={[pal.text, styles.heading]}>
           <Trans>Basics</Trans>
         </Text>
@@ -647,7 +675,7 @@ export function SettingsScreen({}: Props) {
             />
           </View>
           <Text type="lg" style={pal.text}>
-            <Trans>App passwords</Trans>
+            <Trans>App Passwords</Trans>
           </Text>
         </TouchableOpacity>
         <TouchableOpacity
@@ -668,7 +696,7 @@ export function SettingsScreen({}: Props) {
             />
           </View>
           <Text type="lg" style={pal.text} numberOfLines={1}>
-            <Trans>Change handle</Trans>
+            <Trans>Change Handle</Trans>
           </Text>
         </TouchableOpacity>
         {isNative && (
@@ -684,9 +712,30 @@ export function SettingsScreen({}: Props) {
         )}
         <View style={styles.spacer20} />
         <Text type="xl-bold" style={[pal.text, styles.heading]}>
-          <Trans>Danger Zone</Trans>
+          <Trans>Account</Trans>
         </Text>
         <TouchableOpacity
+          testID="changePasswordBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={() => openModal({name: 'change-password'})}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Change password`)}
+          accessibilityHint={_(msg`Change your Bluesky password`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon="lock"
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text} numberOfLines={1}>
+            <Trans>Change Password</Trans>
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity
           style={[pal.view, styles.linkCard]}
           onPress={onPressDeleteAccount}
           accessible={true}
@@ -703,7 +752,7 @@ export function SettingsScreen({}: Props) {
             />
           </View>
           <Text type="lg" style={dangerText}>
-            <Trans>Delete my account…</Trans>
+            <Trans>Delete My Account…</Trans>
           </Text>
         </TouchableOpacity>
         <View style={styles.spacer20} />
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index d8898f20e..40929555e 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -3,7 +3,7 @@ import {View} from 'react-native'
 import {CenteredView, ScrollView} from '#/view/com/util/Views'
 
 import {atoms as a, useTheme, ThemeProvider} from '#/alf'
-import {useSetColorMode} from '#/state/shell'
+import {useSetThemePrefs} from '#/state/shell'
 import {Button} from '#/components/Button'
 
 import {Theming} from './Theming'
@@ -19,7 +19,7 @@ import {Icons} from './Icons'
 
 export function Storybook() {
   const t = useTheme()
-  const setColorMode = useSetColorMode()
+  const {setColorMode, setDarkTheme} = useSetThemePrefs()
 
   return (
     <ScrollView>
@@ -38,7 +38,7 @@ export function Storybook() {
               variant="solid"
               color="secondary"
               size="small"
-              label='Set theme to "system"'
+              label='Set theme to "light"'
               onPress={() => setColorMode('light')}>
               Light
             </Button>
@@ -46,8 +46,22 @@ export function Storybook() {
               variant="solid"
               color="secondary"
               size="small"
-              label='Set theme to "system"'
-              onPress={() => setColorMode('dark')}>
+              label='Set theme to "dim"'
+              onPress={() => {
+                setColorMode('dark')
+                setDarkTheme('dim')
+              }}>
+              Dim
+            </Button>
+            <Button
+              variant="solid"
+              color="secondary"
+              size="small"
+              label='Set theme to "dark"'
+              onPress={() => {
+                setColorMode('dark')
+                setDarkTheme('dark')
+              }}>
               Dark
             </Button>
           </View>
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 5320aebfc..6b0cc6808 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -52,6 +52,8 @@ function ShellInner() {
   const canGoBack = useNavigationState(state => !isStateAtTabRoot(state))
   const {hasSession, currentAccount} = useSession()
   const closeAnyActiveElement = useCloseAnyActiveElement()
+  // start undefined
+  const currentAccountDid = React.useRef<string | undefined>(undefined)
 
   React.useEffect(() => {
     let listener = {remove() {}}
@@ -66,13 +68,10 @@ function ShellInner() {
   }, [closeAnyActiveElement])
 
   React.useEffect(() => {
-    if (currentAccount) {
+    // only runs when did changes
+    if (currentAccount && currentAccountDid.current !== currentAccount.did) {
+      currentAccountDid.current = currentAccount.did
       notifications.requestPermissionsAndRegisterToken(currentAccount)
-    }
-  }, [currentAccount])
-
-  React.useEffect(() => {
-    if (currentAccount) {
       const unsub = notifications.registerTokenChangeHandler(currentAccount)
       return unsub
     }
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 76f4f5c9b..97c065502 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -11,7 +11,6 @@ import {DrawerContent} from './Drawer'
 import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries'
 import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
-import {useAuxClick} from 'lib/hooks/useAuxClick'
 import {t} from '@lingui/macro'
 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
 import {useCloseAllActiveElements} from '#/state/util'
@@ -26,7 +25,6 @@ function ShellInner() {
   const closeAllActiveElements = useCloseAllActiveElements()
 
   useWebBodyScrollLock(isDrawerOpen)
-  useAuxClick()
 
   useEffect(() => {
     const unsubscribe = navigator.addListener('state', () => {