about summary refs log tree commit diff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/Login/ChooseAccountForm.tsx59
-rw-r--r--src/screens/Messages/Conversation/MessageInput.tsx111
-rw-r--r--src/screens/Messages/Conversation/MessageInput.web.tsx94
-rw-r--r--src/screens/Messages/Conversation/MessageListError.tsx60
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx258
-rw-r--r--src/screens/Messages/Conversation/index.tsx142
-rw-r--r--src/screens/Messages/List/index.tsx422
-rw-r--r--src/screens/Messages/Temp/useDmServiceUrlStorage.tsx63
-rw-r--r--src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx8
-rw-r--r--src/screens/Onboarding/StepSuggestedAccounts/index.tsx2
-rw-r--r--src/screens/Profile/Sections/Labels.tsx3
-rw-r--r--src/screens/Signup/StepCaptcha/CaptchaWebView.tsx10
-rw-r--r--src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx22
-rw-r--r--src/screens/Signup/StepCaptcha/index.tsx20
-rw-r--r--src/screens/Signup/index.tsx11
-rw-r--r--src/screens/Signup/state.ts19
16 files changed, 1121 insertions, 183 deletions
diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx
index 134411903..b02b8e162 100644
--- a/src/screens/Login/ChooseAccountForm.tsx
+++ b/src/screens/Login/ChooseAccountForm.tsx
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {logEvent} from '#/lib/statsig/statsig'
+import {logger} from '#/logger'
 import {SessionAccount, useSession, useSessionApi} from '#/state/session'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import * as Toast from '#/view/com/util/Toast'
@@ -21,6 +22,7 @@ export const ChooseAccountForm = ({
   onSelectAccount: (account?: SessionAccount) => void
   onPressBack: () => void
 }) => {
+  const [pendingDid, setPendingDid] = React.useState<string | null>(null)
   const {track, screen} = useAnalytics()
   const {_} = useLingui()
   const {currentAccount} = useSession()
@@ -33,26 +35,48 @@ export const ChooseAccountForm = ({
 
   const onSelect = React.useCallback(
     async (account: SessionAccount) => {
-      if (account.accessJwt) {
-        if (account.did === currentAccount?.did) {
-          setShowLoggedOut(false)
-          Toast.show(_(msg`Already signed in as @${account.handle}`))
-        } else {
-          await initSession(account)
-          logEvent('account:loggedIn', {
-            logContext: 'ChooseAccountForm',
-            withPassword: false,
-          })
-          track('Sign In', {resumedSession: true})
-          setTimeout(() => {
-            Toast.show(_(msg`Signed in as @${account.handle}`))
-          }, 100)
-        }
-      } else {
+      if (pendingDid) {
+        // The session API isn't resilient to race conditions so let's just ignore this.
+        return
+      }
+      if (!account.accessJwt) {
+        // Move to login form.
+        onSelectAccount(account)
+        return
+      }
+      if (account.did === currentAccount?.did) {
+        setShowLoggedOut(false)
+        Toast.show(_(msg`Already signed in as @${account.handle}`))
+        return
+      }
+      try {
+        setPendingDid(account.did)
+        await initSession(account)
+        logEvent('account:loggedIn', {
+          logContext: 'ChooseAccountForm',
+          withPassword: false,
+        })
+        track('Sign In', {resumedSession: true})
+        Toast.show(_(msg`Signed in as @${account.handle}`))
+      } catch (e: any) {
+        logger.error('choose account: initSession failed', {
+          message: e.message,
+        })
+        // Move to login form.
         onSelectAccount(account)
+      } finally {
+        setPendingDid(null)
       }
     },
-    [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
+    [
+      currentAccount,
+      track,
+      initSession,
+      pendingDid,
+      onSelectAccount,
+      setShowLoggedOut,
+      _,
+    ],
   )
 
   return (
@@ -66,6 +90,7 @@ export const ChooseAccountForm = ({
         <AccountList
           onSelectAccount={onSelect}
           onSelectOther={() => onSelectAccount()}
+          pendingDid={pendingDid}
         />
       </View>
       <View style={[a.flex_row]}>
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
new file mode 100644
index 000000000..3de15e661
--- /dev/null
+++ b/src/screens/Messages/Conversation/MessageInput.tsx
@@ -0,0 +1,111 @@
+import React from 'react'
+import {
+  Dimensions,
+  Keyboard,
+  NativeSyntheticEvent,
+  Pressable,
+  TextInput,
+  TextInputContentSizeChangeEventData,
+  View,
+} from 'react-native'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {HITSLOP_10} from '#/lib/constants'
+import {useHaptics} from 'lib/haptics'
+import {atoms as a, useTheme} from '#/alf'
+import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
+
+export function MessageInput({
+  onSendMessage,
+  scrollToEnd,
+}: {
+  onSendMessage: (message: string) => void
+  scrollToEnd: () => void
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const playHaptic = useHaptics()
+  const [message, setMessage] = React.useState('')
+  const [maxHeight, setMaxHeight] = React.useState<number | undefined>()
+  const [isInputScrollable, setIsInputScrollable] = React.useState(false)
+
+  const {top: topInset} = useSafeAreaInsets()
+
+  const inputRef = React.useRef<TextInput>(null)
+
+  const onSubmit = React.useCallback(() => {
+    if (message.trim() === '') {
+      return
+    }
+    onSendMessage(message.trimEnd())
+    playHaptic()
+    setMessage('')
+    setTimeout(() => {
+      inputRef.current?.focus()
+    }, 100)
+  }, [message, onSendMessage, playHaptic])
+
+  const onInputLayout = React.useCallback(
+    (e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
+      const keyboardHeight = Keyboard.metrics()?.height ?? 0
+      const windowHeight = Dimensions.get('window').height
+
+      const max = windowHeight - keyboardHeight - topInset - 100
+      const availableSpace = max - e.nativeEvent.contentSize.height
+
+      setMaxHeight(max)
+      setIsInputScrollable(availableSpace < 30)
+
+      scrollToEnd()
+    },
+    [scrollToEnd, topInset],
+  )
+
+  return (
+    <View style={a.p_sm}>
+      <View
+        style={[
+          a.w_full,
+          a.flex_row,
+          a.py_sm,
+          a.px_sm,
+          a.pl_md,
+          t.atoms.bg_contrast_25,
+          {borderRadius: 23},
+        ]}>
+        <TextInput
+          accessibilityLabel={_(msg`Message input field`)}
+          accessibilityHint={_(msg`Type your message here`)}
+          placeholder={_(msg`Write a message`)}
+          placeholderTextColor={t.palette.contrast_500}
+          value={message}
+          multiline={true}
+          onChangeText={setMessage}
+          style={[a.flex_1, a.text_md, a.px_sm, t.atoms.text, {maxHeight}]}
+          keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
+          scrollEnabled={isInputScrollable}
+          blurOnSubmit={false}
+          onFocus={scrollToEnd}
+          onContentSizeChange={onInputLayout}
+          ref={inputRef}
+        />
+        <Pressable
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Send message`)}
+          accessibilityHint=""
+          hitSlop={HITSLOP_10}
+          style={[
+            a.rounded_full,
+            a.align_center,
+            a.justify_center,
+            {height: 30, width: 30, backgroundColor: t.palette.primary_500},
+          ]}
+          onPress={onSubmit}>
+          <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
+        </Pressable>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx
new file mode 100644
index 000000000..a2f255bdc
--- /dev/null
+++ b/src/screens/Messages/Conversation/MessageInput.web.tsx
@@ -0,0 +1,94 @@
+import React from 'react'
+import {Pressable, StyleSheet, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import TextareaAutosize from 'react-textarea-autosize'
+
+import {atoms as a, useTheme} from '#/alf'
+import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
+
+export function MessageInput({
+  onSendMessage,
+}: {
+  onSendMessage: (message: string) => void
+  scrollToEnd: () => void
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const [message, setMessage] = React.useState('')
+
+  const onSubmit = React.useCallback(() => {
+    if (message.trim() === '') {
+      return
+    }
+    onSendMessage(message.trimEnd())
+    setMessage('')
+  }, [message, onSendMessage])
+
+  const onKeyDown = React.useCallback(
+    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+      if (e.key === 'Enter') {
+        if (e.shiftKey) return
+        e.preventDefault()
+        onSubmit()
+      }
+    },
+    [onSubmit],
+  )
+
+  const onChange = React.useCallback(
+    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+      setMessage(e.target.value)
+    },
+    [],
+  )
+
+  return (
+    <View style={a.p_sm}>
+      <View
+        style={[
+          a.flex_row,
+          a.py_sm,
+          a.px_sm,
+          a.pl_md,
+          t.atoms.bg_contrast_25,
+          {borderRadius: 23},
+        ]}>
+        <TextareaAutosize
+          style={StyleSheet.flatten([
+            a.flex_1,
+            a.px_sm,
+            a.border_0,
+            t.atoms.text,
+            {
+              backgroundColor: 'transparent',
+              resize: 'none',
+              paddingTop: 4,
+            },
+          ])}
+          maxRows={12}
+          placeholder={_(msg`Write a message`)}
+          defaultValue=""
+          value={message}
+          dirName="ltr"
+          autoFocus={true}
+          onChange={onChange}
+          onKeyDown={onKeyDown}
+        />
+        <Pressable
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Send message`)}
+          accessibilityHint=""
+          style={[
+            a.rounded_full,
+            a.align_center,
+            a.justify_center,
+            {height: 30, width: 30, backgroundColor: t.palette.primary_500},
+          ]}
+          onPress={onSubmit}>
+          <PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
+        </Pressable>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/Conversation/MessageListError.tsx b/src/screens/Messages/Conversation/MessageListError.tsx
new file mode 100644
index 000000000..523788d4d
--- /dev/null
+++ b/src/screens/Messages/Conversation/MessageListError.tsx
@@ -0,0 +1,60 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {ConvoItem, ConvoItemError} from '#/state/messages/convo'
+import {atoms as a, useTheme} from '#/alf'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function MessageListError({
+  item,
+}: {
+  item: ConvoItem & {type: 'error-recoverable'}
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const message = React.useMemo(() => {
+    return {
+      [ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
+      [ConvoItemError.ResumeFailed]: _(
+        msg`There was an issue connecting to the chat.`,
+      ),
+      [ConvoItemError.PollFailed]: _(
+        msg`This chat was disconnected due to a network error.`,
+      ),
+    }[item.code]
+  }, [_, item.code])
+
+  return (
+    <View style={[a.py_md, a.align_center]}>
+      <View
+        style={[
+          a.align_center,
+          a.pt_md,
+          a.pb_lg,
+          a.px_3xl,
+          a.rounded_md,
+          t.atoms.bg_contrast_25,
+          {maxWidth: 300},
+        ]}>
+        <CircleInfo size="lg" fill={t.palette.negative_400} />
+        <Text style={[a.pt_sm, a.leading_snug]}>
+          {message}{' '}
+          <InlineLinkText
+            to="#"
+            label={_(msg`Press to retry`)}
+            onPress={e => {
+              e.preventDefault()
+              item.retry()
+              return false
+            }}>
+            {_(msg`Retry.`)}
+          </InlineLinkText>
+        </Text>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
new file mode 100644
index 000000000..1dc26d6c3
--- /dev/null
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -0,0 +1,258 @@
+import React, {useCallback, useRef} from 'react'
+import {FlatList, View} from 'react-native'
+import {
+  KeyboardAvoidingView,
+  useKeyboardHandler,
+} from 'react-native-keyboard-controller'
+import {runOnJS, useSharedValue} from 'react-native-reanimated'
+import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isIOS} from '#/platform/detection'
+import {useChat} from '#/state/messages'
+import {ConvoItem, ConvoStatus} from '#/state/messages/convo'
+import {ScrollProvider} from 'lib/ScrollContext'
+import {isWeb} from 'platform/detection'
+import {List} from 'view/com/util/List'
+import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
+import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {MessageItem} from '#/components/dms/MessageItem'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+function MaybeLoader({isLoading}: {isLoading: boolean}) {
+  return (
+    <View
+      style={{
+        height: 50,
+        width: '100%',
+        alignItems: 'center',
+        justifyContent: 'center',
+      }}>
+      {isLoading && <Loader size="xl" />}
+    </View>
+  )
+}
+
+function RetryButton({onPress}: {onPress: () => unknown}) {
+  const {_} = useLingui()
+
+  return (
+    <View style={{alignItems: 'center'}}>
+      <Button
+        label={_(msg`Press to Retry`)}
+        onPress={onPress}
+        variant="ghost"
+        color="negative"
+        size="small">
+        <ButtonText>
+          <Trans>Press to Retry</Trans>
+        </ButtonText>
+      </Button>
+    </View>
+  )
+}
+
+function renderItem({item}: {item: ConvoItem}) {
+  if (item.type === 'message' || item.type === 'pending-message') {
+    return (
+      <MessageItem
+        item={item.message}
+        next={item.nextMessage}
+        pending={item.type === 'pending-message'}
+      />
+    )
+  } else if (item.type === 'deleted-message') {
+    return <Text>Deleted message</Text>
+  } else if (item.type === 'pending-retry') {
+    return <RetryButton onPress={item.retry} />
+  } else if (item.type === 'error-recoverable') {
+    return <MessageListError item={item} />
+  }
+
+  return null
+}
+
+function keyExtractor(item: ConvoItem) {
+  return item.key
+}
+
+function onScrollToIndexFailed() {
+  // Placeholder function. You have to give FlatList something or else it will error.
+}
+
+export function MessagesList() {
+  const chat = useChat()
+  const flatListRef = useRef<FlatList>(null)
+
+  // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
+  // are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
+  // the bottom.
+  const isAtBottom = useSharedValue(true)
+
+  // This will be used on web to assist in determing if we need to maintain the content offset
+  const isAtTop = useSharedValue(true)
+
+  // Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
+  // onStartReached to fire.
+  const contentHeight = useSharedValue(0)
+
+  // We don't want to call `scrollToEnd` again if we are already scolling to the end, because this creates a bit of jank
+  // Instead, we use `onMomentumScrollEnd` and this value to determine if we need to start scrolling or not.
+  const isMomentumScrolling = useSharedValue(false)
+
+  const [hasInitiallyScrolled, setHasInitiallyScrolled] = React.useState(false)
+
+  // Every time the content size changes, that means one of two things is happening:
+  // 1. New messages are being added from the log or from a message you have sent
+  // 2. Old messages are being prepended to the top
+  //
+  // The first time that the content size changes is when the initial items are rendered. Because we cannot rely on
+  // `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated.
+  //
+  // Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of
+  // the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However
+  // we will not scroll whenever new items get prepended to the top.
+  const onContentSizeChange = useCallback(
+    (_: number, height: number) => {
+      // Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
+      // previous offset whenever we add new content to the previous offset whenever we add new content to the list.
+      if (isWeb && isAtTop.value && hasInitiallyScrolled) {
+        flatListRef.current?.scrollToOffset({
+          animated: false,
+          offset: height - contentHeight.value,
+        })
+      }
+
+      contentHeight.value = height
+
+      // This number _must_ be the height of the MaybeLoader component
+      if (height <= 50 || !isAtBottom.value) {
+        return
+      }
+
+      flatListRef.current?.scrollToOffset({
+        animated: hasInitiallyScrolled,
+        offset: height,
+      })
+      isMomentumScrolling.value = true
+    },
+    [
+      contentHeight,
+      hasInitiallyScrolled,
+      isAtBottom.value,
+      isAtTop.value,
+      isMomentumScrolling,
+    ],
+  )
+
+  // The check for `hasInitiallyScrolled` prevents an initial fetch on mount. FlatList triggers `onStartReached`
+  // immediately on mount, since we are in fact at an offset of zero, so we have to ignore those initial calls.
+  const onStartReached = useCallback(() => {
+    if (chat.status === ConvoStatus.Ready && hasInitiallyScrolled) {
+      chat.fetchMessageHistory()
+    }
+  }, [chat, hasInitiallyScrolled])
+
+  const onSendMessage = useCallback(
+    (text: string) => {
+      if (chat.status === ConvoStatus.Ready) {
+        chat.sendMessage({
+          text,
+        })
+      }
+    },
+    [chat],
+  )
+
+  const onScroll = React.useCallback(
+    (e: ReanimatedScrollEvent) => {
+      'worklet'
+      const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
+
+      // Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
+      // when a new message is added, hence the 100 pixel offset
+      isAtBottom.value = e.contentSize.height - 100 < bottomOffset
+      isAtTop.value = e.contentOffset.y <= 1
+
+      // This number _must_ be the height of the MaybeLoader component.
+      // We don't check for zero, because the `MaybeLoader` component is always present, even when not visible, which
+      // adds a 50 pixel offset.
+      if (contentHeight.value > 50 && !hasInitiallyScrolled) {
+        runOnJS(setHasInitiallyScrolled)(true)
+      }
+    },
+    [contentHeight.value, hasInitiallyScrolled, isAtBottom, isAtTop],
+  )
+
+  const onMomentumEnd = React.useCallback(() => {
+    'worklet'
+    isMomentumScrolling.value = false
+  }, [isMomentumScrolling])
+
+  const scrollToEnd = React.useCallback(() => {
+    requestAnimationFrame(() => {
+      if (isMomentumScrolling.value) return
+
+      flatListRef.current?.scrollToEnd({animated: true})
+      isMomentumScrolling.value = true
+    })
+  }, [isMomentumScrolling])
+
+  const {bottom: bottomInset, top: topInset} = useSafeAreaInsets()
+  const {gtMobile} = useBreakpoints()
+  const bottomBarHeight = gtMobile ? 0 : isIOS ? 40 : 60
+
+  // This is only used inside the useKeyboardHandler because the worklet won't work with a ref directly.
+  const scrollToEndNow = React.useCallback(() => {
+    flatListRef.current?.scrollToEnd({animated: false})
+  }, [])
+
+  useKeyboardHandler({
+    onMove: () => {
+      'worklet'
+      runOnJS(scrollToEndNow)()
+    },
+  })
+
+  return (
+    <KeyboardAvoidingView
+      style={[a.flex_1, {marginBottom: bottomInset + bottomBarHeight}]}
+      keyboardVerticalOffset={isIOS ? topInset : 0}
+      behavior="padding"
+      contentContainerStyle={a.flex_1}>
+      {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
+      <ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
+        <List
+          ref={flatListRef}
+          data={chat.items}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          disableVirtualization={true}
+          initialNumToRender={isWeb ? 50 : 25}
+          maxToRenderPerBatch={isWeb ? 50 : 25}
+          keyboardDismissMode="on-drag"
+          keyboardShouldPersistTaps="handled"
+          maintainVisibleContentPosition={{
+            minIndexForVisible: 1,
+          }}
+          containWeb={true}
+          contentContainerStyle={{paddingHorizontal: 10}}
+          removeClippedSubviews={false}
+          onContentSizeChange={onContentSizeChange}
+          onStartReached={onStartReached}
+          onScrollToIndexFailed={onScrollToIndexFailed}
+          scrollEventThrottle={100}
+          ListHeaderComponent={
+            <MaybeLoader isLoading={chat.isFetchingHistory} />
+          }
+        />
+      </ScrollProvider>
+      <MessageInput onSendMessage={onSendMessage} scrollToEnd={scrollToEnd} />
+    </KeyboardAvoidingView>
+  )
+}
diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx
index 239425a2f..11044c213 100644
--- a/src/screens/Messages/Conversation/index.tsx
+++ b/src/screens/Messages/Conversation/index.tsx
@@ -1,12 +1,26 @@
-import React from 'react'
-import {View} from 'react-native'
+import React, {useCallback} from 'react'
+import {TouchableOpacity, View} from 'react-native'
+import {KeyboardProvider} from 'react-native-keyboard-controller'
+import {AppBskyActorDefs} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
 
-import {CommonNavigatorParams} from '#/lib/routes/types'
+import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
-import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {BACK_HITSLOP} from 'lib/constants'
+import {isWeb} from 'platform/detection'
+import {ChatProvider, useChat} from 'state/messages'
+import {ConvoStatus} from 'state/messages/convo'
+import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
+import {CenteredView} from 'view/com/util/Views'
+import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {ConvoMenu} from '#/components/dms/ConvoMenu'
+import {ListMaybePlaceholder} from '#/components/Lists'
+import {Text} from '#/components/Typography'
 import {ClipClopGate} from '../gate'
 
 type Props = NativeStackScreenProps<
@@ -14,19 +28,121 @@ type Props = NativeStackScreenProps<
   'MessagesConversation'
 >
 export function MessagesConversationScreen({route}: Props) {
-  const chatId = route.params.conversation
-  const {_} = useLingui()
-
   const gate = useGate()
+  const convoId = route.params.conversation
+
   if (!gate('dms')) return <ClipClopGate />
 
   return (
-    <View>
-      <ViewHeader
-        title={_(msg`Chat with ${chatId}`)}
-        showOnDesktop
-        showBorder
-      />
+    <ChatProvider convoId={convoId}>
+      <Inner />
+    </ChatProvider>
+  )
+}
+
+function Inner() {
+  const chat = useChat()
+
+  if (
+    chat.status === ConvoStatus.Uninitialized ||
+    chat.status === ConvoStatus.Initializing
+  ) {
+    return <ListMaybePlaceholder isLoading />
+  }
+
+  if (chat.status === ConvoStatus.Error) {
+    // TODO error
+    return null
+  }
+
+  /*
+   * Any other chat states (atm) are "ready" states
+   */
+
+  return (
+    <KeyboardProvider>
+      <CenteredView style={{flex: 1}} sideBorders>
+        <Header profile={chat.recipients[0]} />
+        <MessagesList />
+      </CenteredView>
+    </KeyboardProvider>
+  )
+}
+
+let Header = ({
+  profile,
+}: {
+  profile: AppBskyActorDefs.ProfileViewBasic
+}): React.ReactNode => {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtTablet} = useBreakpoints()
+  const navigation = useNavigation<NavigationProp>()
+  const chat = useChat()
+
+  const onPressBack = useCallback(() => {
+    if (isWeb) {
+      navigation.replace('Messages')
+    } else {
+      navigation.pop()
+    }
+  }, [navigation])
+
+  const onUpdateConvo = useCallback(() => {
+    // TODO eric update muted state
+  }, [])
+
+  return (
+    <View
+      style={[
+        t.atoms.bg,
+        t.atoms.border_contrast_low,
+        a.border_b,
+        a.flex_row,
+        a.justify_between,
+        a.align_start,
+        a.gap_lg,
+        a.px_lg,
+        a.py_sm,
+      ]}>
+      {!gtTablet ? (
+        <TouchableOpacity
+          testID="conversationHeaderBackBtn"
+          onPress={onPressBack}
+          hitSlop={BACK_HITSLOP}
+          style={{width: 30, height: 30}}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Back`)}
+          accessibilityHint="">
+          <FontAwesomeIcon
+            size={18}
+            icon="angle-left"
+            style={{
+              marginTop: 6,
+            }}
+            color={t.atoms.text.color}
+          />
+        </TouchableOpacity>
+      ) : (
+        <View style={{width: 30}} />
+      )}
+      <View style={[a.align_center, a.gap_sm, a.flex_1]}>
+        <PreviewableUserAvatar size={32} profile={profile} />
+        <Text style={[a.text_lg, a.font_bold, a.text_center]}>
+          {profile.displayName}
+        </Text>
+      </View>
+      {chat.status === ConvoStatus.Ready ? (
+        <ConvoMenu
+          convo={chat.convo}
+          profile={profile}
+          onUpdateConvo={onUpdateConvo}
+          currentScreen="conversation"
+        />
+      ) : (
+        <View style={{width: 30}} />
+      )}
     </View>
   )
 }
+Header = React.memo(Header)
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx
index c4490aa5c..ce8f52af9 100644
--- a/src/screens/Messages/List/index.tsx
+++ b/src/screens/Messages/List/index.tsx
@@ -1,31 +1,59 @@
-import React, {useCallback, useState} from 'react'
+/* eslint-disable react/prop-types */
+
+import React, {useCallback, useMemo, useState} from 'react'
 import {View} from 'react-native'
-import {msg} from '@lingui/macro'
+import {ChatBskyConvoDefs} from '@atproto-labs/api'
+import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
-import {useInfiniteQuery} from '@tanstack/react-query'
+import {sha256} from 'js-sha256'
 
 import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
 import {MessagesTabNavigatorParams} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
-import {useAgent} from '#/state/session'
+import {isNative} from '#/platform/detection'
+import {useListConvos} from '#/state/queries/messages/list-converations'
+import {useSession} from '#/state/session'
 import {List} from '#/view/com/util/List'
+import {TimeElapsed} from '#/view/com/util/TimeElapsed'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
 import {ViewHeader} from '#/view/com/util/ViewHeader'
-import {useTheme} from '#/alf'
-import {atoms as a} from '#/alf'
+import {CenteredView} from '#/view/com/util/Views'
+import {ScrollView} from '#/view/com/util/Views'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {DialogControlProps, useDialogControl} from '#/components/Dialog'
+import {ConvoMenu} from '#/components/dms/ConvoMenu'
+import {NewChat} from '#/components/dms/NewChat'
+import * as TextField from '#/components/forms/TextField'
+import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
 import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
 import {Link} from '#/components/Link'
 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+import {useMenuControl} from '#/components/Menu'
 import {Text} from '#/components/Typography'
 import {ClipClopGate} from '../gate'
+import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage'
 
-type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'>
-export function MessagesListScreen({}: Props) {
+type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'>
+export function MessagesScreen({navigation}: Props) {
   const {_} = useLingui()
   const t = useTheme()
+  const newChatControl = useDialogControl()
+  const {gtMobile} = useBreakpoints()
+
+  // TEMP
+  const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage()
+  const hasValidServiceUrl = useMemo(() => {
+    const hash = sha256(serviceUrl)
+    return (
+      hash ===
+      'a32318b49dd3fe6aa6a35c66c13fcc4c1cb6202b24f5a852d9a2279acee4169f'
+    )
+  }, [serviceUrl])
 
   const renderButton = useCallback(() => {
     return (
@@ -49,18 +77,20 @@ export function MessagesListScreen({}: Props) {
     fetchNextPage,
     error,
     refetch,
-  } = usePlaceholderConversations()
+  } = useListConvos({refetchInterval: 15_000})
+
+  useRefreshOnFocus(refetch)
 
   const isError = !!error
 
-  const conversations = React.useMemo(() => {
+  const conversations = useMemo(() => {
     if (data?.pages) {
-      return data.pages.flat()
+      return data.pages.flatMap(page => page.convos)
     }
     return []
   }, [data])
 
-  const onRefresh = React.useCallback(async () => {
+  const onRefresh = useCallback(async () => {
     setIsPTRing(true)
     try {
       await refetch()
@@ -70,7 +100,7 @@ export function MessagesListScreen({}: Props) {
     setIsPTRing(false)
   }, [refetch, setIsPTRing])
 
-  const onEndReached = React.useCallback(async () => {
+  const onEndReached = useCallback(async () => {
     if (isFetchingNextPage || !hasNextPage || isError) return
     try {
       await fetchNextPage()
@@ -79,85 +109,104 @@ export function MessagesListScreen({}: Props) {
     }
   }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
 
+  const onNewChat = useCallback(
+    (conversation: string) =>
+      navigation.navigate('MessagesConversation', {conversation}),
+    [navigation],
+  )
+
+  const onNavigateToSettings = useCallback(() => {
+    navigation.navigate('MessagesSettings')
+  }, [navigation])
+
+  const renderItem = useCallback(
+    ({item}: {item: ChatBskyConvoDefs.ConvoView}) => {
+      return <ChatListItem key={item.id} convo={item} />
+    },
+    [],
+  )
+
   const gate = useGate()
   if (!gate('dms')) return <ClipClopGate />
 
+  if (!hasValidServiceUrl) {
+    return (
+      <ScrollView contentContainerStyle={a.p_lg}>
+        <View>
+          <TextField.LabelText>Service URL</TextField.LabelText>
+          <TextField.Root>
+            <TextField.Input
+              value={serviceUrl}
+              onChangeText={text => setServiceUrl(text)}
+              autoCapitalize="none"
+              keyboardType="url"
+              label="https://"
+            />
+          </TextField.Root>
+        </View>
+      </ScrollView>
+    )
+  }
+
   if (conversations.length < 1) {
     return (
-      <ListMaybePlaceholder
-        isLoading={isLoading}
-        isError={isError}
-        emptyType="results"
-        emptyMessage={_(
-          msg`You have no messages yet. Start a conversation with someone!`,
+      <View style={a.flex_1}>
+        {gtMobile ? (
+          <CenteredView sideBorders>
+            <DesktopHeader
+              newChatControl={newChatControl}
+              onNavigateToSettings={onNavigateToSettings}
+            />
+          </CenteredView>
+        ) : (
+          <ViewHeader
+            title={_(msg`Messages`)}
+            renderButton={renderButton}
+            showBorder
+            canGoBack={false}
+          />
         )}
-        errorMessage={cleanError(error)}
-        onRetry={isError ? refetch : undefined}
-      />
+        {!isError && <NewChat onNewChat={onNewChat} control={newChatControl} />}
+        <ListMaybePlaceholder
+          isLoading={isLoading}
+          isError={isError}
+          emptyType="results"
+          emptyTitle={_(msg`No messages yet`)}
+          emptyMessage={_(
+            msg`You have no messages yet. Start a conversation with someone!`,
+          )}
+          errorMessage={cleanError(error)}
+          onRetry={isError ? refetch : undefined}
+          hideBackButton
+        />
+      </View>
     )
   }
 
   return (
-    <View>
-      <ViewHeader
-        title={_(msg`Messages`)}
-        showOnDesktop
-        renderButton={renderButton}
-        showBorder
-        canGoBack={false}
-      />
+    <View style={a.flex_1}>
+      {!gtMobile && (
+        <ViewHeader
+          title={_(msg`Messages`)}
+          renderButton={renderButton}
+          showBorder
+          canGoBack={false}
+        />
+      )}
+      <NewChat onNewChat={onNewChat} control={newChatControl} />
       <List
         data={conversations}
-        renderItem={({item}) => {
-          return (
-            <Link
-              to={`/messages/${item.profile.handle}`}
-              style={[a.flex_1, a.pl_md, a.py_sm, a.gap_md, a.pr_2xl]}>
-              <PreviewableUserAvatar profile={item.profile} size={44} />
-              <View style={[a.flex_1]}>
-                <View
-                  style={[
-                    a.flex_row,
-                    a.align_center,
-                    a.justify_between,
-                    a.gap_lg,
-                    a.flex_1,
-                  ]}>
-                  <Text numberOfLines={1}>
-                    <Text style={item.unread && a.font_bold}>
-                      {item.profile.displayName || item.profile.handle}
-                    </Text>{' '}
-                    <Text style={t.atoms.text_contrast_medium}>
-                      @{item.profile.handle}
-                    </Text>
-                  </Text>
-                  {item.unread && (
-                    <View
-                      style={[
-                        a.ml_2xl,
-                        {backgroundColor: t.palette.primary_500},
-                        a.rounded_full,
-                        {height: 7, width: 7},
-                      ]}
-                    />
-                  )}
-                </View>
-                <Text
-                  numberOfLines={2}
-                  style={[
-                    a.text_sm,
-                    item.unread ? a.font_bold : t.atoms.text_contrast_medium,
-                  ]}>
-                  {item.lastMessage}
-                </Text>
-              </View>
-            </Link>
-          )
-        }}
-        keyExtractor={item => item.profile.did}
+        renderItem={renderItem}
+        keyExtractor={item => item.id}
         refreshing={isPTRing}
         onRefresh={onRefresh}
         onEndReached={onEndReached}
+        ListHeaderComponent={
+          <DesktopHeader
+            newChatControl={newChatControl}
+            onNavigateToSettings={onNavigateToSettings}
+          />
+        }
         ListFooterComponent={
           <ListFooter
             isFetchingNextPage={isFetchingNextPage}
@@ -169,61 +218,182 @@ export function MessagesListScreen({}: Props) {
         onEndReachedThreshold={3}
         initialNumToRender={initialNumToRender}
         windowSize={11}
+        // @ts-ignore our .web version only -sfn
+        desktopFixedHeight
       />
     </View>
   )
 }
 
-function usePlaceholderConversations() {
-  const {getAgent} = useAgent()
-
-  return useInfiniteQuery({
-    queryKey: ['messages'],
-    queryFn: async () => {
-      const people = await getAgent().getProfiles({actors: PLACEHOLDER_PEOPLE})
-      return people.data.profiles.map(profile => ({
-        profile,
-        unread: Math.random() > 0.5,
-        lastMessage: getRandomPost(),
-      }))
-    },
-    initialPageParam: undefined,
-    getNextPageParam: () => undefined,
-  })
+function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const menuControl = useMenuControl()
+
+  let lastMessage = _(msg`No messages yet`)
+  let lastMessageSentAt: string | null = null
+  if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
+    if (convo.lastMessage.sender?.did === currentAccount?.did) {
+      lastMessage = _(msg`You: ${convo.lastMessage.text}`)
+    } else {
+      lastMessage = convo.lastMessage.text
+    }
+    lastMessageSentAt = convo.lastMessage.sentAt
+  }
+  if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
+    lastMessage = _(msg`Message deleted`)
+  }
+
+  const otherUser = convo.members.find(
+    member => member.did !== currentAccount?.did,
+  )
+
+  if (!otherUser) {
+    return null
+  }
+
+  return (
+    <Link
+      to={`/messages/${convo.id}`}
+      style={a.flex_1}
+      onLongPress={isNative ? menuControl.open : undefined}>
+      {({hovered, pressed}) => (
+        <View
+          style={[
+            a.flex_row,
+            a.flex_1,
+            a.pl_md,
+            a.py_sm,
+            a.gap_md,
+            a.pr_2xl,
+            (hovered || pressed) && t.atoms.bg_contrast_25,
+          ]}>
+          <View pointerEvents="none">
+            <PreviewableUserAvatar profile={otherUser} size={42} />
+          </View>
+          <View style={[a.flex_1]}>
+            <Text
+              numberOfLines={1}
+              style={[a.text_md, web([a.leading_normal, {marginTop: -4}])]}>
+              <Text
+                style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}>
+                {otherUser.displayName || otherUser.handle}
+              </Text>{' '}
+              {lastMessageSentAt ? (
+                <TimeElapsed timestamp={lastMessageSentAt}>
+                  {({timeElapsed}) => (
+                    <Text style={t.atoms.text_contrast_medium}>
+                      @{otherUser.handle} &middot; {timeElapsed}
+                    </Text>
+                  )}
+                </TimeElapsed>
+              ) : (
+                <Text style={t.atoms.text_contrast_medium}>
+                  @{otherUser.handle}
+                </Text>
+              )}
+            </Text>
+            <Text
+              numberOfLines={2}
+              style={[
+                a.text_sm,
+                a.leading_snug,
+                convo.unreadCount > 0
+                  ? a.font_bold
+                  : t.atoms.text_contrast_medium,
+              ]}>
+              {lastMessage}
+            </Text>
+          </View>
+          {convo.unreadCount > 0 && (
+            <View
+              style={[
+                a.flex_0,
+                a.ml_md,
+                a.mt_sm,
+                a.rounded_full,
+                {
+                  backgroundColor: convo.muted
+                    ? t.palette.contrast_200
+                    : t.palette.primary_500,
+                  height: 7,
+                  width: 7,
+                },
+              ]}
+            />
+          )}
+          <ConvoMenu
+            convo={convo}
+            profile={otherUser}
+            control={menuControl}
+            // TODO(sam) show on hover on web
+            // tricky because it captures the mouse event
+            hideTrigger
+            currentScreen="list"
+          />
+        </View>
+      )}
+    </Link>
+  )
 }
 
-const PLACEHOLDER_PEOPLE = [
-  'pfrazee.com',
-  'haileyok.com',
-  'danabra.mov',
-  'esb.lol',
-  'samuel.bsky.team',
-]
-
-function getRandomPost() {
-  const num = Math.floor(Math.random() * 10)
-  switch (num) {
-    case 0:
-      return 'hello'
-    case 1:
-      return 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'
-    case 2:
-      return 'banger post'
-    case 3:
-      return 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'
-    case 4:
-      return 'lol look at this bug'
-    case 5:
-      return 'wow'
-    case 6:
-      return "that's pretty cool, wow!"
-    case 7:
-      return 'I think this is a bug'
-    case 8:
-      return 'Hello World!'
-    case 9:
-      return 'DMs when???'
-    default:
-      return 'this is unlikely'
+function DesktopHeader({
+  newChatControl,
+  onNavigateToSettings,
+}: {
+  newChatControl: DialogControlProps
+  onNavigateToSettings: () => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile, gtTablet} = useBreakpoints()
+
+  if (!gtMobile) {
+    return null
   }
+
+  return (
+    <View
+      style={[
+        t.atoms.bg,
+        t.atoms.border_contrast_low,
+        a.border_b,
+        a.flex_row,
+        a.align_center,
+        a.justify_between,
+        a.gap_lg,
+        a.px_lg,
+        a.py_sm,
+      ]}>
+      <Text style={[a.text_2xl, a.font_bold]}>
+        <Trans>Messages</Trans>
+      </Text>
+      <View style={[a.flex_row, a.align_center, a.gap_md]}>
+        <Button
+          label={_(msg`Message settings`)}
+          color="secondary"
+          size="large"
+          variant="ghost"
+          style={[{height: 'auto', width: 'auto'}, a.px_sm, a.py_sm]}
+          onPress={onNavigateToSettings}>
+          <ButtonIcon icon={SettingsSlider} />
+        </Button>
+        {gtTablet && (
+          <Button
+            label={_(msg`New chat`)}
+            color="primary"
+            size="large"
+            variant="solid"
+            style={[{height: 'auto', width: 'auto'}, a.px_md, a.py_sm]}
+            onPress={newChatControl.open}>
+            <ButtonIcon icon={Plus} position="right" />
+            <ButtonText>
+              <Trans>New chat</Trans>
+            </ButtonText>
+          </Button>
+        )}
+      </View>
+    </View>
+  )
 }
diff --git a/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx b/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx
new file mode 100644
index 000000000..d78128b5c
--- /dev/null
+++ b/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx
@@ -0,0 +1,63 @@
+import React from 'react'
+import {useAsyncStorage} from '@react-native-async-storage/async-storage'
+
+/**
+ * TEMP: REMOVE BEFORE RELEASE
+ *
+ * Clip clop trivia:
+ *
+ * A little known fact about the term "clip clop" is that it may refer to a unit of time. It is unknown what the exact
+ * length of a clip clop is, but it is generally agreed that it is approximately 9 minutes and 30 seconds, or 570
+ * seconds.
+ *
+ * The term "clip clop" may also be used in other contexts, although it is unknown what all of these contexts may be.
+ * Recently, the term has been used among many young adults to refer to a type of social media functionality, although
+ * the exact nature of this functionality is also unknown. It is believed that the term may have originated from a
+ * popular video game, but this has not been confirmed.
+ *
+ */
+
+const DmServiceUrlStorageContext = React.createContext<{
+  serviceUrl: string
+  setServiceUrl: (value: string) => void
+}>({
+  serviceUrl: '',
+  setServiceUrl: () => {},
+})
+
+export const useDmServiceUrlStorage = () =>
+  React.useContext(DmServiceUrlStorageContext)
+
+export function DmServiceUrlProvider({children}: {children: React.ReactNode}) {
+  const [serviceUrl, setServiceUrl] = React.useState<string>('')
+  const {getItem, setItem: setItemInner} = useAsyncStorage('dmServiceUrl')
+
+  React.useEffect(() => {
+    ;(async () => {
+      const v = await getItem()
+      setServiceUrl(v ?? '')
+    })()
+  }, [getItem])
+
+  const setItem = React.useCallback(
+    (v: string) => {
+      setItemInner(v)
+      setServiceUrl(v)
+    },
+    [setItemInner],
+  )
+
+  const value = React.useMemo(
+    () => ({
+      serviceUrl,
+      setServiceUrl: setItem,
+    }),
+    [serviceUrl, setItem],
+  )
+
+  return (
+    <DmServiceUrlStorageContext.Provider value={value}>
+      {children}
+    </DmServiceUrlStorageContext.Provider>
+  )
+}
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
index 7e4ea1f8b..f0ba36e39 100644
--- a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
+++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
@@ -2,13 +2,13 @@ import React from 'react'
 import {View, ViewStyle} from 'react-native'
 import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
 
-import {useTheme, atoms as a, flatten} from '#/alf'
-import {Text} from '#/components/Typography'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, flatten, useTheme} from '#/alf'
 import {useItemContext} from '#/components/forms/Toggle'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {useModerationOpts} from '#/state/queries/preferences'
 import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
 
 export function SuggestedAccountCard({
   profile,
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
index e9bc3f0fd..7b2ad2b99 100644
--- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
+++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
 import {useAnalytics} from '#/lib/analytics/analytics'
 import {logEvent} from '#/lib/statsig/statsig'
 import {capitalize} from '#/lib/strings/capitalize'
-import {useModerationOpts} from '#/state/queries/preferences'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {useProfilesQuery} from '#/state/queries/profile'
 import {
   DescriptionText,
diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx
index f43e3633d..553d94d2e 100644
--- a/src/screens/Profile/Sections/Labels.tsx
+++ b/src/screens/Profile/Sections/Labels.tsx
@@ -123,6 +123,9 @@ export function ProfileLabelsSectionInner({
     onScroll(e, ctx) {
       contextScrollHandlers.onScroll?.(e, ctx)
     },
+    onMomentumEnd(e, ctx) {
+      contextScrollHandlers.onMomentumEnd?.(e, ctx)
+    },
   })
 
   const {labelValues} = labelerInfo.policies
diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
index 50918c4ce..caa0aa28a 100644
--- a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
+++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
@@ -26,7 +26,7 @@ export function CaptchaWebView({
   stateParam: string
   state?: SignupState
   onSuccess: (code: string) => void
-  onError: () => void
+  onError: (error: unknown) => void
 }) {
   const redirectHost = React.useMemo(() => {
     if (!state?.serviceUrl) return 'bsky.app'
@@ -56,7 +56,7 @@ export function CaptchaWebView({
 
       const code = urlp.searchParams.get('code')
       if (urlp.searchParams.get('state') !== stateParam || !code) {
-        onError()
+        onError({error: 'Invalid state or code'})
         return
       }
 
@@ -74,6 +74,12 @@ export function CaptchaWebView({
       onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
       onNavigationStateChange={onNavigationStateChange}
       scrollEnabled={false}
+      onError={e => {
+        onError(e.nativeEvent)
+      }}
+      onHttpError={e => {
+        onError(e.nativeEvent)
+      }}
     />
   )
 }
diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx
index 7791a58dd..8faaf90a0 100644
--- a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx
+++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx
@@ -13,8 +13,20 @@ export function CaptchaWebView({
   url: string
   stateParam: string
   onSuccess: (code: string) => void
-  onError: () => void
+  onError: (error: unknown) => void
 }) {
+  React.useEffect(() => {
+    const timeout = setTimeout(() => {
+      onError({
+        errorMessage: 'User did not complete the captcha within 30 seconds',
+      })
+    }, 30e3)
+
+    return () => {
+      clearTimeout(timeout)
+    }
+  }, [onError])
+
   const onLoad = React.useCallback(() => {
     // @ts-ignore web
     const frame: HTMLIFrameElement = document.getElementById(
@@ -32,12 +44,14 @@ export function CaptchaWebView({
 
       const code = urlp.searchParams.get('code')
       if (urlp.searchParams.get('state') !== stateParam || !code) {
-        onError()
+        onError({error: 'Invalid state or code'})
         return
       }
       onSuccess(code)
-    } catch (e) {
-      // We don't need to handle this
+    } catch (e: unknown) {
+      // We don't actually want to record an error here, because this will happen quite a bit. We will only be able to
+      // get hte href of the iframe if it's on our domain, so all the hcaptcha requests will throw here, although it's
+      // harmless. Our other indicators of time-to-complete and back press should be more reliable in catching issues.
     }
   }, [stateParam, onSuccess, onError])
 
diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx
index 2429b0c5e..d0fc4e934 100644
--- a/src/screens/Signup/StepCaptcha/index.tsx
+++ b/src/screens/Signup/StepCaptcha/index.tsx
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
 import {nanoid} from 'nanoid/non-secure'
 
 import {createFullHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
 import {ScreenTransition} from '#/screens/Login/ScreenTransition'
 import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
 import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
@@ -43,12 +44,19 @@ export function StepCaptcha() {
     [submit],
   )
 
-  const onError = React.useCallback(() => {
-    dispatch({
-      type: 'setError',
-      value: _(msg`Error receiving captcha response.`),
-    })
-  }, [_, dispatch])
+  const onError = React.useCallback(
+    (error?: unknown) => {
+      dispatch({
+        type: 'setError',
+        value: _(msg`Error receiving captcha response.`),
+      })
+      logger.error('Signup Flow Error', {
+        registrationHandle: state.handle,
+        error,
+      })
+    },
+    [_, dispatch, state.handle],
+  )
 
   return (
     <ScreenTransition>
diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx
index 6758f7fa1..5e2596d8c 100644
--- a/src/screens/Signup/index.tsx
+++ b/src/screens/Signup/index.tsx
@@ -8,6 +8,7 @@ import {useAnalytics} from '#/lib/analytics/analytics'
 import {FEEDBACK_FORM_URL} from '#/lib/constants'
 import {logEvent} from '#/lib/statsig/statsig'
 import {createFullHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
 import {useServiceQuery} from '#/state/queries/service'
 import {useAgent} from '#/state/session'
 import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
@@ -119,11 +120,19 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
 
   const onBackPress = React.useCallback(() => {
     if (state.activeStep !== SignupStep.INFO) {
+      if (state.activeStep === SignupStep.CAPTCHA) {
+        logger.error('Signup Flow Error', {
+          errorMessage:
+            'User went back from captcha step. Possibly encountered an error.',
+          registrationHandle: state.handle,
+        })
+      }
+
       dispatch({type: 'prev'})
     } else {
       onPressBack()
     }
-  }, [onPressBack, state.activeStep])
+  }, [onPressBack, state.activeStep, state.handle])
 
   return (
     <SignupContext.Provider value={{state, dispatch}}>
diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts
index 86a144368..d6cf9c44c 100644
--- a/src/screens/Signup/state.ts
+++ b/src/screens/Signup/state.ts
@@ -246,6 +246,10 @@ export function useSubmitSignup({
         !verificationCode
       ) {
         dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
+        logger.error('Signup Flow Error', {
+          errorMessage: 'Verification captcha code was not set.',
+          registrationHandle: state.handle,
+        })
         return dispatch({
           type: 'setError',
           value: _(msg`Please complete the verification captcha.`),
@@ -282,20 +286,17 @@ export function useSubmitSignup({
           return
         }
 
-        if ([400, 429].includes(e.status)) {
-          logger.warn('Failed to create account', {message: e})
-        } else {
-          logger.error(`Failed to create account (${e.status} status)`, {
-            message: e,
-          })
-        }
-
         const error = cleanError(errMsg)
         const isHandleError = error.toLowerCase().includes('handle')
 
         dispatch({type: 'setIsLoading', value: false})
-        dispatch({type: 'setError', value: cleanError(errMsg)})
+        dispatch({type: 'setError', value: error})
         dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
+
+        logger.error('Signup Flow Error', {
+          errorMessage: error,
+          registrationHandle: state.handle,
+        })
       } finally {
         dispatch({type: 'setIsLoading', value: false})
       }