about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/api/index.ts6
-rw-r--r--src/lib/app-info.ts10
-rw-r--r--src/lib/constants.ts103
-rw-r--r--src/lib/country-codes.ts256
-rw-r--r--src/lib/hooks/useAccountSwitcher.ts8
-rw-r--r--src/lib/hooks/useDedupe.ts17
-rw-r--r--src/lib/hooks/useInitialNumToRender.ts11
-rw-r--r--src/lib/hooks/useIntentHandler.ts93
-rw-r--r--src/lib/hooks/useNavigationDeduped.ts80
-rw-r--r--src/lib/hooks/useOTAUpdate.ts52
-rw-r--r--src/lib/hooks/useOTAUpdates.ts142
-rw-r--r--src/lib/hooks/useWebBodyScrollLock.ts2
-rw-r--r--src/lib/link-meta/link-meta.ts2
-rw-r--r--src/lib/media/picker.e2e.tsx3
-rw-r--r--src/lib/media/picker.shared.ts21
-rw-r--r--src/lib/moderatePost_wrapped.ts62
-rw-r--r--src/lib/moderation.ts189
-rw-r--r--src/lib/moderation/useGlobalLabelStrings.ts52
-rw-r--r--src/lib/moderation/useLabelBehaviorDescription.ts70
-rw-r--r--src/lib/moderation/useLabelInfo.ts100
-rw-r--r--src/lib/moderation/useModerationCauseDescription.ts150
-rw-r--r--src/lib/moderation/useReportOptions.ts94
-rw-r--r--src/lib/notifications/notifications.ts104
-rw-r--r--src/lib/react-query.tsx (renamed from src/lib/react-query.ts)43
-rw-r--r--src/lib/routes/links.ts10
-rw-r--r--src/lib/routes/router.ts10
-rw-r--r--src/lib/routes/types.ts8
-rw-r--r--src/lib/sharing.ts4
-rw-r--r--src/lib/statsig/events.ts97
-rw-r--r--src/lib/statsig/statsig.tsx136
-rw-r--r--src/lib/strings/display-names.ts3
-rw-r--r--src/lib/strings/embed-player.ts41
-rw-r--r--src/lib/strings/handles.ts31
-rw-r--r--src/lib/strings/helpers.ts21
-rw-r--r--src/lib/strings/time.ts2
-rw-r--r--src/lib/strings/url-helpers.ts70
-rw-r--r--src/lib/themes.ts23
37 files changed, 1465 insertions, 661 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 440dfa5ee..5fb7fe50e 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
 
   // add image embed if present
   if (opts.images?.length) {
-    logger.info(`Uploading images`, {
+    logger.debug(`Uploading images`, {
       count: opts.images.length,
     })
 
     const images: AppBskyEmbedImages.Image[] = []
     for (const image of opts.images) {
       opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
-      logger.info(`Compressing image`)
+      logger.debug(`Compressing image`)
       await image.compress()
       const path = image.compressed?.path ?? image.path
       const {width, height} = image.compressed || image
-      logger.info(`Uploading image`)
+      logger.debug(`Uploading image`)
       const res = await uploadBlob(agent, path, 'image/jpeg')
       images.push({
         image: res.data.blob,
diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts
index 3f026d3fe..3071e031b 100644
--- a/src/lib/app-info.ts
+++ b/src/lib/app-info.ts
@@ -1,5 +1,9 @@
 import VersionNumber from 'react-native-version-number'
-import * as Updates from 'expo-updates'
-export const updateChannel = Updates.channel
 
-export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})`
+export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
+export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
+
+const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
+export const appVersion = `${VersionNumber.appVersion} (${
+  VersionNumber.buildVersion
+}, ${IS_DEV ? 'development' : UPDATES_CHANNEL})`
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index aec8338d0..f5a72669a 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -3,9 +3,8 @@ import {Insets, Platform} from 'react-native'
 export const LOCAL_DEV_SERVICE =
   Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
 export const STAGING_SERVICE = 'https://staging.bsky.dev'
-export const PROD_SERVICE = 'https://bsky.social'
-export const DEFAULT_SERVICE = PROD_SERVICE
-
+export const BSKY_SERVICE = 'https://bsky.social'
+export const DEFAULT_SERVICE = BSKY_SERVICE
 const HELP_DESK_LANG = 'en-us'
 export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
 
@@ -36,92 +35,16 @@ export const MAX_GRAPHEME_LENGTH = 300
 // but increasing limit per user feedback
 export const MAX_ALT_TEXT = 1000
 
-export function IS_LOCAL_DEV(url: string) {
-  return url.includes('localhost')
-}
-
-export function IS_STAGING(url: string) {
-  return url.startsWith('https://staging.bsky.dev')
-}
-
-export function IS_PROD(url: string) {
-  // NOTE
-  // until open federation, "production" is defined as the main server
-  // this definition will not work once federation is enabled!
-  // -prf
-  return (
-    url.startsWith('https://bsky.social') ||
-    url.startsWith('https://api.bsky.app') ||
-    /bsky\.network\/?$/.test(url)
-  )
+export function IS_TEST_USER(handle?: string) {
+  return handle && handle?.endsWith('.test')
 }
 
-export const PROD_TEAM_HANDLES = [
-  'jay.bsky.social',
-  'pfrazee.com',
-  'divy.zone',
-  'dholms.xyz',
-  'why.bsky.world',
-  'iamrosewang.bsky.social',
-]
-export const STAGING_TEAM_HANDLES = [
-  'arcalinea.staging.bsky.dev',
-  'paul.staging.bsky.dev',
-  'paul2.staging.bsky.dev',
-]
-export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test']
-
-export function TEAM_HANDLES(serviceUrl: string) {
-  if (serviceUrl.includes('localhost')) {
-    return DEV_TEAM_HANDLES
-  } else if (serviceUrl.includes('staging')) {
-    return STAGING_TEAM_HANDLES
-  } else {
-    return PROD_TEAM_HANDLES
-  }
+export function IS_PROD_SERVICE(url?: string) {
+  return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
 }
 
-export const STAGING_DEFAULT_FEED = (rkey: string) =>
-  `at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}`
 export const PROD_DEFAULT_FEED = (rkey: string) =>
   `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
-export async function DEFAULT_FEEDS(
-  serviceUrl: string,
-  resolveHandle: (name: string) => Promise<string>,
-) {
-  // TODO: remove this when the test suite no longer relies on it
-  if (IS_LOCAL_DEV(serviceUrl)) {
-    // local dev
-    const aliceDid = await resolveHandle('alice.test')
-    return {
-      pinned: [
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
-      ],
-      saved: [
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
-      ],
-    }
-  } else if (IS_STAGING(serviceUrl)) {
-    // staging
-    return {
-      pinned: [STAGING_DEFAULT_FEED('whats-hot')],
-      saved: [
-        STAGING_DEFAULT_FEED('bsky-team'),
-        STAGING_DEFAULT_FEED('with-friends'),
-        STAGING_DEFAULT_FEED('whats-hot'),
-        STAGING_DEFAULT_FEED('hot-classic'),
-      ],
-    }
-  } else {
-    // production
-    return {
-      pinned: [PROD_DEFAULT_FEED('whats-hot')],
-      saved: [PROD_DEFAULT_FEED('whats-hot')],
-    }
-  }
-}
 
 export const POST_IMG_MAX = {
   width: 2000,
@@ -135,13 +58,11 @@ export const STAGING_LINK_META_PROXY =
 export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
 
 export function LINK_META_PROXY(serviceUrl: string) {
-  if (IS_LOCAL_DEV(serviceUrl)) {
-    return STAGING_LINK_META_PROXY
-  } else if (IS_STAGING(serviceUrl)) {
-    return STAGING_LINK_META_PROXY
-  } else {
+  if (IS_PROD_SERVICE(serviceUrl)) {
     return PROD_LINK_META_PROXY
   }
+
+  return STAGING_LINK_META_PROXY
 }
 
 export const STATUS_PAGE_URL = 'https://status.bsky.app/'
@@ -158,3 +79,9 @@ export const HITSLOP_20 = createHitslop(20)
 export const HITSLOP_30 = createHitslop(30)
 export const BACK_HITSLOP = HITSLOP_30
 export const MAX_POST_LINES = 25
+
+export const BSKY_FEED_OWNER_DIDS = [
+  'did:plc:z72i7hdynmk6r22z27h6tvur',
+  'did:plc:vpkhqolt662uhesyj6nxm7ys',
+  'did:plc:q6gjnaw2blty4crticxkmujt',
+]
diff --git a/src/lib/country-codes.ts b/src/lib/country-codes.ts
deleted file mode 100644
index 9c9da84cf..000000000
--- a/src/lib/country-codes.ts
+++ /dev/null
@@ -1,256 +0,0 @@
-import {CountryCode} from 'libphonenumber-js'
-
-// ISO 3166-1 alpha-2 codes
-
-export interface CountryCodeMap {
-  code2: CountryCode
-  name: string
-}
-
-export const COUNTRY_CODES: CountryCodeMap[] = [
-  {code2: 'AF', name: 'Afghanistan (+93)'},
-  {code2: 'AX', name: 'Åland Islands (+358)'},
-  {code2: 'AL', name: 'Albania (+355)'},
-  {code2: 'DZ', name: 'Algeria (+213)'},
-  {code2: 'AS', name: 'American Samoa (+1)'},
-  {code2: 'AD', name: 'Andorra (+376)'},
-  {code2: 'AO', name: 'Angola (+244)'},
-  {code2: 'AI', name: 'Anguilla (+1)'},
-  {code2: 'AG', name: 'Antigua and Barbuda (+1)'},
-  {code2: 'AR', name: 'Argentina (+54)'},
-  {code2: 'AM', name: 'Armenia (+374)'},
-  {code2: 'AW', name: 'Aruba (+297)'},
-  {code2: 'AU', name: 'Australia (+61)'},
-  {code2: 'AT', name: 'Austria (+43)'},
-  {code2: 'AZ', name: 'Azerbaijan (+994)'},
-  {code2: 'BS', name: 'Bahamas (+1)'},
-  {code2: 'BH', name: 'Bahrain (+973)'},
-  {code2: 'BD', name: 'Bangladesh (+880)'},
-  {code2: 'BB', name: 'Barbados (+1)'},
-  {code2: 'BY', name: 'Belarus (+375)'},
-  {code2: 'BE', name: 'Belgium (+32)'},
-  {code2: 'BZ', name: 'Belize (+501)'},
-  {code2: 'BJ', name: 'Benin (+229)'},
-  {code2: 'BM', name: 'Bermuda (+1)'},
-  {code2: 'BT', name: 'Bhutan (+975)'},
-  {code2: 'BO', name: 'Bolivia (Plurinational State of) (+591)'},
-  {code2: 'BQ', name: 'Bonaire, Sint Eustatius and Saba (+599)'},
-  {code2: 'BA', name: 'Bosnia and Herzegovina (+387)'},
-  {code2: 'BW', name: 'Botswana (+267)'},
-  {code2: 'BR', name: 'Brazil (+55)'},
-  {code2: 'IO', name: 'British Indian Ocean Territory (+246)'},
-  {code2: 'BN', name: 'Brunei Darussalam (+673)'},
-  {code2: 'BG', name: 'Bulgaria (+359)'},
-  {code2: 'BF', name: 'Burkina Faso (+226)'},
-  {code2: 'BI', name: 'Burundi (+257)'},
-  {code2: 'CV', name: 'Cabo Verde (+238)'},
-  {code2: 'KH', name: 'Cambodia (+855)'},
-  {code2: 'CM', name: 'Cameroon (+237)'},
-  {code2: 'CA', name: 'Canada (+1)'},
-  {code2: 'KY', name: 'Cayman Islands (+1)'},
-  {code2: 'CF', name: 'Central African Republic (+236)'},
-  {code2: 'TD', name: 'Chad (+235)'},
-  {code2: 'CL', name: 'Chile (+56)'},
-  {code2: 'CN', name: 'China (+86)'},
-  {code2: 'CX', name: 'Christmas Island (+61)'},
-  {code2: 'CC', name: 'Cocos (Keeling) Islands (+61)'},
-  {code2: 'CO', name: 'Colombia (+57)'},
-  {code2: 'KM', name: 'Comoros (+269)'},
-  {code2: 'CG', name: 'Congo (+242)'},
-  {code2: 'CD', name: 'Congo, Democratic Republic of the (+243)'},
-  {code2: 'CK', name: 'Cook Islands (+682)'},
-  {code2: 'CR', name: 'Costa Rica (+506)'},
-  {code2: 'CI', name: "Côte d'Ivoire (+225)"},
-  {code2: 'HR', name: 'Croatia (+385)'},
-  {code2: 'CU', name: 'Cuba (+53)'},
-  {code2: 'CW', name: 'Curaçao (+599)'},
-  {code2: 'CY', name: 'Cyprus (+357)'},
-  {code2: 'CZ', name: 'Czechia (+420)'},
-  {code2: 'DK', name: 'Denmark (+45)'},
-  {code2: 'DJ', name: 'Djibouti (+253)'},
-  {code2: 'DM', name: 'Dominica (+1)'},
-  {code2: 'DO', name: 'Dominican Republic (+1)'},
-  {code2: 'EC', name: 'Ecuador (+593)'},
-  {code2: 'EG', name: 'Egypt (+20)'},
-  {code2: 'SV', name: 'El Salvador (+503)'},
-  {code2: 'GQ', name: 'Equatorial Guinea (+240)'},
-  {code2: 'ER', name: 'Eritrea (+291)'},
-  {code2: 'EE', name: 'Estonia (+372)'},
-  {code2: 'SZ', name: 'Eswatini (+268)'},
-  {code2: 'ET', name: 'Ethiopia (+251)'},
-  {code2: 'FK', name: 'Falkland Islands (Malvinas) (+500)'},
-  {code2: 'FO', name: 'Faroe Islands (+298)'},
-  {code2: 'FJ', name: 'Fiji (+679)'},
-  {code2: 'FI', name: 'Finland (+358)'},
-  {code2: 'FR', name: 'France (+33)'},
-  {code2: 'GF', name: 'French Guiana (+594)'},
-  {code2: 'PF', name: 'French Polynesia (+689)'},
-  {code2: 'GA', name: 'Gabon (+241)'},
-  {code2: 'GM', name: 'Gambia (+220)'},
-  {code2: 'GE', name: 'Georgia (+995)'},
-  {code2: 'DE', name: 'Germany (+49)'},
-  {code2: 'GH', name: 'Ghana (+233)'},
-  {code2: 'GI', name: 'Gibraltar (+350)'},
-  {code2: 'GR', name: 'Greece (+30)'},
-  {code2: 'GL', name: 'Greenland (+299)'},
-  {code2: 'GD', name: 'Grenada (+1)'},
-  {code2: 'GP', name: 'Guadeloupe (+590)'},
-  {code2: 'GU', name: 'Guam (+1)'},
-  {code2: 'GT', name: 'Guatemala (+502)'},
-  {code2: 'GG', name: 'Guernsey (+44)'},
-  {code2: 'GN', name: 'Guinea (+224)'},
-  {code2: 'GW', name: 'Guinea-Bissau (+245)'},
-  {code2: 'GY', name: 'Guyana (+592)'},
-  {code2: 'HT', name: 'Haiti (+509)'},
-  {code2: 'VA', name: 'Holy See (+39)'},
-  {code2: 'HN', name: 'Honduras (+504)'},
-  {code2: 'HK', name: 'Hong Kong (+852)'},
-  {code2: 'HU', name: 'Hungary (+36)'},
-  {code2: 'IS', name: 'Iceland (+354)'},
-  {code2: 'IN', name: 'India (+91)'},
-  {code2: 'ID', name: 'Indonesia (+62)'},
-  {code2: 'IR', name: 'Iran (Islamic Republic of) (+98)'},
-  {code2: 'IQ', name: 'Iraq (+964)'},
-  {code2: 'IE', name: 'Ireland (+353)'},
-  {code2: 'IM', name: 'Isle of Man (+44)'},
-  {code2: 'IL', name: 'Israel (+972)'},
-  {code2: 'IT', name: 'Italy (+39)'},
-  {code2: 'JM', name: 'Jamaica (+1)'},
-  {code2: 'JP', name: 'Japan (+81)'},
-  {code2: 'JE', name: 'Jersey (+44)'},
-  {code2: 'JO', name: 'Jordan (+962)'},
-  {code2: 'KZ', name: 'Kazakhstan (+7)'},
-  {code2: 'KE', name: 'Kenya (+254)'},
-  {code2: 'KI', name: 'Kiribati (+686)'},
-  {code2: 'KP', name: "Korea (Democratic People's Republic of) (+850)"},
-  {code2: 'KR', name: 'Korea, Republic of (+82)'},
-  {code2: 'KW', name: 'Kuwait (+965)'},
-  {code2: 'KG', name: 'Kyrgyzstan (+996)'},
-  {code2: 'LA', name: "Lao People's Democratic Republic (+856)"},
-  {code2: 'LV', name: 'Latvia (+371)'},
-  {code2: 'LB', name: 'Lebanon (+961)'},
-  {code2: 'LS', name: 'Lesotho (+266)'},
-  {code2: 'LR', name: 'Liberia (+231)'},
-  {code2: 'LY', name: 'Libya (+218)'},
-  {code2: 'LI', name: 'Liechtenstein (+423)'},
-  {code2: 'LT', name: 'Lithuania (+370)'},
-  {code2: 'LU', name: 'Luxembourg (+352)'},
-  {code2: 'MO', name: 'Macao (+853)'},
-  {code2: 'MG', name: 'Madagascar (+261)'},
-  {code2: 'MW', name: 'Malawi (+265)'},
-  {code2: 'MY', name: 'Malaysia (+60)'},
-  {code2: 'MV', name: 'Maldives (+960)'},
-  {code2: 'ML', name: 'Mali (+223)'},
-  {code2: 'MT', name: 'Malta (+356)'},
-  {code2: 'MH', name: 'Marshall Islands (+692)'},
-  {code2: 'MQ', name: 'Martinique (+596)'},
-  {code2: 'MR', name: 'Mauritania (+222)'},
-  {code2: 'MU', name: 'Mauritius (+230)'},
-  {code2: 'YT', name: 'Mayotte (+262)'},
-  {code2: 'MX', name: 'Mexico (+52)'},
-  {code2: 'FM', name: 'Micronesia (Federated States of) (+691)'},
-  {code2: 'MD', name: 'Moldova, Republic of (+373)'},
-  {code2: 'MC', name: 'Monaco (+377)'},
-  {code2: 'MN', name: 'Mongolia (+976)'},
-  {code2: 'ME', name: 'Montenegro (+382)'},
-  {code2: 'MS', name: 'Montserrat (+1)'},
-  {code2: 'MA', name: 'Morocco (+212)'},
-  {code2: 'MZ', name: 'Mozambique (+258)'},
-  {code2: 'MM', name: 'Myanmar (+95)'},
-  {code2: 'NA', name: 'Namibia (+264)'},
-  {code2: 'NR', name: 'Nauru (+674)'},
-  {code2: 'NP', name: 'Nepal (+977)'},
-  {code2: 'NL', name: 'Netherlands, Kingdom of the (+31)'},
-  {code2: 'NC', name: 'New Caledonia (+687)'},
-  {code2: 'NZ', name: 'New Zealand (+64)'},
-  {code2: 'NI', name: 'Nicaragua (+505)'},
-  {code2: 'NE', name: 'Niger (+227)'},
-  {code2: 'NG', name: 'Nigeria (+234)'},
-  {code2: 'NU', name: 'Niue (+683)'},
-  {code2: 'NF', name: 'Norfolk Island (+672)'},
-  {code2: 'MK', name: 'North Macedonia (+389)'},
-  {code2: 'MP', name: 'Northern Mariana Islands (+1)'},
-  {code2: 'NO', name: 'Norway (+47)'},
-  {code2: 'OM', name: 'Oman (+968)'},
-  {code2: 'PK', name: 'Pakistan (+92)'},
-  {code2: 'PW', name: 'Palau (+680)'},
-  {code2: 'PS', name: 'Palestine, State of (+970)'},
-  {code2: 'PA', name: 'Panama (+507)'},
-  {code2: 'PG', name: 'Papua New Guinea (+675)'},
-  {code2: 'PY', name: 'Paraguay (+595)'},
-  {code2: 'PE', name: 'Peru (+51)'},
-  {code2: 'PH', name: 'Philippines (+63)'},
-  {code2: 'PL', name: 'Poland (+48)'},
-  {code2: 'PT', name: 'Portugal (+351)'},
-  {code2: 'PR', name: 'Puerto Rico (+1)'},
-  {code2: 'QA', name: 'Qatar (+974)'},
-  {code2: 'RE', name: 'Réunion (+262)'},
-  {code2: 'RO', name: 'Romania (+40)'},
-  {code2: 'RU', name: 'Russian Federation (+7)'},
-  {code2: 'RW', name: 'Rwanda (+250)'},
-  {code2: 'BL', name: 'Saint Barthélemy (+590)'},
-  {code2: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha (+290)'},
-  {code2: 'KN', name: 'Saint Kitts and Nevis (+1)'},
-  {code2: 'LC', name: 'Saint Lucia (+1)'},
-  {code2: 'MF', name: 'Saint Martin (French part) (+590)'},
-  {code2: 'PM', name: 'Saint Pierre and Miquelon (+508)'},
-  {code2: 'VC', name: 'Saint Vincent and the Grenadines (+1)'},
-  {code2: 'WS', name: 'Samoa (+685)'},
-  {code2: 'SM', name: 'San Marino (+378)'},
-  {code2: 'ST', name: 'Sao Tome and Principe (+239)'},
-  {code2: 'SA', name: 'Saudi Arabia (+966)'},
-  {code2: 'SN', name: 'Senegal (+221)'},
-  {code2: 'RS', name: 'Serbia (+381)'},
-  {code2: 'SC', name: 'Seychelles (+248)'},
-  {code2: 'SL', name: 'Sierra Leone (+232)'},
-  {code2: 'SG', name: 'Singapore (+65)'},
-  {code2: 'SX', name: 'Sint Maarten (Dutch part) (+1)'},
-  {code2: 'SK', name: 'Slovakia (+421)'},
-  {code2: 'SI', name: 'Slovenia (+386)'},
-  {code2: 'SB', name: 'Solomon Islands (+677)'},
-  {code2: 'SO', name: 'Somalia (+252)'},
-  {code2: 'ZA', name: 'South Africa (+27)'},
-  {code2: 'SS', name: 'South Sudan (+211)'},
-  {code2: 'ES', name: 'Spain (+34)'},
-  {code2: 'LK', name: 'Sri Lanka (+94)'},
-  {code2: 'SD', name: 'Sudan (+249)'},
-  {code2: 'SR', name: 'Suriname (+597)'},
-  {code2: 'SJ', name: 'Svalbard and Jan Mayen (+47)'},
-  {code2: 'SE', name: 'Sweden (+46)'},
-  {code2: 'CH', name: 'Switzerland (+41)'},
-  {code2: 'SY', name: 'Syrian Arab Republic (+963)'},
-  {code2: 'TW', name: 'Taiwan (+886)'},
-  {code2: 'TJ', name: 'Tajikistan (+992)'},
-  {code2: 'TZ', name: 'Tanzania, United Republic of (+255)'},
-  {code2: 'TH', name: 'Thailand (+66)'},
-  {code2: 'TL', name: 'Timor-Leste (+670)'},
-  {code2: 'TG', name: 'Togo (+228)'},
-  {code2: 'TK', name: 'Tokelau (+690)'},
-  {code2: 'TO', name: 'Tonga (+676)'},
-  {code2: 'TT', name: 'Trinidad and Tobago (+1)'},
-  {code2: 'TN', name: 'Tunisia (+216)'},
-  {code2: 'TR', name: 'Türkiye (+90)'},
-  {code2: 'TM', name: 'Turkmenistan (+993)'},
-  {code2: 'TC', name: 'Turks and Caicos Islands (+1)'},
-  {code2: 'TV', name: 'Tuvalu (+688)'},
-  {code2: 'UG', name: 'Uganda (+256)'},
-  {code2: 'UA', name: 'Ukraine (+380)'},
-  {code2: 'AE', name: 'United Arab Emirates (+971)'},
-  {
-    code2: 'GB',
-    name: 'United Kingdom of Great Britain and Northern Ireland (+44)',
-  },
-  {code2: 'US', name: 'United States of America (+1)'},
-  {code2: 'UY', name: 'Uruguay (+598)'},
-  {code2: 'UZ', name: 'Uzbekistan (+998)'},
-  {code2: 'VU', name: 'Vanuatu (+678)'},
-  {code2: 'VE', name: 'Venezuela (Bolivarian Republic of) (+58)'},
-  {code2: 'VN', name: 'Viet Nam (+84)'},
-  {code2: 'VG', name: 'Virgin Islands (British) (+1)'},
-  {code2: 'VI', name: 'Virgin Islands (U.S.) (+1)'},
-  {code2: 'WF', name: 'Wallis and Futuna (+681)'},
-  {code2: 'EH', name: 'Western Sahara (+212)'},
-  {code2: 'YE', name: 'Yemen (+967)'},
-  {code2: 'ZM', name: 'Zambia (+260)'},
-  {code2: 'ZW', name: 'Zimbabwe (+263)'},
-]
diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts
index 74b5674d5..eb1685a0a 100644
--- a/src/lib/hooks/useAccountSwitcher.ts
+++ b/src/lib/hooks/useAccountSwitcher.ts
@@ -6,6 +6,7 @@ import {useSessionApi, SessionAccount} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import {useCloseAllActiveElements} from '#/state/util'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {LogEvents} from '../statsig/statsig'
 
 export function useAccountSwitcher() {
   const {track} = useAnalytics()
@@ -14,7 +15,10 @@ export function useAccountSwitcher() {
   const {requestSwitchToAccount} = useLoggedOutViewControls()
 
   const onPressSwitchAccount = useCallback(
-    async (account: SessionAccount) => {
+    async (
+      account: SessionAccount,
+      logContext: LogEvents['account:loggedIn']['logContext'],
+    ) => {
       track('Settings:SwitchAccountButtonClicked')
 
       try {
@@ -28,7 +32,7 @@ export function useAccountSwitcher() {
             // So we change the URL ourselves. The navigator will pick it up on remount.
             history.pushState(null, '', '/')
           }
-          await selectAccount(account)
+          await selectAccount(account, logContext)
           setTimeout(() => {
             Toast.show(`Signed in as @${account.handle}`)
           }, 100)
diff --git a/src/lib/hooks/useDedupe.ts b/src/lib/hooks/useDedupe.ts
new file mode 100644
index 000000000..d9432cb2c
--- /dev/null
+++ b/src/lib/hooks/useDedupe.ts
@@ -0,0 +1,17 @@
+import React from 'react'
+
+export const useDedupe = () => {
+  const canDo = React.useRef(true)
+
+  return React.useRef((cb: () => unknown) => {
+    if (canDo.current) {
+      canDo.current = false
+      setTimeout(() => {
+        canDo.current = true
+      }, 250)
+      cb()
+      return true
+    }
+    return false
+  }).current
+}
diff --git a/src/lib/hooks/useInitialNumToRender.ts b/src/lib/hooks/useInitialNumToRender.ts
new file mode 100644
index 000000000..942f0404a
--- /dev/null
+++ b/src/lib/hooks/useInitialNumToRender.ts
@@ -0,0 +1,11 @@
+import React from 'react'
+import {Dimensions} from 'react-native'
+
+const MIN_POST_HEIGHT = 100
+
+export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
+  return React.useMemo(() => {
+    const screenHeight = Dimensions.get('window').height
+    return Math.ceil(screenHeight / minItemHeight) + 1
+  }, [minItemHeight])
+}
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
new file mode 100644
index 000000000..8741530b5
--- /dev/null
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -0,0 +1,93 @@
+import React from 'react'
+import * as Linking from 'expo-linking'
+import {isNative} from 'platform/detection'
+import {useComposerControls} from 'state/shell'
+import {useSession} from 'state/session'
+import {useCloseAllActiveElements} from 'state/util'
+
+type IntentType = 'compose'
+
+const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
+
+export function useIntentHandler() {
+  const incomingUrl = Linking.useURL()
+  const composeIntent = useComposeIntent()
+
+  React.useEffect(() => {
+    const handleIncomingURL = (url: string) => {
+      // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
+      // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
+      // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
+      // path parameter is in pathname rather than in hostname.
+      if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
+        url = url.replace('bluesky://', 'bluesky:///')
+      }
+
+      const urlp = new URL(url)
+      const [_, intent, intentType] = urlp.pathname.split('/')
+
+      // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
+      // intent check. On web, we have to check the first part of the path since we have an actual hostname
+      const isIntent = intent === 'intent'
+      const params = urlp.searchParams
+
+      if (!isIntent) return
+
+      switch (intentType as IntentType) {
+        case 'compose': {
+          composeIntent({
+            text: params.get('text'),
+            imageUrisStr: params.get('imageUris'),
+          })
+        }
+      }
+    }
+
+    if (incomingUrl) handleIncomingURL(incomingUrl)
+  }, [incomingUrl, composeIntent])
+}
+
+function useComposeIntent() {
+  const closeAllActiveElements = useCloseAllActiveElements()
+  const {openComposer} = useComposerControls()
+  const {hasSession} = useSession()
+
+  return React.useCallback(
+    ({
+      text,
+      imageUrisStr,
+    }: {
+      text: string | null
+      imageUrisStr: string | null // unused for right now, will be used later with intents
+    }) => {
+      if (!hasSession) return
+
+      closeAllActiveElements()
+
+      const imageUris = imageUrisStr
+        ?.split(',')
+        .filter(part => {
+          // For some security, we're going to filter out any image uri that is external. We don't want someone to
+          // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
+          // and we load that image
+          if (part.includes('https://') || part.includes('http://')) {
+            return false
+          }
+          // We also should just filter out cases that don't have all the info we need
+          return VALID_IMAGE_REGEX.test(part)
+        })
+        .map(part => {
+          const [uri, width, height] = part.split('|')
+          return {uri, width: Number(width), height: Number(height)}
+        })
+
+      setTimeout(() => {
+        openComposer({
+          text: text ?? undefined,
+          imageUris: isNative ? imageUris : undefined,
+        })
+      }, 500)
+    },
+    [hasSession, closeAllActiveElements, openComposer],
+  )
+}
diff --git a/src/lib/hooks/useNavigationDeduped.ts b/src/lib/hooks/useNavigationDeduped.ts
new file mode 100644
index 000000000..d913f7f3d
--- /dev/null
+++ b/src/lib/hooks/useNavigationDeduped.ts
@@ -0,0 +1,80 @@
+import React from 'react'
+import {useNavigation} from '@react-navigation/core'
+import {AllNavigatorParams, NavigationProp} from 'lib/routes/types'
+import type {NavigationAction} from '@react-navigation/routers'
+import {NavigationState} from '@react-navigation/native'
+import {useDedupe} from 'lib/hooks/useDedupe'
+
+export type DebouncedNavigationProp = Pick<
+  NavigationProp,
+  | 'popToTop'
+  | 'push'
+  | 'navigate'
+  | 'canGoBack'
+  | 'replace'
+  | 'dispatch'
+  | 'goBack'
+  | 'getState'
+>
+
+export function useNavigationDeduped() {
+  const navigation = useNavigation<NavigationProp>()
+  const dedupe = useDedupe()
+
+  return React.useMemo(
+    (): DebouncedNavigationProp => ({
+      // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
+      push: <RouteName extends keyof AllNavigatorParams>(
+        ...args: undefined extends AllNavigatorParams[RouteName]
+          ?
+              | [screen: RouteName]
+              | [screen: RouteName, params: AllNavigatorParams[RouteName]]
+          : [screen: RouteName, params: AllNavigatorParams[RouteName]]
+      ) => {
+        dedupe(() => navigation.push(...args))
+      },
+      // Types from @react-navigation/core/src/types.tsx
+      navigate: <RouteName extends keyof AllNavigatorParams>(
+        ...args: RouteName extends unknown
+          ? undefined extends AllNavigatorParams[RouteName]
+            ?
+                | [screen: RouteName]
+                | [screen: RouteName, params: AllNavigatorParams[RouteName]]
+            : [screen: RouteName, params: AllNavigatorParams[RouteName]]
+          : never
+      ) => {
+        dedupe(() => navigation.navigate(...args))
+      },
+      // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
+      replace: <RouteName extends keyof AllNavigatorParams>(
+        ...args: undefined extends AllNavigatorParams[RouteName]
+          ?
+              | [screen: RouteName]
+              | [screen: RouteName, params: AllNavigatorParams[RouteName]]
+          : [screen: RouteName, params: AllNavigatorParams[RouteName]]
+      ) => {
+        dedupe(() => navigation.replace(...args))
+      },
+      dispatch: (
+        action:
+          | NavigationAction
+          | ((state: NavigationState) => NavigationAction),
+      ) => {
+        dedupe(() => navigation.dispatch(action))
+      },
+      popToTop: () => {
+        dedupe(() => navigation.popToTop())
+      },
+      goBack: () => {
+        dedupe(() => navigation.goBack())
+      },
+      canGoBack: () => {
+        return navigation.canGoBack()
+      },
+      getState: () => {
+        return navigation.getState()
+      },
+    }),
+    [dedupe, navigation],
+  )
+}
diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 53eab300e..d35179256 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -2,25 +2,9 @@ import * as Updates from 'expo-updates'
 import {useCallback, useEffect} from 'react'
 import {AppState} from 'react-native'
 import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
-import {t} from '@lingui/macro'
 
 export function useOTAUpdate() {
-  const {openModal} = useModalControls()
-
   // HELPER FUNCTIONS
-  const showUpdatePopup = useCallback(() => {
-    openModal({
-      name: 'confirm',
-      title: t`Update Available`,
-      message: t`A new version of the app is available. Please update to continue using the app.`,
-      onPressConfirm: async () => {
-        Updates.reloadAsync().catch(err => {
-          throw err
-        })
-      },
-    })
-  }, [openModal])
   const checkForUpdate = useCallback(async () => {
     logger.debug('useOTAUpdate: Checking for update...')
     try {
@@ -32,32 +16,26 @@ export function useOTAUpdate() {
       }
       // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch.
       await Updates.fetchUpdateAsync()
-      // show a popup modal
-      showUpdatePopup()
     } catch (e) {
       logger.error('useOTAUpdate: Error while checking for update', {
         message: e,
       })
     }
-  }, [showUpdatePopup])
-  const updateEventListener = useCallback(
-    (event: Updates.UpdateEvent) => {
-      logger.debug('useOTAUpdate: Listening for update...')
-      if (event.type === Updates.UpdateEventType.ERROR) {
-        logger.error('useOTAUpdate: Error while listening for update', {
-          message: event.message,
-        })
-      } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
-        // Handle no update available
-        // do nothing
-      } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
-        // Handle update available
-        // open modal, ask for user confirmation, and reload the app
-        showUpdatePopup()
-      }
-    },
-    [showUpdatePopup],
-  )
+  }, [])
+  const updateEventListener = useCallback((event: Updates.UpdateEvent) => {
+    logger.debug('useOTAUpdate: Listening for update...')
+    if (event.type === Updates.UpdateEventType.ERROR) {
+      logger.error('useOTAUpdate: Error while listening for update', {
+        message: event.message,
+      })
+    } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
+      // Handle no update available
+      // do nothing
+    } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
+      // Handle update available
+      // open modal, ask for user confirmation, and reload the app
+    }
+  }, [])
 
   useEffect(() => {
     // ADD EVENT LISTENERS
diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts
new file mode 100644
index 000000000..181f0b2c6
--- /dev/null
+++ b/src/lib/hooks/useOTAUpdates.ts
@@ -0,0 +1,142 @@
+import React from 'react'
+import {Alert, AppState, AppStateStatus} from 'react-native'
+import app from 'react-native-version-number'
+import {
+  checkForUpdateAsync,
+  fetchUpdateAsync,
+  isEnabled,
+  reloadAsync,
+  setExtraParamAsync,
+  useUpdates,
+} from 'expo-updates'
+
+import {logger} from '#/logger'
+import {IS_TESTFLIGHT} from 'lib/app-info'
+import {isIOS} from 'platform/detection'
+
+const MINIMUM_MINIMIZE_TIME = 15 * 60e3
+
+async function setExtraParams() {
+  await setExtraParamAsync(
+    isIOS ? 'ios-build-number' : 'android-build-number',
+    // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
+    // This just ensures it gets passed as a string
+    `${app.buildVersion}`,
+  )
+  await setExtraParamAsync(
+    'channel',
+    IS_TESTFLIGHT ? 'testflight' : 'production',
+  )
+}
+
+export function useOTAUpdates() {
+  const appState = React.useRef<AppStateStatus>('active')
+  const lastMinimize = React.useRef(0)
+  const ranInitialCheck = React.useRef(false)
+  const timeout = React.useRef<NodeJS.Timeout>()
+  const {isUpdatePending} = useUpdates()
+
+  const setCheckTimeout = React.useCallback(() => {
+    timeout.current = setTimeout(async () => {
+      try {
+        await setExtraParams()
+
+        logger.debug('Checking for update...')
+        const res = await checkForUpdateAsync()
+
+        if (res.isAvailable) {
+          logger.debug('Attempting to fetch update...')
+          await fetchUpdateAsync()
+        } else {
+          logger.debug('No update available.')
+        }
+      } catch (e) {
+        logger.warn('OTA Update Error', {error: `${e}`})
+      }
+    }, 10e3)
+  }, [])
+
+  const onIsTestFlight = React.useCallback(() => {
+    setTimeout(async () => {
+      try {
+        await setExtraParams()
+
+        const res = await checkForUpdateAsync()
+        if (res.isAvailable) {
+          await fetchUpdateAsync()
+
+          Alert.alert(
+            'Update Available',
+            'A new version of the app is available. Relaunch now?',
+            [
+              {
+                text: 'No',
+                style: 'cancel',
+              },
+              {
+                text: 'Relaunch',
+                style: 'default',
+                onPress: async () => {
+                  await reloadAsync()
+                },
+              },
+            ],
+          )
+        }
+      } catch (e: any) {
+        // No need to handle
+      }
+    }, 3e3)
+  }, [])
+
+  React.useEffect(() => {
+    // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
+    // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
+    // immediately.
+    if (IS_TESTFLIGHT) {
+      onIsTestFlight()
+      return
+    } else if (!isEnabled || __DEV__ || ranInitialCheck.current) {
+      // Development client shouldn't check for updates at all, so we skip that here.
+      return
+    }
+
+    setCheckTimeout()
+    ranInitialCheck.current = true
+  }, [onIsTestFlight, setCheckTimeout])
+
+  // After the app has been minimized for 30 minutes, we want to either A. install an update if one has become available
+  // or B check for an update again.
+  React.useEffect(() => {
+    if (!isEnabled) return
+
+    const subscription = AppState.addEventListener(
+      'change',
+      async nextAppState => {
+        if (
+          appState.current.match(/inactive|background/) &&
+          nextAppState === 'active'
+        ) {
+          // If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since
+          // chances are that there isn't anything important going on in the current session.
+          if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
+            if (isUpdatePending) {
+              await reloadAsync()
+            } else {
+              setCheckTimeout()
+            }
+          }
+        } else {
+          lastMinimize.current = Date.now()
+        }
+
+        appState.current = nextAppState
+      },
+    )
+
+    return () => {
+      clearTimeout(timeout.current)
+      subscription.remove()
+    }
+  }, [isUpdatePending, setCheckTimeout])
+}
diff --git a/src/lib/hooks/useWebBodyScrollLock.ts b/src/lib/hooks/useWebBodyScrollLock.ts
index 585f193f1..0dcf911fe 100644
--- a/src/lib/hooks/useWebBodyScrollLock.ts
+++ b/src/lib/hooks/useWebBodyScrollLock.ts
@@ -6,6 +6,7 @@ let refCount = 0
 function incrementRefCount() {
   if (refCount === 0) {
     document.body.style.overflow = 'hidden'
+    document.documentElement.style.scrollbarGutter = 'auto'
   }
   refCount++
 }
@@ -14,6 +15,7 @@ function decrementRefCount() {
   refCount--
   if (refCount === 0) {
     document.body.style.overflow = ''
+    document.documentElement.style.scrollbarGutter = ''
   }
 }
 
diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts
index c7c8d4130..fa951432e 100644
--- a/src/lib/link-meta/link-meta.ts
+++ b/src/lib/link-meta/link-meta.ts
@@ -26,7 +26,7 @@ export interface LinkMeta {
 export async function getLinkMeta(
   agent: BskyAgent,
   url: string,
-  timeout = 5e3,
+  timeout = 15e3,
 ): Promise<LinkMeta> {
   if (isBskyAppUrl(url)) {
     return extractBskyMeta(agent, url)
diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx
index d7b608041..31702ab22 100644
--- a/src/lib/media/picker.e2e.tsx
+++ b/src/lib/media/picker.e2e.tsx
@@ -3,7 +3,6 @@ import RNFS from 'react-native-fs'
 import {CropperOptions} from './types'
 import {compressIfNeeded} from './manip'
 
-let _imageCounter = 0
 async function getFile() {
   let files = await RNFS.readDir(
     RNFS.LibraryDirectoryPath.split('/')
@@ -12,7 +11,7 @@ async function getFile() {
       .join('/'),
   )
   files = files.filter(file => file.path.endsWith('.JPG'))
-  const file = files[_imageCounter++ % files.length]
+  const file = files[0]
   return await compressIfNeeded({
     path: file.path,
     mime: 'image/jpeg',
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index 8bade34e2..96e82e4c7 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -18,11 +18,18 @@ export async function openPicker(opts?: ImagePickerOptions) {
     Toast.show('You may only select up to 4 images')
   }
 
-  return (response.assets ?? []).slice(0, 4).map(image => ({
-    mime: 'image/jpeg',
-    height: image.height,
-    width: image.width,
-    path: image.uri,
-    size: getDataUriSize(image.uri),
-  }))
+  return (response.assets ?? [])
+    .slice(0, 4)
+    .filter(asset => {
+      if (asset.mimeType?.startsWith('image/')) return true
+      Toast.show('Only image files are supported')
+      return false
+    })
+    .map(image => ({
+      mime: 'image/jpeg',
+      height: image.height,
+      width: image.width,
+      path: image.uri,
+      size: getDataUriSize(image.uri),
+    }))
 }
diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts
index 2195b2304..0ce01368a 100644
--- a/src/lib/moderatePost_wrapped.ts
+++ b/src/lib/moderatePost_wrapped.ts
@@ -1,58 +1,30 @@
-import {
-  AppBskyEmbedRecord,
-  AppBskyEmbedRecordWithMedia,
-  moderatePost,
-} from '@atproto/api'
+import {moderatePost, BSKY_LABELER_DID} from '@atproto/api'
 
 type ModeratePost = typeof moderatePost
-type Options = Parameters<ModeratePost>[1] & {
-  hiddenPosts?: string[]
-}
+type Options = Parameters<ModeratePost>[1]
 
 export function moderatePost_wrapped(
   subject: Parameters<ModeratePost>[0],
   opts: Options,
 ) {
-  const {hiddenPosts = [], ...options} = opts
-  const moderations = moderatePost(subject, options)
+  // HACK
+  // temporarily translate 'gore' into 'graphic-media' during the transition period
+  // can remove this in a few months
+  // -prf
+  translateOldLabels(subject)
 
-  if (hiddenPosts.includes(subject.uri)) {
-    moderations.content.filter = true
-    moderations.content.blur = true
-    if (!moderations.content.cause) {
-      moderations.content.cause = {
-        // @ts-ignore Temporary extension to the moderation system -prf
-        type: 'post-hidden',
-        source: {type: 'user'},
-        priority: 1,
-      }
-    }
-  }
+  return moderatePost(subject, opts)
+}
 
-  if (subject.embed) {
-    let embedHidden = false
-    if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
-      embedHidden = hiddenPosts.includes(subject.embed.record.uri)
-    }
-    if (
-      AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
-      AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
-    ) {
-      embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
-    }
-    if (embedHidden) {
-      moderations.embed.filter = true
-      moderations.embed.blur = true
-      if (!moderations.embed.cause) {
-        moderations.embed.cause = {
-          // @ts-ignore Temporary extension to the moderation system -prf
-          type: 'post-hidden',
-          source: {type: 'user'},
-          priority: 1,
-        }
+function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
+  if (subject.labels) {
+    for (const label of subject.labels) {
+      if (
+        label.val === 'gore' &&
+        (!label.src || label.src === BSKY_LABELER_DID)
+      ) {
+        label.val = 'graphic-media'
       }
     }
   }
-
-  return moderations
 }
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index bf19c208a..4105c2c2d 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -1,142 +1,81 @@
-import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api'
+import {
+  ModerationCause,
+  ModerationUI,
+  InterpretedLabelValueDefinition,
+  LABELS,
+  AppBskyLabelerDefs,
+  BskyAgent,
+  ModerationOpts,
+} from '@atproto/api'
 
-export interface ModerationCauseDescription {
-  name: string
-  description: string
-}
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
 
-export function describeModerationCause(
-  cause: ModerationCause | undefined,
-  context: 'account' | 'content',
-): ModerationCauseDescription {
-  if (!cause) {
-    return {
-      name: 'Content Warning',
-      description:
-        'Moderator has chosen to set a general warning on the content.',
-    }
-  }
-  if (cause.type === 'blocking') {
-    if (cause.source.type === 'list') {
-      return {
-        name: `User Blocked by "${cause.source.list.name}"`,
-        description:
-          'You have blocked this user. You cannot view their content.',
-      }
-    } else {
-      return {
-        name: 'User Blocked',
-        description:
-          'You have blocked this user. You cannot view their content.',
-      }
-    }
-  }
-  if (cause.type === 'blocked-by') {
-    return {
-      name: 'User Blocking You',
-      description: 'This user has blocked you. You cannot view their content.',
-    }
-  }
-  if (cause.type === 'block-other') {
-    return {
-      name: 'Content Not Available',
-      description:
-        'This content is not available because one of the users involved has blocked the other.',
-    }
-  }
-  if (cause.type === 'muted') {
-    if (cause.source.type === 'list') {
-      return {
-        name:
-          context === 'account'
-            ? `Muted by "${cause.source.list.name}"`
-            : `Post by muted user ("${cause.source.list.name}")`,
-        description: 'You have muted this user',
-      }
-    } else {
-      return {
-        name: context === 'account' ? 'Muted User' : 'Post by muted user',
-        description: 'You have muted this user',
-      }
-    }
-  }
-  // @ts-ignore Temporary extension to the moderation system -prf
-  if (cause.type === 'post-hidden') {
-    return {
-      name: 'Post Hidden by You',
-      description: 'You have hidden this post',
-    }
+export function getModerationCauseKey(cause: ModerationCause): string {
+  const source =
+    cause.source.type === 'labeler'
+      ? cause.source.did
+      : cause.source.type === 'list'
+      ? cause.source.list.uri
+      : 'user'
+  if (cause.type === 'label') {
+    return `label:${cause.label.val}:${source}`
   }
-  return cause.labelDef.strings[context].en
+  return `${cause.type}:${source}`
 }
 
-export function getProfileModerationCauses(
-  moderation: ProfileModeration,
-): ModerationCause[] {
-  /*
-  Gather everything on profile and account that blurs or alerts
-  */
-  return [
-    moderation.decisions.profile.cause,
-    ...moderation.decisions.profile.additionalCauses,
-    moderation.decisions.account.cause,
-    ...moderation.decisions.account.additionalCauses,
-  ].filter(cause => {
-    if (!cause) {
-      return false
-    }
-    if (cause?.type === 'label') {
-      if (
-        cause.labelDef.onwarn === 'blur' ||
-        cause.labelDef.onwarn === 'alert'
-      ) {
-        return true
-      } else {
-        return false
-      }
-    }
-    return true
-  }) as ModerationCause[]
+export function isJustAMute(modui: ModerationUI): boolean {
+  return modui.filters.length === 1 && modui.filters[0].type === 'muted'
 }
 
-export function isPostMediaBlurred(
-  decisions: PostModeration['decisions'],
-): boolean {
-  return decisions.post.blurMedia
+export function getLabelingServiceTitle({
+  displayName,
+  handle,
+}: {
+  displayName?: string
+  handle: string
+}) {
+  return displayName
+    ? sanitizeDisplayName(displayName)
+    : sanitizeHandle(handle, '@')
 }
 
-export function isQuoteBlurred(
-  decisions: PostModeration['decisions'],
-): boolean {
-  return (
-    decisions.quote?.blur ||
-    decisions.quote?.blurMedia ||
-    decisions.quote?.filter ||
-    decisions.quotedAccount?.blur ||
-    decisions.quotedAccount?.filter ||
-    false
-  )
+export function lookupLabelValueDefinition(
+  labelValue: string,
+  customDefs: InterpretedLabelValueDefinition[] | undefined,
+): InterpretedLabelValueDefinition | undefined {
+  let def
+  if (!labelValue.startsWith('!') && customDefs) {
+    def = customDefs.find(d => d.identifier === labelValue)
+  }
+  if (!def) {
+    def = LABELS[labelValue as keyof typeof LABELS]
+  }
+  return def
 }
 
-export function isCauseALabelOnUri(
-  cause: ModerationCause | undefined,
-  uri: string,
+export function isAppLabeler(
+  labeler:
+    | string
+    | AppBskyLabelerDefs.LabelerView
+    | AppBskyLabelerDefs.LabelerViewDetailed,
 ): boolean {
-  if (cause?.type !== 'label') {
-    return false
+  if (typeof labeler === 'string') {
+    return BskyAgent.appLabelers.includes(labeler)
   }
-  return cause.label.uri === uri
+  return BskyAgent.appLabelers.includes(labeler.creator.did)
 }
 
-export function getModerationCauseKey(cause: ModerationCause): string {
-  const source =
-    cause.source.type === 'labeler'
-      ? cause.source.labeler.did
-      : cause.source.type === 'list'
-      ? cause.source.list.uri
-      : 'user'
-  if (cause.type === 'label') {
-    return `label:${cause.label.val}:${source}`
+export function isLabelerSubscribed(
+  labeler:
+    | string
+    | AppBskyLabelerDefs.LabelerView
+    | AppBskyLabelerDefs.LabelerViewDetailed,
+  modOpts: ModerationOpts,
+) {
+  labeler = typeof labeler === 'string' ? labeler : labeler.creator.did
+  if (isAppLabeler(labeler)) {
+    return true
   }
-  return `${cause.type}:${source}`
+  return modOpts.prefs.labelers.find(l => l.did === labeler)
 }
diff --git a/src/lib/moderation/useGlobalLabelStrings.ts b/src/lib/moderation/useGlobalLabelStrings.ts
new file mode 100644
index 000000000..1c5a48231
--- /dev/null
+++ b/src/lib/moderation/useGlobalLabelStrings.ts
@@ -0,0 +1,52 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+
+export type GlobalLabelStrings = Record<
+  string,
+  {
+    name: string
+    description: string
+  }
+>
+
+export function useGlobalLabelStrings(): GlobalLabelStrings {
+  const {_} = useLingui()
+  return useMemo(
+    () => ({
+      '!hide': {
+        name: _(msg`Content Blocked`),
+        description: _(msg`This content has been hidden by the moderators.`),
+      },
+      '!warn': {
+        name: _(msg`Content Warning`),
+        description: _(
+          msg`This content has received a general warning from moderators.`,
+        ),
+      },
+      '!no-unauthenticated': {
+        name: _(msg`Sign-in Required`),
+        description: _(
+          msg`This user has requested that their content only be shown to signed-in users.`,
+        ),
+      },
+      porn: {
+        name: _(msg`Pornography`),
+        description: _(msg`Explicit sexual images.`),
+      },
+      sexual: {
+        name: _(msg`Sexually Suggestive`),
+        description: _(msg`Does not include nudity.`),
+      },
+      nudity: {
+        name: _(msg`Non-sexual Nudity`),
+        description: _(msg`E.g. artistic nudes.`),
+      },
+      'graphic-media': {
+        name: _(msg`Graphic Media`),
+        description: _(msg`Explicit or potentially disturbing media.`),
+      },
+    }),
+    [_],
+  )
+}
diff --git a/src/lib/moderation/useLabelBehaviorDescription.ts b/src/lib/moderation/useLabelBehaviorDescription.ts
new file mode 100644
index 000000000..0250c1bc8
--- /dev/null
+++ b/src/lib/moderation/useLabelBehaviorDescription.ts
@@ -0,0 +1,70 @@
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+export function useLabelBehaviorDescription(
+  labelValueDef: InterpretedLabelValueDefinition,
+  pref: LabelPreference,
+) {
+  const {_} = useLingui()
+  if (pref === 'ignore') {
+    return _(msg`Off`)
+  }
+  if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Warn`)
+  } else if (labelValueDef.severity === 'alert') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Warn`)
+  } else if (labelValueDef.severity === 'inform') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Show badge`)
+  } else {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Disabled`)
+  }
+}
+
+export function useLabelLongBehaviorDescription(
+  labelValueDef: InterpretedLabelValueDefinition,
+  pref: LabelPreference,
+) {
+  const {_} = useLingui()
+  if (pref === 'ignore') {
+    return _(msg`Disabled`)
+  }
+  if (labelValueDef.blurs === 'content') {
+    if (pref === 'hide') {
+      return _(msg`Warn content and filter from feeds`)
+    }
+    return _(msg`Warn content`)
+  } else if (labelValueDef.blurs === 'media') {
+    if (pref === 'hide') {
+      return _(msg`Blur images and filter from feeds`)
+    }
+    return _(msg`Blur images`)
+  } else if (labelValueDef.severity === 'alert') {
+    if (pref === 'hide') {
+      return _(msg`Show warning and filter from feeds`)
+    }
+    return _(msg`Show warning`)
+  } else if (labelValueDef.severity === 'inform') {
+    if (pref === 'hide') {
+      return _(msg`Show badge and filter from feeds`)
+    }
+    return _(msg`Show badge`)
+  } else {
+    if (pref === 'hide') {
+      return _(msg`Filter from feeds`)
+    }
+    return _(msg`Disabled`)
+  }
+}
diff --git a/src/lib/moderation/useLabelInfo.ts b/src/lib/moderation/useLabelInfo.ts
new file mode 100644
index 000000000..b1cffe1e7
--- /dev/null
+++ b/src/lib/moderation/useLabelInfo.ts
@@ -0,0 +1,100 @@
+import {
+  ComAtprotoLabelDefs,
+  AppBskyLabelerDefs,
+  LABELS,
+  interpretLabelValueDefinition,
+  InterpretedLabelValueDefinition,
+} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import * as bcp47Match from 'bcp-47-match'
+
+import {
+  GlobalLabelStrings,
+  useGlobalLabelStrings,
+} from '#/lib/moderation/useGlobalLabelStrings'
+import {useLabelDefinitions} from '#/state/preferences'
+
+export interface LabelInfo {
+  label: ComAtprotoLabelDefs.Label
+  def: InterpretedLabelValueDefinition
+  strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+}
+
+export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo {
+  const {i18n} = useLingui()
+  const {labelDefs, labelers} = useLabelDefinitions()
+  const globalLabelStrings = useGlobalLabelStrings()
+  const def = getDefinition(labelDefs, label)
+  return {
+    label,
+    def,
+    strings: getLabelStrings(i18n.locale, globalLabelStrings, def),
+    labeler: labelers.find(labeler => label.src === labeler.creator.did),
+  }
+}
+
+export function getDefinition(
+  labelDefs: Record<string, InterpretedLabelValueDefinition[]>,
+  label: ComAtprotoLabelDefs.Label,
+): InterpretedLabelValueDefinition {
+  // check local definitions
+  const customDef =
+    !label.val.startsWith('!') &&
+    labelDefs[label.src]?.find(
+      def => def.identifier === label.val && def.definedBy === label.src,
+    )
+  if (customDef) {
+    return customDef
+  }
+
+  // check global definitions
+  const globalDef = LABELS[label.val as keyof typeof LABELS]
+  if (globalDef) {
+    return globalDef
+  }
+
+  // fallback to a noop definition
+  return interpretLabelValueDefinition(
+    {
+      identifier: label.val,
+      severity: 'none',
+      blurs: 'none',
+      defaultSetting: 'ignore',
+      locales: [],
+    },
+    label.src,
+  )
+}
+
+export function getLabelStrings(
+  locale: string,
+  globalLabelStrings: GlobalLabelStrings,
+  def: InterpretedLabelValueDefinition,
+): ComAtprotoLabelDefs.LabelValueDefinitionStrings {
+  if (!def.definedBy) {
+    // global definition, look up strings
+    if (def.identifier in globalLabelStrings) {
+      return globalLabelStrings[
+        def.identifier
+      ] as ComAtprotoLabelDefs.LabelValueDefinitionStrings
+    }
+  } else {
+    // try to find locale match in the definition's strings
+    const localeMatch = def.locales.find(
+      strings => bcp47Match.basicFilter(locale, strings.lang).length > 0,
+    )
+    if (localeMatch) {
+      return localeMatch
+    }
+    // fall back to the zero item if no match
+    if (def.locales[0]) {
+      return def.locales[0]
+    }
+  }
+  return {
+    lang: locale,
+    name: def.identifier,
+    description: `Labeled "${def.identifier}"`,
+  }
+}
diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts
new file mode 100644
index 000000000..57b50d777
--- /dev/null
+++ b/src/lib/moderation/useModerationCauseDescription.ts
@@ -0,0 +1,150 @@
+import React from 'react'
+import {
+  BSKY_LABELER_DID,
+  ModerationCause,
+  ModerationCauseSource,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {getDefinition, getLabelStrings} from './useLabelInfo'
+import {useLabelDefinitions} from '#/state/preferences'
+import {useGlobalLabelStrings} from './useGlobalLabelStrings'
+
+import {Props as SVGIconProps} from '#/components/icons/common'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+
+export interface ModerationCauseDescription {
+  icon: React.ComponentType<SVGIconProps>
+  name: string
+  description: string
+  source?: string
+  sourceType?: ModerationCauseSource['type']
+}
+
+export function useModerationCauseDescription(
+  cause: ModerationCause | undefined,
+): ModerationCauseDescription {
+  const {_, i18n} = useLingui()
+  const {labelDefs, labelers} = useLabelDefinitions()
+  const globalLabelStrings = useGlobalLabelStrings()
+
+  return React.useMemo(() => {
+    if (!cause) {
+      return {
+        icon: Warning,
+        name: _(msg`Content Warning`),
+        description: _(
+          msg`Moderator has chosen to set a general warning on the content.`,
+        ),
+      }
+    }
+    if (cause.type === 'blocking') {
+      if (cause.source.type === 'list') {
+        return {
+          icon: CircleBanSign,
+          name: _(msg`User Blocked by "${cause.source.list.name}"`),
+          description: _(
+            msg`You have blocked this user. You cannot view their content.`,
+          ),
+        }
+      } else {
+        return {
+          icon: CircleBanSign,
+          name: _(msg`User Blocked`),
+          description: _(
+            msg`You have blocked this user. You cannot view their content.`,
+          ),
+        }
+      }
+    }
+    if (cause.type === 'blocked-by') {
+      return {
+        icon: CircleBanSign,
+        name: _(msg`User Blocking You`),
+        description: _(
+          msg`This user has blocked you. You cannot view their content.`,
+        ),
+      }
+    }
+    if (cause.type === 'block-other') {
+      return {
+        icon: CircleBanSign,
+        name: _(msg`Content Not Available`),
+        description: _(
+          msg`This content is not available because one of the users involved has blocked the other.`,
+        ),
+      }
+    }
+    if (cause.type === 'muted') {
+      if (cause.source.type === 'list') {
+        return {
+          icon: EyeSlash,
+          name: _(msg`Muted by "${cause.source.list.name}"`),
+          description: _(msg`You have muted this user`),
+        }
+      } else {
+        return {
+          icon: EyeSlash,
+          name: _(msg`Account Muted`),
+          description: _(msg`You have muted this account.`),
+        }
+      }
+    }
+    if (cause.type === 'mute-word') {
+      return {
+        icon: EyeSlash,
+        name: _(msg`Post Hidden by Muted Word`),
+        description: _(
+          msg`You've chosen to hide a word or tag within this post.`,
+        ),
+      }
+    }
+    if (cause.type === 'hidden') {
+      return {
+        icon: EyeSlash,
+        name: _(msg`Post Hidden by You`),
+        description: _(msg`You have hidden this post`),
+      }
+    }
+    if (cause.type === 'label') {
+      const def = cause.labelDef || getDefinition(labelDefs, cause.label)
+      const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
+      const labeler = labelers.find(l => l.creator.did === cause.label.src)
+      let source =
+        labeler?.creator.displayName ||
+        (labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined)
+      if (!source) {
+        if (cause.label.src === BSKY_LABELER_DID) {
+          source = 'Bluesky Moderation Service'
+        } else {
+          source = cause.label.src
+        }
+      }
+      if (def.identifier === 'porn' || def.identifier === 'sexual') {
+        strings.name = 'Adult Content'
+      }
+
+      return {
+        icon:
+          def.identifier === '!no-unauthenticated'
+            ? EyeSlash
+            : def.severity === 'alert'
+            ? Warning
+            : CircleInfo,
+        name: strings.name,
+        description: strings.description,
+        source,
+        sourceType: cause.source.type,
+      }
+    }
+    // should never happen
+    return {
+      icon: CircleInfo,
+      name: '',
+      description: ``,
+    }
+  }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
+}
diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts
new file mode 100644
index 000000000..e00170594
--- /dev/null
+++ b/src/lib/moderation/useReportOptions.ts
@@ -0,0 +1,94 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+import {ComAtprotoModerationDefs} from '@atproto/api'
+
+export interface ReportOption {
+  reason: string
+  title: string
+  description: string
+}
+
+interface ReportOptions {
+  account: ReportOption[]
+  post: ReportOption[]
+  list: ReportOption[]
+  feedgen: ReportOption[]
+  other: ReportOption[]
+}
+
+export function useReportOptions(): ReportOptions {
+  const {_} = useLingui()
+  return useMemo(() => {
+    const other = {
+      reason: ComAtprotoModerationDefs.REASONOTHER,
+      title: _(msg`Other`),
+      description: _(msg`An issue not included in these options`),
+    }
+    const common = [
+      {
+        reason: ComAtprotoModerationDefs.REASONRUDE,
+        title: _(msg`Anti-Social Behavior`),
+        description: _(msg`Harassment, trolling, or intolerance`),
+      },
+      {
+        reason: ComAtprotoModerationDefs.REASONVIOLATION,
+        title: _(msg`Illegal and Urgent`),
+        description: _(msg`Glaring violations of law or terms of service`),
+      },
+      other,
+    ]
+    return {
+      account: [
+        {
+          reason: ComAtprotoModerationDefs.REASONMISLEADING,
+          title: _(msg`Misleading Account`),
+          description: _(
+            msg`Impersonation or false claims about identity or affiliation`,
+          ),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Frequently Posts Unwanted Content`),
+          description: _(msg`Spam; excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        other,
+      ],
+      post: [
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Spam`),
+          description: _(msg`Excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSEXUAL,
+          title: _(msg`Unwanted Sexual Content`),
+          description: _(msg`Nudity or pornography not labeled as such`),
+        },
+        ...common,
+      ],
+      list: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      feedgen: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      other: common,
+    }
+  }, [_])
+}
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index 62d0bfc4b..0f628f428 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -1,12 +1,15 @@
+import {useEffect} from 'react'
 import * as Notifications from 'expo-notifications'
 import {QueryClient} from '@tanstack/react-query'
-import {resetToTab} from '../../Navigation'
-import {devicePlatform, isIOS} from 'platform/detection'
-import {track} from 'lib/analytics/analytics'
+
 import {logger} from '#/logger'
 import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
 import {truncateAndInvalidate} from '#/state/queries/util'
-import {SessionAccount, getAgent} from '#/state/session'
+import {getAgent, SessionAccount} from '#/state/session'
+import {track} from 'lib/analytics/analytics'
+import {devicePlatform, isIOS} from 'platform/detection'
+import {resetToTab} from '../../Navigation'
+import {logEvent} from '../statsig/statsig'
 
 const SERVICE_DID = (serviceUrl?: string) =>
   serviceUrl?.includes('staging')
@@ -79,52 +82,63 @@ export function registerTokenChangeHandler(
   }
 }
 
-export function init(queryClient: QueryClient) {
-  // handle notifications that are received, both in the foreground or background
-  // NOTE: currently just here for debug logging
-  Notifications.addNotificationReceivedListener(event => {
-    logger.debug(
-      'Notifications: received',
-      {event},
-      logger.DebugContext.notifications,
-    )
-    if (event.request.trigger.type === 'push') {
-      // handle payload-based deeplinks
-      let payload
-      if (isIOS) {
-        payload = event.request.trigger.payload
-      } else {
-        // TODO: handle android payload deeplink
+export function useNotificationsListener(queryClient: QueryClient) {
+  useEffect(() => {
+    // handle notifications that are received, both in the foreground or background
+    // NOTE: currently just here for debug logging
+    const sub1 = Notifications.addNotificationReceivedListener(event => {
+      logger.debug(
+        'Notifications: received',
+        {event},
+        logger.DebugContext.notifications,
+      )
+      if (event.request.trigger.type === 'push') {
+        // handle payload-based deeplinks
+        let payload
+        if (isIOS) {
+          payload = event.request.trigger.payload
+        } else {
+          // TODO: handle android payload deeplink
+        }
+        if (payload) {
+          logger.debug(
+            'Notifications: received payload',
+            payload,
+            logger.DebugContext.notifications,
+          )
+          // TODO: deeplink notif here
+        }
       }
-      if (payload) {
+    })
+
+    // handle notifications that are tapped on
+    const sub2 = Notifications.addNotificationResponseReceivedListener(
+      response => {
         logger.debug(
-          'Notifications: received payload',
-          payload,
+          'Notifications: response received',
+          {
+            actionIdentifier: response.actionIdentifier,
+          },
           logger.DebugContext.notifications,
         )
-        // TODO: deeplink notif here
-      }
-    }
-  })
-
-  // handle notifications that are tapped on
-  Notifications.addNotificationResponseReceivedListener(response => {
-    logger.debug(
-      'Notifications: response received',
-      {
-        actionIdentifier: response.actionIdentifier,
+        if (
+          response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
+        ) {
+          logger.debug(
+            'User pressed a notification, opening notifications tab',
+            {},
+            logger.DebugContext.notifications,
+          )
+          track('Notificatons:OpenApp')
+          logEvent('notifications:openApp', {})
+          truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
+          resetToTab('NotificationsTab') // open notifications tab
+        }
       },
-      logger.DebugContext.notifications,
     )
-    if (response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER) {
-      logger.debug(
-        'User pressed a notification, opening notifications tab',
-        {},
-        logger.DebugContext.notifications,
-      )
-      track('Notificatons:OpenApp')
-      truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
-      resetToTab('NotificationsTab') // open notifications tab
+    return () => {
+      sub1.remove()
+      sub2.remove()
     }
-  })
+  }, [queryClient])
 }
diff --git a/src/lib/react-query.ts b/src/lib/react-query.tsx
index 7fe3fe7a4..08b61ee20 100644
--- a/src/lib/react-query.ts
+++ b/src/lib/react-query.tsx
@@ -1,7 +1,18 @@
+import React from 'react'
 import {AppState, AppStateStatus} from 'react-native'
-import {QueryClient, focusManager} from '@tanstack/react-query'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
+import {focusManager, QueryClient} from '@tanstack/react-query'
+import {
+  PersistQueryClientProvider,
+  PersistQueryClientProviderProps,
+} from '@tanstack/react-query-persist-client'
+
 import {isNative} from '#/platform/detection'
 
+// any query keys in this array will be persisted to AsyncStorage
+const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
+
 focusManager.setEventListener(onFocus => {
   if (isNative) {
     const subscription = AppState.addEventListener(
@@ -28,7 +39,7 @@ focusManager.setEventListener(onFocus => {
   }
 })
 
-export const queryClient = new QueryClient({
+const queryClient = new QueryClient({
   defaultOptions: {
     queries: {
       // NOTE
@@ -48,3 +59,31 @@ export const queryClient = new QueryClient({
     },
   },
 })
+
+const asyncStoragePersister = createAsyncStoragePersister({
+  storage: AsyncStorage,
+  key: 'queryCache',
+})
+
+const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
+  {
+    shouldDehydrateMutation: (_: any) => false,
+    shouldDehydrateQuery: query => {
+      return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0]))
+    },
+  }
+
+const persistOptions = {
+  persister: asyncStoragePersister,
+  dehydrateOptions,
+}
+
+export function QueryProvider({children}: {children: React.ReactNode}) {
+  return (
+    <PersistQueryClientProvider
+      client={queryClient}
+      persistOptions={persistOptions}>
+      {children}
+    </PersistQueryClientProvider>
+  )
+}
diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts
index 538f30cd3..9dfdab909 100644
--- a/src/lib/routes/links.ts
+++ b/src/lib/routes/links.ts
@@ -25,3 +25,13 @@ export function makeCustomFeedLink(
 export function makeListLink(did: string, rkey: string, ...segments: string[]) {
   return [`/profile`, did, 'lists', rkey, ...segments].join('/')
 }
+
+export function makeTagLink(did: string) {
+  return `/search?q=${encodeURIComponent(did)}`
+}
+
+export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
+  return `/search?q=${encodeURIComponent(
+    props.query + (props.from ? ` from:${props.from}` : ''),
+  )}`
+}
diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts
index 00defaeda..8c8be3739 100644
--- a/src/lib/routes/router.ts
+++ b/src/lib/routes/router.ts
@@ -2,9 +2,15 @@ import {RouteParams, Route} from './types'
 
 export class Router {
   routes: [string, Route][] = []
-  constructor(description: Record<string, string>) {
+  constructor(description: Record<string, string | string[]>) {
     for (const [screen, pattern] of Object.entries(description)) {
-      this.routes.push([screen, createRoute(pattern)])
+      if (typeof pattern === 'string') {
+        this.routes.push([screen, createRoute(pattern)])
+      } else {
+        pattern.forEach(subPattern => {
+          this.routes.push([screen, createRoute(subPattern)])
+        })
+      }
     }
   }
 
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 90ae75830..95af2f237 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -21,7 +21,9 @@ export type CommonNavigatorParams = {
   PostRepostedBy: {name: string; rkey: string}
   ProfileFeed: {name: string; rkey: string}
   ProfileFeedLikedBy: {name: string; rkey: string}
+  ProfileLabelerLikedBy: {name: string}
   Debug: undefined
+  DebugMod: undefined
   Log: undefined
   Support: undefined
   PrivacyPolicy: undefined
@@ -30,9 +32,11 @@ export type CommonNavigatorParams = {
   CopyrightPolicy: undefined
   AppPasswords: undefined
   SavedFeeds: undefined
-  PreferencesHomeFeed: undefined
+  PreferencesFollowingFeed: undefined
   PreferencesThreads: undefined
   PreferencesExternalEmbeds: undefined
+  Search: {q?: string}
+  Hashtag: {tag: string; author?: string}
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -68,6 +72,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
   Search: {q?: string}
   Feeds: undefined
   Notifications: undefined
+  Hashtag: {tag: string; author?: string}
 }
 
 export type AllNavigatorParams = CommonNavigatorParams & {
@@ -80,6 +85,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   NotificationsTab: undefined
   Notifications: undefined
   MyProfileTab: undefined
+  Hashtag: {tag: string; author?: string}
 }
 
 // NOTE
diff --git a/src/lib/sharing.ts b/src/lib/sharing.ts
index b294d7464..9f402f873 100644
--- a/src/lib/sharing.ts
+++ b/src/lib/sharing.ts
@@ -12,9 +12,9 @@ import {Share} from 'react-native'
  */
 export async function shareUrl(url: string) {
   if (isAndroid) {
-    Share.share({message: url})
+    await Share.share({message: url})
   } else if (isIOS) {
-    Share.share({url})
+    await Share.share({url})
   } else {
     // React Native Share is not supported by web. Web Share API
     // has increasing but not full support, so default to clipboard
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
new file mode 100644
index 000000000..2de15b64e
--- /dev/null
+++ b/src/lib/statsig/events.ts
@@ -0,0 +1,97 @@
+export type LogEvents = {
+  // App events
+  init: {
+    initMs: number
+  }
+  'account:loggedIn': {
+    logContext: 'LoginForm' | 'SwitchAccount' | 'ChooseAccountForm' | 'Settings'
+    withPassword: boolean
+  }
+  'account:loggedOut': {
+    logContext: 'SwitchAccount' | 'Settings' | 'Deactivated'
+  }
+  'notifications:openApp': {}
+  'state:background': {
+    secondsActive: number
+  }
+  'state:foreground': {}
+  'router:navigate': {}
+
+  // Screen events
+  'splash:signInPressed': {}
+  'splash:createAccountPressed': {}
+  'signup:nextPressed': {
+    activeStep: number
+  }
+  'onboarding:interests:nextPressed': {
+    selectedInterests: string[]
+    selectedInterestsLength: number
+  }
+  'onboarding:suggestedAccounts:nextPressed': {
+    selectedAccountsLength: number
+    skipped: boolean
+  }
+  'onboarding:followingFeed:nextPressed': {}
+  'onboarding:algoFeeds:nextPressed': {
+    selectedPrimaryFeeds: string[]
+    selectedPrimaryFeedsLength: number
+    selectedSecondaryFeeds: string[]
+    selectedSecondaryFeedsLength: number
+  }
+  'onboarding:topicalFeeds:nextPressed': {
+    selectedFeeds: string[]
+    selectedFeedsLength: number
+  }
+  'onboarding:moderation:nextPressed': {}
+  'onboarding:finished:nextPressed': {}
+  'feed:endReached': {
+    feedType: string
+    itemCount: number
+  }
+  'feed:refresh': {
+    feedType: string
+    reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
+  }
+
+  // Data events
+  'account:create:begin': {}
+  'account:create:success': {}
+  'post:create': {
+    imageCount: number
+    isReply: boolean
+    hasLink: boolean
+    hasQuote: boolean
+    langs: string
+    logContext: 'Composer'
+  }
+  'post:like': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'post:repost': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'post:unlike': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'post:unrepost': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'profile:follow': {
+    logContext:
+      | 'RecommendedFollowsItem'
+      | 'PostThreadItem'
+      | 'ProfileCard'
+      | 'ProfileHeader'
+      | 'ProfileHeaderSuggestedFollows'
+      | 'ProfileMenu'
+  }
+  'profile:unfollow': {
+    logContext:
+      | 'RecommendedFollowsItem'
+      | 'PostThreadItem'
+      | 'ProfileCard'
+      | 'ProfileHeader'
+      | 'ProfileHeaderSuggestedFollows'
+      | 'ProfileMenu'
+  }
+}
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
new file mode 100644
index 000000000..68c63de61
--- /dev/null
+++ b/src/lib/statsig/statsig.tsx
@@ -0,0 +1,136 @@
+import React from 'react'
+import {Platform} from 'react-native'
+import {AppState, AppStateStatus} from 'react-native'
+import {sha256} from 'js-sha256'
+import {
+  Statsig,
+  StatsigProvider,
+  useGate as useStatsigGate,
+} from 'statsig-react-native-expo'
+
+import {logger} from '#/logger'
+import {useSession} from '../../state/session'
+import {LogEvents} from './events'
+
+export type {LogEvents}
+
+const statsigOptions = {
+  environment: {
+    tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
+  },
+  // Don't block on waiting for network. The fetched config will kick in on next load.
+  // This ensures the UI is always consistent and doesn't update mid-session.
+  // Note this makes cold load (no local storage) and private mode return `false` for all gates.
+  initTimeoutMs: 1,
+}
+
+type FlatJSONRecord = Record<
+  string,
+  | string
+  | number
+  | boolean
+  | null
+  | undefined
+  // Technically not scalar but Statsig will stringify it which works for us:
+  | string[]
+>
+
+let getCurrentRouteName: () => string | null | undefined = () => null
+
+export function attachRouteToLogEvents(
+  getRouteName: () => string | null | undefined,
+) {
+  getCurrentRouteName = getRouteName
+}
+
+export function logEvent<E extends keyof LogEvents>(
+  eventName: E & string,
+  rawMetadata: LogEvents[E] & FlatJSONRecord,
+) {
+  try {
+    const fullMetadata = {
+      ...rawMetadata,
+    } as Record<string, string> // Statsig typings are unnecessarily strict here.
+    fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
+    if (Statsig.initializeCalled()) {
+      Statsig.logEvent(eventName, null, fullMetadata)
+    }
+  } catch (e) {
+    // A log should never interrupt the calling code, whatever happens.
+    logger.error('Failed to log an event', {message: e})
+  }
+}
+
+export function useGate(gateName: string) {
+  const {isLoading, value} = useStatsigGate(gateName)
+  if (isLoading) {
+    // This should not happen because of waitForInitialization={true}.
+    console.error('Did not expected isLoading to ever be true.')
+  }
+  return value
+}
+
+function toStatsigUser(did: string | undefined) {
+  let userID: string | undefined
+  if (did) {
+    userID = sha256(did)
+  }
+  return {
+    userID,
+    platform: Platform.OS,
+  }
+}
+
+let lastState: AppStateStatus = AppState.currentState
+let lastActive = lastState === 'active' ? performance.now() : null
+AppState.addEventListener('change', (state: AppStateStatus) => {
+  if (state === lastState) {
+    return
+  }
+  lastState = state
+  if (state === 'active') {
+    lastActive = performance.now()
+    logEvent('state:foreground', {})
+  } else {
+    let secondsActive = 0
+    if (lastActive != null) {
+      secondsActive = Math.round((performance.now() - lastActive) / 1e3)
+    }
+    lastActive = null
+    logEvent('state:background', {
+      secondsActive,
+    })
+  }
+})
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const {currentAccount} = useSession()
+  const currentStatsigUser = React.useMemo(
+    () => toStatsigUser(currentAccount?.did),
+    [currentAccount?.did],
+  )
+
+  React.useEffect(() => {
+    function refresh() {
+      // Intentionally refetching the config using the JS SDK rather than React SDK
+      // so that the new config is stored in cache but isn't used during this session.
+      // It will kick in for the next reload.
+      Statsig.updateUser(currentStatsigUser)
+    }
+    const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
+    return () => clearInterval(id)
+  }, [currentStatsigUser])
+
+  return (
+    <StatsigProvider
+      sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
+      mountKey={currentStatsigUser.userID}
+      user={currentStatsigUser}
+      // This isn't really blocking due to short initTimeoutMs above.
+      // However, it ensures `isLoading` is always `false`.
+      waitForInitialization={true}
+      options={statsigOptions}>
+      {children}
+    </StatsigProvider>
+  )
+}
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index 75383dd4f..e0f23fa2c 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -1,5 +1,4 @@
 import {ModerationUI} from '@atproto/api'
-import {describeModerationCause} from '../moderation'
 
 // \u2705 = ✅
 // \u2713 = ✓
@@ -14,7 +13,7 @@ export function sanitizeDisplayName(
   moderation?: ModerationUI,
 ): string {
   if (moderation?.blur) {
-    return `⚠${describeModerationCause(moderation.cause, 'account').name}`
+    return ''
   }
   if (typeof str === 'string') {
     return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 21a575b91..ee7328478 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -2,6 +2,15 @@ import {Dimensions} from 'react-native'
 import {isWeb} from 'platform/detection'
 const {height: SCREEN_HEIGHT} = Dimensions.get('window')
 
+const IFRAME_HOST = isWeb
+  ? // @ts-ignore only for web
+    window.location.host === 'localhost:8100'
+    ? 'http://localhost:8100'
+    : 'https://bsky.app'
+  : __DEV__ && !process.env.JEST_WORKER_ID
+  ? 'http://localhost:8100'
+  : 'https://bsky.app'
+
 export const embedPlayerSources = [
   'youtube',
   'youtubeShorts',
@@ -74,7 +83,7 @@ export function parseEmbedPlayerFromUrl(
       return {
         type: 'youtube_video',
         source: 'youtube',
-        playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+        playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
       }
     }
   }
@@ -93,7 +102,7 @@ export function parseEmbedPlayerFromUrl(
         type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
         source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
         hideDetails: page === 'shorts' ? true : undefined,
-        playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+        playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
       }
     }
   }
@@ -343,45 +352,45 @@ export function parseEmbedPlayerFromUrl(
   }
 }
 
-export function getPlayerHeight({
+export function getPlayerAspect({
   type,
-  width,
   hasThumb,
+  width,
 }: {
   type: EmbedPlayerParams['type']
-  width: number
   hasThumb: boolean
-}) {
-  if (!hasThumb) return (width / 16) * 9
+  width: number
+}): {aspectRatio?: number; height?: number} {
+  if (!hasThumb) return {aspectRatio: 16 / 9}
 
   switch (type) {
     case 'youtube_video':
     case 'twitch_video':
     case 'vimeo_video':
-      return (width / 16) * 9
+      return {aspectRatio: 16 / 9}
     case 'youtube_short':
       if (SCREEN_HEIGHT < 600) {
-        return ((width / 9) * 16) / 1.75
+        return {aspectRatio: (9 / 16) * 1.75}
       } else {
-        return ((width / 9) * 16) / 1.5
+        return {aspectRatio: (9 / 16) * 1.5}
       }
     case 'spotify_album':
     case 'apple_music_album':
     case 'apple_music_playlist':
     case 'spotify_playlist':
     case 'soundcloud_set':
-      return 380
+      return {height: 380}
     case 'spotify_song':
       if (width <= 300) {
-        return 155
+        return {height: 155}
       }
-      return 232
+      return {height: 232}
     case 'soundcloud_track':
-      return 165
+      return {height: 165}
     case 'apple_music_song':
-      return 150
+      return {height: 150}
     default:
-      return width
+      return {aspectRatio: 16 / 9}
   }
 }
 
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
index 6ce462435..bc07b32ec 100644
--- a/src/lib/strings/handles.ts
+++ b/src/lib/strings/handles.ts
@@ -1,3 +1,8 @@
+// Regex from the go implementation
+// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
+const VALIDATE_REGEX =
+  /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
+
 export function makeValidHandle(str: string): string {
   if (str.length > 20) {
     str = str.slice(0, 20)
@@ -19,3 +24,29 @@ export function isInvalidHandle(handle: string): boolean {
 export function sanitizeHandle(handle: string, prefix = ''): string {
   return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
 }
+
+export interface IsValidHandle {
+  handleChars: boolean
+  hyphenStartOrEnd: boolean
+  frontLength: boolean
+  totalLength: boolean
+  overall: boolean
+}
+
+// More checks from https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/index.ts#L72
+export function validateHandle(str: string, userDomain: string): IsValidHandle {
+  const fullHandle = createFullHandle(str, userDomain)
+
+  const results = {
+    handleChars:
+      !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
+    hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
+    frontLength: str.length >= 3,
+    totalLength: fullHandle.length <= 253,
+  }
+
+  return {
+    ...results,
+    overall: !Object.values(results).includes(false),
+  }
+}
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
index e2abe9019..de4562d2c 100644
--- a/src/lib/strings/helpers.ts
+++ b/src/lib/strings/helpers.ts
@@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
   return base + 's'
 }
 
-export function enforceLen(str: string, len: number, ellipsis = false): string {
+export function enforceLen(
+  str: string,
+  len: number,
+  ellipsis = false,
+  mode: 'end' | 'middle' = 'end',
+): string {
   str = str || ''
   if (str.length > len) {
-    return str.slice(0, len) + (ellipsis ? '...' : '')
+    if (ellipsis) {
+      if (mode === 'end') {
+        return str.slice(0, len) + '…'
+      } else if (mode === 'middle') {
+        const half = Math.floor(len / 2)
+        return str.slice(0, half) + '…' + str.slice(-half)
+      } else {
+        // fallback
+        return str.slice(0, len)
+      }
+    } else {
+      return str.slice(0, len)
+    }
   }
   return str
 }
diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts
index 05a60e94b..3e162af1a 100644
--- a/src/lib/strings/time.ts
+++ b/src/lib/strings/time.ts
@@ -23,7 +23,7 @@ export function ago(date: number | string | Date): string {
   } else if (diffSeconds < DAY) {
     return `${Math.floor(diffSeconds / HOUR)}h`
   } else if (diffSeconds < MONTH) {
-    return `${Math.floor(diffSeconds / DAY)}d`
+    return `${Math.round(diffSeconds / DAY)}d`
   } else if (diffSeconds < YEAR) {
     return `${Math.floor(diffSeconds / MONTH)}mo`
   } else {
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 8a71718c8..70a2b7069 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -1,8 +1,27 @@
 import {AtUri} from '@atproto/api'
-import {PROD_SERVICE} from 'lib/constants'
+import {BSKY_SERVICE} from 'lib/constants'
 import TLDs from 'tlds'
 import psl from 'psl'
 
+export const BSKY_APP_HOST = 'https://bsky.app'
+const BSKY_TRUSTED_HOSTS = [
+  'bsky.app',
+  'bsky.social',
+  'blueskyweb.xyz',
+  'blueskyweb.zendesk.com',
+  ...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
+]
+
+/*
+ * This will allow any BSKY_TRUSTED_HOSTS value by itself or with a subdomain.
+ * It will also allow relative paths like /profile as well as #.
+ */
+const TRUSTED_REGEX = new RegExp(
+  `^(http(s)?://(([\\w-]+\\.)?${BSKY_TRUSTED_HOSTS.join(
+    '|([\\w-]+\\.)?',
+  )})|/|#)`,
+)
+
 export function isValidDomain(str: string): boolean {
   return !!TLDs.find(tld => {
     let i = str.lastIndexOf(tld)
@@ -28,7 +47,7 @@ export function makeRecordUri(
 export function toNiceDomain(url: string): string {
   try {
     const urlp = new URL(url)
-    if (`https://${urlp.host}` === PROD_SERVICE) {
+    if (`https://${urlp.host}` === BSKY_SERVICE) {
       return 'Bluesky Social'
     }
     return urlp.host ? urlp.host : url
@@ -67,8 +86,25 @@ export function isBskyAppUrl(url: string): boolean {
   return url.startsWith('https://bsky.app/')
 }
 
+export function isRelativeUrl(url: string): boolean {
+  return /^\/[^/]/.test(url)
+}
+
+export function isBskyRSSUrl(url: string): boolean {
+  return (
+    (url.startsWith('https://bsky.app/') || isRelativeUrl(url)) &&
+    /\/rss\/?$/.test(url)
+  )
+}
+
 export function isExternalUrl(url: string): boolean {
-  return !isBskyAppUrl(url) && url.startsWith('http')
+  const external = !isBskyAppUrl(url) && url.startsWith('http')
+  const rss = isBskyRSSUrl(url)
+  return external || rss
+}
+
+export function isTrustedUrl(url: string): boolean {
+  return TRUSTED_REGEX.test(url)
 }
 
 export function isBskyPostUrl(url: string): boolean {
@@ -148,6 +184,11 @@ export function feedUriToHref(url: string): string {
 export function linkRequiresWarning(uri: string, label: string) {
   const labelDomain = labelToDomain(label)
 
+  // We should trust any relative URL or a # since we know it links to internal content
+  if (isRelativeUrl(uri) || uri === '#') {
+    return false
+  }
+
   let urip
   try {
     urip = new URL(uri)
@@ -156,21 +197,11 @@ export function linkRequiresWarning(uri: string, label: string) {
   }
 
   const host = urip.hostname.toLowerCase()
-
-  if (host === 'bsky.app') {
-    // if this is a link to internal content,
-    // warn if it represents itself as a URL to another app
-    if (
-      labelDomain &&
-      labelDomain !== 'bsky.app' &&
-      isPossiblyAUrl(labelDomain)
-    ) {
-      return true
-    }
-    return false
+  if (isTrustedUrl(uri)) {
+    // if this is a link to internal content, warn if it represents itself as a URL to another app
+    return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
   } else {
-    // if this is a link to external content,
-    // warn if the label doesnt match the target
+    // if this is a link to external content, warn if the label doesnt match the target
     if (!labelDomain) {
       return true
     }
@@ -220,3 +251,8 @@ export function splitApexDomain(hostname: string): [string, string] {
     hostnamep.domain,
   ]
 }
+
+export function createBskyAppAbsoluteUrl(path: string): string {
+  const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
+  return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
+}
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index 9a3880b92..6fada40a7 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -9,7 +9,7 @@ export const defaultTheme: Theme = {
   palette: {
     default: {
       background: lightPalette.white,
-      backgroundLight: lightPalette.contrast_50,
+      backgroundLight: lightPalette.contrast_25,
       text: lightPalette.black,
       textLight: lightPalette.contrast_700,
       textInverted: lightPalette.white,
@@ -306,7 +306,7 @@ export const darkTheme: Theme = {
 
       // non-standard
       textVeryLight: darkPalette.contrast_400,
-      replyLine: darkPalette.contrast_100,
+      replyLine: darkPalette.contrast_200,
       replyLineDot: darkPalette.contrast_200,
       unreadNotifBg: darkPalette.primary_975,
       unreadNotifBorder: darkPalette.primary_900,
@@ -344,6 +344,25 @@ export const dimTheme: Theme = {
     default: {
       ...darkTheme.palette.default,
       background: dimPalette.black,
+      backgroundLight: dimPalette.contrast_50,
+      text: dimPalette.white,
+      textLight: dimPalette.contrast_700,
+      textInverted: dimPalette.black,
+      link: dimPalette.primary_500,
+      border: dimPalette.contrast_100,
+      borderDark: dimPalette.contrast_200,
+      icon: dimPalette.contrast_500,
+
+      // non-standard
+      textVeryLight: dimPalette.contrast_400,
+      replyLine: dimPalette.contrast_200,
+      replyLineDot: dimPalette.contrast_200,
+      unreadNotifBg: dimPalette.primary_975,
+      unreadNotifBorder: dimPalette.primary_900,
+      postCtrl: dimPalette.contrast_500,
+      brandText: dimPalette.primary_500,
+      emptyStateIcon: dimPalette.contrast_300,
+      borderLinkHover: dimPalette.contrast_300,
     },
   },
 }