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/feed/custom.ts27
-rw-r--r--src/lib/api/feed/home.ts89
-rw-r--r--src/lib/api/feed/merge.ts17
-rw-r--r--src/lib/batchedUpdates.web.ts1
-rw-r--r--src/lib/country-codes.ts256
-rw-r--r--src/lib/hooks/useWebBodyScrollLock.ts28
-rw-r--r--src/lib/hooks/useWebScrollRestoration.native.ts3
-rw-r--r--src/lib/hooks/useWebScrollRestoration.ts52
-rw-r--r--src/lib/link-meta/bsky.ts13
-rw-r--r--src/lib/strings/embed-player.ts6
-rw-r--r--src/lib/strings/rich-text-helpers.ts4
-rw-r--r--src/lib/styles.ts4
-rw-r--r--src/lib/themes.ts110
13 files changed, 530 insertions, 80 deletions
diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts
index 94cbff130..41c5367e5 100644
--- a/src/lib/api/feed/custom.ts
+++ b/src/lib/api/feed/custom.ts
@@ -4,15 +4,20 @@ import {
 } from '@atproto/api'
 import {FeedAPI, FeedAPIResponse} from './types'
 import {getAgent} from '#/state/session'
+import {getContentLanguages} from '#/state/preferences/languages'
 
 export class CustomFeedAPI implements FeedAPI {
   constructor(public params: GetCustomFeed.QueryParams) {}
 
   async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
-    const res = await getAgent().app.bsky.feed.getFeed({
-      ...this.params,
-      limit: 1,
-    })
+    const contentLangs = getContentLanguages().join(',')
+    const res = await getAgent().app.bsky.feed.getFeed(
+      {
+        ...this.params,
+        limit: 1,
+      },
+      {headers: {'Accept-Language': contentLangs}},
+    )
     return res.data.feed[0]
   }
 
@@ -23,11 +28,15 @@ export class CustomFeedAPI implements FeedAPI {
     cursor: string | undefined
     limit: number
   }): Promise<FeedAPIResponse> {
-    const res = await getAgent().app.bsky.feed.getFeed({
-      ...this.params,
-      cursor,
-      limit,
-    })
+    const contentLangs = getContentLanguages().join(',')
+    const res = await getAgent().app.bsky.feed.getFeed(
+      {
+        ...this.params,
+        cursor,
+        limit,
+      },
+      {headers: {'Accept-Language': contentLangs}},
+    )
     if (res.success) {
       // NOTE
       // some custom feeds fail to enforce the pagination limit
diff --git a/src/lib/api/feed/home.ts b/src/lib/api/feed/home.ts
new file mode 100644
index 000000000..436a66d07
--- /dev/null
+++ b/src/lib/api/feed/home.ts
@@ -0,0 +1,89 @@
+import {AppBskyFeedDefs} from '@atproto/api'
+import {FeedAPI, FeedAPIResponse} from './types'
+import {FollowingFeedAPI} from './following'
+import {CustomFeedAPI} from './custom'
+import {PROD_DEFAULT_FEED} from '#/lib/constants'
+
+// HACK
+// the feed API does not include any facilities for passing down
+// non-post elements. adding that is a bit of a heavy lift, and we
+// have just one temporary usecase for it: flagging when the home feed
+// falls back to discover.
+// we use this fallback marker post to drive this instead. see Feed.tsx
+// for the usage.
+// -prf
+export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {
+  post: {
+    uri: 'fallback-marker-post',
+    cid: 'fake',
+    record: {},
+    author: {
+      did: 'did:fake',
+      handle: 'fake.com',
+    },
+    indexedAt: new Date().toISOString(),
+  },
+}
+
+export class HomeFeedAPI implements FeedAPI {
+  following: FollowingFeedAPI
+  discover: CustomFeedAPI
+  usingDiscover = false
+  itemCursor = 0
+
+  constructor() {
+    this.following = new FollowingFeedAPI()
+    this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
+  }
+
+  reset() {
+    this.following = new FollowingFeedAPI()
+    this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
+    this.usingDiscover = false
+    this.itemCursor = 0
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    if (this.usingDiscover) {
+      return this.discover.peekLatest()
+    }
+    return this.following.peekLatest()
+  }
+
+  async fetch({
+    cursor,
+    limit,
+  }: {
+    cursor: string | undefined
+    limit: number
+  }): Promise<FeedAPIResponse> {
+    if (!cursor) {
+      this.reset()
+    }
+
+    let returnCursor
+    let posts: AppBskyFeedDefs.FeedViewPost[] = []
+
+    if (!this.usingDiscover) {
+      const res = await this.following.fetch({cursor, limit})
+      returnCursor = res.cursor
+      posts = posts.concat(res.feed)
+      if (!returnCursor) {
+        cursor = ''
+        posts.push(FALLBACK_MARKER_POST)
+        this.usingDiscover = true
+      }
+    }
+
+    if (this.usingDiscover) {
+      const res = await this.discover.fetch({cursor, limit})
+      returnCursor = res.cursor
+      posts = posts.concat(res.feed)
+    }
+
+    return {
+      cursor: returnCursor,
+      feed: posts,
+    }
+  }
+}
diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts
index 2314e2b95..28bf143cb 100644
--- a/src/lib/api/feed/merge.ts
+++ b/src/lib/api/feed/merge.ts
@@ -8,6 +8,7 @@ import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
 import {FeedParams} from '#/state/queries/post-feed'
 import {FeedTunerFn} from '../feed-manip'
 import {getAgent} from '#/state/session'
+import {getContentLanguages} from '#/state/preferences/languages'
 
 const REQUEST_WAIT_MS = 500 // 500ms
 const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -25,7 +26,7 @@ export class MergeFeedAPI implements FeedAPI {
 
   reset() {
     this.following = new MergeFeedSource_Following(this.feedTuners)
-    this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
+    this.customFeeds = []
     this.feedCursor = 0
     this.itemCursor = 0
     this.sampleCursor = 0
@@ -231,11 +232,15 @@ class MergeFeedSource_Custom extends MergeFeedSource {
     limit: number,
   ): Promise<AppBskyFeedGetTimeline.Response> {
     try {
-      const res = await getAgent().app.bsky.feed.getFeed({
-        cursor,
-        limit,
-        feed: this.feedUri,
-      })
+      const contentLangs = getContentLanguages().join(',')
+      const res = await getAgent().app.bsky.feed.getFeed(
+        {
+          cursor,
+          limit,
+          feed: this.feedUri,
+        },
+        {headers: {'Accept-Language': contentLangs}},
+      )
       // NOTE
       // some custom feeds fail to enforce the pagination limit
       // so we manually truncate here
diff --git a/src/lib/batchedUpdates.web.ts b/src/lib/batchedUpdates.web.ts
index 03147ed67..ba82549b5 100644
--- a/src/lib/batchedUpdates.web.ts
+++ b/src/lib/batchedUpdates.web.ts
@@ -1,2 +1 @@
-// @ts-ignore
 export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
diff --git a/src/lib/country-codes.ts b/src/lib/country-codes.ts
new file mode 100644
index 000000000..ae0152876
--- /dev/null
+++ b/src/lib/country-codes.ts
@@ -0,0 +1,256 @@
+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, Province of China (+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/useWebBodyScrollLock.ts b/src/lib/hooks/useWebBodyScrollLock.ts
new file mode 100644
index 000000000..585f193f1
--- /dev/null
+++ b/src/lib/hooks/useWebBodyScrollLock.ts
@@ -0,0 +1,28 @@
+import {useEffect} from 'react'
+import {isWeb} from '#/platform/detection'
+
+let refCount = 0
+
+function incrementRefCount() {
+  if (refCount === 0) {
+    document.body.style.overflow = 'hidden'
+  }
+  refCount++
+}
+
+function decrementRefCount() {
+  refCount--
+  if (refCount === 0) {
+    document.body.style.overflow = ''
+  }
+}
+
+export function useWebBodyScrollLock(isLockActive: boolean) {
+  useEffect(() => {
+    if (!isWeb || !isLockActive) {
+      return
+    }
+    incrementRefCount()
+    return () => decrementRefCount()
+  })
+}
diff --git a/src/lib/hooks/useWebScrollRestoration.native.ts b/src/lib/hooks/useWebScrollRestoration.native.ts
new file mode 100644
index 000000000..c7d96607f
--- /dev/null
+++ b/src/lib/hooks/useWebScrollRestoration.native.ts
@@ -0,0 +1,3 @@
+export function useWebScrollRestoration() {
+  return undefined
+}
diff --git a/src/lib/hooks/useWebScrollRestoration.ts b/src/lib/hooks/useWebScrollRestoration.ts
new file mode 100644
index 000000000..f68fbf0f2
--- /dev/null
+++ b/src/lib/hooks/useWebScrollRestoration.ts
@@ -0,0 +1,52 @@
+import {useMemo, useState, useEffect} from 'react'
+import {EventArg, useNavigation} from '@react-navigation/core'
+
+if ('scrollRestoration' in history) {
+  // Tell the brower not to mess with the scroll.
+  // We're doing that manually below.
+  history.scrollRestoration = 'manual'
+}
+
+function createInitialScrollState() {
+  return {
+    scrollYs: new Map(),
+    focusedKey: null as string | null,
+  }
+}
+
+export function useWebScrollRestoration() {
+  const [state] = useState(createInitialScrollState)
+  const navigation = useNavigation()
+
+  useEffect(() => {
+    function onDispatch() {
+      if (state.focusedKey) {
+        // Remember where we were for later.
+        state.scrollYs.set(state.focusedKey, window.scrollY)
+        // TODO: Strictly speaking, this is a leak. We never clean up.
+        // This is because I'm not sure when it's appropriate to clean it up.
+        // It doesn't seem like popstate is enough because it can still Forward-Back again.
+        // Maybe we should use sessionStorage. Or check what Next.js is doing?
+      }
+    }
+    // We want to intercept any push/pop/replace *before* the re-render.
+    // There is no official way to do this yet, but this works okay for now.
+    // https://twitter.com/satya164/status/1737301243519725803
+    navigation.addListener('__unsafe_action__' as any, onDispatch)
+    return () => {
+      navigation.removeListener('__unsafe_action__' as any, onDispatch)
+    }
+  }, [state, navigation])
+
+  const screenListeners = useMemo(
+    () => ({
+      focus(e: EventArg<'focus', boolean | undefined, unknown>) {
+        const scrollY = state.scrollYs.get(e.target) ?? 0
+        window.scrollTo(0, scrollY)
+        state.focusedKey = e.target ?? null
+      },
+    }),
+    [state],
+  )
+  return screenListeners
+}
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index 322b02332..c1fbb34b3 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -5,6 +5,7 @@ import {LikelyType, LinkMeta} from './link-meta'
 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
 import {ComposerOptsQuote} from 'state/shell/composer'
 import {useGetPost} from '#/state/queries/post'
+import {useFetchDid} from '#/state/queries/handle'
 
 // TODO
 // import {Home} from 'view/screens/Home'
@@ -120,11 +121,13 @@ export async function getPostAsQuote(
 
 export async function getFeedAsEmbed(
   agent: BskyAgent,
+  fetchDid: ReturnType<typeof useFetchDid>,
   url: string,
 ): Promise<apilib.ExternalEmbedDraft> {
   url = convertBskyAppUrlIfNeeded(url)
-  const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
-  const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
+  const [_0, handleOrDid, _1, rkey] = url.split('/').filter(Boolean)
+  const did = await fetchDid(handleOrDid)
+  const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
   const res = await agent.app.bsky.feed.getFeedGenerator({feed})
   return {
     isLoading: false,
@@ -146,11 +149,13 @@ export async function getFeedAsEmbed(
 
 export async function getListAsEmbed(
   agent: BskyAgent,
+  fetchDid: ReturnType<typeof useFetchDid>,
   url: string,
 ): Promise<apilib.ExternalEmbedDraft> {
   url = convertBskyAppUrlIfNeeded(url)
-  const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
-  const list = makeRecordUri(user, 'app.bsky.graph.list', rkey)
+  const [_0, handleOrDid, _1, rkey] = url.split('/').filter(Boolean)
+  const did = await fetchDid(handleOrDid)
+  const list = makeRecordUri(did, 'app.bsky.graph.list', rkey)
   const res = await agent.app.bsky.graph.getList({list})
   return {
     isLoading: false,
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 0f97eb080..3270b6f07 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -68,11 +68,12 @@ export function parseEmbedPlayerFromUrl(
   // youtube
   if (urlp.hostname === 'youtu.be') {
     const videoId = urlp.pathname.split('/')[1]
+    const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
     if (videoId) {
       return {
         type: 'youtube_video',
         source: 'youtube',
-        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
+        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1&start=${seek}`,
       }
     }
   }
@@ -84,13 +85,14 @@ export function parseEmbedPlayerFromUrl(
     const [_, page, shortVideoId] = urlp.pathname.split('/')
     const videoId =
       page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
+    const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
 
     if (videoId) {
       return {
         type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
         source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
         hideDetails: page === 'shorts' ? true : undefined,
-        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
+        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1&start=${seek}`,
       }
     }
   }
diff --git a/src/lib/strings/rich-text-helpers.ts b/src/lib/strings/rich-text-helpers.ts
index 08971ca03..662004599 100644
--- a/src/lib/strings/rich-text-helpers.ts
+++ b/src/lib/strings/rich-text-helpers.ts
@@ -1,7 +1,7 @@
 import {AppBskyRichtextFacet, RichText} from '@atproto/api'
 import {linkRequiresWarning} from './url-helpers'
 
-export function richTextToString(rt: RichText): string {
+export function richTextToString(rt: RichText, loose: boolean): string {
   const {text, facets} = rt
 
   if (!facets?.length) {
@@ -19,7 +19,7 @@ export function richTextToString(rt: RichText): string {
 
       const requiresWarning = linkRequiresWarning(href, text)
 
-      result += !requiresWarning ? href : `[${text}](${href})`
+      result += !requiresWarning ? href : loose ? `[${text}](${href})` : text
     } else {
       result += segment.text
     }
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index 5a10fea86..df9b49260 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -1,6 +1,6 @@
 import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
 import {Theme, TypographyVariant} from './ThemeContext'
-import {isMobileWeb} from 'platform/detection'
+import {isWeb} from 'platform/detection'
 
 // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
 export const colors = {
@@ -175,7 +175,7 @@ export const s = StyleSheet.create({
   // dimensions
   w100pct: {width: '100%'},
   h100pct: {height: '100%'},
-  hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'},
+  hContentRegion: isWeb ? {minHeight: '100%'} : {height: '100%'},
   window: {
     width: Dimensions.get('window').width,
     height: Dimensions.get('window').height,
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index ad7574db6..2d4515c77 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -2,30 +2,32 @@ import {Platform} from 'react-native'
 import type {Theme} from './ThemeContext'
 import {colors} from './styles'
 
+import {darkPalette, lightPalette} from '#/alf/themes'
+
 export const defaultTheme: Theme = {
   colorScheme: 'light',
   palette: {
     default: {
-      background: colors.white,
-      backgroundLight: colors.gray1,
-      text: colors.black,
-      textLight: colors.gray5,
-      textInverted: colors.white,
-      link: colors.blue3,
-      border: '#f0e9e9',
-      borderDark: '#e0d9d9',
-      icon: colors.gray4,
+      background: lightPalette.white,
+      backgroundLight: lightPalette.contrast_50,
+      text: lightPalette.black,
+      textLight: lightPalette.contrast_700,
+      textInverted: lightPalette.white,
+      link: lightPalette.primary_500,
+      border: lightPalette.contrast_100,
+      borderDark: lightPalette.contrast_200,
+      icon: lightPalette.contrast_500,
 
       // non-standard
-      textVeryLight: colors.gray4,
-      replyLine: colors.gray2,
-      replyLineDot: colors.gray3,
-      unreadNotifBg: '#ebf6ff',
-      unreadNotifBorder: colors.blue1,
-      postCtrl: '#71768A',
-      brandText: '#0066FF',
-      emptyStateIcon: '#B6B6C9',
-      borderLinkHover: '#cac1c1',
+      textVeryLight: lightPalette.contrast_400,
+      replyLine: lightPalette.contrast_100,
+      replyLineDot: lightPalette.contrast_200,
+      unreadNotifBg: lightPalette.primary_25,
+      unreadNotifBorder: lightPalette.primary_100,
+      postCtrl: lightPalette.contrast_500,
+      brandText: lightPalette.primary_500,
+      emptyStateIcon: lightPalette.contrast_300,
+      borderLinkHover: lightPalette.contrast_300,
     },
     primary: {
       background: colors.blue3,
@@ -50,15 +52,15 @@ export const defaultTheme: Theme = {
       icon: colors.green4,
     },
     inverted: {
-      background: colors.black,
-      backgroundLight: colors.gray6,
-      text: colors.white,
-      textLight: colors.gray3,
-      textInverted: colors.black,
-      link: colors.blue2,
-      border: colors.gray3,
-      borderDark: colors.gray2,
-      icon: colors.gray5,
+      background: darkPalette.black,
+      backgroundLight: darkPalette.contrast_50,
+      text: darkPalette.white,
+      textLight: darkPalette.contrast_700,
+      textInverted: darkPalette.black,
+      link: darkPalette.primary_500,
+      border: darkPalette.contrast_100,
+      borderDark: darkPalette.contrast_200,
+      icon: darkPalette.contrast_500,
     },
     error: {
       background: colors.red3,
@@ -292,26 +294,26 @@ export const darkTheme: Theme = {
   palette: {
     ...defaultTheme.palette,
     default: {
-      background: colors.black,
-      backgroundLight: colors.gray7,
-      text: colors.white,
-      textLight: colors.gray3,
-      textInverted: colors.black,
-      link: colors.blue3,
-      border: colors.gray7,
-      borderDark: colors.gray6,
-      icon: colors.gray4,
+      background: darkPalette.black,
+      backgroundLight: darkPalette.contrast_50,
+      text: darkPalette.white,
+      textLight: darkPalette.contrast_700,
+      textInverted: darkPalette.black,
+      link: darkPalette.primary_500,
+      border: darkPalette.contrast_100,
+      borderDark: darkPalette.contrast_200,
+      icon: darkPalette.contrast_500,
 
       // non-standard
-      textVeryLight: colors.gray4,
-      replyLine: colors.gray5,
-      replyLineDot: colors.gray6,
-      unreadNotifBg: colors.blue7,
-      unreadNotifBorder: colors.blue6,
-      postCtrl: '#707489',
-      brandText: '#0085ff',
-      emptyStateIcon: colors.gray4,
-      borderLinkHover: colors.gray5,
+      textVeryLight: darkPalette.contrast_400,
+      replyLine: darkPalette.contrast_100,
+      replyLineDot: darkPalette.contrast_200,
+      unreadNotifBg: darkPalette.primary_975,
+      unreadNotifBorder: darkPalette.primary_900,
+      postCtrl: darkPalette.contrast_500,
+      brandText: darkPalette.primary_500,
+      emptyStateIcon: darkPalette.contrast_300,
+      borderLinkHover: darkPalette.contrast_300,
     },
     primary: {
       ...defaultTheme.palette.primary,
@@ -322,15 +324,15 @@ export const darkTheme: Theme = {
       textInverted: colors.green2,
     },
     inverted: {
-      background: colors.white,
-      backgroundLight: colors.gray2,
-      text: colors.black,
-      textLight: colors.gray5,
-      textInverted: colors.white,
-      link: colors.blue3,
-      border: colors.gray3,
-      borderDark: colors.gray4,
-      icon: colors.gray1,
+      background: lightPalette.white,
+      backgroundLight: lightPalette.contrast_50,
+      text: lightPalette.black,
+      textLight: lightPalette.contrast_700,
+      textInverted: lightPalette.white,
+      link: lightPalette.primary_500,
+      border: lightPalette.contrast_100,
+      borderDark: lightPalette.contrast_200,
+      icon: lightPalette.contrast_500,
     },
   },
 }