about summary refs log tree commit diff
path: root/src/view/com
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com')
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx96
-rw-r--r--src/view/com/composer/text-input/web/LinkDecorator.ts13
-rw-r--r--src/view/com/modals/ChangeEmail.tsx280
-rw-r--r--src/view/com/modals/Confirm.tsx3
-rw-r--r--src/view/com/modals/Modal.tsx8
-rw-r--r--src/view/com/modals/Modal.web.tsx6
-rw-r--r--src/view/com/modals/VerifyEmail.tsx296
-rw-r--r--src/view/com/notifications/FeedItem.tsx55
-rw-r--r--src/view/com/posts/FeedItem.tsx3
-rw-r--r--src/view/com/util/forms/Button.tsx14
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx4
-rw-r--r--src/view/com/util/post-ctrls/RepostButton.web.tsx91
-rw-r--r--src/view/com/util/post-embeds/index.tsx9
13 files changed, 745 insertions, 133 deletions
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 7eea904ab..31e372567 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -17,6 +17,7 @@ import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
 import {isUriImage, blobToDataUri} from 'lib/media/util'
 import {Emoji} from './web/EmojiPicker.web'
 import {LinkDecorator} from './web/LinkDecorator'
+import {generateJSON} from '@tiptap/html'
 
 export interface TextInputRef {
   focus: () => void
@@ -52,6 +53,26 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   ref,
 ) {
   const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark')
+  const extensions = React.useMemo(
+    () => [
+      Document,
+      LinkDecorator,
+      Mention.configure({
+        HTMLAttributes: {
+          class: 'mention',
+        },
+        suggestion: createSuggestion({autocompleteView}),
+      }),
+      Paragraph,
+      Placeholder.configure({
+        placeholder,
+      }),
+      Text,
+      History,
+      Hardbreak,
+    ],
+    [autocompleteView, placeholder],
+  )
 
   React.useEffect(() => {
     textInputWebEmitter.addListener('publish', onPressPublish)
@@ -68,23 +89,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
 
   const editor = useEditor(
     {
-      extensions: [
-        Document,
-        LinkDecorator,
-        Mention.configure({
-          HTMLAttributes: {
-            class: 'mention',
-          },
-          suggestion: createSuggestion({autocompleteView}),
-        }),
-        Paragraph,
-        Placeholder.configure({
-          placeholder,
-        }),
-        Text,
-        History,
-        Hardbreak,
-      ],
+      extensions,
       editorProps: {
         attributes: {
           class: modeClass,
@@ -107,7 +112,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
           }
         },
       },
-      content: textToEditorJson(richtext.text.toString()),
+      content: generateJSON(richtext.text.toString(), extensions),
       autofocus: 'end',
       editable: true,
       injectCSS: true,
@@ -182,61 +187,6 @@ function editorJsonToText(json: JSONContent): string {
   return text
 }
 
-function textToEditorJson(text: string): JSONContent {
-  if (text === '' || text.length === 0) {
-    return {
-      text: '',
-    }
-  }
-
-  const lines = text.split('\n')
-  const docContent: JSONContent[] = []
-
-  for (const line of lines) {
-    if (line.trim() === '') {
-      continue // skip empty lines
-    }
-
-    const paragraphContent: JSONContent[] = []
-    let position = 0
-
-    while (position < line.length) {
-      if (line[position] === '@') {
-        // Handle mentions
-        let endPosition = position + 1
-        while (endPosition < line.length && /\S/.test(line[endPosition])) {
-          endPosition++
-        }
-        const mentionId = line.substring(position + 1, endPosition)
-        paragraphContent.push({
-          type: 'mention',
-          attrs: {id: mentionId},
-        })
-        position = endPosition
-      } else {
-        // Handle regular text
-        let endPosition = line.indexOf('@', position)
-        if (endPosition === -1) endPosition = line.length
-        paragraphContent.push({
-          type: 'text',
-          text: line.substring(position, endPosition),
-        })
-        position = endPosition
-      }
-    }
-
-    docContent.push({
-      type: 'paragraph',
-      content: paragraphContent,
-    })
-  }
-
-  return {
-    type: 'doc',
-    content: docContent,
-  }
-}
-
 const styles = StyleSheet.create({
   container: {
     flex: 1,
diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts
index 531e8d5a0..19945de08 100644
--- a/src/view/com/composer/text-input/web/LinkDecorator.ts
+++ b/src/view/com/composer/text-input/web/LinkDecorator.ts
@@ -16,7 +16,6 @@
 
 import {Mark} from '@tiptap/core'
 import {Plugin, PluginKey} from '@tiptap/pm/state'
-import {findChildren} from '@tiptap/core'
 import {Node as ProsemirrorNode} from '@tiptap/pm/model'
 import {Decoration, DecorationSet} from '@tiptap/pm/view'
 import {isValidDomain} from 'lib/strings/url-helpers'
@@ -36,20 +35,20 @@ export const LinkDecorator = Mark.create({
 function getDecorations(doc: ProsemirrorNode) {
   const decorations: Decoration[] = []
 
-  findChildren(doc, node => node.type.name === 'paragraph').forEach(
-    paragraphNode => {
-      const textContent = paragraphNode.node.textContent
+  doc.descendants((node, pos) => {
+    if (node.isText && node.text) {
+      const textContent = node.textContent
 
       // links
       iterateUris(textContent, (from, to) => {
         decorations.push(
-          Decoration.inline(paragraphNode.pos + from, paragraphNode.pos + to, {
+          Decoration.inline(pos + from, pos + to, {
             class: 'autolink',
           }),
         )
       })
-    },
-  )
+    }
+  })
 
   return DecorationSet.create(doc, decorations)
 }
diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx
new file mode 100644
index 000000000..c92dabdca
--- /dev/null
+++ b/src/view/com/modals/ChangeEmail.tsx
@@ -0,0 +1,280 @@
+import React, {useState} from 'react'
+import {
+  ActivityIndicator,
+  KeyboardAvoidingView,
+  SafeAreaView,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {ScrollView, TextInput} from './util'
+import {observer} from 'mobx-react-lite'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import * as Toast from '../util/Toast'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isWeb} from 'platform/detection'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {cleanError} from 'lib/strings/errors'
+
+enum Stages {
+  InputEmail,
+  ConfirmCode,
+  Done,
+}
+
+export const snapPoints = ['90%']
+
+export const Component = observer(function Component({}: {}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const [stage, setStage] = useState<Stages>(Stages.InputEmail)
+  const [email, setEmail] = useState<string>(
+    store.session.currentSession?.email || '',
+  )
+  const [confirmationCode, setConfirmationCode] = useState<string>('')
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [error, setError] = useState<string>('')
+  const {isMobile} = useWebMediaQueries()
+
+  const onRequestChange = async () => {
+    if (email === store.session.currentSession?.email) {
+      setError('Enter your new email above')
+      return
+    }
+    setError('')
+    setIsProcessing(true)
+    try {
+      const res = await store.agent.com.atproto.server.requestEmailUpdate()
+      if (res.data.tokenRequired) {
+        setStage(Stages.ConfirmCode)
+      } else {
+        await store.agent.com.atproto.server.updateEmail({email: email.trim()})
+        store.session.updateLocalAccountData({
+          email: email.trim(),
+          emailConfirmed: false,
+        })
+        Toast.show('Email updated')
+        setStage(Stages.Done)
+      }
+    } catch (e) {
+      let err = cleanError(String(e))
+      // TEMP
+      // while rollout is occuring, we're giving a temporary error message
+      // you can remove this any time after Oct2023
+      // -prf
+      if (err === 'email must be confirmed (temporary)') {
+        err = `Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.`
+      }
+      setError(err)
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onConfirm = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      await store.agent.com.atproto.server.updateEmail({
+        email: email.trim(),
+        token: confirmationCode.trim(),
+      })
+      store.session.updateLocalAccountData({
+        email: email.trim(),
+        emailConfirmed: false,
+      })
+      Toast.show('Email updated')
+      setStage(Stages.Done)
+    } catch (e) {
+      setError(cleanError(String(e)))
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onVerify = async () => {
+    store.shell.closeModal()
+    store.shell.openModal({name: 'verify-email'})
+  }
+
+  return (
+    <KeyboardAvoidingView
+      behavior="padding"
+      style={[pal.view, styles.container]}>
+      <SafeAreaView style={s.flex1}>
+        <ScrollView
+          testID="changeEmailModal"
+          style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
+          <View style={styles.titleSection}>
+            <Text type="title-lg" style={[pal.text, styles.title]}>
+              {stage === Stages.InputEmail ? 'Change Your Email' : ''}
+              {stage === Stages.ConfirmCode ? 'Security Step Required' : ''}
+              {stage === Stages.Done ? 'Email Updated' : ''}
+            </Text>
+          </View>
+
+          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
+            {stage === Stages.InputEmail ? (
+              <>Enter your new email address below.</>
+            ) : stage === Stages.ConfirmCode ? (
+              <>
+                An email has been sent to your previous address,{' '}
+                {store.session.currentSession?.email || ''}. It includes a
+                confirmation code which you can enter below.
+              </>
+            ) : (
+              <>
+                Your email has been updated but not verified. As a next step,
+                please verify your new email.
+              </>
+            )}
+          </Text>
+
+          {stage === Stages.InputEmail && (
+            <TextInput
+              testID="emailInput"
+              style={[styles.textInput, pal.border, pal.text]}
+              placeholder="alice@mail.com"
+              placeholderTextColor={pal.colors.textLight}
+              value={email}
+              onChangeText={setEmail}
+              accessible={true}
+              accessibilityLabel="Email"
+              accessibilityHint=""
+              autoCapitalize="none"
+              autoComplete="email"
+              autoCorrect={false}
+            />
+          )}
+          {stage === Stages.ConfirmCode && (
+            <TextInput
+              testID="confirmCodeInput"
+              style={[styles.textInput, pal.border, pal.text]}
+              placeholder="XXXXX-XXXXX"
+              placeholderTextColor={pal.colors.textLight}
+              value={confirmationCode}
+              onChangeText={setConfirmationCode}
+              accessible={true}
+              accessibilityLabel="Confirmation code"
+              accessibilityHint=""
+              autoCapitalize="none"
+              autoComplete="off"
+              autoCorrect={false}
+            />
+          )}
+
+          {error ? (
+            <ErrorMessage message={error} style={styles.error} />
+          ) : undefined}
+
+          <View style={[styles.btnContainer]}>
+            {isProcessing ? (
+              <View style={styles.btn}>
+                <ActivityIndicator color="#fff" />
+              </View>
+            ) : (
+              <View style={{gap: 6}}>
+                {stage === Stages.InputEmail && (
+                  <Button
+                    testID="requestChangeBtn"
+                    type="primary"
+                    onPress={onRequestChange}
+                    accessibilityLabel="Request Change"
+                    accessibilityHint=""
+                    label="Request Change"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                {stage === Stages.ConfirmCode && (
+                  <Button
+                    testID="confirmBtn"
+                    type="primary"
+                    onPress={onConfirm}
+                    accessibilityLabel="Confirm Change"
+                    accessibilityHint=""
+                    label="Confirm Change"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                {stage === Stages.Done && (
+                  <Button
+                    testID="verifyBtn"
+                    type="primary"
+                    onPress={onVerify}
+                    accessibilityLabel="Verify New Email"
+                    accessibilityHint=""
+                    label="Verify New Email"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                <Button
+                  testID="cancelBtn"
+                  type="default"
+                  onPress={() => store.shell.closeModal()}
+                  accessibilityLabel="Cancel"
+                  accessibilityHint=""
+                  label="Cancel"
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              </View>
+            )}
+          </View>
+        </ScrollView>
+      </SafeAreaView>
+    </KeyboardAvoidingView>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isWeb ? 0 : 40,
+  },
+  titleSection: {
+    paddingTop: isWeb ? 0 : 4,
+    paddingBottom: isWeb ? 14 : 10,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: '600',
+    marginBottom: 5,
+  },
+  error: {
+    borderRadius: 6,
+    marginTop: 10,
+  },
+  emailContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 6,
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 14,
+    paddingVertical: 12,
+  },
+  textInput: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 14,
+    paddingVertical: 10,
+    fontSize: 16,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnContainer: {
+    paddingTop: 20,
+  },
+})
diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx
index 270177182..c1324b1cb 100644
--- a/src/view/com/modals/Confirm.tsx
+++ b/src/view/com/modals/Confirm.tsx
@@ -23,6 +23,7 @@ export function Component({
   onPressCancel,
   confirmBtnText,
   confirmBtnStyle,
+  cancelBtnText,
 }: ConfirmModal) {
   const pal = usePalette('default')
   const store = useStores()
@@ -84,7 +85,7 @@ export function Component({
           accessibilityLabel="Cancel"
           accessibilityHint="">
           <Text type="button-lg" style={pal.textLight}>
-            Cancel
+            {cancelBtnText ?? 'Cancel'}
           </Text>
         </TouchableOpacity>
       )}
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index d79c77db3..8590a2698 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -30,6 +30,8 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as ModerationDetailsModal from './ModerationDetails'
 import * as BirthDateSettingsModal from './BirthDateSettings'
+import * as VerifyEmailModal from './VerifyEmail'
+import * as ChangeEmailModal from './ChangeEmail'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 
@@ -136,6 +138,12 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'birth-date-settings') {
     snapPoints = BirthDateSettingsModal.snapPoints
     element = <BirthDateSettingsModal.Component />
+  } else if (activeModal?.name === 'verify-email') {
+    snapPoints = VerifyEmailModal.snapPoints
+    element = <VerifyEmailModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'change-email') {
+    snapPoints = ChangeEmailModal.snapPoints
+    element = <ChangeEmailModal.Component />
   } else {
     return null
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 3e87e0e3c..7548fb806 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -28,6 +28,8 @@ import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguages
 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
 import * as ModerationDetailsModal from './ModerationDetails'
 import * as BirthDateSettingsModal from './BirthDateSettings'
+import * as VerifyEmailModal from './VerifyEmail'
+import * as ChangeEmailModal from './ChangeEmail'
 
 export const ModalsContainer = observer(function ModalsContainer() {
   const store = useStores()
@@ -110,6 +112,10 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ModerationDetailsModal.Component {...modal} />
   } else if (modal.name === 'birth-date-settings') {
     element = <BirthDateSettingsModal.Component />
+  } else if (modal.name === 'verify-email') {
+    element = <VerifyEmailModal.Component {...modal} />
+  } else if (modal.name === 'change-email') {
+    element = <ChangeEmailModal.Component />
   } else {
     return null
   }
diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx
new file mode 100644
index 000000000..1b4ddcda4
--- /dev/null
+++ b/src/view/com/modals/VerifyEmail.tsx
@@ -0,0 +1,296 @@
+import React, {useState} from 'react'
+import {
+  ActivityIndicator,
+  KeyboardAvoidingView,
+  Pressable,
+  SafeAreaView,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {ScrollView, TextInput} from './util'
+import {observer} from 'mobx-react-lite'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {Text} from '../util/text/Text'
+import {Button} from '../util/forms/Button'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import * as Toast from '../util/Toast'
+import {useStores} from 'state/index'
+import {s, colors} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isWeb} from 'platform/detection'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {cleanError} from 'lib/strings/errors'
+
+export const snapPoints = ['90%']
+
+enum Stages {
+  Reminder,
+  Email,
+  ConfirmCode,
+}
+
+export const Component = observer(function Component({
+  showReminder,
+}: {
+  showReminder?: boolean
+}) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const [stage, setStage] = useState<Stages>(
+    showReminder ? Stages.Reminder : Stages.Email,
+  )
+  const [confirmationCode, setConfirmationCode] = useState<string>('')
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [error, setError] = useState<string>('')
+  const {isMobile} = useWebMediaQueries()
+
+  const onSendEmail = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      await store.agent.com.atproto.server.requestEmailConfirmation()
+      setStage(Stages.ConfirmCode)
+    } catch (e) {
+      setError(cleanError(String(e)))
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onConfirm = async () => {
+    setError('')
+    setIsProcessing(true)
+    try {
+      await store.agent.com.atproto.server.confirmEmail({
+        email: (store.session.currentSession?.email || '').trim(),
+        token: confirmationCode.trim(),
+      })
+      store.session.updateLocalAccountData({emailConfirmed: true})
+      Toast.show('Email verified')
+      store.shell.closeModal()
+    } catch (e) {
+      setError(cleanError(String(e)))
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onEmailIncorrect = () => {
+    store.shell.closeModal()
+    store.shell.openModal({name: 'change-email'})
+  }
+
+  return (
+    <KeyboardAvoidingView
+      behavior="padding"
+      style={[pal.view, styles.container]}>
+      <SafeAreaView style={s.flex1}>
+        <ScrollView
+          testID="verifyEmailModal"
+          style={[s.flex1, isMobile && {paddingHorizontal: 18}]}>
+          <View style={styles.titleSection}>
+            <Text type="title-lg" style={[pal.text, styles.title]}>
+              {stage === Stages.Reminder ? 'Please Verify Your Email' : ''}
+              {stage === Stages.ConfirmCode ? 'Enter Confirmation Code' : ''}
+              {stage === Stages.Email ? 'Verify Your Email' : ''}
+            </Text>
+          </View>
+
+          <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
+            {stage === Stages.Reminder ? (
+              <>
+                Your email has not yet been verified. This is an important
+                security step which we recommend.
+              </>
+            ) : stage === Stages.Email ? (
+              <>
+                This is important in case you ever need to change your email or
+                reset your password.
+              </>
+            ) : stage === Stages.ConfirmCode ? (
+              <>
+                An email has been sent to{' '}
+                {store.session.currentSession?.email || ''}. It includes a
+                confirmation code which you can enter below.
+              </>
+            ) : (
+              ''
+            )}
+          </Text>
+
+          {stage === Stages.Email ? (
+            <>
+              <View style={styles.emailContainer}>
+                <FontAwesomeIcon
+                  icon="envelope"
+                  color={pal.colors.text}
+                  size={16}
+                />
+                <Text
+                  type="xl-medium"
+                  style={[pal.text, s.flex1, {minWidth: 0}]}>
+                  {store.session.currentSession?.email || ''}
+                </Text>
+              </View>
+              <Pressable
+                accessibilityRole="link"
+                accessibilityLabel="Change my email"
+                accessibilityHint=""
+                onPress={onEmailIncorrect}
+                style={styles.changeEmailLink}>
+                <Text type="lg" style={pal.link}>
+                  Change
+                </Text>
+              </Pressable>
+            </>
+          ) : stage === Stages.ConfirmCode ? (
+            <TextInput
+              testID="confirmCodeInput"
+              style={[styles.textInput, pal.border, pal.text]}
+              placeholder="XXXXX-XXXXX"
+              placeholderTextColor={pal.colors.textLight}
+              value={confirmationCode}
+              onChangeText={setConfirmationCode}
+              accessible={true}
+              accessibilityLabel="Confirmation code"
+              accessibilityHint=""
+              autoCapitalize="none"
+              autoComplete="off"
+              autoCorrect={false}
+            />
+          ) : undefined}
+
+          {error ? (
+            <ErrorMessage message={error} style={styles.error} />
+          ) : undefined}
+
+          <View style={[styles.btnContainer]}>
+            {isProcessing ? (
+              <View style={styles.btn}>
+                <ActivityIndicator color="#fff" />
+              </View>
+            ) : (
+              <View style={{gap: 6}}>
+                {stage === Stages.Reminder && (
+                  <Button
+                    testID="getStartedBtn"
+                    type="primary"
+                    onPress={() => setStage(Stages.Email)}
+                    accessibilityLabel="Get Started"
+                    accessibilityHint=""
+                    label="Get Started"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                {stage === Stages.Email && (
+                  <>
+                    <Button
+                      testID="sendEmailBtn"
+                      type="primary"
+                      onPress={onSendEmail}
+                      accessibilityLabel="Send Confirmation Email"
+                      accessibilityHint=""
+                      label="Send Confirmation Email"
+                      labelContainerStyle={{
+                        justifyContent: 'center',
+                        padding: 4,
+                      }}
+                      labelStyle={[s.f18]}
+                    />
+                    <Button
+                      testID="haveCodeBtn"
+                      type="default"
+                      accessibilityLabel="I have a code"
+                      accessibilityHint=""
+                      label="I have a confirmation code"
+                      labelContainerStyle={{
+                        justifyContent: 'center',
+                        padding: 4,
+                      }}
+                      labelStyle={[s.f18]}
+                      onPress={() => setStage(Stages.ConfirmCode)}
+                    />
+                  </>
+                )}
+                {stage === Stages.ConfirmCode && (
+                  <Button
+                    testID="confirmBtn"
+                    type="primary"
+                    onPress={onConfirm}
+                    accessibilityLabel="Confirm"
+                    accessibilityHint=""
+                    label="Confirm"
+                    labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                    labelStyle={[s.f18]}
+                  />
+                )}
+                <Button
+                  testID="cancelBtn"
+                  type="default"
+                  onPress={() => store.shell.closeModal()}
+                  accessibilityLabel={
+                    stage === Stages.Reminder ? 'Not right now' : 'Cancel'
+                  }
+                  accessibilityHint=""
+                  label={stage === Stages.Reminder ? 'Not right now' : 'Cancel'}
+                  labelContainerStyle={{justifyContent: 'center', padding: 4}}
+                  labelStyle={[s.f18]}
+                />
+              </View>
+            )}
+          </View>
+        </ScrollView>
+      </SafeAreaView>
+    </KeyboardAvoidingView>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+    paddingBottom: isWeb ? 0 : 40,
+  },
+  titleSection: {
+    paddingTop: isWeb ? 0 : 4,
+    paddingBottom: isWeb ? 14 : 10,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: '600',
+    marginBottom: 5,
+  },
+  error: {
+    borderRadius: 6,
+    marginTop: 10,
+  },
+  emailContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 6,
+    paddingHorizontal: 14,
+    marginTop: 10,
+  },
+  changeEmailLink: {
+    marginHorizontal: 12,
+    marginBottom: 12,
+  },
+  textInput: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingHorizontal: 14,
+    paddingVertical: 10,
+    fontSize: 16,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.blue3,
+  },
+  btnContainer: {
+    paddingTop: 20,
+  },
+})
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 00e56e1cc..c51335c72 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -22,7 +22,7 @@ import {
 import {NotificationsFeedItemModel} from 'state/models/feeds/notifications'
 import {PostThreadModel} from 'state/models/content/post-thread'
 import {s, colors} from 'lib/styles'
-import {ago} from 'lib/strings/time'
+import {niceDate} from 'lib/strings/time'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
 import {sanitizeHandle} from 'lib/strings/handles'
 import {pluralize} from 'lib/strings/helpers'
@@ -38,6 +38,7 @@ import {usePalette} from 'lib/hooks/usePalette'
 import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
 import {formatCount} from '../util/numeric/format'
 import {makeProfileLink} from 'lib/routes/links'
+import {TimeElapsed} from '../util/TimeElapsed'
 
 const MAX_AUTHORS = 5
 
@@ -88,7 +89,7 @@ export const FeedItem = observer(function FeedItemImpl({
   }, [item])
 
   const onToggleAuthorsExpanded = () => {
-    setAuthorsExpanded(!isAuthorsExpanded)
+    setAuthorsExpanded(currentlyExpanded => !currentlyExpanded)
   }
 
   const authors: Author[] = useMemo(() => {
@@ -179,7 +180,6 @@ export const FeedItem = observer(function FeedItemImpl({
   }
 
   return (
-    // eslint-disable-next-line react-native-a11y/no-nested-touchables
     <Link
       testID={`feedItem-by-${item.author.handle}`}
       style={[
@@ -211,9 +211,9 @@ export const FeedItem = observer(function FeedItemImpl({
         )}
       </View>
       <View style={styles.layoutContent}>
-        <Pressable
-          onPress={authors.length > 1 ? onToggleAuthorsExpanded : undefined}
-          accessible={false}>
+        <ExpandListPressable
+          hasMultipleAuthors={authors.length > 1}
+          onToggleAuthorsExpanded={onToggleAuthorsExpanded}>
           <CondensedAuthorsList
             visible={!isAuthorsExpanded}
             authors={authors}
@@ -239,9 +239,17 @@ export const FeedItem = observer(function FeedItemImpl({
               </>
             ) : undefined}
             <Text style={[pal.text]}> {action}</Text>
-            <Text style={[pal.textLight]}> {ago(item.indexedAt)}</Text>
+            <TimeElapsed timestamp={item.indexedAt}>
+              {({timeElapsed}) => (
+                <Text
+                  style={[pal.textLight, styles.pointer]}
+                  title={niceDate(item.indexedAt)}>
+                  {' ' + timeElapsed}
+                </Text>
+              )}
+            </TimeElapsed>
           </Text>
-        </Pressable>
+        </ExpandListPressable>
         {item.isLike || item.isRepost || item.isQuote ? (
           <AdditionalPostText additionalPost={item.additionalPost} />
         ) : null}
@@ -250,6 +258,29 @@ export const FeedItem = observer(function FeedItemImpl({
   )
 })
 
+function ExpandListPressable({
+  hasMultipleAuthors,
+  children,
+  onToggleAuthorsExpanded,
+}: {
+  hasMultipleAuthors: boolean
+  children: React.ReactNode
+  onToggleAuthorsExpanded: () => void
+}) {
+  if (hasMultipleAuthors) {
+    return (
+      <Pressable
+        onPress={onToggleAuthorsExpanded}
+        style={[styles.expandedAuthorsTrigger]}
+        accessible={false}>
+        {children}
+      </Pressable>
+    )
+  } else {
+    return <>{children}</>
+  }
+}
+
 function CondensedAuthorsList({
   visible,
   authors,
@@ -419,6 +450,10 @@ const styles = StyleSheet.create({
   overflowHidden: {
     overflow: 'hidden',
   },
+  pointer: {
+    // @ts-ignore web only
+    cursor: 'pointer',
+  },
 
   outer: {
     padding: 10,
@@ -466,7 +501,9 @@ const styles = StyleSheet.create({
     paddingTop: 4,
     paddingLeft: 36,
   },
-
+  expandedAuthorsTrigger: {
+    zIndex: 1,
+  },
   expandedAuthorsCloseBtn: {
     flexDirection: 'row',
     alignItems: 'center',
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index f6b6e5339..1ceae80ae 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -178,7 +178,7 @@ export const FeedItem = observer(function FeedItemImpl({
           )}
         </View>
 
-        <View style={{paddingTop: 12}}>
+        <View style={{paddingTop: 12, flexShrink: 1}}>
           {source ? (
             <Link
               title={sanitizeDisplayName(source.displayName)}
@@ -211,6 +211,7 @@ export const FeedItem = observer(function FeedItemImpl({
                 style={{
                   marginRight: 4,
                   color: pal.colors.textLight,
+                  minWidth: 16,
                 }}
               />
               <Text
diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx
index 076fa1baa..270d98317 100644
--- a/src/view/com/util/forms/Button.tsx
+++ b/src/view/com/util/forms/Button.tsx
@@ -42,6 +42,7 @@ export function Button({
   type = 'primary',
   label,
   style,
+  labelContainerStyle,
   labelStyle,
   onPress,
   children,
@@ -55,6 +56,7 @@ export function Button({
   type?: ButtonType
   label?: string
   style?: StyleProp<ViewStyle>
+  labelContainerStyle?: StyleProp<ViewStyle>
   labelStyle?: StyleProp<TextStyle>
   onPress?: () => void | Promise<void>
   testID?: string
@@ -173,7 +175,7 @@ export function Button({
     }
 
     return (
-      <View style={styles.labelContainer}>
+      <View style={[styles.labelContainer, labelContainerStyle]}>
         {label && withLoading && isLoading ? (
           <ActivityIndicator size={12} color={typeLabelStyle.color} />
         ) : null}
@@ -182,7 +184,15 @@ export function Button({
         </Text>
       </View>
     )
-  }, [children, label, withLoading, isLoading, typeLabelStyle, labelStyle])
+  }, [
+    children,
+    label,
+    withLoading,
+    isLoading,
+    labelContainerStyle,
+    typeLabelStyle,
+    labelStyle,
+  ])
 
   return (
     <Pressable
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index da2f7ab45..035e29c25 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -11,6 +11,7 @@ const MAX_ASPECT_RATIO = 5 // 5/1
 interface Props {
   alt?: string
   uri: string
+  dimensionsHint?: Dimensions
   onPress?: () => void
   onLongPress?: () => void
   onPressIn?: () => void
@@ -21,6 +22,7 @@ interface Props {
 export function AutoSizedImage({
   alt,
   uri,
+  dimensionsHint,
   onPress,
   onLongPress,
   onPressIn,
@@ -29,7 +31,7 @@ export function AutoSizedImage({
 }: Props) {
   const store = useStores()
   const [dim, setDim] = React.useState<Dimensions | undefined>(
-    store.imageSizes.get(uri),
+    dimensionsHint || store.imageSizes.get(uri),
   )
   const [aspectRatio, setAspectRatio] = React.useState<number>(
     dim ? calc(dim) : 1,
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index eab6e2fef..57f544d41 100644
--- a/src/view/com/util/post-ctrls/RepostButton.web.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx
@@ -1,17 +1,23 @@
-import React, {useMemo} from 'react'
+import React from 'react'
 import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
 import {RepostIcon} from 'lib/icons'
-import {DropdownButton} from '../forms/DropdownButton'
 import {colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
 import {Text} from '../text/Text'
 
+import {
+  NativeDropdown,
+  DropdownItem as NativeDropdownItem,
+} from '../forms/NativeDropdown'
+import {EventStopper} from '../EventStopper'
+
 interface Props {
   isReposted: boolean
   repostCount?: number
   big?: boolean
   onRepost: () => void
   onQuote: () => void
+  style?: StyleProp<ViewStyle>
 }
 
 export const RepostButton = ({
@@ -30,44 +36,55 @@ export const RepostButton = ({
     [theme],
   )
 
-  const items = useMemo(
-    () => [
-      {
-        label: isReposted ? 'Undo repost' : 'Repost',
-        icon: 'retweet' as const,
-        onPress: onRepost,
+  const dropdownItems: NativeDropdownItem[] = [
+    {
+      label: isReposted ? 'Undo repost' : 'Repost',
+      testID: 'repostDropdownRepostBtn',
+      icon: {
+        ios: {name: 'repeat'},
+        android: '',
+        web: 'retweet',
       },
-      {label: 'Quote post', icon: 'quote-left' as const, onPress: onQuote},
-    ],
-    [isReposted, onRepost, onQuote],
-  )
+      onPress: onRepost,
+    },
+    {
+      label: 'Quote post',
+      testID: 'repostDropdownQuoteBtn',
+      icon: {
+        ios: {name: 'quote.bubble'},
+        android: '',
+        web: 'quote-left',
+      },
+      onPress: onQuote,
+    },
+  ]
 
   return (
-    <DropdownButton
-      type="bare"
-      items={items}
-      bottomOffset={4}
-      openToRight
-      rightOffset={-40}>
-      <View
-        style={[
-          styles.control,
-          !big && styles.controlPad,
-          (isReposted
-            ? styles.reposted
-            : defaultControlColor) as StyleProp<ViewStyle>,
-        ]}>
-        <RepostIcon strokeWidth={2.4} size={big ? 24 : 20} />
-        {typeof repostCount !== 'undefined' ? (
-          <Text
-            testID="repostCount"
-            type={isReposted ? 'md-bold' : 'md'}
-            style={styles.repostCount}>
-            {repostCount ?? 0}
-          </Text>
-        ) : undefined}
-      </View>
-    </DropdownButton>
+    <EventStopper>
+      <NativeDropdown
+        items={dropdownItems}
+        accessibilityLabel="Repost or quote post"
+        accessibilityHint="">
+        <View
+          style={[
+            styles.control,
+            !big && styles.controlPad,
+            (isReposted
+              ? styles.reposted
+              : defaultControlColor) as StyleProp<ViewStyle>,
+          ]}>
+          <RepostIcon strokeWidth={2.2} size={big ? 24 : 20} />
+          {typeof repostCount !== 'undefined' ? (
+            <Text
+              testID="repostCount"
+              type={isReposted ? 'md-bold' : 'md'}
+              style={styles.repostCount}>
+              {repostCount ?? 0}
+            </Text>
+          ) : undefined}
+        </View>
+      </NativeDropdown>
+    </EventStopper>
   )
 }
 
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index ce6da4a1b..2d79eed8f 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -93,7 +93,11 @@ export function PostEmbeds({
     const {images} = embed
 
     if (images.length > 0) {
-      const items = embed.images.map(img => ({uri: img.fullsize, alt: img.alt}))
+      const items = embed.images.map(img => ({
+        uri: img.fullsize,
+        alt: img.alt,
+        aspectRatio: img.aspectRatio,
+      }))
       const openLightbox = (index: number) => {
         store.shell.openLightbox(new ImagesLightbox(items, index))
       }
@@ -104,12 +108,13 @@ export function PostEmbeds({
       }
 
       if (images.length === 1) {
-        const {alt, thumb} = images[0]
+        const {alt, thumb, aspectRatio} = images[0]
         return (
           <View style={[styles.imagesContainer, style]}>
             <AutoSizedImage
               alt={alt}
               uri={thumb}
+              dimensionsHint={aspectRatio}
               onPress={() => openLightbox(0)}
               onPressIn={() => onPressIn(0)}
               style={[