about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/FeedInterstitials.tsx201
-rw-r--r--src/components/PostControls/DiscoverDebug.tsx2
-rw-r--r--src/components/PostControls/PostMenu/PostMenuItems.tsx2
-rw-r--r--src/components/ProfileCard.tsx45
-rw-r--r--src/env.ts6
-rw-r--r--src/env/common.ts79
-rw-r--r--src/env/index.ts19
-rw-r--r--src/env/index.web.ts15
-rw-r--r--src/lib/app-info.ts18
-rw-r--r--src/lib/app-info.web.ts18
-rw-r--r--src/lib/hooks/useOTAUpdates.ts2
-rw-r--r--src/lib/statsig/statsig.tsx20
-rw-r--r--src/locale/locales/en/messages.po53
-rw-r--r--src/logger/bitdrift/setup/index.ts3
-rw-r--r--src/logger/index.ts3
-rw-r--r--src/logger/sentry/setup/index.ts29
-rw-r--r--src/screens/Settings/AboutSettings.tsx10
-rw-r--r--src/screens/Settings/AppIconSettings/index.tsx8
-rw-r--r--src/screens/Settings/AppearanceSettings.tsx2
-rw-r--r--src/screens/Settings/Settings.tsx2
-rw-r--r--src/state/queries/messages/const.ts4
-rw-r--r--src/state/session/logging.ts10
-rw-r--r--src/view/com/util/Link.tsx22
-rw-r--r--src/view/com/util/Toast.style.tsx201
-rw-r--r--src/view/com/util/Toast.tsx137
-rw-r--r--src/view/com/util/Toast.web.tsx118
-rw-r--r--src/view/screens/Storybook/Toasts.tsx102
-rw-r--r--src/view/screens/Storybook/index.tsx2
28 files changed, 833 insertions, 300 deletions
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index a92e7be7f..2a3a00ba7 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -25,18 +25,17 @@ import {
   type ViewStyleProp,
   web,
 } from '#/alf'
-import {Button} from '#/components/Button'
+import {Button, ButtonText} from '#/components/Button'
 import * as FeedCard from '#/components/FeedCard'
 import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
 import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
-import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
 import {InlineLinkText} from '#/components/Link'
 import * as ProfileCard from '#/components/ProfileCard'
 import {Text} from '#/components/Typography'
 import type * as bsky from '#/types/bsky'
 import {ProgressGuideList} from './ProgressGuide/List'
 
-const MOBILE_CARD_WIDTH = 300
+const MOBILE_CARD_WIDTH = 165
 
 function CardOuter({
   children,
@@ -48,8 +47,8 @@ function CardOuter({
     <View
       style={[
         a.w_full,
-        a.p_lg,
-        a.rounded_md,
+        a.p_md,
+        a.rounded_lg,
         a.border,
         t.atoms.bg,
         t.atoms.border_contrast_low,
@@ -65,14 +64,30 @@ function CardOuter({
 
 export function SuggestedFollowPlaceholder() {
   const t = useTheme()
+
   return (
-    <CardOuter style={[a.gap_md, t.atoms.border_contrast_low]}>
-      <ProfileCard.Header>
-        <ProfileCard.AvatarPlaceholder />
-        <ProfileCard.NameAndHandlePlaceholder />
-      </ProfileCard.Header>
+    <CardOuter
+      style={[a.gap_md, t.atoms.border_contrast_low, t.atoms.shadow_sm]}>
+      <ProfileCard.Outer>
+        <View
+          style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
+          <ProfileCard.AvatarPlaceholder size={88} />
+          <ProfileCard.NamePlaceholder />
+          <View style={[a.w_full]}>
+            <ProfileCard.DescriptionPlaceholder numberOfLines={2} />
+          </View>
+        </View>
 
-      <ProfileCard.DescriptionPlaceholder numberOfLines={2} />
+        <Button
+          label=""
+          size="small"
+          variant="solid"
+          color="secondary"
+          disabled
+          style={[a.w_full, a.rounded_sm]}>
+          <ButtonText>Follow</ButtonText>
+        </Button>
+      </ProfileCard.Outer>
     </CardOuter>
   )
 }
@@ -243,10 +258,9 @@ export function ProfileGrid({
   const t = useTheme()
   const {_} = useLingui()
   const moderationOpts = useModerationOpts()
-  const navigation = useNavigation<NavigationProp>()
   const {gtMobile} = useBreakpoints()
   const isLoading = isSuggestionsLoading || !moderationOpts
-  const maxLength = gtMobile ? 4 : 6
+  const maxLength = gtMobile ? 3 : 6
 
   const content = isLoading ? (
     Array(maxLength)
@@ -254,7 +268,14 @@ export function ProfileGrid({
       .map((_, i) => (
         <View
           key={i}
-          style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}>
+          style={[
+            gtMobile &&
+              web([
+                a.flex_0,
+                a.flex_grow,
+                {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
+              ]),
+          ]}>
           <SuggestedFollowPlaceholder />
         </View>
       ))
@@ -276,44 +297,69 @@ export function ProfileGrid({
           }}
           style={[
             a.flex_1,
-            gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]),
+            gtMobile &&
+              web([
+                a.flex_0,
+                a.flex_grow,
+                {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
+              ]),
           ]}>
           {({hovered, pressed}) => (
             <CardOuter
               style={[
                 a.flex_1,
+                t.atoms.shadow_sm,
                 (hovered || pressed) && t.atoms.border_contrast_high,
               ]}>
               <ProfileCard.Outer>
-                <ProfileCard.Header>
+                <View
+                  style={[
+                    a.flex_col,
+                    a.align_center,
+                    a.gap_sm,
+                    a.pb_sm,
+                    a.mb_auto,
+                  ]}>
                   <ProfileCard.Avatar
                     profile={profile}
                     moderationOpts={moderationOpts}
+                    size={88}
                   />
-                  <ProfileCard.NameAndHandle
-                    profile={profile}
-                    moderationOpts={moderationOpts}
-                  />
-                  <ProfileCard.FollowButton
-                    profile={profile}
-                    moderationOpts={moderationOpts}
-                    logContext="FeedInterstitial"
-                    shape="round"
-                    colorInverted
-                    onFollow={() => {
-                      logEvent('suggestedUser:follow', {
-                        logContext:
-                          viewContext === 'feed'
-                            ? 'InterstitialDiscover'
-                            : 'InterstitialProfile',
-                        location: 'Card',
-                        recId,
-                        position: index,
-                      })
-                    }}
-                  />
-                </ProfileCard.Header>
-                <ProfileCard.Description profile={profile} numberOfLines={2} />
+                  <View style={[a.flex_col, a.align_center, a.max_w_full]}>
+                    <ProfileCard.Name
+                      profile={profile}
+                      moderationOpts={moderationOpts}
+                    />
+                    <ProfileCard.Description
+                      profile={profile}
+                      numberOfLines={2}
+                      style={[
+                        t.atoms.text_contrast_medium,
+                        a.text_center,
+                        a.text_xs,
+                      ]}
+                    />
+                  </View>
+                </View>
+
+                <ProfileCard.FollowButton
+                  profile={profile}
+                  moderationOpts={moderationOpts}
+                  logContext="FeedInterstitial"
+                  withIcon={false}
+                  style={[a.rounded_sm]}
+                  onFollow={() => {
+                    logEvent('suggestedUser:follow', {
+                      logContext:
+                        viewContext === 'feed'
+                          ? 'InterstitialDiscover'
+                          : 'InterstitialProfile',
+                      location: 'Card',
+                      recId,
+                      position: index,
+                    })
+                  }}
+                />
               </ProfileCard.Outer>
             </CardOuter>
           )}
@@ -333,36 +379,30 @@ export function ProfileGrid({
       <View
         style={[
           a.p_lg,
-          a.pb_xs,
+          a.py_md,
           a.flex_row,
           a.align_center,
           a.justify_between,
         ]}>
-        <Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
+        <Text style={[a.text_sm, a.font_bold, t.atoms.text]}>
           {viewContext === 'profile' ? (
             <Trans>Similar accounts</Trans>
           ) : (
             <Trans>Suggested for you</Trans>
           )}
         </Text>
-        <Person fill={t.atoms.text_contrast_low.color} size="sm" />
+        <InlineLinkText
+          label={_(msg`See more suggested profiles on the Explore page`)}
+          to="/search">
+          <Trans>See more</Trans>
+        </InlineLinkText>
       </View>
 
       {gtMobile ? (
-        <View style={[a.flex_1, a.px_lg, a.pt_sm, a.pb_lg, a.gap_md]}>
-          <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_sm]}>
+        <View style={[a.px_lg, a.pb_lg]}>
+          <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
             {content}
           </View>
-
-          <View style={[a.flex_row, a.justify_end, a.align_center, a.gap_md]}>
-            <InlineLinkText
-              label={_(msg`Browse more suggestions`)}
-              to="/search"
-              style={[t.atoms.text_contrast_medium]}>
-              <Trans>Browse more suggestions</Trans>
-            </InlineLinkText>
-            <Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
-          </View>
         </View>
       ) : (
         <BlockDrawerGesture>
@@ -371,29 +411,12 @@ export function ProfileGrid({
               horizontal
               showsHorizontalScrollIndicator={false}
               snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
-              decelerationRate="fast">
-              <View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
+              decelerationRate="fast"
+              style={[a.overflow_visible]}>
+              <View style={[a.px_lg, a.pb_lg, a.flex_row, a.gap_md]}>
                 {content}
 
-                <Button
-                  label={_(msg`Browse more accounts on the Explore page`)}
-                  onPress={() => {
-                    navigation.navigate('SearchTab')
-                  }}>
-                  <CardOuter style={[a.flex_1, {borderWidth: 0}]}>
-                    <View style={[a.flex_1, a.justify_center]}>
-                      <View style={[a.flex_row, a.px_lg]}>
-                        <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
-                          <Trans>
-                            Browse more suggestions on the Explore page
-                          </Trans>
-                        </Text>
-
-                        <Arrow size="xl" />
-                      </View>
-                    </View>
-                  </CardOuter>
-                </Button>
+                <SeeMoreSuggestedProfilesCard />
               </View>
             </ScrollView>
           </View>
@@ -403,6 +426,32 @@ export function ProfileGrid({
   )
 }
 
+function SeeMoreSuggestedProfilesCard() {
+  const navigation = useNavigation<NavigationProp>()
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <Button
+      label={_(msg`Browse more accounts on the Explore page`)}
+      onPress={() => {
+        navigation.navigate('SearchTab')
+      }}>
+      <CardOuter style={[a.flex_1, t.atoms.shadow_sm]}>
+        <View style={[a.flex_1, a.justify_center]}>
+          <View style={[a.flex_col, a.align_center, a.gap_md]}>
+            <Text style={[a.leading_snug, a.text_center]}>
+              <Trans>See more accounts you might like</Trans>
+            </Text>
+
+            <Arrow size="xl" />
+          </View>
+        </View>
+      </CardOuter>
+    </Button>
+  )
+}
+
 export function SuggestedFeeds() {
   const numFeedsToDisplay = 3
   const t = useTheme()
diff --git a/src/components/PostControls/DiscoverDebug.tsx b/src/components/PostControls/DiscoverDebug.tsx
index 796981f0c..403b50cca 100644
--- a/src/components/PostControls/DiscoverDebug.tsx
+++ b/src/components/PostControls/DiscoverDebug.tsx
@@ -2,13 +2,13 @@ import {Pressable} from 'react-native'
 import * as Clipboard from 'expo-clipboard'
 import {t} from '@lingui/macro'
 
-import {IS_INTERNAL} from '#/lib/app-info'
 import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
 import {useGate} from '#/lib/statsig/statsig'
 import {useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
+import {IS_INTERNAL} from '#/env'
 
 export function DiscoverDebug({
   feedContext,
diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx
index f0ef9ed05..ecc3d0174 100644
--- a/src/components/PostControls/PostMenu/PostMenuItems.tsx
+++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx
@@ -17,7 +17,6 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 
-import {IS_INTERNAL} from '#/lib/app-info'
 import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
 import {useOpenLink} from '#/lib/hooks/useOpenLink'
 import {getCurrentRoute} from '#/lib/routes/helpers'
@@ -83,6 +82,7 @@ import {
   useReportDialogControl,
 } from '#/components/moderation/ReportDialog'
 import * as Prompt from '#/components/Prompt'
+import {IS_INTERNAL} from '#/env'
 import * as bsky from '#/types/bsky'
 
 let PostMenuItems = ({
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index e01c27655..f12d922fd 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -20,7 +20,13 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, platform, useTheme} from '#/alf'
+import {
+  atoms as a,
+  platform,
+  type TextStyleProp,
+  useTheme,
+  type ViewStyleProp,
+} from '#/alf'
 import {
   Button,
   ButtonIcon,
@@ -136,12 +142,14 @@ export function Avatar({
   onPress,
   disabledPreview,
   liveOverride,
+  size = 40,
 }: {
   profile: bsky.profile.AnyProfileView
   moderationOpts: ModerationOpts
   onPress?: () => void
   disabledPreview?: boolean
   liveOverride?: boolean
+  size?: number
 }) {
   const moderation = moderateProfile(profile, moderationOpts)
 
@@ -149,7 +157,7 @@ export function Avatar({
 
   return disabledPreview ? (
     <UserAvatar
-      size={40}
+      size={size}
       avatar={profile.avatar}
       type={profile.associated?.labeler ? 'labeler' : 'user'}
       moderation={moderation.ui('avatar')}
@@ -157,7 +165,7 @@ export function Avatar({
     />
   ) : (
     <PreviewableUserAvatar
-      size={40}
+      size={size}
       profile={profile}
       moderation={moderation.ui('avatar')}
       onBeforePress={onPress}
@@ -166,7 +174,7 @@ export function Avatar({
   )
 }
 
-export function AvatarPlaceholder() {
+export function AvatarPlaceholder({size = 40}: {size?: number}) {
   const t = useTheme()
   return (
     <View
@@ -174,8 +182,8 @@ export function AvatarPlaceholder() {
         a.rounded_full,
         t.atoms.bg_contrast_25,
         {
-          width: 40,
-          height: 40,
+          width: size,
+          height: size,
         },
       ]}
     />
@@ -274,7 +282,7 @@ export function Name({
   )
   const verification = useSimpleVerificationState({profile})
   return (
-    <View style={[a.flex_row, a.align_center]}>
+    <View style={[a.flex_row, a.align_center, a.max_w_full]}>
       <Text
         emoji
         style={[
@@ -343,13 +351,32 @@ export function NameAndHandlePlaceholder() {
   )
 }
 
+export function NamePlaceholder({style}: ViewStyleProp) {
+  const t = useTheme()
+
+  return (
+    <View
+      style={[
+        a.rounded_xs,
+        t.atoms.bg_contrast_25,
+        {
+          width: '60%',
+          height: 14,
+        },
+        style,
+      ]}
+    />
+  )
+}
+
 export function Description({
   profile: profileUnshadowed,
   numberOfLines = 3,
+  style,
 }: {
   profile: bsky.profile.AnyProfileView
   numberOfLines?: number
-}) {
+} & TextStyleProp) {
   const profile = useProfileShadow(profileUnshadowed)
   const rt = useMemo(() => {
     if (!('description' in profile)) return
@@ -369,7 +396,7 @@ export function Description({
     <View style={[a.pt_xs]}>
       <RichText
         value={rt}
-        style={[a.leading_snug]}
+        style={[a.leading_snug, style]}
         numberOfLines={numberOfLines}
         disableLinks
       />
diff --git a/src/env.ts b/src/env.ts
deleted file mode 100644
index 32ce70670..000000000
--- a/src/env.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export const LOG_DEBUG = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
-export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as
-  | 'debug'
-  | 'info'
-  | 'warn'
-  | 'error'
diff --git a/src/env/common.ts b/src/env/common.ts
new file mode 100644
index 000000000..e68e9fab8
--- /dev/null
+++ b/src/env/common.ts
@@ -0,0 +1,79 @@
+import {type Did} from '@atproto/api'
+
+import packageJson from '#/../package.json'
+
+/**
+ * The semver version of the app, as defined in `package.json.`
+ *
+ * N.B. The fallback is needed for Render.com deployments
+ */
+export const RELEASE_VERSION: string =
+  process.env.EXPO_PUBLIC_RELEASE_VERSION || packageJson.version
+
+/**
+ * The env the app is running in e.g. development, testflight, production
+ */
+export const ENV: string = process.env.EXPO_PUBLIC_ENV
+
+/**
+ * Indicates whether the app is running in TestFlight
+ */
+export const IS_TESTFLIGHT = ENV === 'testflight'
+
+/**
+ * Indicates whether the app is __DEV__
+ */
+export const IS_DEV = __DEV__
+
+/**
+ * Indicates whether the app is __DEV__ or TestFlight
+ */
+export const IS_INTERNAL = IS_DEV || IS_TESTFLIGHT
+
+/**
+ * The commit hash that the current bundle was made from. The user can
+ * see the commit hash in the app's settings along with the other version info.
+ * Useful for debugging/reporting.
+ */
+export const BUNDLE_IDENTIFIER: string =
+  process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER || 'dev'
+
+/**
+ * This will always be in the format of YYMMDDHH, so that it always increases
+ * for each build. This should only be used for StatSig reporting and shouldn't
+ * be used to identify a specific bundle.
+ */
+export const BUNDLE_DATE: number = !process.env.EXPO_PUBLIC_BUNDLE_DATE
+  ? 0
+  : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
+
+/**
+ * The log level for the app.
+ */
+export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as
+  | 'debug'
+  | 'info'
+  | 'warn'
+  | 'error'
+
+/**
+ * Enable debug logs for specific logger instances
+ */
+export const LOG_DEBUG: string = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
+
+/**
+ * The DID of the chat service to proxy to
+ */
+export const CHAT_PROXY_DID: Did =
+  process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat'
+
+/**
+ * Sentry DSN for telemetry
+ */
+export const SENTRY_DSN: string | undefined = process.env.EXPO_PUBLIC_SENTRY_DSN
+
+/**
+ * Bitdrift API key. If undefined, Bitdrift should be disabled.
+ */
+export const BITDRIFT_API_KEY: string | undefined =
+  process.env.EXPO_PUBLIC_BITDRIFT_API_KEY
diff --git a/src/env/index.ts b/src/env/index.ts
new file mode 100644
index 000000000..8558c55b5
--- /dev/null
+++ b/src/env/index.ts
@@ -0,0 +1,19 @@
+import {nativeBuildVersion} from 'expo-application'
+
+import {BUNDLE_IDENTIFIER, IS_TESTFLIGHT, RELEASE_VERSION} from '#/env/common'
+
+export * from '#/env/common'
+
+/**
+ * The semver version of the app, specified in our `package.json`.file. On
+ * iOs/Android, the native build version is appended to the semver version, so
+ * that it can be used to identify a specific build.
+ */
+export const APP_VERSION = `${RELEASE_VERSION}.${nativeBuildVersion}`
+
+/**
+ * The short commit hash and environment of the current bundle.
+ */
+export const APP_METADATA = `${BUNDLE_IDENTIFIER.slice(0, 7)} (${
+  __DEV__ ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod'
+})`
diff --git a/src/env/index.web.ts b/src/env/index.web.ts
new file mode 100644
index 000000000..66087749b
--- /dev/null
+++ b/src/env/index.web.ts
@@ -0,0 +1,15 @@
+import {BUNDLE_IDENTIFIER, RELEASE_VERSION} from '#/env/common'
+
+export * from '#/env/common'
+
+/**
+ * The semver version of the app, specified in our `package.json`.file. On
+ * iOs/Android, the native build version is appended to the semver version, so
+ * that it can be used to identify a specific build.
+ */
+export const APP_VERSION = RELEASE_VERSION
+
+/**
+ * The short commit hash and environment of the current bundle.
+ */
+export const APP_METADATA = `${BUNDLE_IDENTIFIER.slice(0, 7)} (${__DEV__ ? 'dev' : 'prod'})`
diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts
deleted file mode 100644
index 0749087ea..000000000
--- a/src/lib/app-info.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'
-
-export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
-export const IS_INTERNAL = __DEV__ || IS_TESTFLIGHT
-
-// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
-// along with the other version info. Useful for debugging/reporting.
-export const BUNDLE_IDENTIFIER = process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? ''
-
-// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
-// for Statsig reporting and shouldn't be used to identify a specific bundle.
-export const BUNDLE_DATE =
-  IS_TESTFLIGHT || __DEV__ ? 0 : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
-
-export const appVersion = `${nativeApplicationVersion}.${nativeBuildVersion}`
-export const bundleInfo = `${BUNDLE_IDENTIFIER} (${
-  __DEV__ ? 'dev' : IS_TESTFLIGHT ? 'tf' : 'prod'
-})`
diff --git a/src/lib/app-info.web.ts b/src/lib/app-info.web.ts
deleted file mode 100644
index 1530d9976..000000000
--- a/src/lib/app-info.web.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import packageDotJson from '../../package.json'
-
-export const IS_TESTFLIGHT = false
-export const IS_INTERNAL = __DEV__
-
-// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
-// along with the other version info. Useful for debugging/reporting.
-export const BUNDLE_IDENTIFIER =
-  process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
-
-// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
-// for Statsig reporting and shouldn't be used to identify a specific bundle.
-export const BUNDLE_DATE = __DEV__
-  ? 0
-  : Number(process.env.EXPO_PUBLIC_BUNDLE_DATE)
-
-export const appVersion = packageDotJson.version
-export const bundleInfo = `${BUNDLE_IDENTIFIER} (${__DEV__ ? 'dev' : 'prod'})`
diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts
index 864d5d697..ba46b6055 100644
--- a/src/lib/hooks/useOTAUpdates.ts
+++ b/src/lib/hooks/useOTAUpdates.ts
@@ -10,9 +10,9 @@ import {
   useUpdates,
 } from 'expo-updates'
 
-import {IS_TESTFLIGHT} from '#/lib/app-info'
 import {logger} from '#/logger'
 import {isIOS} from '#/platform/detection'
+import {IS_TESTFLIGHT} from '#/env'
 
 const MINIMUM_MINIMIZE_TIME = 15 * 60e3
 
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index f2d3ffca9..1091c82e0 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -3,12 +3,11 @@ import {Platform} from 'react-native'
 import {AppState, type AppStateStatus} from 'react-native'
 import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
 
-import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
 import {logger} from '#/logger'
 import {type MetricEvents} from '#/logger/metrics'
 import {isWeb} from '#/platform/detection'
 import * as persisted from '#/state/persisted'
-import packageDotJson from '../../../package.json'
+import * as env from '#/env'
 import {useSession} from '../../state/session'
 import {timeout} from '../async/timeout'
 import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
@@ -49,12 +48,11 @@ export type {MetricEvents as LogEvents}
 function createStatsigOptions(prefetchUsers: StatsigUser[]) {
   return {
     environment: {
-      tier:
-        process.env.NODE_ENV === 'development'
-          ? 'development'
-          : IS_TESTFLIGHT
-            ? 'staging'
-            : 'production',
+      tier: env.IS_DEV
+        ? 'development'
+        : env.IS_TESTFLIGHT
+          ? 'staging'
+          : 'production',
     },
     // Don't block on waiting for network. The fetched config will kick in on next load.
     // This ensures the UI is always consistent and doesn't update mid-session.
@@ -212,9 +210,9 @@ function toStatsigUser(did: string | undefined): StatsigUser {
       refSrc,
       refUrl,
       platform: Platform.OS as 'ios' | 'android' | 'web',
-      appVersion: packageDotJson.version,
-      bundleIdentifier: BUNDLE_IDENTIFIER,
-      bundleDate: BUNDLE_DATE,
+      appVersion: env.RELEASE_VERSION,
+      bundleIdentifier: env.BUNDLE_IDENTIFIER,
+      bundleDate: env.BUNDLE_DATE,
       appLanguage: languagePrefs.appLanguage,
       contentLanguages: languagePrefs.contentLanguages,
     },
diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po
index 69532591c..3a5480a5c 100644
--- a/src/locale/locales/en/messages.po
+++ b/src/locale/locales/en/messages.po
@@ -966,8 +966,8 @@ msgstr ""
 
 #: src/components/hooks/useFollowMethods.ts:35
 #: src/components/hooks/useFollowMethods.ts:50
-#: src/components/ProfileCard.tsx:457
-#: src/components/ProfileCard.tsx:478
+#: src/components/ProfileCard.tsx:484
+#: src/components/ProfileCard.tsx:505
 #: src/view/com/profile/FollowButton.tsx:38
 #: src/view/com/profile/FollowButton.tsx:48
 msgid "An issue occurred, please try again."
@@ -1262,7 +1262,7 @@ msgid "Birthday"
 msgstr ""
 
 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:757
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:319
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:320
 #: src/view/com/profile/ProfileMenu.tsx:473
 msgid "Block"
 msgstr ""
@@ -1423,23 +1423,20 @@ msgstr ""
 msgid "Books"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:379
+#: src/components/FeedInterstitials.tsx:436
 msgid "Browse more accounts on the Explore page"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:517
+#: src/components/FeedInterstitials.tsx:566
 msgid "Browse more feeds on the Explore page"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:359
-#: src/components/FeedInterstitials.tsx:362
-#: src/components/FeedInterstitials.tsx:498
-#: src/components/FeedInterstitials.tsx:501
+#: src/components/FeedInterstitials.tsx:547
+#: src/components/FeedInterstitials.tsx:550
 msgid "Browse more suggestions"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:387
-#: src/components/FeedInterstitials.tsx:526
+#: src/components/FeedInterstitials.tsx:575
 msgid "Browse more suggestions on the Explore page"
 msgstr ""
 
@@ -3618,7 +3615,7 @@ msgid "Flexible"
 msgstr ""
 
 #. User is not following this account, click to follow
-#: src/components/ProfileCard.tsx:490
+#: src/components/ProfileCard.tsx:517
 #: src/components/ProfileHoverCard/index.web.tsx:494
 #: src/components/ProfileHoverCard/index.web.tsx:505
 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:245
@@ -3696,7 +3693,7 @@ msgid "Followers you know"
 msgstr ""
 
 #. User is following this account, click to unfollow
-#: src/components/ProfileCard.tsx:484
+#: src/components/ProfileCard.tsx:511
 #: src/components/ProfileHoverCard/index.web.tsx:493
 #: src/components/ProfileHoverCard/index.web.tsx:504
 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:241
@@ -3711,7 +3708,7 @@ msgctxt "feed-name"
 msgid "Following"
 msgstr ""
 
-#: src/components/ProfileCard.tsx:447
+#: src/components/ProfileCard.tsx:474
 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:89
 msgid "Following {0}"
 msgstr ""
@@ -5459,7 +5456,7 @@ msgstr ""
 msgid "No likes yet"
 msgstr ""
 
-#: src/components/ProfileCard.tsx:469
+#: src/components/ProfileCard.tsx:496
 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:110
 msgid "No longer following {0}"
 msgstr ""
@@ -7398,6 +7395,18 @@ msgstr ""
 msgid "See jobs at Bluesky"
 msgstr ""
 
+#: src/components/FeedInterstitials.tsx:397
+msgid "See more"
+msgstr ""
+
+#: src/components/FeedInterstitials.tsx:444
+msgid "See more accounts you might like"
+msgstr ""
+
+#: src/components/FeedInterstitials.tsx:395
+msgid "See more suggested profiles on the Explore page"
+msgstr ""
+
 #: src/view/screens/SavedFeeds.tsx:213
 msgid "See this guide"
 msgstr ""
@@ -7975,7 +7984,7 @@ msgstr ""
 msgid "Signed in as @{0}"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:343
+#: src/components/FeedInterstitials.tsx:389
 msgid "Similar accounts"
 msgstr ""
 
@@ -8005,7 +8014,7 @@ msgstr ""
 msgid "Some of your verifications are invalid."
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:480
+#: src/components/FeedInterstitials.tsx:529
 msgid "Some other feeds you might like"
 msgstr ""
 
@@ -8237,7 +8246,7 @@ msgstr ""
 msgid "Suggested Accounts"
 msgstr ""
 
-#: src/components/FeedInterstitials.tsx:345
+#: src/components/FeedInterstitials.tsx:391
 msgid "Suggested for you"
 msgstr ""
 
@@ -8421,7 +8430,7 @@ msgstr ""
 msgid "That's everything!"
 msgstr ""
 
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:315
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:316
 #: src/view/com/profile/ProfileMenu.tsx:461
 msgid "The account will be able to interact with you after unblocking."
 msgstr ""
@@ -9046,7 +9055,7 @@ msgstr ""
 #: src/components/dms/MessagesListBlockedFooter.tsx:112
 #: src/components/dms/MessagesListBlockedFooter.tsx:119
 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:203
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:319
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:320
 #: src/view/com/profile/ProfileMenu.tsx:473
 #: src/view/screens/ProfileList.tsx:723
 msgid "Unblock"
@@ -9064,7 +9073,7 @@ msgstr ""
 msgid "Unblock account"
 msgstr ""
 
-#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:313
+#: src/screens/Profile/Header/ProfileHeaderStandard.tsx:314
 #: src/view/com/profile/ProfileMenu.tsx:455
 msgid "Unblock Account?"
 msgstr ""
@@ -9571,7 +9580,7 @@ msgstr ""
 msgid "View {0}'s avatar"
 msgstr ""
 
-#: src/components/ProfileCard.tsx:118
+#: src/components/ProfileCard.tsx:124
 #: src/screens/Profile/components/ProfileFeedHeader.tsx:454
 #: src/screens/Search/components/SearchProfileCard.tsx:36
 #: src/screens/VideoFeed/index.tsx:790
diff --git a/src/logger/bitdrift/setup/index.ts b/src/logger/bitdrift/setup/index.ts
index d6af3fe24..dd2560acc 100644
--- a/src/logger/bitdrift/setup/index.ts
+++ b/src/logger/bitdrift/setup/index.ts
@@ -2,8 +2,7 @@ import {init, SessionStrategy} from '@bitdrift/react-native'
 import {Statsig} from 'statsig-react-native-expo'
 
 import {initPromise} from '#/lib/statsig/statsig'
-
-const BITDRIFT_API_KEY = process.env.BITDRIFT_API_KEY
+import {BITDRIFT_API_KEY} from '#/env'
 
 initPromise.then(() => {
   let isEnabled = false
diff --git a/src/logger/index.ts b/src/logger/index.ts
index e7aaf666a..998d02581 100644
--- a/src/logger/index.ts
+++ b/src/logger/index.ts
@@ -14,9 +14,10 @@ import {
 } from '#/logger/types'
 import {enabledLogLevels} from '#/logger/util'
 import {isNative} from '#/platform/detection'
+import {ENV} from '#/env'
 
 const TRANSPORTS: Transport[] = (function configureTransports() {
-  switch (process.env.NODE_ENV) {
+  switch (ENV) {
     case 'production': {
       return [sentryTransport, isNative && bitdriftTransport].filter(
         Boolean,
diff --git a/src/logger/sentry/setup/index.ts b/src/logger/sentry/setup/index.ts
index f05a7fc83..d062f05d2 100644
--- a/src/logger/sentry/setup/index.ts
+++ b/src/logger/sentry/setup/index.ts
@@ -1,32 +1,15 @@
-/**
- * Importing these separately from `platform/detection` and `lib/app-info` to
- * avoid future conflicts and/or circular deps
- */
-
 import {init} from '@sentry/react-native'
 
-import pkgJson from '#/../package.json'
-
-/**
- * Examples:
- * - `dev`
- * - `1.99.0`
- */
-const release = process.env.SENTRY_RELEASE || pkgJson.version
-
-/**
- * The latest deployed commit hash
- */
-const dist = process.env.SENTRY_DIST || 'dev'
+import * as env from '#/env'
 
 init({
-  enabled: !__DEV__ && !!process.env.SENTRY_DSN,
+  enabled: !env.IS_DEV && !!env.SENTRY_DSN,
   autoSessionTracking: false,
-  dsn: process.env.SENTRY_DSN,
+  dsn: env.SENTRY_DSN,
   debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
-  environment: process.env.NODE_ENV,
-  dist,
-  release,
+  environment: env.ENV,
+  dist: env.BUNDLE_IDENTIFIER,
+  release: env.RELEASE_VERSION,
   ignoreErrors: [
     /*
      * Unknown internals errors
diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx
index 0ce127ff3..e48841d9f 100644
--- a/src/screens/Settings/AboutSettings.tsx
+++ b/src/screens/Settings/AboutSettings.tsx
@@ -9,7 +9,6 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 import {useMutation} from '@tanstack/react-query'
 import {Statsig} from 'statsig-react-native-expo'
 
-import {appVersion, BUNDLE_DATE, bundleInfo} from '#/lib/app-info'
 import {STATUS_PAGE_URL} from '#/lib/constants'
 import {type CommonNavigatorParams} from '#/lib/routes/types'
 import {isAndroid, isIOS, isNative} from '#/platform/detection'
@@ -23,6 +22,7 @@ import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/i
 import {Wrench_Stroke2_Corner2_Rounded as WrenchIcon} from '#/components/icons/Wrench'
 import * as Layout from '#/components/Layout'
 import {Loader} from '#/components/Loader'
+import * as env from '#/env'
 import {useDemoMode} from '#/storage/hooks/demo-mode'
 import {useDevMode} from '#/storage/hooks/dev-mode'
 import {OTAInfo} from './components/OTAInfo'
@@ -123,7 +123,7 @@ export function AboutSettingsScreen({}: Props) {
             </SettingsList.PressableItem>
           )}
           <SettingsList.PressableItem
-            label={_(msg`Version ${appVersion}`)}
+            label={_(msg`Version ${env.APP_VERSION}`)}
             accessibilityHint={_(msg`Copies build version to clipboard`)}
             onLongPress={() => {
               const newDevModeEnabled = !devModeEnabled
@@ -146,15 +146,15 @@ export function AboutSettingsScreen({}: Props) {
             }}
             onPress={() => {
               setStringAsync(
-                `Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}; Platform version: ${Platform.Version}; Anonymous ID: ${stableID}`,
+                `Build version: ${env.APP_VERSION}; Bundle info: ${env.APP_METADATA}; Bundle date: ${env.BUNDLE_DATE}; Platform: ${Platform.OS}; Platform version: ${Platform.Version}; Anonymous ID: ${stableID}`,
               )
               Toast.show(_(msg`Copied build version to clipboard`))
             }}>
             <SettingsList.ItemIcon icon={WrenchIcon} />
             <SettingsList.ItemText>
-              <Trans>Version {appVersion}</Trans>
+              <Trans>Version {env.APP_VERSION}</Trans>
             </SettingsList.ItemText>
-            <SettingsList.BadgeText>{bundleInfo}</SettingsList.BadgeText>
+            <SettingsList.BadgeText>{env.APP_METADATA}</SettingsList.BadgeText>
           </SettingsList.PressableItem>
           {devModeEnabled && (
             <>
diff --git a/src/screens/Settings/AppIconSettings/index.tsx b/src/screens/Settings/AppIconSettings/index.tsx
index 954bac68a..799873c2d 100644
--- a/src/screens/Settings/AppIconSettings/index.tsx
+++ b/src/screens/Settings/AppIconSettings/index.tsx
@@ -3,20 +3,20 @@ import {Alert, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import * as DynamicAppIcon from '@mozzius/expo-dynamic-app-icon'
-import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 
-import {IS_INTERNAL} from '#/lib/app-info'
 import {PressableScale} from '#/lib/custom-animations/PressableScale'
-import {CommonNavigatorParams} from '#/lib/routes/types'
+import {type CommonNavigatorParams} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
 import {isAndroid} from '#/platform/detection'
 import {AppIconImage} from '#/screens/Settings/AppIconSettings/AppIconImage'
-import {AppIconSet} from '#/screens/Settings/AppIconSettings/types'
+import {type AppIconSet} from '#/screens/Settings/AppIconSettings/types'
 import {useAppIconSets} from '#/screens/Settings/AppIconSettings/useAppIconSets'
 import {atoms as a, useTheme} from '#/alf'
 import * as Toggle from '#/components/forms/Toggle'
 import * as Layout from '#/components/Layout'
 import {Text} from '#/components/Typography'
+import {IS_INTERNAL} from '#/env'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppIconSettings'>
 export function AppIconSettingsScreen({}: Props) {
diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx
index d0158aaa8..492d6d172 100644
--- a/src/screens/Settings/AppearanceSettings.tsx
+++ b/src/screens/Settings/AppearanceSettings.tsx
@@ -8,7 +8,6 @@ import Animated, {
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {IS_INTERNAL} from '#/lib/app-info'
 import {
   type CommonNavigatorParams,
   type NativeStackScreenProps,
@@ -26,6 +25,7 @@ import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/T
 import {TitleCase_Stroke2_Corner0_Rounded as Aa} from '#/components/icons/TitleCase'
 import * as Layout from '#/components/Layout'
 import {Text} from '#/components/Typography'
+import {IS_INTERNAL} from '#/env'
 import * as SettingsList from './components/SettingsList'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index b712c054c..719bbf9a2 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -9,7 +9,6 @@ import {useNavigation} from '@react-navigation/native'
 import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 
 import {useActorStatus} from '#/lib/actor-status'
-import {IS_INTERNAL} from '#/lib/app-info'
 import {HELP_DESK_URL} from '#/lib/constants'
 import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
 import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates'
@@ -66,6 +65,7 @@ import {
   shouldShowVerificationCheckButton,
   VerificationCheckButton,
 } from '#/components/verification/VerificationCheckButton'
+import {IS_INTERNAL} from '#/env'
 import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
diff --git a/src/state/queries/messages/const.ts b/src/state/queries/messages/const.ts
index 7642c5d7b..1c5519a63 100644
--- a/src/state/queries/messages/const.ts
+++ b/src/state/queries/messages/const.ts
@@ -1,3 +1,5 @@
+import {CHAT_PROXY_DID} from '#/env'
+
 export const DM_SERVICE_HEADERS = {
-  'atproto-proxy': 'did:web:api.bsky.chat#bsky_chat',
+  'atproto-proxy': `${CHAT_PROXY_DID}#bsky_chat`,
 }
diff --git a/src/state/session/logging.ts b/src/state/session/logging.ts
index 98de5a396..bf847f08f 100644
--- a/src/state/session/logging.ts
+++ b/src/state/session/logging.ts
@@ -1,11 +1,11 @@
-import {AtpSessionData, AtpSessionEvent} from '@atproto/api'
+import {type AtpSessionData, type AtpSessionEvent} from '@atproto/api'
 import {sha256} from 'js-sha256'
 import {Statsig} from 'statsig-react-native-expo'
 
-import {IS_INTERNAL} from '#/lib/app-info'
-import {Schema} from '../persisted'
-import {Action, State} from './reducer'
-import {SessionAccount} from './types'
+import {IS_INTERNAL} from '#/env'
+import {type Schema} from '../persisted'
+import {type Action, type State} from './reducer'
+import {type SessionAccount} from './types'
 
 type Reducer = (state: State, action: Action) => State
 
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index 6a931d9a4..496b77182 100644
--- a/src/view/com/util/Link.tsx
+++ b/src/view/com/util/Link.tsx
@@ -101,13 +101,9 @@ export const Link = memo(function Link({
     {name: 'activate', label: title},
   ]
 
-  const dataSet = useMemo(() => {
-    const ds = {...dataSetProp}
-    if (anchorNoUnderline) {
-      ds.noUnderline = 1
-    }
-    return ds
-  }, [dataSetProp, anchorNoUnderline])
+  const dataSet = anchorNoUnderline
+    ? {...dataSetProp, noUnderline: 1}
+    : dataSetProp
 
   if (noFeedback) {
     return (
@@ -125,6 +121,8 @@ export const Link = memo(function Link({
               onAccessibilityAction?.(e)
             }
           }}
+          // @ts-ignore web only -sfn
+          dataSet={dataSet}
           {...props}
           android_ripple={{
             color: t.atoms.bg_contrast_25.backgroundColor,
@@ -198,13 +196,9 @@ export const TextLink = memo(function TextLink({
     console.error('Unable to detect mismatching label')
   }
 
-  const dataSet = useMemo(() => {
-    const ds = {...dataSetProp}
-    if (anchorNoUnderline) {
-      ds.noUnderline = 1
-    }
-    return ds
-  }, [dataSetProp, anchorNoUnderline])
+  const dataSet = anchorNoUnderline
+    ? {...dataSetProp, noUnderline: 1}
+    : dataSetProp
 
   const onPress = useCallback(
     (e?: Event) => {
diff --git a/src/view/com/util/Toast.style.tsx b/src/view/com/util/Toast.style.tsx
new file mode 100644
index 000000000..3869e6890
--- /dev/null
+++ b/src/view/com/util/Toast.style.tsx
@@ -0,0 +1,201 @@
+import {select, type Theme} from '#/alf'
+import {Check_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
+import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+
+export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info'
+
+export type LegacyToastType =
+  | 'xmark'
+  | 'exclamation-circle'
+  | 'check'
+  | 'clipboard-check'
+  | 'circle-exclamation'
+
+export const convertLegacyToastType = (
+  type: ToastType | LegacyToastType,
+): ToastType => {
+  switch (type) {
+    // these ones are fine
+    case 'default':
+    case 'success':
+    case 'error':
+    case 'warning':
+    case 'info':
+      return type
+    // legacy ones need conversion
+    case 'xmark':
+      return 'error'
+    case 'exclamation-circle':
+      return 'warning'
+    case 'check':
+      return 'success'
+    case 'clipboard-check':
+      return 'success'
+    case 'circle-exclamation':
+      return 'warning'
+    default:
+      return 'default'
+  }
+}
+
+export const TOAST_ANIMATION_CONFIG = {
+  duration: 300,
+  damping: 15,
+  stiffness: 150,
+  mass: 0.8,
+  overshootClamping: false,
+  restSpeedThreshold: 0.01,
+  restDisplacementThreshold: 0.01,
+}
+
+export const TOAST_TYPE_TO_ICON = {
+  default: SuccessIcon,
+  success: SuccessIcon,
+  error: ErrorIcon,
+  warning: WarningIcon,
+  info: CircleInfo,
+}
+
+export const getToastTypeStyles = (t: Theme) => ({
+  default: {
+    backgroundColor: select(t.name, {
+      light: t.atoms.bg_contrast_25.backgroundColor,
+      dim: t.atoms.bg_contrast_100.backgroundColor,
+      dark: t.atoms.bg_contrast_100.backgroundColor,
+    }),
+    borderColor: select(t.name, {
+      light: t.atoms.border_contrast_low.borderColor,
+      dim: t.atoms.border_contrast_high.borderColor,
+      dark: t.atoms.border_contrast_high.borderColor,
+    }),
+    iconColor: select(t.name, {
+      light: t.atoms.text_contrast_medium.color,
+      dim: t.atoms.text_contrast_medium.color,
+      dark: t.atoms.text_contrast_medium.color,
+    }),
+    textColor: select(t.name, {
+      light: t.atoms.text_contrast_medium.color,
+      dim: t.atoms.text_contrast_medium.color,
+      dark: t.atoms.text_contrast_medium.color,
+    }),
+  },
+  success: {
+    backgroundColor: select(t.name, {
+      light: t.palette.primary_100,
+      dim: t.palette.primary_100,
+      dark: t.palette.primary_50,
+    }),
+    borderColor: select(t.name, {
+      light: t.palette.primary_500,
+      dim: t.palette.primary_500,
+      dark: t.palette.primary_500,
+    }),
+    iconColor: select(t.name, {
+      light: t.palette.primary_500,
+      dim: t.palette.primary_600,
+      dark: t.palette.primary_600,
+    }),
+    textColor: select(t.name, {
+      light: t.palette.primary_500,
+      dim: t.palette.primary_600,
+      dark: t.palette.primary_600,
+    }),
+  },
+  error: {
+    backgroundColor: select(t.name, {
+      light: t.palette.negative_200,
+      dim: t.palette.negative_25,
+      dark: t.palette.negative_25,
+    }),
+    borderColor: select(t.name, {
+      light: t.palette.negative_300,
+      dim: t.palette.negative_300,
+      dark: t.palette.negative_300,
+    }),
+    iconColor: select(t.name, {
+      light: t.palette.negative_600,
+      dim: t.palette.negative_600,
+      dark: t.palette.negative_600,
+    }),
+    textColor: select(t.name, {
+      light: t.palette.negative_600,
+      dim: t.palette.negative_600,
+      dark: t.palette.negative_600,
+    }),
+  },
+  warning: {
+    backgroundColor: select(t.name, {
+      light: t.atoms.bg_contrast_25.backgroundColor,
+      dim: t.atoms.bg_contrast_100.backgroundColor,
+      dark: t.atoms.bg_contrast_100.backgroundColor,
+    }),
+    borderColor: select(t.name, {
+      light: t.atoms.border_contrast_low.borderColor,
+      dim: t.atoms.border_contrast_high.borderColor,
+      dark: t.atoms.border_contrast_high.borderColor,
+    }),
+    iconColor: select(t.name, {
+      light: t.atoms.text_contrast_medium.color,
+      dim: t.atoms.text_contrast_medium.color,
+      dark: t.atoms.text_contrast_medium.color,
+    }),
+    textColor: select(t.name, {
+      light: t.atoms.text_contrast_medium.color,
+      dim: t.atoms.text_contrast_medium.color,
+      dark: t.atoms.text_contrast_medium.color,
+    }),
+  },
+  info: {
+    backgroundColor: select(t.name, {
+      light: t.atoms.bg_contrast_25.backgroundColor,
+      dim: t.atoms.bg_contrast_100.backgroundColor,
+      dark: t.atoms.bg_contrast_100.backgroundColor,
+    }),
+    borderColor: select(t.name, {
+      light: t.atoms.border_contrast_low.borderColor,
+      dim: t.atoms.border_contrast_high.borderColor,
+      dark: t.atoms.border_contrast_high.borderColor,
+    }),
+    iconColor: select(t.name, {
+      light: t.atoms.text_contrast_medium.color,
+      dim: t.atoms.text_contrast_medium.color,
+      dark: t.atoms.text_contrast_medium.color,
+    }),
+    textColor: select(t.name, {
+      light: t.atoms.text_contrast_medium.color,
+      dim: t.atoms.text_contrast_medium.color,
+      dark: t.atoms.text_contrast_medium.color,
+    }),
+  },
+})
+
+export const getToastWebAnimationStyles = () => ({
+  entering: {
+    animation: 'toastFadeIn 0.3s ease-out forwards',
+  },
+  exiting: {
+    animation: 'toastFadeOut 0.2s ease-in forwards',
+  },
+})
+
+export const TOAST_WEB_KEYFRAMES = `
+  @keyframes toastFadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+
+  @keyframes toastFadeOut {
+    from {
+      opacity: 1;
+    }
+    to {
+      opacity: 0;
+    }
+  }
+`
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index 56c6780ad..54ef7042d 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -6,8 +6,8 @@ import {
   GestureHandlerRootView,
 } from 'react-native-gesture-handler'
 import Animated, {
-  FadeInUp,
-  FadeOutUp,
+  FadeIn,
+  FadeOut,
   runOnJS,
   useAnimatedReaction,
   useAnimatedStyle,
@@ -17,37 +17,55 @@ import Animated, {
 } from 'react-native-reanimated'
 import RootSiblings from 'react-native-root-siblings'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {
-  FontAwesomeIcon,
-  type Props as FontAwesomeProps,
-} from '@fortawesome/react-native-fontawesome'
 
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {
+  convertLegacyToastType,
+  getToastTypeStyles,
+  type LegacyToastType,
+  TOAST_ANIMATION_CONFIG,
+  TOAST_TYPE_TO_ICON,
+  type ToastType,
+} from '#/view/com/util/Toast.style'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
 
 const TIMEOUT = 2e3
 
+// Use type overloading to mark certain types as deprecated -sfn
+// https://stackoverflow.com/a/78325851/13325987
+export function show(message: string, type?: ToastType): void
+/**
+ * @deprecated type is deprecated - use one of `'default' | 'success' | 'error' | 'warning' | 'info'`
+ */
+export function show(message: string, type?: LegacyToastType): void
 export function show(
   message: string,
-  icon: FontAwesomeProps['icon'] = 'check',
-) {
+  type: ToastType | LegacyToastType = 'default',
+): void {
   if (process.env.NODE_ENV === 'test') {
     return
   }
+
   AccessibilityInfo.announceForAccessibility(message)
   const item = new RootSiblings(
-    <Toast message={message} icon={icon} destroy={() => item.destroy()} />,
+    (
+      <Toast
+        message={message}
+        type={convertLegacyToastType(type)}
+        destroy={() => item.destroy()}
+      />
+    ),
   )
 }
 
 function Toast({
   message,
-  icon,
+  type,
   destroy,
 }: {
   message: string
-  icon: FontAwesomeProps['icon']
+  type: ToastType
   destroy: () => void
 }) {
   const t = useTheme()
@@ -56,6 +74,10 @@ function Toast({
   const dismissSwipeTranslateY = useSharedValue(0)
   const [cardHeight, setCardHeight] = useState(0)
 
+  const toastStyles = getToastTypeStyles(t)
+  const colors = toastStyles[type]
+  const IconComponent = TOAST_TYPE_TO_ICON[type]
+
   // for the exit animation to work on iOS the animated component
   // must not be the root component
   // so we need to wrap it in a view and unmount the toast ahead of time
@@ -159,55 +181,52 @@ function Toast({
       pointerEvents="box-none">
       {alive && (
         <Animated.View
-          entering={FadeInUp}
-          exiting={FadeOutUp}
-          style={[a.flex_1]}>
-          <Animated.View
-            onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
-            accessibilityRole="alert"
-            accessible={true}
-            accessibilityLabel={message}
-            accessibilityHint=""
-            onAccessibilityEscape={hideAndDestroyImmediately}
-            style={[
-              a.flex_1,
-              t.name === 'dark' ? t.atoms.bg_contrast_25 : t.atoms.bg,
-              a.shadow_lg,
-              t.atoms.border_contrast_medium,
-              a.rounded_sm,
-              a.border,
-              animatedStyle,
-            ]}>
-            <GestureDetector gesture={panGesture}>
-              <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}>
-                <View
-                  style={[
-                    a.flex_shrink_0,
-                    a.rounded_full,
-                    {width: 32, height: 32},
-                    a.align_center,
-                    a.justify_center,
-                    {
-                      backgroundColor:
-                        t.name === 'dark'
-                          ? t.palette.black
-                          : t.palette.primary_50,
-                    },
-                  ]}>
-                  <FontAwesomeIcon
-                    icon={icon}
-                    size={16}
-                    style={t.atoms.text_contrast_medium}
-                  />
-                </View>
-                <View style={[a.h_full, a.justify_center, a.flex_1]}>
-                  <Text style={a.text_md} emoji>
-                    {message}
-                  </Text>
-                </View>
+          entering={FadeIn.duration(TOAST_ANIMATION_CONFIG.duration)}
+          exiting={FadeOut.duration(TOAST_ANIMATION_CONFIG.duration * 0.7)}
+          onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
+          accessibilityRole="alert"
+          accessible={true}
+          accessibilityLabel={message}
+          accessibilityHint=""
+          onAccessibilityEscape={hideAndDestroyImmediately}
+          style={[
+            a.flex_1,
+            {backgroundColor: colors.backgroundColor},
+            a.shadow_sm,
+            {borderColor: colors.borderColor, borderWidth: 1},
+            a.rounded_sm,
+            animatedStyle,
+          ]}>
+          <GestureDetector gesture={panGesture}>
+            <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}>
+              <View
+                style={[
+                  a.flex_shrink_0,
+                  a.rounded_full,
+                  {width: 32, height: 32},
+                  a.align_center,
+                  a.justify_center,
+                  {
+                    backgroundColor: colors.backgroundColor,
+                  },
+                ]}>
+                <IconComponent fill={colors.iconColor} size="sm" />
+              </View>
+              <View
+                style={[
+                  a.h_full,
+                  a.justify_center,
+                  a.flex_1,
+                  a.justify_center,
+                ]}>
+                <Text
+                  style={[a.text_md, a.font_bold, {color: colors.textColor}]}
+                  emoji>
+                  {message}
+                </Text>
               </View>
-            </GestureDetector>
-          </Animated.View>
+            </View>
+          </GestureDetector>
         </Animated.View>
       )}
     </GestureHandlerRootView>
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
index d3b7bda33..6b99b30bf 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -4,17 +4,23 @@
 
 import {useEffect, useState} from 'react'
 import {Pressable, StyleSheet, Text, View} from 'react-native'
+
 import {
-  FontAwesomeIcon,
-  type FontAwesomeIconStyle,
-  type Props as FontAwesomeProps,
-} from '@fortawesome/react-native-fontawesome'
+  convertLegacyToastType,
+  getToastTypeStyles,
+  getToastWebAnimationStyles,
+  type LegacyToastType,
+  TOAST_TYPE_TO_ICON,
+  TOAST_WEB_KEYFRAMES,
+  type ToastType,
+} from '#/view/com/util/Toast.style'
+import {atoms as a, useTheme} from '#/alf'
 
 const DURATION = 3500
 
 interface ActiveToast {
   text: string
-  icon: FontAwesomeProps['icon']
+  type: ToastType
 }
 type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
 
@@ -28,21 +34,82 @@ let toastTimeout: NodeJS.Timeout | undefined
 type ToastContainerProps = {}
 export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
   const [activeToast, setActiveToast] = useState<ActiveToast | undefined>()
+  const [isExiting, setIsExiting] = useState(false)
+
   useEffect(() => {
     globalSetActiveToast = (t: ActiveToast | undefined) => {
-      setActiveToast(t)
+      if (!t && activeToast) {
+        setIsExiting(true)
+        setTimeout(() => {
+          setActiveToast(t)
+          setIsExiting(false)
+        }, 200)
+      } else {
+        setActiveToast(t)
+        setIsExiting(false)
+      }
+    }
+  }, [activeToast])
+
+  useEffect(() => {
+    const styleId = 'toast-animations'
+    if (!document.getElementById(styleId)) {
+      const style = document.createElement('style')
+      style.id = styleId
+      style.textContent = TOAST_WEB_KEYFRAMES
+      document.head.appendChild(style)
     }
-  })
+  }, [])
+
+  const t = useTheme()
+
+  const toastTypeStyles = getToastTypeStyles(t)
+  const toastStyles = activeToast
+    ? toastTypeStyles[activeToast.type]
+    : toastTypeStyles.default
+
+  const IconComponent = activeToast
+    ? TOAST_TYPE_TO_ICON[activeToast.type]
+    : TOAST_TYPE_TO_ICON.default
+
+  const animationStyles = getToastWebAnimationStyles()
+
   return (
     <>
       {activeToast && (
-        <View style={styles.container}>
-          <FontAwesomeIcon
-            icon={activeToast.icon}
-            size={20}
-            style={styles.icon as FontAwesomeIconStyle}
-          />
-          <Text style={styles.text}>{activeToast.text}</Text>
+        <View
+          style={[
+            styles.container,
+            {
+              backgroundColor: toastStyles.backgroundColor,
+              borderColor: toastStyles.borderColor,
+              ...(isExiting
+                ? animationStyles.exiting
+                : animationStyles.entering),
+            },
+          ]}>
+          <View
+            style={[
+              styles.iconContainer,
+              {
+                backgroundColor: 'transparent',
+              },
+            ]}>
+            <IconComponent
+              fill={toastStyles.iconColor}
+              size="sm"
+              style={styles.icon}
+            />
+          </View>
+          <Text
+            style={[
+              styles.text,
+              a.text_sm,
+              a.font_bold,
+              {color: toastStyles.textColor},
+            ]}>
+            {activeToast.text}
+          </Text>
           <Pressable
             style={styles.dismissBackdrop}
             accessibilityLabel="Dismiss"
@@ -60,11 +127,15 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
 // methods
 // =
 
-export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') {
+export function show(
+  text: string,
+  type: ToastType | LegacyToastType = 'default',
+) {
   if (toastTimeout) {
     clearTimeout(toastTimeout)
   }
-  globalSetActiveToast?.({text, icon})
+
+  globalSetActiveToast?.({text, type: convertLegacyToastType(type)})
   toastTimeout = setTimeout(() => {
     globalSetActiveToast?.(undefined)
   }, DURATION)
@@ -78,12 +149,12 @@ const styles = StyleSheet.create({
     bottom: 20,
     // @ts-ignore web only
     width: 'calc(100% - 40px)',
-    maxWidth: 350,
+    maxWidth: 380,
     padding: 20,
     flexDirection: 'row',
     alignItems: 'center',
-    backgroundColor: '#000c',
     borderRadius: 10,
+    borderWidth: 1,
   },
   dismissBackdrop: {
     position: 'absolute',
@@ -92,13 +163,18 @@ const styles = StyleSheet.create({
     bottom: 0,
     right: 0,
   },
+  iconContainer: {
+    width: 32,
+    height: 32,
+    borderRadius: 16,
+    alignItems: 'center',
+    justifyContent: 'center',
+    flexShrink: 0,
+  },
   icon: {
-    color: '#fff',
     flexShrink: 0,
   },
   text: {
-    color: '#fff',
-    fontSize: 18,
     marginLeft: 10,
   },
 })
diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx
new file mode 100644
index 000000000..4c17f1c33
--- /dev/null
+++ b/src/view/screens/Storybook/Toasts.tsx
@@ -0,0 +1,102 @@
+import {Pressable, View} from 'react-native'
+
+import * as Toast from '#/view/com/util/Toast'
+import {
+  getToastTypeStyles,
+  TOAST_TYPE_TO_ICON,
+  type ToastType,
+} from '#/view/com/util/Toast.style'
+import {atoms as a, useTheme} from '#/alf'
+import {H1, Text} from '#/components/Typography'
+
+function ToastPreview({message, type}: {message: string; type: ToastType}) {
+  const t = useTheme()
+  const toastStyles = getToastTypeStyles(t)
+  const colors = toastStyles[type as keyof typeof toastStyles]
+  const IconComponent =
+    TOAST_TYPE_TO_ICON[type as keyof typeof TOAST_TYPE_TO_ICON]
+
+  return (
+    <Pressable
+      accessibilityRole="button"
+      onPress={() => Toast.show(message, type)}
+      style={[
+        {backgroundColor: colors.backgroundColor},
+        a.shadow_sm,
+        {borderColor: colors.borderColor},
+        a.rounded_sm,
+        a.border,
+        a.px_sm,
+        a.py_sm,
+        a.flex_row,
+        a.gap_sm,
+        a.align_center,
+      ]}>
+      <View
+        style={[
+          a.flex_shrink_0,
+          a.rounded_full,
+          {width: 24, height: 24},
+          a.align_center,
+          a.justify_center,
+          {
+            backgroundColor: colors.backgroundColor,
+          },
+        ]}>
+        <IconComponent fill={colors.iconColor} size="xs" />
+      </View>
+      <View style={[a.flex_1]}>
+        <Text
+          style={[
+            a.text_sm,
+            a.font_bold,
+            a.leading_snug,
+            {color: colors.textColor},
+          ]}
+          emoji>
+          {message}
+        </Text>
+      </View>
+    </Pressable>
+  )
+}
+
+export function Toasts() {
+  return (
+    <View style={[a.gap_md]}>
+      <H1>Toast Examples</H1>
+
+      <View style={[a.gap_md]}>
+        <View style={[a.gap_xs]}>
+          <ToastPreview message="Default Toast" type="default" />
+        </View>
+
+        <View style={[a.gap_xs]}>
+          <ToastPreview
+            message="Operation completed successfully!"
+            type="success"
+          />
+        </View>
+
+        <View style={[a.gap_xs]}>
+          <ToastPreview message="Something went wrong!" type="error" />
+        </View>
+
+        <View style={[a.gap_xs]}>
+          <ToastPreview message="Please check your input" type="warning" />
+        </View>
+
+        <View style={[a.gap_xs]}>
+          <ToastPreview message="Here's some helpful information" type="info" />
+        </View>
+
+        <View style={[a.gap_xs]}>
+          <ToastPreview
+            message="This is a longer message to test how the toast handles multiple lines of text content."
+            type="info"
+          />
+        </View>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index a6c2ecdde..afcc1c4e7 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -20,6 +20,7 @@ import {Settings} from './Settings'
 import {Shadows} from './Shadows'
 import {Spacing} from './Spacing'
 import {Theming} from './Theming'
+import {Toasts} from './Toasts'
 import {Typography} from './Typography'
 
 export function Storybook() {
@@ -122,6 +123,7 @@ function StorybookInner() {
             <Breakpoints />
             <Dialogs />
             <Admonitions />
+            <Toasts />
             <Settings />
 
             <Button