diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
index 0da9551e30..f226de992b 100644
--- a/__tests__/lib/string.test.ts
+++ b/__tests__/lib/string.test.ts
@@ -340,12 +340,14 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://youtube.com/watch?v=videoId',
'https://youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/shorts/videoId',
+ 'https://youtube.com/live/videoId',
'https://m.youtube.com/watch?v=videoId',
'https://music.youtube.com/watch?v=videoId',
'https://youtube.com/shorts/',
'https://youtube.com/',
'https://youtube.com/random',
+ 'https://youtube.com/live/',
'https://twitch.tv/channelName',
'https://www.twitch.tv/channelName',
@@ -475,10 +477,16 @@ describe('parseEmbedPlayerFromUrl', () => {
source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
},
+ {
+ type: 'youtube_video',
+ source: 'youtube',
+ playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
+ },
undefined,
undefined,
undefined,
+ undefined,
{
type: 'twitch_video',
diff --git a/bskyembed/.eslintrc b/bskyembed/.eslintrc
index e6e575a11c..2b290d5815 100644
--- a/bskyembed/.eslintrc
+++ b/bskyembed/.eslintrc
@@ -10,11 +10,12 @@
],
"rules": {
"simple-import-sort/imports": "warn",
- "simple-import-sort/exports": "warn"
+ "simple-import-sort/exports": "warn",
+ 'no-else-return': 'off'
},
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest",
"project": "./bskyembed/tsconfig.json"
}
-}
\ No newline at end of file
+}
diff --git a/bskyembed/assets/starterPack.svg b/bskyembed/assets/starterPack.svg
new file mode 100644
index 0000000000..eb8dd710fe
--- /dev/null
+++ b/bskyembed/assets/starterPack.svg
@@ -0,0 +1 @@
+
diff --git a/bskyembed/package.json b/bskyembed/package.json
index f610e8c064..cb9a46213b 100644
--- a/bskyembed/package.json
+++ b/bskyembed/package.json
@@ -9,7 +9,7 @@
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
},
"dependencies": {
- "@atproto/api": "^0.12.2",
+ "@atproto/api": "0.13.1",
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8",
diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx
index 1dadfee38e..600c7c2c3a 100644
--- a/bskyembed/src/components/embed.tsx
+++ b/bskyembed/src/components/embed.tsx
@@ -6,12 +6,15 @@ import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphDefs,
+ AppBskyGraphStarterpack,
AppBskyLabelerDefs,
+ AtUri,
} from '@atproto/api'
import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
+import starterPackIcon from '../../assets/starterPack.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils'
import {Link} from './link'
@@ -105,7 +108,7 @@ export function Embed({
// Case 3.2: List
if (AppBskyGraphDefs.isListView(record)) {
return (
-
- )
+ // Embed type does not exist in the app, so show nothing
+ return null
}
- // Case 3.5: Post not found
+ // Case 3.5: Starter pack
+ if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
+ return
+ }
+
+ // Case 3.6: Post not found
if (AppBskyEmbedRecord.isViewNotFound(record)) {
return Quoted post not found, it may have been deleted.
}
- // Case 3.6: Post blocked
+ // Case 3.7: Post blocked
if (AppBskyEmbedRecord.isViewBlocked(record)) {
return The quoted post is blocked.
}
- throw new Error('Unknown embed type')
+ // Unknown embed type
+ return null
}
// Case 4: Record with media
@@ -182,7 +184,8 @@ export function Embed({
)
}
- throw new Error('Unsupported embed type')
+ // Unknown embed type
+ return null
} catch (err) {
return (
{err instanceof Error ? err.message : 'An error occurred'}
@@ -314,7 +317,7 @@ function ExternalEmbed({
)
}
-function GenericWithImage({
+function GenericWithImageEmbed({
title,
subtitle,
href,
@@ -350,3 +353,60 @@ function GenericWithImage({
)
}
+
+function StarterPackEmbed({
+ content,
+}: {
+ content: AppBskyGraphDefs.StarterPackViewBasic
+}) {
+ if (!AppBskyGraphStarterpack.isRecord(content.record)) {
+ return null
+ }
+
+ const starterPackHref = getStarterPackHref(content)
+ const imageUri = getStarterPackImage(content)
+
+ return (
+
+
+
+
+

+
+
+ {content.record.name}
+
+
+ Starter pack by{' '}
+ {content.creator.displayName || `@${content.creator.handle}`}
+
+
+
+ {content.record.description && (
+
{content.record.description}
+ )}
+ {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && (
+
+ {content.joinedAllTimeCount} users have joined!
+
+ )}
+
+
+ )
+}
+
+// from #/lib/strings/starter-pack.ts
+function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) {
+ const rkey = new AtUri(starterPack.uri).rkey
+ return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`
+}
+
+function getStarterPackHref(
+ starterPack: AppBskyGraphDefs.StarterPackViewBasic,
+) {
+ const rkey = new AtUri(starterPack.uri).rkey
+ const handleOrDid = starterPack.creator.handle || starterPack.creator.did
+ return `/starter-pack/${handleOrDid}/${rkey}`
+}
diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx
index d23c84cbfb..1d1e8f4d81 100644
--- a/bskyembed/src/components/post.tsx
+++ b/bskyembed/src/components/post.tsx
@@ -132,7 +132,10 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
key={counter}
href={segment.link.uri}
className="text-blue-400 hover:underline"
- disableTracking={!segment.link.uri.startsWith('https://bsky.app')}>
+ disableTracking={
+ !segment.link.uri.startsWith('https://bsky.app') &&
+ !segment.link.uri.startsWith('https://go.bsky.app')
+ }>
{segment.text}
,
)
diff --git a/bskyembed/src/screens/post.tsx b/bskyembed/src/screens/post.tsx
index 337bf01007..6ccf10a791 100644
--- a/bskyembed/src/screens/post.tsx
+++ b/bskyembed/src/screens/post.tsx
@@ -1,6 +1,6 @@
import '../index.css'
-import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
+import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
import {h, render} from 'preact'
import logo from '../../assets/logo.svg'
@@ -12,7 +12,7 @@ import {getRkey} from '../utils'
const root = document.getElementById('app')
if (!root) throw new Error('No root element')
-const agent = new BskyAgent({
+const agent = new AtpAgent({
service: 'https://public.api.bsky.app',
})
diff --git a/bskyembed/yarn.lock b/bskyembed/yarn.lock
index 60efe36845..ca52dc0747 100644
--- a/bskyembed/yarn.lock
+++ b/bskyembed/yarn.lock
@@ -20,15 +20,16 @@
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24"
-"@atproto/api@^0.12.2":
- version "0.12.2"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.2.tgz#5df6d4f60dea0395c84fdebd9e81a7e853edf130"
- integrity sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA==
+"@atproto/api@0.13.1":
+ version "0.13.1"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
+ integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
dependencies:
"@atproto/common-web" "^0.3.0"
- "@atproto/lexicon" "^0.4.0"
+ "@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
- "@atproto/xrpc" "^0.5.0"
+ "@atproto/xrpc" "^0.6.0"
+ await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
@@ -42,29 +43,29 @@
uint8arrays "3.0.0"
zod "^3.21.4"
-"@atproto/lexicon@^0.4.0":
- version "0.4.0"
- resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576"
- integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==
+"@atproto/lexicon@^0.4.1":
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0"
+ integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/syntax" "^0.3.0"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
- zod "^3.21.4"
+ zod "^3.23.8"
"@atproto/syntax@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
-"@atproto/xrpc@^0.5.0":
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032"
- integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
+"@atproto/xrpc@^0.6.0":
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
+ integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
dependencies:
- "@atproto/lexicon" "^0.4.0"
- zod "^3.21.4"
+ "@atproto/lexicon" "^0.4.1"
+ zod "^3.23.8"
"@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2":
version "7.24.2"
@@ -1710,6 +1711,11 @@ available-typed-arrays@^1.0.7:
dependencies:
possible-typed-array-names "^1.0.0"
+await-lock@^2.2.2:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
+ integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
+
babel-plugin-polyfill-corejs2@^0.4.10:
version "0.4.10"
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz#276f41710b03a64f6467433cab72cbc2653c38b1"
@@ -3730,8 +3736,16 @@ stack-trace@^1.0.0-pre2:
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-1.0.0-pre2.tgz#46a83a79f1b287807e9aaafc6a5dd8bcde626f9c"
integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==
-"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
- name string-width-cjs
+"string-width-cjs@npm:string-width@^4.2.0":
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string-width@^4.1.0:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -3795,7 +3809,14 @@ string.prototype.trimstart@^1.0.8:
define-properties "^1.2.1"
es-object-atoms "^1.0.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -4192,3 +4213,8 @@ zod@^3.21.4:
version "3.22.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==
+
+zod@^3.23.8:
+ version "3.23.8"
+ resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
+ integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
diff --git a/index.js b/index.js
index 7630d0538a..2f13ce1ea1 100644
--- a/index.js
+++ b/index.js
@@ -1,14 +1,11 @@
import 'react-native-gesture-handler' // must be first
-import {LogBox} from 'react-native'
-
import '#/platform/polyfills'
-import {IS_TEST} from '#/env'
+
+import {LogBox} from 'react-native'
import {registerRootComponent} from 'expo'
-import {doPolyfill} from '#/lib/api/api-polyfill'
import App from '#/App'
-
-doPolyfill()
+import {IS_TEST} from '#/env'
if (IS_TEST) {
LogBox.ignoreAllLogs() // suppress all logs in tests
diff --git a/index.web.js b/index.web.js
index 9623734512..be75bc772e 100644
--- a/index.web.js
+++ b/index.web.js
@@ -1,9 +1,8 @@
import '#/platform/markBundleStartTime'
-
import '#/platform/polyfills'
+
import {registerRootComponent} from 'expo'
-import {doPolyfill} from '#/lib/api/api-polyfill'
+
import App from '#/App'
-doPolyfill()
registerRootComponent(App)
diff --git a/jest/test-pds.ts b/jest/test-pds.ts
index 2fe623ca98..bfcc970c2f 100644
--- a/jest/test-pds.ts
+++ b/jest/test-pds.ts
@@ -156,7 +156,7 @@ class Mocker {
}
async createUser(name: string) {
- const agent = new BskyAgent({service: this.agent.service})
+ const agent = new BskyAgent({service: this.service})
const inviteRes = await agent.api.com.atproto.server.createInviteCode(
{useCount: 1},
@@ -332,7 +332,7 @@ class Mocker {
}
async createInvite(forAccount: string) {
- const agent = new BskyAgent({service: this.agent.service})
+ const agent = new BskyAgent({service: this.service})
await agent.api.com.atproto.server.createInviteCode(
{useCount: 1, forAccount},
{
diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts
index ebd67913e0..2cf4f36c52 100644
--- a/modules/expo-bluesky-swiss-army/index.ts
+++ b/modules/expo-bluesky-swiss-army/index.ts
@@ -1,6 +1,7 @@
import * as PlatformInfo from './src/PlatformInfo'
+import {AudioCategory} from './src/PlatformInfo/types'
import * as Referrer from './src/Referrer'
import * as SharedPrefs from './src/SharedPrefs'
import VisibilityView from './src/VisibilityView'
-export {PlatformInfo, Referrer, SharedPrefs, VisibilityView}
+export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView}
diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift
index 4a1e6d7e7d..02bf5c6628 100644
--- a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift
+++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift
@@ -1,11 +1,46 @@
import ExpoModulesCore
public class ExpoPlatformInfoModule: Module {
+ private var prevAudioActive: Bool?
+ private var prevAudioCategory: AVAudioSession.Category?
+
public func definition() -> ModuleDefinition {
Name("ExpoPlatformInfo")
Function("getIsReducedMotionEnabled") {
return UIAccessibility.isReduceMotionEnabled
}
+
+ Function("setAudioCategory") { (audioCategoryString: String) in
+ let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
+ if audioCategory == self.prevAudioCategory {
+ return
+ }
+ self.prevAudioCategory = audioCategory
+ DispatchQueue.global(qos: .background).async {
+ try? AVAudioSession.sharedInstance().setCategory(audioCategory)
+ }
+ }
+
+ Function("setAudioActive") { (active: Bool) in
+ if active == self.prevAudioActive {
+ return
+ }
+ self.prevAudioActive = active
+ if active {
+ DispatchQueue.global(qos: .background).async {
+ try? AVAudioSession.sharedInstance().setActive(true)
+ }
+ } else {
+ DispatchQueue.global(qos: .background).async {
+ try? AVAudioSession
+ .sharedInstance()
+ .setActive(
+ false,
+ options: [.notifyOthersOnDeactivation]
+ )
+ }
+ }
+ }
}
}
diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts
index e05f173d64..b515206d9f 100644
--- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts
+++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts
@@ -1,7 +1,20 @@
+import {Platform} from 'react-native'
import {requireNativeModule} from 'expo-modules-core'
+import {AudioCategory} from './types'
+
const NativeModule = requireNativeModule('ExpoPlatformInfo')
export function getIsReducedMotionEnabled(): boolean {
return NativeModule.getIsReducedMotionEnabled()
}
+
+export function setAudioActive(active: boolean): void {
+ if (Platform.OS !== 'ios') return
+ NativeModule.setAudioActive(active)
+}
+
+export function setAudioCategory(audioCategory: AudioCategory): void {
+ if (Platform.OS !== 'ios') return
+ NativeModule.setAudioCategory(audioCategory)
+}
diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts
index 9b9b7fc0c7..81f8c45f4d 100644
--- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts
+++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts
@@ -1,5 +1,25 @@
import {NotImplementedError} from '../NotImplemented'
+import {AudioCategory} from './types'
export function getIsReducedMotionEnabled(): boolean {
throw new NotImplementedError()
}
+
+/**
+ * Set whether the app's audio should mix with other apps' audio. Will also resume background music playback when `false`
+ * if it was previously playing.
+ * @param mixWithOthers
+ * @see https://developer.apple.com/documentation/avfaudio/avaudiosession/setactiveoptions/1616603-notifyothersondeactivation
+ */
+export function setAudioActive(active: boolean): void {
+ throw new NotImplementedError({active})
+}
+
+/**
+ * Set the audio category for the app.
+ * @param audioCategory
+ * @platform ios
+ */
+export function setAudioCategory(audioCategory: AudioCategory): void {
+ throw new NotImplementedError({audioCategory})
+}
diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts
index c7ae6b7cd4..61412753c9 100644
--- a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts
+++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts
@@ -1,6 +1,17 @@
+import {NotImplementedError} from '../NotImplemented'
+import {AudioCategory} from './types'
+
export function getIsReducedMotionEnabled(): boolean {
if (typeof window === 'undefined') {
return false
}
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
+
+export function setAudioActive(active: boolean): void {
+ throw new NotImplementedError({active})
+}
+
+export function setAudioCategory(audioCategory: AudioCategory): void {
+ throw new NotImplementedError({audioCategory})
+}
diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts
new file mode 100644
index 0000000000..374f343183
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/types.ts
@@ -0,0 +1,15 @@
+/**
+ * Sets the audio session category on iOS. In general, we should only need to use this for the `playback` and `ambient`
+ * categories. This enum however includes other categories that are available in the native API for clarity and
+ * potential future use.
+ * @see https://developer.apple.com/documentation/avfoundation/avaudiosession/category
+ * @platform ios
+ */
+export enum AudioCategory {
+ Ambient = 'AVAudioSessionCategoryAmbient',
+ Playback = 'AVAudioSessionCategoryPlayback',
+ _SoloAmbient = 'AVAudioSessionCategorySoloAmbient',
+ _Record = 'AVAudioSessionCategoryRecord',
+ _PlayAndRecord = 'AVAudioSessionCategoryPlayAndRecord',
+ _MultiRoute = 'AVAudioSessionCategoryMultiRoute',
+}
diff --git a/package.json b/package.json
index faeee448c9..a4523d988f 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
- "@atproto/api": "0.12.29",
+ "@atproto/api": "0.13.0",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -139,7 +139,7 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
- "expo-video": "^1.1.10",
+ "expo-video": "^1.2.4",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@@ -208,7 +208,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
- "@atproto/dev-env": "^0.3.5",
+ "@atproto/dev-env": "^0.3.39",
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
diff --git a/patches/@atproto+lexicon+0.4.0.patch b/patches/@atproto+lexicon+0.4.0.patch
deleted file mode 100644
index 4643db32af..0000000000
--- a/patches/@atproto+lexicon+0.4.0.patch
+++ /dev/null
@@ -1,28 +0,0 @@
-diff --git a/node_modules/@atproto/lexicon/dist/validators/complex.js b/node_modules/@atproto/lexicon/dist/validators/complex.js
-index 32d7798..9d688b7 100644
---- a/node_modules/@atproto/lexicon/dist/validators/complex.js
-+++ b/node_modules/@atproto/lexicon/dist/validators/complex.js
-@@ -113,7 +113,22 @@ function object(lexicons, path, def, value) {
- if (value[key] === null && nullableProps.has(key)) {
- continue;
- }
-- const propDef = def.properties[key];
-+ const propDef = def.properties[key]
-+ if (typeof value[key] === 'undefined' && !requiredProps.has(key)) {
-+ // Fast path for non-required undefined props.
-+ if (
-+ propDef.type === 'integer' ||
-+ propDef.type === 'boolean' ||
-+ propDef.type === 'string'
-+ ) {
-+ if (typeof propDef.default === 'undefined') {
-+ continue
-+ }
-+ } else {
-+ // Other types have no defaults.
-+ continue
-+ }
-+ }
- const propPath = `${path}/${key}`;
- const validated = (0, util_1.validateOneOf)(lexicons, propPath, propDef, value[key]);
- const propValue = validated.success ? validated.value : value[key];
diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch
index a4ee027c81..4bfecb3880 100644
--- a/patches/expo-modules-core+1.12.11.patch
+++ b/patches/expo-modules-core+1.12.11.patch
@@ -13,7 +13,7 @@ index bb74e80..0aa0202 100644
Map constants = new HashMap<>(3);
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
-index 109d3fe..c7fce9e 100644
+index 109d3fe..c421931 100644
--- a/node_modules/expo-modules-core/build/uuid/uuid.js
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js
@@ -1,5 +1,7 @@
@@ -24,3 +24,16 @@ index 109d3fe..c7fce9e 100644
const nativeUuidv4 = globalThis?.expo?.uuidv4;
const nativeUuidv5 = globalThis?.expo?.uuidv5;
function uuidv4() {
+diff --git a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
+index ee2268a..4851b67 100644
+--- a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
++++ b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
+@@ -173,7 +173,7 @@ public final class SharedObjectRegistry {
+ }
+
+ internal func clear() {
+- Self.lockQueue.async {
++ Self.lockQueue.sync {
+ self.pairs.removeAll()
+ }
+ }
diff --git a/patches/expo-video+1.1.10.patch b/patches/expo-video+1.1.10.patch
deleted file mode 100644
index b183be9d41..0000000000
--- a/patches/expo-video+1.1.10.patch
+++ /dev/null
@@ -1,20 +0,0 @@
---- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
-+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
-@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
- setShowPreviousButton(!requireLinearPlayback)
- setShowNextButton(!requireLinearPlayback)
- setTimeBarInteractive(requireLinearPlayback)
-+ setShowSubtitleButton(true)
- }
-
- @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
-@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
-
- @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
- internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
-- val fullscreenButton = findViewById(androidx.media3.ui.R.id.exo_fullscreen)
-+ val fullscreenButton =
-+ findViewById(androidx.media3.ui.R.id.exo_fullscreen)
- fullscreenButton?.visibility = if (visible) {
- android.view.View.VISIBLE
- } else {
diff --git a/patches/expo-video+1.2.4.patch b/patches/expo-video+1.2.4.patch
new file mode 100644
index 0000000000..918c8a8d25
--- /dev/null
+++ b/patches/expo-video+1.2.4.patch
@@ -0,0 +1,135 @@
+diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
+index 9905e13..47342ff 100644
+--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
+@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
+ setShowPreviousButton(!requireLinearPlayback)
+ setShowNextButton(!requireLinearPlayback)
+ setTimeBarInteractive(requireLinearPlayback)
++ setShowSubtitleButton(true)
+ }
+
+ @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
+
+ @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
+ internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
+- val fullscreenButton = findViewById(androidx.media3.ui.R.id.exo_fullscreen)
++ val fullscreenButton =
++ findViewById(androidx.media3.ui.R.id.exo_fullscreen)
+ fullscreenButton?.visibility = if (visible) {
+ android.view.View.VISIBLE
+ } else {
+diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
+index ec3da2a..5a1397a 100644
+--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
+@@ -43,7 +43,9 @@ class VideoModule : Module() {
+ View(VideoView::class) {
+ Events(
+ "onPictureInPictureStart",
+- "onPictureInPictureStop"
++ "onPictureInPictureStop",
++ "onEnterFullscreen",
++ "onExitFullscreen"
+ )
+
+ Prop("player") { view: VideoView, player: VideoPlayer ->
+diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
+index a951d80..3932535 100644
+--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
++++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
+@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
+ val playerView: PlayerView = PlayerView(context.applicationContext)
+ val onPictureInPictureStart by EventDispatcher()
+ val onPictureInPictureStop by EventDispatcher()
++ val onEnterFullscreen by EventDispatcher()
++ val onExitFullscreen by EventDispatcher()
+
+ var willEnterPiP: Boolean = false
+ var isInFullscreen: Boolean = false
+@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
+ @Suppress("DEPRECATION")
+ currentActivity.overridePendingTransition(0, 0)
+ }
++ onEnterFullscreen(mapOf())
+ isInFullscreen = true
+ }
+
+@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
+ val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
+ fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
+ videoPlayer?.changePlayerView(playerView)
++ this.onExitFullscreen(mapOf())
+ isInFullscreen = false
+ }
+
+diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
+index cb9ca6d..60e9f4e 100644
+--- a/node_modules/expo-video/build/VideoView.types.d.ts
++++ b/node_modules/expo-video/build/VideoView.types.d.ts
+@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
+ * @platform ios 16.0+
+ */
+ allowsVideoFrameAnalysis?: boolean;
++
++ onEnterFullscreen?: () => void;
++ onExitFullscreen?: () => void;
+ }
+ //# sourceMappingURL=VideoView.types.d.ts.map
+diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
+index c537a12..e4a918f 100644
+--- a/node_modules/expo-video/ios/VideoModule.swift
++++ b/node_modules/expo-video/ios/VideoModule.swift
+@@ -16,7 +16,9 @@ public final class VideoModule: Module {
+ View(VideoView.self) {
+ Events(
+ "onPictureInPictureStart",
+- "onPictureInPictureStop"
++ "onPictureInPictureStop",
++ "onEnterFullscreen",
++ "onExitFullscreen"
+ )
+
+ Prop("player") { (view, player: VideoPlayer?) in
+diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
+index f4579e4..10c5908 100644
+--- a/node_modules/expo-video/ios/VideoView.swift
++++ b/node_modules/expo-video/ios/VideoView.swift
+@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
+
+ let onPictureInPictureStart = EventDispatcher()
+ let onPictureInPictureStop = EventDispatcher()
++ let onEnterFullscreen = EventDispatcher()
++ let onExitFullscreen = EventDispatcher()
+
+ public override var bounds: CGRect {
+ didSet {
+@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
+ _ playerViewController: AVPlayerViewController,
+ willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
+ ) {
++ onEnterFullscreen()
+ isFullscreen = true
+ }
+
+@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
+ if wasPlaying {
+ self.player?.pointer.play()
+ }
++ self.onExitFullscreen()
+ self.isFullscreen = false
+ }
+ }
+diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
+index 29fe5db..e1fbf59 100644
+--- a/node_modules/expo-video/src/VideoView.types.ts
++++ b/node_modules/expo-video/src/VideoView.types.ts
+@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps {
+ * @platform ios 16.0+
+ */
+ allowsVideoFrameAnalysis?: boolean;
++
++ onEnterFullscreen?: () => void;
++ onExitFullscreen?: () => void;
+ }
diff --git a/patches/expo-video+1.2.4.patch.md b/patches/expo-video+1.2.4.patch.md
new file mode 100644
index 0000000000..689cf9a926
--- /dev/null
+++ b/patches/expo-video+1.2.4.patch.md
@@ -0,0 +1,6 @@
+## uwu woad beawing, do not wemove
+
+## `expo-video` Patch
+
+This patch adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
+the tin.
diff --git a/src/App.native.tsx b/src/App.native.tsx
index d2c20fc8e7..bce439a717 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -61,6 +61,7 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
+import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
SplashScreen.preventAutoHideAsync()
@@ -157,6 +158,8 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
+ PlatformInfo.setAudioCategory(AudioCategory.Ambient)
+ PlatformInfo.setAudioActive(false)
initPersistedState().then(() => setReady(true))
}, [])
diff --git a/src/alf/themes.ts b/src/alf/themes.ts
index ba18ee0072..f5d2247f9f 100644
--- a/src/alf/themes.ts
+++ b/src/alf/themes.ts
@@ -186,19 +186,19 @@ export function createThemes({
white: color.gray_0,
black: color.trueBlack,
- contrast_25: color.gray_1000,
- contrast_50: color.gray_975,
- contrast_100: color.gray_950,
- contrast_200: color.gray_900,
- contrast_300: color.gray_800,
- contrast_400: color.gray_700,
- contrast_500: color.gray_600,
- contrast_600: color.gray_500,
- contrast_700: color.gray_400,
- contrast_800: color.gray_300,
- contrast_900: color.gray_200,
- contrast_950: color.gray_100,
- contrast_975: color.gray_50,
+ contrast_25: color.gray_975,
+ contrast_50: color.gray_950,
+ contrast_100: color.gray_900,
+ contrast_200: color.gray_800,
+ contrast_300: color.gray_700,
+ contrast_400: color.gray_600,
+ contrast_500: color.gray_500,
+ contrast_600: color.gray_400,
+ contrast_700: color.gray_300,
+ contrast_800: color.gray_200,
+ contrast_900: color.gray_100,
+ contrast_950: color.gray_50,
+ contrast_975: color.gray_25,
primary_25: color.primary_975,
primary_50: color.primary_950,
@@ -400,7 +400,7 @@ export function createThemes({
color: darkPalette.contrast_400,
},
text_contrast_medium: {
- color: darkPalette.contrast_700,
+ color: darkPalette.contrast_600,
},
text_contrast_high: {
color: darkPalette.contrast_900,
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 4fe0ab4b12..d65444e1f7 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -1,6 +1,8 @@
import React from 'react'
import {
AccessibilityProps,
+ GestureResponderEvent,
+ MouseEvent,
Pressable,
PressableProps,
StyleProp,
@@ -65,7 +67,15 @@ type NonTextElements =
export type ButtonProps = Pick<
PressableProps,
- 'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
+ | 'disabled'
+ | 'onPress'
+ | 'testID'
+ | 'onLongPress'
+ | 'hitSlop'
+ | 'onHoverIn'
+ | 'onHoverOut'
+ | 'onPressIn'
+ | 'onPressOut'
> &
AccessibilityProps &
VariantProps & {
@@ -115,30 +125,50 @@ export const Button = React.forwardRef(
focused: false,
})
- const onPressIn = React.useCallback(() => {
- setState(s => ({
- ...s,
- pressed: true,
- }))
- }, [setState])
- const onPressOut = React.useCallback(() => {
- setState(s => ({
- ...s,
- pressed: false,
- }))
- }, [setState])
- const onHoverIn = React.useCallback(() => {
- setState(s => ({
- ...s,
- hovered: true,
- }))
- }, [setState])
- const onHoverOut = React.useCallback(() => {
- setState(s => ({
- ...s,
- hovered: false,
- }))
- }, [setState])
+ const onPressInOuter = rest.onPressIn
+ const onPressIn = React.useCallback(
+ (e: GestureResponderEvent) => {
+ setState(s => ({
+ ...s,
+ pressed: true,
+ }))
+ onPressInOuter?.(e)
+ },
+ [setState, onPressInOuter],
+ )
+ const onPressOutOuter = rest.onPressOut
+ const onPressOut = React.useCallback(
+ (e: GestureResponderEvent) => {
+ setState(s => ({
+ ...s,
+ pressed: false,
+ }))
+ onPressOutOuter?.(e)
+ },
+ [setState, onPressOutOuter],
+ )
+ const onHoverInOuter = rest.onHoverIn
+ const onHoverIn = React.useCallback(
+ (e: MouseEvent) => {
+ setState(s => ({
+ ...s,
+ hovered: true,
+ }))
+ onHoverInOuter?.(e)
+ },
+ [setState, onHoverInOuter],
+ )
+ const onHoverOutOuter = rest.onHoverOut
+ const onHoverOut = React.useCallback(
+ (e: MouseEvent) => {
+ setState(s => ({
+ ...s,
+ hovered: false,
+ }))
+ onHoverOutOuter?.(e)
+ },
+ [setState, onHoverOutOuter],
+ )
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
@@ -202,28 +232,10 @@ export const Button = React.forwardRef(
} else if (color === 'secondary') {
if (variant === 'solid') {
if (!disabled) {
- baseStyles.push({
- backgroundColor: select(t.name, {
- light: t.palette.contrast_25,
- dim: t.palette.contrast_100,
- dark: t.palette.contrast_100,
- }),
- })
- hoverStyles.push({
- backgroundColor: select(t.name, {
- light: t.palette.contrast_50,
- dim: t.palette.contrast_200,
- dark: t.palette.contrast_200,
- }),
- })
+ baseStyles.push(t.atoms.bg_contrast_25)
+ hoverStyles.push(t.atoms.bg_contrast_50)
} else {
- baseStyles.push({
- backgroundColor: select(t.name, {
- light: t.palette.contrast_100,
- dim: t.palette.contrast_25,
- dark: t.palette.contrast_25,
- }),
- })
+ baseStyles.push(t.atoms.bg_contrast_100)
}
} else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, {
diff --git a/src/components/Error.tsx b/src/components/Error.tsx
index 481532434a..59d219831c 100644
--- a/src/components/Error.tsx
+++ b/src/components/Error.tsx
@@ -2,21 +2,18 @@ import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/core'
-import {StackActions} from '@react-navigation/native'
-import {NavigationProp} from 'lib/routes/types'
+import {useGoBack} from 'lib/hooks/useGoBack'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
-import {router} from '#/routes'
export function Error({
title,
message,
onRetry,
- onGoBack: onGoBackProp,
+ onGoBack,
hideBackButton,
sideBorders = true,
}: {
@@ -27,31 +24,10 @@ export function Error({
hideBackButton?: boolean
sideBorders?: boolean
}) {
- const navigation = useNavigation()
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
-
- const canGoBack = navigation.canGoBack()
- const onGoBack = React.useCallback(() => {
- if (onGoBackProp) {
- onGoBackProp()
- return
- }
- if (canGoBack) {
- navigation.goBack()
- } else {
- navigation.navigate('HomeTab')
-
- // Checking the state for routes ensures that web doesn't encounter errors while going back
- if (navigation.getState()?.routes) {
- navigation.dispatch(StackActions.push(...router.matchPath('/')))
- } else {
- navigation.navigate('HomeTab')
- navigation.dispatch(StackActions.popToTop())
- }
- }
- }, [navigation, canGoBack, onGoBackProp])
+ const goBack = useGoBack(onGoBack)
return (
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index eca1c86f00..e37d2c3e0e 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -133,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => {
- const {likes, follows, seen} = userActionSnapshot
+ const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes
.map(l => new AtUri(l))
.map(uri => uri.host)
.filter(did => !follows.includes(did))
+ let suggestedDids: string[] = []
+ if (followSuggestions.length > 0) {
+ suggestedDids = [
+ // It's ok if these will pick the same item (weighed by its frequency)
+ followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
+ followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
+ followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
+ followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
+ ]
+ }
const seenDids = seen
.sort(sortSeenPosts)
.map(l => new AtUri(l.uri))
.map(uri => uri.host)
- return [...new Set([...likeDids, ...seenDids])].filter(
+ return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
did => did !== currentAccount?.did,
)
}, [userActionSnapshot, currentAccount])
diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx
index 0ed27cf50f..829f36d471 100644
--- a/src/components/ListCard.tsx
+++ b/src/components/ListCard.tsx
@@ -1,13 +1,20 @@
import React from 'react'
import {View} from 'react-native'
-import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
+import {
+ AppBskyActorDefs,
+ AppBskyGraphDefs,
+ AtUri,
+ moderateUserList,
+ ModerationUI,
+} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
import {precacheList} from 'state/queries/feed'
-import {useTheme} from '#/alf'
-import {atoms as a} from '#/alf'
+import {useSession} from 'state/session'
+import {atoms as a, useTheme} from '#/alf'
import {
Avatar,
Description,
@@ -16,6 +23,7 @@ import {
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link'
+import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography'
/*
@@ -43,6 +51,11 @@ type Props = {
export function Default(props: Props) {
const {view, showPinButton} = props
+ const moderationOpts = useModerationOpts()
+ const moderation = moderationOpts
+ ? moderateUserList(view, moderationOpts)
+ : undefined
+
return (
@@ -52,6 +65,7 @@ export function Default(props: Props) {
title={view.name}
creator={view.creator}
purpose={view.purpose}
+ modUi={moderation?.ui('contentView')}
/>
{showPinButton && view.purpose === CURATELIST && (
@@ -89,18 +103,40 @@ export function TitleAndByline({
title,
creator,
purpose = CURATELIST,
+ modUi,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
purpose?: AppBskyGraphDefs.ListView['purpose']
+ modUi?: ModerationUI
}) {
const t = useTheme()
+ const {currentAccount} = useSession()
return (
-
- {title}
-
+
+
+
+ Hidden list
+
+
+
+
+ {title}
+
+
+
+
{creator && (
({
// @ts-ignore
control: null,
})
+
+export const ItemContext = React.createContext({
+ disabled: false,
+})
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index 3be69b3486..a0a21a50f9 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -9,7 +9,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
-import {Context} from '#/components/Menu/context'
+import {Context, ItemContext} from '#/components/Menu/context'
import {
ContextType,
GroupProps,
@@ -125,8 +125,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
}}
onFocus={onFocus}
onBlur={onBlur}
- onPressIn={onPressIn}
- onPressOut={onPressOut}
+ onPressIn={e => {
+ onPressIn()
+ rest.onPressIn?.(e)
+ }}
+ onPressOut={e => {
+ onPressOut()
+ rest.onPressOut?.(e)
+ }}
style={[
a.flex_row,
a.align_center,
@@ -138,15 +144,18 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
t.atoms.border_contrast_low,
{minHeight: 44, paddingVertical: 10},
style,
- (focused || pressed) && [t.atoms.bg_contrast_50],
+ (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
]}>
- {children}
+
+ {children}
+
)
}
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
+ const {disabled} = React.useContext(ItemContext)
return (
{children}
@@ -166,7 +176,17 @@ export function ItemText({children, style}: ItemTextProps) {
export function ItemIcon({icon: Comp}: ItemIconProps) {
const t = useTheme()
- return
+ const {disabled} = React.useContext(ItemContext)
+ return (
+
+ )
}
export function Group({children, style}: GroupProps) {
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index 031250ddef..6d2f5e9416 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -9,7 +9,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {atoms as a, flatten, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
-import {Context} from '#/components/Menu/context'
+import {Context, ItemContext} from '#/components/Menu/context'
import {
ContextType,
GroupProps,
@@ -239,18 +239,21 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
a.rounded_xs,
{minHeight: 32, paddingHorizontal: 10},
web({outline: 0}),
- (hovered || focused) && [
- web({outline: '0 !important'}),
- t.name === 'light'
- ? t.atoms.bg_contrast_25
- : t.atoms.bg_contrast_50,
- ],
+ (hovered || focused) &&
+ !rest.disabled && [
+ web({outline: '0 !important'}),
+ t.name === 'light'
+ ? t.atoms.bg_contrast_25
+ : t.atoms.bg_contrast_50,
+ ],
])}
{...web({
onMouseEnter,
onMouseLeave,
})}>
- {children}
+
+ {children}
+
)
@@ -258,8 +261,16 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
+ const {disabled} = React.useContext(ItemContext)
return (
-
+
{children}
)
@@ -267,10 +278,9 @@ export function ItemText({children, style}: ItemTextProps) {
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
const t = useTheme()
+ const {disabled} = React.useContext(ItemContext)
return (
-
+ ]}>
+
+
)
}
diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts
index e710971ee9..2f7aea5de5 100644
--- a/src/components/Menu/types.ts
+++ b/src/components/Menu/types.ts
@@ -1,18 +1,22 @@
import React from 'react'
import {
+ AccessibilityProps,
GestureResponderEvent,
PressableProps,
- AccessibilityProps,
} from 'react-native'
-import {Props as SVGIconProps} from '#/components/icons/common'
-import * as Dialog from '#/components/Dialog'
import {TextStyleProp, ViewStyleProp} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {Props as SVGIconProps} from '#/components/icons/common'
export type ContextType = {
control: Dialog.DialogOuterProps['control']
}
+export type ItemContextType = {
+ disabled: boolean
+}
+
export type RadixPassThroughTriggerProps = {
id: string
type: 'button'
diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx
index 7ceece75b6..2def0fa4b4 100644
--- a/src/components/ReportDialog/SubmitView.tsx
+++ b/src/components/ReportDialog/SubmitView.tsx
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions'
-import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
@@ -37,7 +36,6 @@ export function SubmitView({
const t = useTheme()
const {_} = useLingui()
const agent = useAgent()
- const gate = useGate()
const [details, setDetails] = React.useState('')
const [submitting, setSubmitting] = React.useState(false)
const [selectedServices, setSelectedServices] = React.useState([
@@ -63,27 +61,17 @@ export function SubmitView({
}
const results = await Promise.all(
selectedServices.map(did => {
- if (gate('session_withproxy_fix')) {
- return agent
- .createModerationReport(report, {
- encoding: 'application/json',
- headers: {
- 'atproto-proxy': `${did}#atproto_labeler`,
- },
- })
- .then(
- _ => true,
- _ => false,
- )
- } else {
- return agent
- .withProxy('atproto_labeler', did)
- .createModerationReport(report)
- .then(
- _ => true,
- _ => false,
- )
- }
+ return agent
+ .createModerationReport(report, {
+ encoding: 'application/json',
+ headers: {
+ 'atproto-proxy': `${did}#atproto_labeler`,
+ },
+ })
+ .then(
+ _ => true,
+ _ => false,
+ )
}),
)
@@ -108,7 +96,6 @@ export function SubmitView({
onSubmitComplete,
setError,
agent,
- gate,
])
return (
diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx
index dc9e4b70da..4c4bf246ea 100644
--- a/src/components/StarterPack/StarterPackCard.tsx
+++ b/src/components/StarterPack/StarterPackCard.tsx
@@ -1,8 +1,7 @@
import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
-import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
-import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
+import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -17,7 +16,11 @@ import {StarterPack} from '#/components/icons/StarterPack'
import {BaseLink} from '#/components/Link'
import {Text} from '#/components/Typography'
-export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
+export function Default({
+ starterPack,
+}: {
+ starterPack?: AppBskyGraphDefs.StarterPackViewBasic
+}) {
if (!starterPack) return null
return (
@@ -29,7 +32,7 @@ export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
export function Notification({
starterPack,
}: {
- starterPack?: StarterPackViewBasic
+ starterPack?: AppBskyGraphDefs.StarterPackViewBasic
}) {
if (!starterPack) return null
return (
@@ -44,7 +47,7 @@ export function Card({
noIcon,
noDescription,
}: {
- starterPack: StarterPackViewBasic
+ starterPack: AppBskyGraphDefs.StarterPackViewBasic
noIcon?: boolean
noDescription?: boolean
}) {
@@ -94,7 +97,7 @@ export function Link({
starterPack,
children,
}: {
- starterPack: StarterPackViewBasic
+ starterPack: AppBskyGraphDefs.StarterPackViewBasic
onPress?: () => void
children: React.ReactNode
}) {
@@ -129,7 +132,11 @@ export function Link({
)
}
-export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
+export function Embed({
+ starterPack,
+}: {
+ starterPack: AppBskyGraphDefs.StarterPackViewBasic
+}) {
const t = useTheme()
const imageUri = getStarterPackOgCard(starterPack)
diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx
index a64edcd6f0..4c60c6ebeb 100644
--- a/src/components/dialogs/GifSelect.tsx
+++ b/src/components/dialogs/GifSelect.tsx
@@ -249,7 +249,9 @@ function DialogError({details}: {details?: string}) {
const control = Dialog.useDialogContext()
return (
-
+
+
{children}
)
@@ -163,21 +163,3 @@ export function ContentHider({
)
}
-
-const styles = StyleSheet.create({
- outer: {},
- cover: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 6,
- borderRadius: 8,
- marginTop: 4,
- paddingVertical: 14,
- paddingLeft: 14,
- paddingRight: 18,
- },
- showBtn: {
- marginLeft: 'auto',
- alignSelf: 'center',
- },
-})
diff --git a/src/components/moderation/Hider.tsx b/src/components/moderation/Hider.tsx
new file mode 100644
index 0000000000..fcb88ddd9e
--- /dev/null
+++ b/src/components/moderation/Hider.tsx
@@ -0,0 +1,89 @@
+import React from 'react'
+import {ModerationUI} from '@atproto/api'
+
+import {
+ ModerationCauseDescription,
+ useModerationCauseDescription,
+} from '#/lib/moderation/useModerationCauseDescription'
+import {
+ ModerationDetailsDialog,
+ useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+type Context = {
+ isContentVisible: boolean
+ setIsContentVisible: (show: boolean) => void
+ info: ModerationCauseDescription
+ showInfoDialog: () => void
+ meta: {
+ isNoPwi: boolean
+ allowOverride: boolean
+ }
+}
+
+const Context = React.createContext({} as Context)
+
+export const useHider = () => React.useContext(Context)
+
+export function Outer({
+ modui,
+ isContentVisibleInitialState,
+ allowOverride,
+ children,
+}: React.PropsWithChildren<{
+ isContentVisibleInitialState?: boolean
+ allowOverride?: boolean
+ modui: ModerationUI | undefined
+}>) {
+ const control = useModerationDetailsDialogControl()
+ const blur = modui?.blurs[0]
+ const [isContentVisible, setIsContentVisible] = React.useState(
+ isContentVisibleInitialState || !blur,
+ )
+ const info = useModerationCauseDescription(blur)
+
+ const meta = {
+ isNoPwi: Boolean(
+ modui?.blurs.find(
+ cause =>
+ cause.type === 'label' &&
+ cause.labelDef.identifier === '!no-unauthenticated',
+ ),
+ ),
+ allowOverride: allowOverride ?? !modui?.noOverride,
+ }
+
+ const showInfoDialog = () => {
+ control.open()
+ }
+
+ const onSetContentVisible = (show: boolean) => {
+ if (meta.allowOverride) return
+ setIsContentVisible(show)
+ }
+
+ const ctx = {
+ isContentVisible,
+ setIsContentVisible: onSetContentVisible,
+ showInfoDialog,
+ info,
+ meta,
+ }
+
+ return (
+
+ {children}
+
+
+ )
+}
+
+export function Content({children}: {children: React.ReactNode}) {
+ const ctx = useHider()
+ return ctx.isContentVisible ? children : null
+}
+
+export function Mask({children}: {children: React.ReactNode}) {
+ const ctx = useHider()
+ return ctx.isContentVisible ? null : children
+}
diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx
index b920a0d252..cc11b41017 100644
--- a/src/components/moderation/LabelsOnMeDialog.tsx
+++ b/src/components/moderation/LabelsOnMeDialog.tsx
@@ -7,7 +7,6 @@ import {useMutation} from '@tanstack/react-query'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links'
-import {useGate} from '#/lib/statsig/statsig'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
@@ -204,7 +203,6 @@ function AppealForm({
const [details, setDetails] = React.useState('')
const isAccountReport = 'did' in subject
const agent = useAgent()
- const gate = useGate()
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src
@@ -214,35 +212,22 @@ function AppealForm({
const $type = !isAccountReport
? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef'
- if (gate('session_withproxy_fix')) {
- await agent.createModerationReport(
- {
- reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
- subject: {
- $type,
- ...subject,
- },
- reason: details,
+ await agent.createModerationReport(
+ {
+ reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
+ subject: {
+ $type,
+ ...subject,
},
- {
- encoding: 'application/json',
- headers: {
- 'atproto-proxy': `${label.src}#atproto_labeler`,
- },
+ reason: details,
+ },
+ {
+ encoding: 'application/json',
+ headers: {
+ 'atproto-proxy': `${label.src}#atproto_labeler`,
},
- )
- } else {
- await agent
- .withProxy('atproto_labeler', label.src)
- .createModerationReport({
- reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
- subject: {
- $type,
- ...subject,
- },
- reason: details,
- })
- }
+ },
+ )
},
onError: err => {
logger.error('Failed to submit label appeal', {message: err})
diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx
index ebfe452325..b8f02582c6 100644
--- a/src/components/moderation/ModerationDetailsDialog.tsx
+++ b/src/components/moderation/ModerationDetailsDialog.tsx
@@ -18,7 +18,7 @@ export {useDialogControl as useModerationDetailsDialogControl} from '#/component
export interface ModerationDetailsDialogProps {
control: Dialog.DialogOuterProps['control']
- modcause: ModerationCause
+ modcause?: ModerationCause
}
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@@ -123,7 +123,7 @@ function ModerationDetailsDialogInner({
{description}
- {modcause.type === 'label' && (
+ {modcause?.type === 'label' && (
<>
diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts
deleted file mode 100644
index e3aec76316..0000000000
--- a/src/lib/api/api-polyfill.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import RNFS from 'react-native-fs'
-import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api'
-
-const GET_TIMEOUT = 15e3 // 15s
-const POST_TIMEOUT = 60e3 // 60s
-
-export function doPolyfill() {
- BskyAgent.configure({fetch: fetchHandler})
-}
-
-interface FetchHandlerResponse {
- status: number
- headers: Record
- body: any
-}
-
-async function fetchHandler(
- reqUri: string,
- reqMethod: string,
- reqHeaders: Record,
- reqBody: any,
-): Promise {
- const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type']
- if (reqMimeType && reqMimeType.startsWith('application/json')) {
- reqBody = stringifyLex(reqBody)
- } else if (
- typeof reqBody === 'string' &&
- (reqBody.startsWith('/') || reqBody.startsWith('file:'))
- ) {
- if (reqBody.endsWith('.jpeg') || reqBody.endsWith('.jpg')) {
- // HACK
- // React native has a bug that inflates the size of jpegs on upload
- // we get around that by renaming the file ext to .bin
- // see https://github.com/facebook/react-native/issues/27099
- // -prf
- const newPath = reqBody.replace(/\.jpe?g$/, '.bin')
- await RNFS.moveFile(reqBody, newPath)
- reqBody = newPath
- }
- // NOTE
- // React native treats bodies with {uri: string} as file uploads to pull from cache
- // -prf
- reqBody = {uri: reqBody}
- }
-
- const controller = new AbortController()
- const to = setTimeout(
- () => controller.abort(),
- reqMethod === 'post' ? POST_TIMEOUT : GET_TIMEOUT,
- )
-
- const res = await fetch(reqUri, {
- method: reqMethod,
- headers: reqHeaders,
- body: reqBody,
- signal: controller.signal,
- })
-
- const resStatus = res.status
- const resHeaders: Record = {}
- res.headers.forEach((value: string, key: string) => {
- resHeaders[key] = value
- })
- const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type']
- let resBody
- if (resMimeType) {
- if (resMimeType.startsWith('application/json')) {
- resBody = jsonToLex(await res.json())
- } else if (resMimeType.startsWith('text/')) {
- resBody = await res.text()
- } else if (resMimeType === 'application/vnd.ipld.car') {
- resBody = await res.arrayBuffer()
- } else {
- throw new Error('Non-supported mime type')
- }
- }
-
- clearTimeout(to)
-
- return {
- status: resStatus,
- headers: resHeaders,
- body: resBody,
- }
-}
diff --git a/src/lib/api/api-polyfill.web.ts b/src/lib/api/api-polyfill.web.ts
deleted file mode 100644
index 1ad22b3d02..0000000000
--- a/src/lib/api/api-polyfill.web.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function doPolyfill() {
- // no polyfill is needed on web
-}
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 61de795a14..c2b80ca042 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -23,6 +23,7 @@ type FeedSliceItem = {
record: AppBskyFeedPost.Record
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
isParentBlocked: boolean
+ isParentNotFound: boolean
}
type AuthorContext = {
@@ -68,6 +69,7 @@ export class FeedViewPostsSlice {
}
const parent = reply?.parent
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
+ const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent)
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
if (AppBskyFeedDefs.isPostView(parent)) {
parentAuthor = parent.author
@@ -77,6 +79,7 @@ export class FeedViewPostsSlice {
record: post.record,
parentAuthor,
isParentBlocked,
+ isParentNotFound,
})
if (!reply || reason) {
return
@@ -89,23 +92,40 @@ export class FeedViewPostsSlice {
this.isOrphan = true
return
}
+ const root = reply.root
+ const rootIsView =
+ AppBskyFeedDefs.isPostView(root) ||
+ AppBskyFeedDefs.isBlockedPost(root) ||
+ AppBskyFeedDefs.isNotFoundPost(root)
+ /*
+ * If the parent is also the root, we just so happen to have the data we
+ * need to compute if the parent's parent (grandparent) is blocked. This
+ * doesn't always happen, of course, but we can take advantage of it when
+ * it does.
+ */
+ const grandparent =
+ rootIsView && parent.record.reply?.parent.uri === root.uri
+ ? root
+ : undefined
const grandparentAuthor = reply.grandparentAuthor
const isGrandparentBlocked = Boolean(
- grandparentAuthor?.viewer?.blockedBy ||
- grandparentAuthor?.viewer?.blocking ||
- grandparentAuthor?.viewer?.blockingByList,
+ grandparent && AppBskyFeedDefs.isBlockedPost(grandparent),
+ )
+ const isGrandparentNotFound = Boolean(
+ grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent),
)
this.items.unshift({
post: parent,
record: parent.record,
parentAuthor: grandparentAuthor,
isParentBlocked: isGrandparentBlocked,
+ isParentNotFound: isGrandparentNotFound,
})
if (isGrandparentBlocked) {
this.isOrphan = true
- // Keep going, it might still have a root.
+ // Keep going, it might still have a root, and we need this for thread
+ // de-deduping
}
- const root = reply.root
if (
!AppBskyFeedDefs.isPostView(root) ||
!AppBskyFeedPost.isRecord(root.record) ||
@@ -121,6 +141,7 @@ export class FeedViewPostsSlice {
post: root,
record: root.record,
isParentBlocked: false,
+ isParentNotFound: false,
parentAuthor: undefined,
})
if (parent.record.reply?.parent.uri !== root.uri) {
diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts
index eb54dd29c1..6db96a8d63 100644
--- a/src/lib/api/feed/custom.ts
+++ b/src/lib/api/feed/custom.ts
@@ -1,7 +1,6 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
- AtpAgent,
BskyAgent,
} from '@atproto/api'
@@ -51,7 +50,7 @@ export class CustomFeedAPI implements FeedAPI {
const agent = this.agent
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
- const res = agent.session
+ const res = agent.did
? await this.agent.app.bsky.feed.getFeed(
{
...this.params,
@@ -106,34 +105,32 @@ async function loggedOutFetch({
let contentLangs = getContentLanguages().join(',')
// manually construct fetch call so we can add the `lang` cache-busting param
- let res = await AtpAgent.fetch!(
+ let res = await fetch(
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
cursor ? `&cursor=${cursor}` : ''
}&limit=${limit}&lang=${contentLangs}`,
- 'GET',
- {'Accept-Language': contentLangs},
- undefined,
+ {method: 'GET', headers: {'Accept-Language': contentLangs}},
)
- if (res.body?.feed?.length) {
+ let data = res.ok ? await res.json() : null
+ if (data?.feed?.length) {
return {
success: true,
- data: res.body,
+ data,
}
}
// no data, try again with language headers removed
- res = await AtpAgent.fetch!(
+ res = await fetch(
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
cursor ? `&cursor=${cursor}` : ''
}&limit=${limit}`,
- 'GET',
- {'Accept-Language': ''},
- undefined,
+ {method: 'GET', headers: {'Accept-Language': ''}},
)
- if (res.body?.feed?.length) {
+ data = res.ok ? await res.json() : null
+ if (data?.feed?.length) {
return {
success: true,
- data: res.body,
+ data,
}
}
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 12e30bf6c1..658ed78de4 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -6,7 +6,6 @@ import {
AppBskyFeedThreadgate,
BskyAgent,
ComAtprotoLabelDefs,
- ComAtprotoRepoUploadBlob,
RichText,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
@@ -15,10 +14,13 @@ import {logger} from '#/logger'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
-import {isNative, isWeb} from 'platform/detection'
+import {isNative} from 'platform/detection'
import {ImageModel} from 'state/models/media/image'
import {LinkMeta} from '../link-meta/link-meta'
import {safeDeleteAsync} from '../media/manip'
+import {uploadBlob} from './upload-blob'
+
+export {uploadBlob}
export interface ExternalEmbedDraft {
uri: string
@@ -28,25 +30,6 @@ export interface ExternalEmbedDraft {
localThumb?: ImageModel
}
-export async function uploadBlob(
- agent: BskyAgent,
- blob: string,
- encoding: string,
-): Promise {
- if (isWeb) {
- // `blob` should be a data uri
- return agent.uploadBlob(convertDataURIToUint8Array(blob), {
- encoding,
- })
- } else {
- // `blob` should be a path to a file in the local FS
- return agent.uploadBlob(
- blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
- {encoding},
- )
- }
-}
-
interface PostOpts {
rawText: string
replyTo?: string
@@ -301,7 +284,7 @@ export async function createThreadgate(
const postUrip = new AtUri(postUri)
await agent.api.com.atproto.repo.putRecord({
- repo: agent.session!.did,
+ repo: agent.accountDid,
collection: 'app.bsky.feed.threadgate',
rkey: postUrip.rkey,
record: {
@@ -312,15 +295,3 @@ export async function createThreadgate(
},
})
}
-
-// helpers
-// =
-
-function convertDataURIToUint8Array(uri: string): Uint8Array {
- var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8))
- var binary = new Uint8Array(new ArrayBuffer(raw.length))
- for (let i = 0; i < raw.length; i++) {
- binary[i] = raw.charCodeAt(i)
- }
- return binary
-}
diff --git a/src/lib/api/upload-blob.ts b/src/lib/api/upload-blob.ts
new file mode 100644
index 0000000000..0814d5185b
--- /dev/null
+++ b/src/lib/api/upload-blob.ts
@@ -0,0 +1,82 @@
+import RNFS from 'react-native-fs'
+import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api'
+
+/**
+ * @param encoding Allows overriding the blob's type
+ */
+export async function uploadBlob(
+ agent: BskyAgent,
+ input: string | Blob,
+ encoding?: string,
+): Promise {
+ if (typeof input === 'string' && input.startsWith('file:')) {
+ const blob = await asBlob(input)
+ return agent.uploadBlob(blob, {encoding})
+ }
+
+ if (typeof input === 'string' && input.startsWith('/')) {
+ const blob = await asBlob(`file://${input}`)
+ return agent.uploadBlob(blob, {encoding})
+ }
+
+ if (typeof input === 'string' && input.startsWith('data:')) {
+ const blob = await fetch(input).then(r => r.blob())
+ return agent.uploadBlob(blob, {encoding})
+ }
+
+ if (input instanceof Blob) {
+ return agent.uploadBlob(input, {encoding})
+ }
+
+ throw new TypeError(`Invalid uploadBlob input: ${typeof input}`)
+}
+
+async function asBlob(uri: string): Promise {
+ return withSafeFile(uri, async safeUri => {
+ // Note
+ // Android does not support `fetch()` on `file://` URIs. for this reason, we
+ // use XMLHttpRequest instead of simply calling:
+
+ // return fetch(safeUri.replace('file:///', 'file:/')).then(r => r.blob())
+
+ return await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest()
+ xhr.onload = () => resolve(xhr.response)
+ xhr.onerror = () => reject(new Error('Failed to load blob'))
+ xhr.responseType = 'blob'
+ xhr.open('GET', safeUri, true)
+ xhr.send(null)
+ })
+ })
+}
+
+// HACK
+// React native has a bug that inflates the size of jpegs on upload
+// we get around that by renaming the file ext to .bin
+// see https://github.com/facebook/react-native/issues/27099
+// -prf
+async function withSafeFile(
+ uri: string,
+ fn: (path: string) => Promise,
+): Promise {
+ if (uri.endsWith('.jpeg') || uri.endsWith('.jpg')) {
+ // Since we don't "own" the file, we should avoid renaming or modifying it.
+ // Instead, let's copy it to a temporary file and use that (then remove the
+ // temporary file).
+ const newPath = uri.replace(/\.jpe?g$/, '.bin')
+ try {
+ await RNFS.copyFile(uri, newPath)
+ } catch {
+ // Failed to copy the file, just use the original
+ return await fn(uri)
+ }
+ try {
+ return await fn(newPath)
+ } finally {
+ // Remove the temporary file
+ await RNFS.unlink(newPath)
+ }
+ } else {
+ return fn(uri)
+ }
+}
diff --git a/src/lib/api/upload-blob.web.ts b/src/lib/api/upload-blob.web.ts
new file mode 100644
index 0000000000..d3c52190c1
--- /dev/null
+++ b/src/lib/api/upload-blob.web.ts
@@ -0,0 +1,26 @@
+import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api'
+
+/**
+ * @note It is recommended, on web, to use the `file` instance of the file
+ * selector input element, rather than a `data:` URL, to avoid
+ * loading the file into memory. `File` extends `Blob` "file" instances can
+ * be passed directly to this function.
+ */
+export async function uploadBlob(
+ agent: BskyAgent,
+ input: string | Blob,
+ encoding?: string,
+): Promise {
+ if (typeof input === 'string' && input.startsWith('data:')) {
+ const blob = await fetch(input).then(r => r.blob())
+ return agent.uploadBlob(blob, {encoding})
+ }
+
+ if (input instanceof Blob) {
+ return agent.uploadBlob(input, {
+ encoding,
+ })
+ }
+
+ throw new TypeError(`Invalid uploadBlob input: ${typeof input}`)
+}
diff --git a/src/lib/hooks/useGoBack.ts b/src/lib/hooks/useGoBack.ts
new file mode 100644
index 0000000000..59555bdac1
--- /dev/null
+++ b/src/lib/hooks/useGoBack.ts
@@ -0,0 +1,23 @@
+import {StackActions, useNavigation} from '@react-navigation/native'
+
+import {NavigationProp} from 'lib/routes/types'
+import {router} from '#/routes'
+
+export function useGoBack(onGoBack?: () => unknown) {
+ const navigation = useNavigation()
+ return () => {
+ onGoBack?.()
+ if (navigation.canGoBack()) {
+ navigation.goBack()
+ } else {
+ navigation.navigate('HomeTab')
+ // Checking the state for routes ensures that web doesn't encounter errors while going back
+ if (navigation.getState()?.routes) {
+ navigation.dispatch(StackActions.push(...router.matchPath('/')))
+ } else {
+ navigation.navigate('HomeTab')
+ navigation.dispatch(StackActions.popToTop())
+ }
+ }
+ }
+}
diff --git a/src/lib/hooks/useMinimalShellTransform.ts b/src/lib/hooks/useMinimalShellTransform.ts
index 9875840d65..17fe058e9b 100644
--- a/src/lib/hooks/useMinimalShellTransform.ts
+++ b/src/lib/hooks/useMinimalShellTransform.ts
@@ -2,6 +2,7 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout'
+import {useGate} from '../statsig/statsig'
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
@@ -27,8 +28,13 @@ export function useMinimalShellHeaderTransform() {
export function useMinimalShellFooterTransform() {
const mode = useMinimalShellMode()
const {footerHeight} = useShellLayout()
+ const gate = useGate()
+ const isFixedBottomBar = gate('fixed_bottom_bar')
const footerTransform = useAnimatedStyle(() => {
+ if (isFixedBottomBar) {
+ return {}
+ }
return {
pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2),
@@ -39,13 +45,25 @@ export function useMinimalShellFooterTransform() {
],
}
})
+
return footerTransform
}
export function useMinimalShellFabTransform() {
const mode = useMinimalShellMode()
+ const gate = useGate()
+ const isFixedBottomBar = gate('fixed_bottom_bar')
const fabTransform = useAnimatedStyle(() => {
+ if (isFixedBottomBar) {
+ return {
+ transform: [
+ {
+ translateY: -44,
+ },
+ ],
+ }
+ }
return {
transform: [
{
diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts
index 3e647004bb..3f01e98c5e 100644
--- a/src/lib/media/manip.ts
+++ b/src/lib/media/manip.ts
@@ -218,13 +218,7 @@ export async function safeDeleteAsync(path: string) {
// Normalize is necessary for Android, otherwise it doesn't delete.
const normalizedPath = normalizePath(path)
try {
- await Promise.allSettled([
- deleteAsync(normalizedPath, {idempotent: true}),
- // HACK: Try this one too. Might exist due to api-polyfill hack.
- deleteAsync(normalizedPath.replace(/\.jpe?g$/, '.bin'), {
- idempotent: true,
- }),
- ])
+ await deleteAsync(normalizedPath, {idempotent: true})
} catch (e) {
console.error('Failed to delete file', e)
}
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index fafc66143a..88b9eee3a5 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api'
import {logger} from '#/logger'
import {SessionAccount, useAgent, useSession} from '#/state/session'
-import {logEvent, useGate} from 'lib/statsig/statsig'
+import {logEvent} from 'lib/statsig/statsig'
import {devicePlatform, isAndroid, isNative} from 'platform/detection'
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
@@ -86,7 +86,6 @@ export function useNotificationsRegistration() {
}
export function useRequestNotificationsPermission() {
- const gate = useGate()
const {currentAccount} = useSession()
const agent = useAgent()
@@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() {
) {
return
}
- if (
- context === 'StartOnboarding' &&
- gate('request_notifications_permission_after_onboarding_v2')
- ) {
- return
- }
- if (
- context === 'AfterOnboarding' &&
- !gate('request_notifications_permission_after_onboarding_v2')
- ) {
+ if (context === 'AfterOnboarding') {
return
}
if (context === 'Home' && !currentAccount) {
diff --git a/src/lib/react-query.tsx b/src/lib/react-query.tsx
index be507216aa..5abfccd7f6 100644
--- a/src/lib/react-query.tsx
+++ b/src/lib/react-query.tsx
@@ -2,18 +2,83 @@ import React, {useRef, useState} from 'react'
import {AppState, AppStateStatus} from 'react-native'
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 {focusManager, onlineManager, QueryClient} from '@tanstack/react-query'
import {
PersistQueryClientProvider,
PersistQueryClientProviderProps,
} from '@tanstack/react-query-persist-client'
import {isNative} from '#/platform/detection'
+import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
// any query keys in this array will be persisted to AsyncStorage
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
+async function checkIsOnline(): Promise {
+ try {
+ const controller = new AbortController()
+ setTimeout(() => {
+ controller.abort()
+ }, 15e3)
+ const res = await fetch('https://public.api.bsky.app/xrpc/_health', {
+ cache: 'no-store',
+ signal: controller.signal,
+ })
+ const json = await res.json()
+ if (json.version) {
+ return true
+ } else {
+ return false
+ }
+ } catch (e) {
+ return false
+ }
+}
+
+let receivedNetworkLost = false
+let receivedNetworkConfirmed = false
+let isNetworkStateUnclear = false
+
+listenNetworkLost(() => {
+ receivedNetworkLost = true
+ onlineManager.setOnline(false)
+})
+
+listenNetworkConfirmed(() => {
+ receivedNetworkConfirmed = true
+ onlineManager.setOnline(true)
+})
+
+let checkPromise: Promise | undefined
+function checkIsOnlineIfNeeded() {
+ if (checkPromise) {
+ return
+ }
+ receivedNetworkLost = false
+ receivedNetworkConfirmed = false
+ checkPromise = checkIsOnline().then(nextIsOnline => {
+ checkPromise = undefined
+ if (nextIsOnline && receivedNetworkLost) {
+ isNetworkStateUnclear = true
+ }
+ if (!nextIsOnline && receivedNetworkConfirmed) {
+ isNetworkStateUnclear = true
+ }
+ if (!isNetworkStateUnclear) {
+ onlineManager.setOnline(nextIsOnline)
+ }
+ })
+}
+
+setInterval(() => {
+ if (AppState.currentState === 'active') {
+ if (!onlineManager.isOnline() || isNetworkStateUnclear) {
+ checkIsOnlineIfNeeded()
+ }
+ }
+}, 2000)
+
focusManager.setEventListener(onFocus => {
if (isNative) {
const subscription = AppState.addEventListener(
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 9a427ad40f..7ef0c9e2e6 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -14,6 +14,7 @@ export type LogEvents = {
}
'account:loggedOut': {
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
+ scope: 'current' | 'every'
}
'notifications:openApp': {}
'notifications:request': {
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index fed19adc86..8343edafd2 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -2,18 +2,10 @@ export type Gate =
// Keep this alphabetic please.
| 'auto_show_prof_sugg_follows'
| 'debug_show_feedcontext'
- | 'explore_page_profile_card_social_proof'
- | 'native_pwi_disabled'
+ | 'fixed_bottom_bar'
| 'new_user_guided_tour'
- | 'new_user_progress_guide'
| 'onboarding_minimum_interests'
- | 'request_notifications_permission_after_onboarding_v2'
- | 'session_withproxy_fix'
- | 'show_avi_follow_button'
| 'show_follow_back_label_v2'
| 'suggested_feeds_interstitial'
- | 'suggested_follows_interstitial'
- | 'ungroup_follow_backs'
| 'video_debug'
| 'videos'
- | 'small_avi_thumb'
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 44e42fae1c..3bae771c0a 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -103,16 +103,21 @@ export function parseEmbedPlayerFromUrl(
urlp.hostname === 'm.youtube.com' ||
urlp.hostname === 'music.youtube.com'
) {
- const [_, page, shortVideoId] = urlp.pathname.split('/')
+ const [_, page, shortOrLiveVideoId] = urlp.pathname.split('/')
+
+ const isShorts = page === 'shorts'
+ const isLive = page === 'live'
const videoId =
- page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
+ isShorts || isLive
+ ? shortOrLiveVideoId
+ : (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,
+ type: isShorts ? 'youtube_short' : 'youtube_video',
+ source: isShorts ? 'youtubeShorts' : 'youtube',
+ hideDetails: isShorts ? true : undefined,
playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
}
}
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index 263127440f..d0ea4cdc17 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -1,6 +1,7 @@
import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
-import {Theme, TypographyVariant} from './ThemeContext'
+
import {isWeb} from 'platform/detection'
+import {Theme, TypographyVariant} from './ThemeContext'
// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
export const colors = {
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index f3382fc438..9590f16596 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -295,9 +295,9 @@ export const darkTheme: Theme = {
...defaultTheme.palette,
default: {
background: darkPalette.black,
- backgroundLight: darkPalette.contrast_50,
+ backgroundLight: darkPalette.contrast_25,
text: darkPalette.white,
- textLight: darkPalette.contrast_700,
+ textLight: darkPalette.contrast_600,
textInverted: darkPalette.black,
link: darkPalette.primary_500,
border: darkPalette.contrast_100,
@@ -344,7 +344,7 @@ export const dimTheme: Theme = {
default: {
...darkTheme.palette.default,
background: dimPalette.black,
- backgroundLight: dimPalette.contrast_50,
+ backgroundLight: dimPalette.contrast_25,
text: dimPalette.white,
textLight: dimPalette.contrast_700,
textInverted: dimPalette.black,
diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx
index add550f93c..997fe419ed 100644
--- a/src/screens/Deactivated.tsx
+++ b/src/screens/Deactivated.tsx
@@ -38,7 +38,7 @@ export function Deactivated() {
const {setShowLoggedOut} = useLoggedOutViewControls()
const hasOtherAccounts = accounts.length > 1
const setMinimalShellMode = useSetMinimalShellMode()
- const {logout} = useSessionApi()
+ const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent()
const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState()
@@ -72,8 +72,8 @@ export function Deactivated() {
// So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/')
}
- logout('Deactivated')
- }, [logout])
+ logoutCurrentAccount('Deactivated')
+ }, [logoutCurrentAccount])
const handleActivate = React.useCallback(async () => {
try {
diff --git a/src/screens/List/ListHiddenScreen.tsx b/src/screens/List/ListHiddenScreen.tsx
new file mode 100644
index 0000000000..473bb08ea4
--- /dev/null
+++ b/src/screens/List/ListHiddenScreen.tsx
@@ -0,0 +1,216 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyGraphDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {RQKEY_ROOT as listQueryRoot} from '#/state/queries/list'
+import {useGoBack} from 'lib/hooks/useGoBack'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useListBlockMutation, useListMuteMutation} from 'state/queries/list'
+import {
+ UsePreferencesQueryResponse,
+ useRemoveFeedMutation,
+} from 'state/queries/preferences'
+import {useSession} from 'state/session'
+import * as Toast from 'view/com/util/Toast'
+import {CenteredView} from 'view/com/util/Views'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {Loader} from '#/components/Loader'
+import {useHider} from '#/components/moderation/Hider'
+import {Text} from '#/components/Typography'
+
+export function ListHiddenScreen({
+ list,
+ preferences,
+}: {
+ list: AppBskyGraphDefs.ListView
+ preferences: UsePreferencesQueryResponse
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const {currentAccount} = useSession()
+ const {gtMobile} = useBreakpoints()
+ const isOwner = currentAccount?.did === list.creator.did
+ const goBack = useGoBack()
+ const queryClient = useQueryClient()
+
+ const isModList = list.purpose === AppBskyGraphDefs.MODLIST
+
+ const [isProcessing, setIsProcessing] = React.useState(false)
+ const listBlockMutation = useListBlockMutation()
+ const listMuteMutation = useListMuteMutation()
+ const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation()
+
+ const {setIsContentVisible} = useHider()
+
+ const savedFeedConfig = preferences.savedFeeds.find(f => f.value === list.uri)
+
+ const onUnsubscribe = async () => {
+ setIsProcessing(true)
+ if (list.viewer?.muted) {
+ try {
+ await listMuteMutation.mutateAsync({uri: list.uri, mute: false})
+ } catch (e) {
+ setIsProcessing(false)
+ logger.error('Failed to unmute list', {message: e})
+ Toast.show(
+ _(
+ msg`There was an issue. Please check your internet connection and try again.`,
+ ),
+ )
+ return
+ }
+ }
+ if (list.viewer?.blocked) {
+ try {
+ await listBlockMutation.mutateAsync({uri: list.uri, block: false})
+ } catch (e) {
+ setIsProcessing(false)
+ logger.error('Failed to unblock list', {message: e})
+ Toast.show(
+ _(
+ msg`There was an issue. Please check your internet connection and try again.`,
+ ),
+ )
+ return
+ }
+ }
+ queryClient.invalidateQueries({
+ queryKey: [listQueryRoot],
+ })
+ Toast.show(_(msg`Unsubscribed from list`))
+ setIsProcessing(false)
+ }
+
+ const onRemoveList = async () => {
+ if (!savedFeedConfig) return
+ try {
+ await removeSavedFeed(savedFeedConfig)
+ Toast.show(_(msg`Removed from saved feeds`))
+ } catch (e) {
+ logger.error('Failed to remove list from saved feeds', {message: e})
+ Toast.show(
+ _(
+ msg`There was an issue. Please check your internet connection and try again.`,
+ ),
+ )
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ List has been hidden
+
+
+
+ This list - created by{' '}
+
+ {isOwner
+ ? _(msg`you`)
+ : sanitizeHandle(list.creator.handle, '@')}
+ {' '}
+ - contains possible violations of Bluesky's community guidelines
+ in its name or description.
+
+
+
+
+
+
+ {savedFeedConfig ? (
+
+ ) : null}
+ {isOwner ? (
+
+ ) : list.viewer?.muted || list.viewer?.blocked ? (
+
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index f54bde01fa..36b00ff26f 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -162,7 +162,7 @@ let ProfileHeaderStandard = ({
hideBackButton={hideBackButton}
isPlaceholderProfile={isPlaceholderProfile}>
()
@@ -44,7 +44,7 @@ function DeactivateAccountDialogInner({
setPending(true)
await agent.com.atproto.server.deactivateAccount({})
control.close(() => {
- logout('Deactivated')
+ logoutCurrentAccount('Deactivated')
})
} catch (e: any) {
switch (e.message) {
@@ -66,7 +66,7 @@ function DeactivateAccountDialogInner({
} finally {
setPending(false)
}
- }, [agent, control, logout, _, setPending])
+ }, [agent, control, logoutCurrentAccount, _, setPending])
return (
<>
diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx
index 4e4fedcfae..e7336569c6 100644
--- a/src/screens/SignupQueued.tsx
+++ b/src/screens/SignupQueued.tsx
@@ -23,7 +23,7 @@ export function SignupQueued() {
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
- const {logout} = useSessionApi()
+ const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent()
const [isProcessing, setProcessing] = React.useState(false)
@@ -40,7 +40,7 @@ export function SignupQueued() {
const res = await agent.com.atproto.temp.checkSignupQueue()
if (res.data.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
- await agent.refreshSession()
+ await agent.sessionManager.refreshSession()
if (!isSignupQueued(agent.session?.accessJwt)) {
onboardingDispatch({type: 'start'})
}
@@ -153,7 +153,7 @@ export function SignupQueued() {
variant="ghost"
size="large"
label={_(msg`Log out`)}
- onPress={() => logout('SignupQueued')}>
+ onPress={() => logoutCurrentAccount('SignupQueued')}>
Log out
@@ -182,7 +182,7 @@ export function SignupQueued() {
variant="ghost"
size="large"
label={_(msg`Log out`)}
- onPress={() => logout('SignupQueued')}>
+ onPress={() => logoutCurrentAccount('SignupQueued')}>
Log out
diff --git a/src/state/events.ts b/src/state/events.ts
index 1384abdeda..dcd36464ec 100644
--- a/src/state/events.ts
+++ b/src/state/events.ts
@@ -22,6 +22,22 @@ export function listenSessionDropped(fn: () => void): UnlistenFn {
return () => emitter.off('session-dropped', fn)
}
+export function emitNetworkConfirmed() {
+ emitter.emit('network-confirmed')
+}
+export function listenNetworkConfirmed(fn: () => void): UnlistenFn {
+ emitter.on('network-confirmed', fn)
+ return () => emitter.off('network-confirmed', fn)
+}
+
+export function emitNetworkLost() {
+ emitter.emit('network-lost')
+}
+export function listenNetworkLost(fn: () => void): UnlistenFn {
+ emitter.on('network-lost', fn)
+ return () => emitter.off('network-lost', fn)
+}
+
export function emitPostCreated() {
emitter.emit('post-created')
}
diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx
index aab2737e5a..29f328a626 100644
--- a/src/state/feed-feedback.tsx
+++ b/src/state/feed-feedback.tsx
@@ -1,11 +1,10 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
-import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
+import {AppBskyFeedDefs} from '@atproto/api'
import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
-import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
import {getFeedPostSlice} from '#/view/com/posts/Feed'
@@ -25,7 +24,6 @@ const stateContext = React.createContext({
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const agent = useAgent()
- const gate = useGate()
const enabled = isDiscoverFeed(feed) && hasSession
const queue = React.useRef>(new Set())
const history = React.useRef<
@@ -49,34 +47,20 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
queue.current.clear()
// Send to the feed
- if (gate('session_withproxy_fix')) {
- agent.app.bsky.feed
- .sendInteractions(
- {interactions},
- {
- encoding: 'application/json',
- headers: {
- // TODO when we start sending to other feeds, we need to grab their DID -prf
- 'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
- },
+ agent.app.bsky.feed
+ .sendInteractions(
+ {interactions},
+ {
+ encoding: 'application/json',
+ headers: {
+ // TODO when we start sending to other feeds, we need to grab their DID -prf
+ 'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
},
- )
- .catch((e: any) => {
- logger.warn('Failed to send feed interactions', {error: e})
- })
- } else {
- const proxyAgent = agent.withProxy(
- // @ts-ignore TODO need to update withProxy() to support this key -prf
- 'bsky_fg',
- // TODO when we start sending to other feeds, we need to grab their DID -prf
- 'did:web:discover.bsky.app',
- ) as BskyAgent
- proxyAgent.app.bsky.feed
- .sendInteractions({interactions})
- .catch((e: any) => {
- logger.warn('Failed to send feed interactions', {error: e})
- })
- }
+ },
+ )
+ .catch((e: any) => {
+ logger.warn('Failed to send feed interactions', {error: e})
+ })
// Send to Statsig
if (aggregatedStats.current === null) {
@@ -84,7 +68,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
}
sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
throttledFlushAggregatedStats()
- }, [agent, gate, throttledFlushAggregatedStats])
+ }, [agent, throttledFlushAggregatedStats])
const sendToFeed = React.useMemo(
() =>
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index 2b6751e890..e5ce19a9ad 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -454,7 +454,8 @@ export function usePinnedFeedsInfos() {
}),
)
- await Promise.allSettled([feedsPromise, ...listsPromises])
+ await feedsPromise // Fail the whole query if it fails.
+ await Promise.allSettled(listsPromises) // Ignore individual failing ones.
// order the feeds/lists in the order they were pinned
const result: SavedFeedSourceInfo[] = []
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
index eeb9c3b381..405cb4ae3e 100644
--- a/src/state/queries/list.ts
+++ b/src/state/queries/list.ts
@@ -17,7 +17,7 @@ import {useAgent, useSession} from '../session'
import {invalidate as invalidateMyLists} from './my-lists'
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
-const RQKEY_ROOT = 'list'
+export const RQKEY_ROOT = 'list'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
export function useListQuery(uri?: string) {
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 3054860db2..997076e819 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -26,7 +26,6 @@ import {
useQueryClient,
} from '@tanstack/react-query'
-import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
@@ -59,7 +58,6 @@ export function useNotificationFeedQuery(opts?: {
const moderationOpts = useModerationOpts()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false
- const gate = useGate()
// false: force showing all notifications
// undefined: let the server decide
@@ -88,7 +86,6 @@ export function useNotificationFeedQuery(opts?: {
queryClient,
moderationOpts,
fetchAdditionalData: true,
- shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
priority,
})
page = fetchedPage
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
index b5f7d0d60b..7bb325ea98 100644
--- a/src/state/queries/notifications/unread.tsx
+++ b/src/state/queries/notifications/unread.tsx
@@ -8,7 +8,6 @@ import {useQueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
-import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
import {resetBadgeCount} from 'lib/notifications/notifications'
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const agent = useAgent()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
- const gate = useGate()
const [numUnread, setNumUnread] = React.useState('')
@@ -151,7 +149,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// only fetch subjects when the page is going to be used
// in the notifications query, otherwise skip it
fetchAdditionalData: !!invalidate,
- shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
})
const unreadCount = countUnread(page)
const unreadCountStr =
@@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
},
}
- }, [setNumUnread, queryClient, moderationOpts, agent, gate])
+ }, [setNumUnread, queryClient, moderationOpts, agent])
checkUnreadRef.current = api.checkUnread
return (
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 7651e414a4..e0ee02294e 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -30,7 +30,6 @@ export async function fetchPage({
queryClient,
moderationOpts,
fetchAdditionalData,
- shouldUngroupFollowBacks,
}: {
agent: BskyAgent
cursor: string | undefined
@@ -38,7 +37,6 @@ export async function fetchPage({
queryClient: QueryClient
moderationOpts: ModerationOpts | undefined
fetchAdditionalData: boolean
- shouldUngroupFollowBacks?: () => boolean
priority?: boolean
}): Promise<{
page: FeedPage
@@ -58,7 +56,7 @@ export async function fetchPage({
)
// group notifications which are essentially similar (follows, likes on a post)
- let notifsGrouped = groupNotifications(notifs, {shouldUngroupFollowBacks})
+ let notifsGrouped = groupNotifications(notifs)
// we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts
@@ -117,7 +115,6 @@ export function shouldFilterNotif(
export function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[],
- options?: {shouldUngroupFollowBacks?: () => boolean},
): FeedNotification[] {
const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) {
@@ -137,9 +134,7 @@ export function groupNotifications(
const prevIsFollowBack =
groupedNotif.notification.reason === 'follow' &&
groupedNotif.notification.author.viewer?.following
- const shouldUngroup =
- (nextIsFollowBack || prevIsFollowBack) &&
- options?.shouldUngroupFollowBacks?.()
+ const shouldUngroup = nextIsFollowBack || prevIsFollowBack
if (!shouldUngroup) {
groupedNotif.additional = groupedNotif.additional || []
groupedNotif.additional.push(notif)
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 724043e586..ee3e2c14d2 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -80,6 +80,7 @@ export interface FeedPostSliceItem {
moderation: ModerationDecision
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
isParentBlocked?: boolean
+ isParentNotFound?: boolean
}
export interface FeedPostSlice {
@@ -326,6 +327,7 @@ export function usePostFeedQuery(
moderation: moderations[i],
parentAuthor: item.parentAuthor,
isParentBlocked: item.isParentBlocked,
+ isParentNotFound: item.isParentNotFound,
}
return feedPostSliceItem
}),
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index c01b96ed81..fd419d1c44 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -137,6 +137,7 @@ export function sortThread(
opts: UsePreferencesQueryResponse['threadViewPrefs'],
modCache: ThreadModerationCache,
currentDid: string | undefined,
+ justPostedUris: Set,
): ThreadNode {
if (node.type !== 'post') {
return node
@@ -150,6 +151,20 @@ export function sortThread(
return -1
}
+ if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
+ const aIsJustPosted =
+ a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
+ const bIsJustPosted =
+ b.post.author.did === currentDid && justPostedUris.has(b.post.uri)
+ if (aIsJustPosted && bIsJustPosted) {
+ return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
+ } else if (aIsJustPosted) {
+ return -1 // reply while onscreen
+ } else if (bIsJustPosted) {
+ return 1 // reply while onscreen
+ }
+ }
+
const aIsByOp = a.post.author.did === node.post?.author.did
const bIsByOp = b.post.author.did === node.post?.author.did
if (aIsByOp && bIsByOp) {
@@ -206,7 +221,9 @@ export function sortThread(
}
return b.post.indexedAt.localeCompare(a.post.indexedAt)
})
- node.replies.forEach(reply => sortThread(reply, opts, modCache, currentDid))
+ node.replies.forEach(reply =>
+ sortThread(reply, opts, modCache, currentDid, justPostedUris),
+ )
}
return node
}
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 6991f8647b..ab866d5e2a 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -37,14 +37,14 @@ export function usePreferencesQuery() {
refetchOnWindowFocus: true,
queryKey: preferencesQueryKey,
queryFn: async () => {
- if (agent.session?.did === undefined) {
+ if (!agent.did) {
return DEFAULT_LOGGED_OUT_PREFERENCES
} else {
const res = await agent.getPreferences()
// save to local storage to ensure there are labels on initial requests
saveLabelers(
- agent.session.did,
+ agent.did,
res.moderationPrefs.labelers.map(l => l.did),
)
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
index 75e3dd6e48..03c983ff80 100644
--- a/src/state/queries/profile-lists.ts
+++ b/src/state/queries/profile-lists.ts
@@ -40,18 +40,10 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
pages: data.pages.map(page => {
return {
...page,
- lists: page.lists
- /*
- * Starter packs use a reference list, which we do not want to
- * show on profiles. At some point we could probably just filter
- * this out on the backend instead of in the client.
- */
- .filter(l => l.purpose !== 'app.bsky.graph.defs#referencelist')
- // filter by labels
- .filter(list => {
- const decision = moderateUserList(list, moderationOpts!)
- return !decision.ui('contentList').filter
- }),
+ lists: page.lists.filter(list => {
+ const decision = moderateUserList(list, moderationOpts!)
+ return !decision.ui('contentList').filter
+ }),
}
}),
}
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 1f866d26d2..6682cf3c89 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -222,6 +222,7 @@ export function useProfileFollowMutationQueue(
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'],
) {
+ const agent = useAgent()
const queryClient = useQueryClient()
const did = profile.did
const initialFollowingUri = profile.viewer?.following
@@ -253,6 +254,20 @@ export function useProfileFollowMutationQueue(
updateProfileShadow(queryClient, did, {
followingUri: finalFollowingUri,
})
+
+ if (finalFollowingUri) {
+ agent.app.bsky.graph
+ .getSuggestedFollowsByActor({
+ actor: did,
+ })
+ .then(res => {
+ const dids = res.data.suggestions
+ .filter(a => !a.viewer?.following)
+ .map(a => a.did)
+ .slice(0, 8)
+ userActionHistory.followSuggestion(dids)
+ })
+ }
},
})
diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts
index 486604169a..3e22c262cb 100644
--- a/src/state/session/__tests__/session-test.ts
+++ b/src/state/session/__tests__/session-test.ts
@@ -27,7 +27,7 @@ describe('session', () => {
`)
const agent = new BskyAgent({service: 'https://alice.com'})
- agent.session = {
+ agent.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -76,7 +76,7 @@ describe('session', () => {
state = run(state, [
{
- type: 'logged-out',
+ type: 'logged-out-every-account',
},
])
// Should keep the account but clear out the tokens.
@@ -118,7 +118,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -166,7 +166,7 @@ describe('session', () => {
`)
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -230,7 +230,7 @@ describe('session', () => {
`)
const agent3 = new BskyAgent({service: 'https://alice.com'})
- agent3.session = {
+ agent3.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -294,7 +294,7 @@ describe('session', () => {
`)
const agent4 = new BskyAgent({service: 'https://jay.com'})
- agent4.session = {
+ agent4.sessionManager.session = {
active: true,
did: 'jay-did',
handle: 'jay.test',
@@ -372,7 +372,7 @@ describe('session', () => {
state = run(state, [
{
// Log everyone out.
- type: 'logged-out',
+ type: 'logged-out-every-account',
},
])
expect(state.accounts.length).toBe(3)
@@ -445,7 +445,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -466,7 +466,7 @@ describe('session', () => {
state = run(state, [
{
- type: 'logged-out',
+ type: 'logged-out-every-account',
},
])
expect(state.accounts.length).toBe(1)
@@ -502,7 +502,7 @@ describe('session', () => {
`)
const agent2 = new BskyAgent({service: 'https://alice.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -553,7 +553,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -598,7 +598,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -606,7 +606,7 @@ describe('session', () => {
refreshJwt: 'alice-refresh-jwt-1',
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -674,11 +674,108 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe(undefined)
})
+ it('can log out of the current account', () => {
+ let state = getInitialState([])
+
+ const agent1 = new BskyAgent({service: 'https://alice.com'})
+ agent1.sessionManager.session = {
+ active: true,
+ did: 'alice-did',
+ handle: 'alice.test',
+ accessJwt: 'alice-access-jwt-1',
+ refreshJwt: 'alice-refresh-jwt-1',
+ }
+ state = run(state, [
+ {
+ type: 'switched-to-account',
+ newAgent: agent1,
+ newAccount: agentToSessionAccountOrThrow(agent1),
+ },
+ ])
+ expect(state.accounts.length).toBe(1)
+ expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
+ expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
+ expect(state.currentAgentState.did).toBe('alice-did')
+
+ const agent2 = new BskyAgent({service: 'https://bob.com'})
+ agent2.sessionManager.session = {
+ active: true,
+ did: 'bob-did',
+ handle: 'bob.test',
+ accessJwt: 'bob-access-jwt-1',
+ refreshJwt: 'bob-refresh-jwt-1',
+ }
+ state = run(state, [
+ {
+ type: 'switched-to-account',
+ newAgent: agent2,
+ newAccount: agentToSessionAccountOrThrow(agent2),
+ },
+ ])
+ expect(state.accounts.length).toBe(2)
+ expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1')
+ expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-1')
+ expect(state.currentAgentState.did).toBe('bob-did')
+
+ state = run(state, [
+ {
+ type: 'logged-out-current-account',
+ },
+ ])
+ expect(state.accounts.length).toBe(2)
+ expect(state.accounts[0].accessJwt).toBe(undefined)
+ expect(state.accounts[0].refreshJwt).toBe(undefined)
+ expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1')
+ expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1')
+ expect(state.currentAgentState.did).toBe(undefined)
+ expect(printState(state)).toMatchInlineSnapshot(`
+ {
+ "accounts": [
+ {
+ "accessJwt": undefined,
+ "active": true,
+ "did": "bob-did",
+ "email": undefined,
+ "emailAuthFactor": false,
+ "emailConfirmed": false,
+ "handle": "bob.test",
+ "pdsUrl": undefined,
+ "refreshJwt": undefined,
+ "service": "https://bob.com/",
+ "signupQueued": false,
+ "status": undefined,
+ },
+ {
+ "accessJwt": "alice-access-jwt-1",
+ "active": true,
+ "did": "alice-did",
+ "email": undefined,
+ "emailAuthFactor": false,
+ "emailConfirmed": false,
+ "handle": "alice.test",
+ "pdsUrl": undefined,
+ "refreshJwt": "alice-refresh-jwt-1",
+ "service": "https://alice.com/",
+ "signupQueued": false,
+ "status": undefined,
+ },
+ ],
+ "currentAgentState": {
+ "agent": {
+ "service": "https://public.api.bsky.app/",
+ },
+ "did": undefined,
+ },
+ "needsPersist": true,
+ }
+ `)
+ })
+
it('updates stored account with refreshed tokens', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -695,7 +792,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -748,7 +845,7 @@ describe('session', () => {
}
`)
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -801,7 +898,7 @@ describe('session', () => {
}
`)
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -859,7 +956,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -876,7 +973,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -907,7 +1004,7 @@ describe('session', () => {
])
expect(lastState === state).toBe(true)
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -931,7 +1028,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -940,7 +1037,7 @@ describe('session', () => {
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -965,7 +1062,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(2)
expect(state.currentAgentState.did).toBe('bob-did')
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -1032,7 +1129,7 @@ describe('session', () => {
}
`)
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob-updated.test',
@@ -1099,7 +1196,7 @@ describe('session', () => {
// Ignore other events for inactive agent.
const lastState = state
- agent1.session = undefined
+ agent1.sessionManager.session = undefined
state = run(state, [
{
type: 'received-agent-event',
@@ -1126,7 +1223,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1135,7 +1232,7 @@ describe('session', () => {
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -1162,7 +1259,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('bob-did')
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1184,11 +1281,11 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('bob-did')
})
- it('does soft logout on network error', () => {
+ it('ignores network errors', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1206,7 +1303,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = undefined
+ agent1.sessionManager.session = undefined
state = run(state, [
{
type: 'received-agent-event',
@@ -1217,11 +1314,9 @@ describe('session', () => {
},
])
expect(state.accounts.length).toBe(1)
- // Network error should reset current user but not reset the tokens.
- // TODO: We might want to remove or change this behavior?
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
- expect(state.currentAgentState.did).toBe(undefined)
+ expect(state.currentAgentState.did).toBe('alice-did')
expect(printState(state)).toMatchInlineSnapshot(`
{
"accounts": [
@@ -1242,9 +1337,9 @@ describe('session', () => {
],
"currentAgentState": {
"agent": {
- "service": "https://public.api.bsky.app/",
+ "service": "https://alice.com/",
},
- "did": undefined,
+ "did": "alice-did",
},
"needsPersist": true,
}
@@ -1255,7 +1350,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1273,7 +1368,7 @@ describe('session', () => {
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = undefined
+ agent1.sessionManager.session = undefined
state = run(state, [
{
type: 'received-agent-event',
@@ -1320,7 +1415,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1338,7 +1433,7 @@ describe('session', () => {
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = undefined
+ agent1.sessionManager.session = undefined
state = run(state, [
{
type: 'received-agent-event',
@@ -1385,7 +1480,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1393,7 +1488,7 @@ describe('session', () => {
refreshJwt: 'alice-refresh-jwt-1',
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -1416,7 +1511,7 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('bob-did')
const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'})
- anotherTabAgent1.session = {
+ anotherTabAgent1.sessionManager.session = {
active: true,
did: 'jay-did',
handle: 'jay.test',
@@ -1424,7 +1519,7 @@ describe('session', () => {
refreshJwt: 'jay-refresh-jwt-1',
}
const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'})
- anotherTabAgent2.session = {
+ anotherTabAgent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -1492,7 +1587,7 @@ describe('session', () => {
`)
const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'})
- anotherTabAgent3.session = {
+ anotherTabAgent3.sessionManager.session = {
active: true,
did: 'clarence-did',
handle: 'clarence.test',
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 4456ab0bf9..8a48cf95e5 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -12,6 +12,7 @@ import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
+import {emitNetworkConfirmed, emitNetworkLost} from '../events'
import {addSessionErrorLog} from './logging'
import {
configureModerationForAccount,
@@ -22,7 +23,7 @@ import {isSessionExpired, isSignupQueued} from './util'
export function createPublicAgent() {
configureModerationForGuest() // Side effect but only relevant for tests
- return new BskyAgent({service: PUBLIC_BSKY_SERVICE})
+ return new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
}
export async function createAgentAndResume(
@@ -33,9 +34,9 @@ export async function createAgentAndResume(
event: AtpSessionEvent,
) => void,
) {
- const agent = new BskyAgent({service: storedAccount.service})
+ const agent = new BskyAppAgent({service: storedAccount.service})
if (storedAccount.pdsUrl) {
- agent.pdsUrl = agent.api.xrpc.uri = new URL(storedAccount.pdsUrl)
+ agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
}
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
const moderation = configureModerationForAccount(agent, storedAccount)
@@ -43,9 +44,8 @@ export async function createAgentAndResume(
if (isSessionExpired(storedAccount)) {
await networkRetry(1, () => agent.resumeSession(prevSession))
} else {
- agent.session = prevSession
+ agent.sessionManager.session = prevSession
if (!storedAccount.signupQueued) {
- // Intentionally not awaited to unblock the UI:
networkRetry(3, () => agent.resumeSession(prevSession)).catch(
(e: any) => {
logger.error(`networkRetry failed to resume session`, {
@@ -60,7 +60,7 @@ export async function createAgentAndResume(
}
}
- return prepareAgent(agent, gates, moderation, onSessionChange)
+ return agent.prepare(gates, moderation, onSessionChange)
}
export async function createAgentAndLogin(
@@ -81,13 +81,13 @@ export async function createAgentAndLogin(
event: AtpSessionEvent,
) => void,
) {
- const agent = new BskyAgent({service})
+ const agent = new BskyAppAgent({service})
await agent.login({identifier, password, authFactorToken})
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const moderation = configureModerationForAccount(agent, account)
- return prepareAgent(agent, moderation, gates, onSessionChange)
+ return agent.prepare(gates, moderation, onSessionChange)
}
export async function createAgentAndCreateAccount(
@@ -116,7 +116,7 @@ export async function createAgentAndCreateAccount(
event: AtpSessionEvent,
) => void,
) {
- const agent = new BskyAgent({service})
+ const agent = new BskyAppAgent({service})
await agent.createAccount({
email,
password,
@@ -174,32 +174,7 @@ export async function createAgentAndCreateAccount(
logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`})
}
- return prepareAgent(agent, gates, moderation, onSessionChange)
-}
-
-async function prepareAgent(
- agent: BskyAgent,
- // Not awaited in the calling code so we can delay blocking on them.
- gates: Promise,
- moderation: Promise,
- onSessionChange: (
- agent: BskyAgent,
- did: string,
- event: AtpSessionEvent,
- ) => void,
-) {
- // There's nothing else left to do, so block on them here.
- await Promise.all([gates, moderation])
-
- // Now the agent is ready.
- const account = agentToSessionAccountOrThrow(agent)
- agent.setPersistSessionHandler(event => {
- onSessionChange(agent, account.did, event)
- if (event !== 'create' && event !== 'update') {
- addSessionErrorLog(account.did, event)
- }
- })
- return {agent, account}
+ return agent.prepare(gates, moderation, onSessionChange)
}
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
@@ -251,3 +226,77 @@ export function sessionAccountToSession(
status: account.status,
}
}
+
+// Not exported. Use factories above to create it.
+let realFetch = globalThis.fetch
+class BskyAppAgent extends BskyAgent {
+ persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
+ undefined
+
+ constructor({service}: {service: string}) {
+ super({
+ service,
+ async fetch(...args) {
+ let success = false
+ try {
+ const result = await realFetch(...args)
+ success = true
+ return result
+ } catch (e) {
+ success = false
+ throw e
+ } finally {
+ if (success) {
+ emitNetworkConfirmed()
+ } else {
+ emitNetworkLost()
+ }
+ }
+ },
+ persistSession: (event: AtpSessionEvent) => {
+ if (this.persistSessionHandler) {
+ this.persistSessionHandler(event)
+ }
+ },
+ })
+ }
+
+ async prepare(
+ // Not awaited in the calling code so we can delay blocking on them.
+ gates: Promise,
+ moderation: Promise,
+ onSessionChange: (
+ agent: BskyAgent,
+ did: string,
+ event: AtpSessionEvent,
+ ) => void,
+ ) {
+ // There's nothing else left to do, so block on them here.
+ await Promise.all([gates, moderation])
+
+ // Now the agent is ready.
+ const account = agentToSessionAccountOrThrow(this)
+ let lastSession = this.sessionManager.session
+ this.persistSessionHandler = event => {
+ if (this.sessionManager.session) {
+ lastSession = this.sessionManager.session
+ } else if (event === 'network-error') {
+ // Put it back, we'll try again later.
+ this.sessionManager.session = lastSession
+ }
+
+ onSessionChange(this, account.did, event)
+ if (event !== 'create' && event !== 'update') {
+ addSessionErrorLog(account.did, event)
+ }
+ }
+ return {account, agent: this}
+ }
+
+ dispose() {
+ this.sessionManager.session = undefined
+ this.persistSessionHandler = undefined
+ }
+}
+
+export type {BskyAppAgent}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 09fcf86642..21fe7f75b9 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -11,6 +11,7 @@ import {IS_DEV} from '#/env'
import {emitSessionDropped} from '../events'
import {
agentToSessionAccount,
+ BskyAppAgent,
createAgentAndCreateAccount,
createAgentAndLogin,
createAgentAndResume,
@@ -34,7 +35,8 @@ const AgentContext = React.createContext(null)
const ApiContext = React.createContext({
createAccount: async () => {},
login: async () => {},
- logout: async () => {},
+ logoutCurrentAccount: async () => {},
+ logoutEveryAccount: async () => {},
resumeSession: async () => {},
removeAccount: () => {},
})
@@ -114,14 +116,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[onAgentSessionChange, cancelPendingTask],
)
- const logout = React.useCallback(
+ const logoutCurrentAccount = React.useCallback<
+ SessionApiContext['logoutEveryAccount']
+ >(
logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
dispatch({
- type: 'logged-out',
+ type: 'logged-out-current-account',
})
- logEvent('account:loggedOut', {logContext})
+ logEvent('account:loggedOut', {logContext, scope: 'current'})
+ addSessionDebugLog({type: 'method:end', method: 'logout'})
+ },
+ [cancelPendingTask],
+ )
+
+ const logoutEveryAccount = React.useCallback<
+ SessionApiContext['logoutEveryAccount']
+ >(
+ logContext => {
+ addSessionDebugLog({type: 'method:start', method: 'logout'})
+ cancelPendingTask()
+ dispatch({
+ type: 'logged-out-every-account',
+ })
+ logEvent('account:loggedOut', {logContext, scope: 'every'})
addSessionDebugLog({type: 'method:end', method: 'logout'})
},
[cancelPendingTask],
@@ -202,7 +221,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} else {
const agent = state.currentAgentState.agent as BskyAgent
const prevSession = agent.session
- agent.session = sessionAccountToSession(syncedAccount)
+ agent.sessionManager.session = sessionAccountToSession(syncedAccount)
addSessionDebugLog({
type: 'agent:patch',
agent,
@@ -229,17 +248,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({
createAccount,
login,
- logout,
+ logoutCurrentAccount,
+ logoutEveryAccount,
resumeSession,
removeAccount,
}),
- [createAccount, login, logout, resumeSession, removeAccount],
+ [
+ createAccount,
+ login,
+ logoutCurrentAccount,
+ logoutEveryAccount,
+ resumeSession,
+ removeAccount,
+ ],
)
// @ts-ignore
if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent
- const agent = state.currentAgentState.agent as BskyAgent
+ const agent = state.currentAgentState.agent as BskyAppAgent
const currentAgentRef = React.useRef(agent)
React.useEffect(() => {
if (currentAgentRef.current !== agent) {
@@ -249,8 +276,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
// We never reuse agents so let's fully neutralize the previous one.
// This ensures it won't try to consume any refresh tokens.
- prevAgent.session = undefined
- prevAgent.setPersistSessionHandler(undefined)
+ prevAgent.dispose()
}
}, [agent])
diff --git a/src/state/session/logging.ts b/src/state/session/logging.ts
index b57f1fa0b0..7e1df500be 100644
--- a/src/state/session/logging.ts
+++ b/src/state/session/logging.ts
@@ -56,7 +56,7 @@ type Log =
type: 'agent:patch'
agent: object
prevSession: AtpSessionData | undefined
- nextSession: AtpSessionData
+ nextSession: AtpSessionData | undefined
}
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts
index 0a537b42c6..22ba47162a 100644
--- a/src/state/session/reducer.ts
+++ b/src/state/session/reducer.ts
@@ -42,7 +42,10 @@ export type Action =
accountDid: string
}
| {
- type: 'logged-out'
+ type: 'logged-out-current-account'
+ }
+ | {
+ type: 'logged-out-every-account'
}
| {
type: 'synced-accounts'
@@ -79,12 +82,8 @@ let reducer = (state: State, action: Action): State => {
return state
}
if (sessionEvent === 'network-error') {
- // Don't change stored accounts but kick to the choose account screen.
- return {
- accounts: state.accounts,
- currentAgentState: createPublicAgentState(),
- needsPersist: true,
- }
+ // Assume it's transient.
+ return state
}
const existingAccount = state.accounts.find(a => a.did === accountDid)
if (
@@ -142,7 +141,23 @@ let reducer = (state: State, action: Action): State => {
needsPersist: true,
}
}
- case 'logged-out': {
+ case 'logged-out-current-account': {
+ const {currentAgentState} = state
+ return {
+ accounts: state.accounts.map(a =>
+ a.did === currentAgentState.did
+ ? {
+ ...a,
+ refreshJwt: undefined,
+ accessJwt: undefined,
+ }
+ : a,
+ ),
+ currentAgentState: createPublicAgentState(),
+ needsPersist: true,
+ }
+ }
+ case 'logged-out-every-account': {
return {
accounts: state.accounts.map(a => ({
...a,
diff --git a/src/state/session/types.ts b/src/state/session/types.ts
index d43b57cca9..d32259de9d 100644
--- a/src/state/session/types.ts
+++ b/src/state/session/types.ts
@@ -29,12 +29,12 @@ export type SessionApiContext = {
},
logContext: LogEvents['account:loggedIn']['logContext'],
) => Promise
- /**
- * A full logout. Clears the `currentAccount` from session, AND removes
- * access tokens from all accounts, so that returning as any user will
- * require a full login.
- */
- logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void
+ logoutCurrentAccount: (
+ logContext: LogEvents['account:loggedOut']['logContext'],
+ ) => void
+ logoutEveryAccount: (
+ logContext: LogEvents['account:loggedOut']['logContext'],
+ ) => void
resumeSession: (account: SessionAccount) => Promise
removeAccount: (account: SessionAccount) => void
}
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index 5b4e505439..e28d6b4ac0 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -1,10 +1,11 @@
import React from 'react'
import {
+ AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyRichtextFacet,
ModerationDecision,
- AppBskyActorDefs,
} from '@atproto/api'
+
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export interface ComposerOptsPostRef {
@@ -31,7 +32,7 @@ export interface ComposerOptsQuote {
}
export interface ComposerOpts {
replyTo?: ComposerOptsPostRef
- onPost?: () => void
+ onPost?: (postUri: string | undefined) => void
quote?: ComposerOptsQuote
mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void
diff --git a/src/state/shell/progress-guide.tsx b/src/state/shell/progress-guide.tsx
index c9b42a263e..d64e9984f5 100644
--- a/src/state/shell/progress-guide.tsx
+++ b/src/state/shell/progress-guide.tsx
@@ -2,7 +2,6 @@ import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useGate} from '#/lib/statsig/statsig'
import {
ProgressGuideToast,
ProgressGuideToastRef,
@@ -61,7 +60,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const {data: preferences} = usePreferencesQuery()
const {mutateAsync, variables, isPending} =
useSetActiveProgressGuideMutation()
- const gate = useGate()
const activeProgressGuide = (
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
@@ -89,9 +87,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const controls = React.useMemo(() => {
return {
startProgressGuide(guide: ProgressGuideName) {
- if (!gate('new_user_progress_guide')) {
- return
- }
if (guide === 'like-10-and-follow-7') {
const guideObj = {
guide: 'like-10-and-follow-7',
@@ -148,7 +143,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
mutateAsync(guide?.isComplete ? undefined : guide)
},
}
- }, [activeProgressGuide, mutateAsync, gate, setLocalGuideState])
+ }, [activeProgressGuide, mutateAsync, setLocalGuideState])
return (
diff --git a/src/state/userActionHistory.ts b/src/state/userActionHistory.ts
index d82b3723a4..8ffe7241e6 100644
--- a/src/state/userActionHistory.ts
+++ b/src/state/userActionHistory.ts
@@ -2,6 +2,7 @@ import React from 'react'
const LIKE_WINDOW = 100
const FOLLOW_WINDOW = 100
+const FOLLOW_SUGGESTION_WINDOW = 100
const SEEN_WINDOW = 100
export type SeenPost = {
@@ -22,6 +23,10 @@ export type UserActionHistory = {
* The last 100 DIDs the user has followed
*/
follows: string[]
+ /*
+ * The last 100 DIDs of suggested follows based on last follows
+ */
+ followSuggestions: string[]
/**
* The last 100 post URIs the user has seen from the Discover feed only
*/
@@ -31,6 +36,7 @@ export type UserActionHistory = {
const userActionHistory: UserActionHistory = {
likes: [],
follows: [],
+ followSuggestions: [],
seen: [],
}
@@ -58,6 +64,13 @@ export function follow(dids: string[]) {
.concat(dids)
.slice(-FOLLOW_WINDOW)
}
+
+export function followSuggestion(dids: string[]) {
+ userActionHistory.followSuggestions = userActionHistory.followSuggestions
+ .concat(dids)
+ .slice(-FOLLOW_SUGGESTION_WINDOW)
+}
+
export function unfollow(dids: string[]) {
userActionHistory.follows = userActionHistory.follows.filter(
uri => !dids.includes(uri),
diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx
index cc71f648e9..cc57238053 100644
--- a/src/view/com/auth/LoggedOut.tsx
+++ b/src/view/com/auth/LoggedOut.tsx
@@ -1,25 +1,20 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg, Trans} from '@lingui/macro'
+import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/native'
import {useAnalytics} from '#/lib/analytics/analytics'
import {usePalette} from '#/lib/hooks/usePalette'
import {logEvent} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
-import {isIOS, isNative} from '#/platform/detection'
-import {useSession} from '#/state/session'
+import {isIOS} from '#/platform/detection'
import {
useLoggedOutView,
useLoggedOutViewControls,
} from '#/state/shell/logged-out'
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
-import {NavigationProp} from 'lib/routes/types'
-import {useGate} from 'lib/statsig/statsig'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
-import {Text} from '#/view/com/util/text/Text'
import {Login} from '#/screens/Login'
import {Signup} from '#/screens/Signup'
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
@@ -34,7 +29,6 @@ enum ScreenState {
export {ScreenState as LoggedOutScreenState}
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
- const {hasSession} = useSession()
const {_} = useLingui()
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
@@ -52,10 +46,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
}
})
const {clearRequestedAccount} = useLoggedOutViewControls()
- const navigation = useNavigation()
- const gate = useGate()
- const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount
React.useEffect(() => {
screen('Login')
setMinimalShellMode(true)
@@ -68,10 +59,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
clearRequestedAccount()
}, [clearRequestedAccount, onDismiss])
- const onPressSearch = React.useCallback(() => {
- navigation.navigate(`SearchTab`)
- }, [navigation])
-
return (
@@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
}}
/>
- ) : isNative &&
- !hasSession &&
- isFirstScreen &&
- !gate('native_pwi_disabled') ? (
-
-
- Search{' '}
-
-
-
) : null}
{screenState === ScreenState.S_StarterPack ? (
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 08ce4441f0..dba37d82bf 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({
emitPostCreated()
}
setLangPrefs.savePostLanguageToHistory()
- onPost?.()
+ onPost?.(postUri)
onClose()
Toast.show(
replyTo
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index 6b38caff03..d4ba1f3a86 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
/>
-
+
{sanitizeDisplayName(
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
)}
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index cb16e3c666..f69c895693 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -85,71 +85,59 @@ export const TextInput = forwardRef(function TextInputImpl(
const pastSuggestedUris = useRef(new Set())
const prevDetectedUris = useRef(new Map())
const onChangeText = useCallback(
- (newText: string) => {
- /*
- * This is a hack to bump the rendering of our styled
- * `textDecorated` to _after_ whatever processing is happening
- * within the `PasteInput` library. Without this, the elements in
- * `textDecorated` are not correctly painted to screen.
- *
- * NB: we tried a `0` timeout as well, but only positive values worked.
- *
- * @see https://github.com/bluesky-social/social-app/issues/929
- */
- setTimeout(async () => {
- const mayBePaste = newText.length > prevLength.current + 1
+ async (newText: string) => {
+ const mayBePaste = newText.length > prevLength.current + 1
- const newRt = new RichText({text: newText})
- newRt.detectFacetsWithoutResolution()
- setRichText(newRt)
+ const newRt = new RichText({text: newText})
+ newRt.detectFacetsWithoutResolution()
+ setRichText(newRt)
- const prefix = getMentionAt(
- newText,
- textInputSelection.current?.start || 0,
- )
- if (prefix) {
- setAutocompletePrefix(prefix.value)
- } else if (autocompletePrefix) {
- setAutocompletePrefix('')
- }
+ const prefix = getMentionAt(
+ newText,
+ textInputSelection.current?.start || 0,
+ )
+ if (prefix) {
+ setAutocompletePrefix(prefix.value)
+ } else if (autocompletePrefix) {
+ setAutocompletePrefix('')
+ }
- const nextDetectedUris = new Map()
- if (newRt.facets) {
- for (const facet of newRt.facets) {
- for (const feature of facet.features) {
- if (AppBskyRichtextFacet.isLink(feature)) {
- if (isUriImage(feature.uri)) {
- const res = await downloadAndResize({
- uri: feature.uri,
- width: POST_IMG_MAX.width,
- height: POST_IMG_MAX.height,
- mode: 'contain',
- maxSize: POST_IMG_MAX.size,
- timeout: 15e3,
- })
+ const nextDetectedUris = new Map()
+ if (newRt.facets) {
+ for (const facet of newRt.facets) {
+ for (const feature of facet.features) {
+ if (AppBskyRichtextFacet.isLink(feature)) {
+ if (isUriImage(feature.uri)) {
+ const res = await downloadAndResize({
+ uri: feature.uri,
+ width: POST_IMG_MAX.width,
+ height: POST_IMG_MAX.height,
+ mode: 'contain',
+ maxSize: POST_IMG_MAX.size,
+ timeout: 15e3,
+ })
- if (res !== undefined) {
- onPhotoPasted(res.path)
- }
- } else {
- nextDetectedUris.set(feature.uri, {facet, rt: newRt})
+ if (res !== undefined) {
+ onPhotoPasted(res.path)
}
+ } else {
+ nextDetectedUris.set(feature.uri, {facet, rt: newRt})
}
}
}
}
- const suggestedUri = suggestLinkCardUri(
- mayBePaste,
- nextDetectedUris,
- prevDetectedUris.current,
- pastSuggestedUris.current,
- )
- prevDetectedUris.current = nextDetectedUris
- if (suggestedUri) {
- onNewLink(suggestedUri)
- }
- prevLength.current = newText.length
- }, 1)
+ }
+ const suggestedUri = suggestLinkCardUri(
+ mayBePaste,
+ nextDetectedUris,
+ prevDetectedUris.current,
+ pastSuggestedUris.current,
+ )
+ prevDetectedUris.current = nextDetectedUris
+ if (suggestedUri) {
+ onNewLink(suggestedUri)
+ }
+ prevLength.current = newText.length
},
[setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
)
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 8e2a22852d..6956c8c4f8 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -16,8 +16,8 @@ export function VideoPreview({
}) {
const player = useVideoPlayer(video.uri, player => {
player.loop = true
+ player.muted = true
player.play()
- player.volume = 0
})
return (
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
index db58448a30..a44b633cd5 100644
--- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
@@ -3,6 +3,7 @@ import {View} from 'react-native'
// @ts-expect-error no type definition
import ProgressPie from 'react-native-progress/Pie'
import {ImagePickerAsset} from 'expo-image-picker'
+import {Trans} from '@lingui/macro'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
@@ -46,7 +47,9 @@ export function VideoTranscodeProgress({
color={t.atoms.text.color}
progress={progress}
/>
- Compressing...
+
+ Compressing...
+
)
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
deleted file mode 100644
index 587885502b..0000000000
--- a/src/view/com/lists/ListCard.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {AppBskyGraphDefs, AtUri, RichText} from '@atproto/api'
-import {Trans} from '@lingui/macro'
-
-import {useSession} from '#/state/session'
-import {usePalette} from 'lib/hooks/usePalette'
-import {makeProfileLink} from 'lib/routes/links'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {s} from 'lib/styles'
-import {atoms as a} from '#/alf'
-import {RichText as RichTextCom} from '#/components/RichText'
-import {Link} from '../util/Link'
-import {Text} from '../util/text/Text'
-import {UserAvatar} from '../util/UserAvatar'
-
-export const ListCard = ({
- testID,
- list,
- noBg,
- noBorder,
- renderButton,
- style,
-}: {
- testID?: string
- list: AppBskyGraphDefs.ListView
- noBg?: boolean
- noBorder?: boolean
- renderButton?: () => JSX.Element
- style?: StyleProp
-}) => {
- const pal = usePalette('default')
- const {currentAccount} = useSession()
-
- const rkey = React.useMemo(() => {
- try {
- const urip = new AtUri(list.uri)
- return urip.rkey
- } catch {
- return ''
- }
- }, [list])
-
- const descriptionRichText = React.useMemo(() => {
- if (list.description) {
- return new RichText({
- text: list.description,
- facets: list.descriptionFacets,
- })
- }
- return undefined
- }, [list])
-
- return (
-
-
-
-
-
-
-
- {sanitizeDisplayName(list.name)}
-
-
- {list.purpose === 'app.bsky.graph.defs#curatelist' &&
- (list.creator.did === currentAccount?.did ? (
- User list by you
- ) : (
-
- User list by {sanitizeHandle(list.creator.handle, '@')}
-
- ))}
- {list.purpose === 'app.bsky.graph.defs#modlist' &&
- (list.creator.did === currentAccount?.did ? (
- Moderation list by you
- ) : (
-
- Moderation list by {sanitizeHandle(list.creator.handle, '@')}
-
- ))}
-
-
- {list.viewer?.muted ? (
-
-
- Muted
-
-
- ) : null}
-
- {list.viewer?.blocked ? (
-
-
- Blocked
-
-
- ) : null}
-
-
- {renderButton ? (
- {renderButton()}
- ) : undefined}
-
- {descriptionRichText ? (
-
-
-
- ) : undefined}
-
- )
-}
-
-const styles = StyleSheet.create({
- outer: {
- borderTopWidth: StyleSheet.hairlineWidth,
- paddingHorizontal: 6,
- },
- outerNoBorder: {
- borderTopWidth: 0,
- },
- layout: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- layoutAvi: {
- width: 54,
- paddingLeft: 4,
- paddingTop: 8,
- paddingBottom: 10,
- },
- avi: {
- width: 40,
- height: 40,
- borderRadius: 20,
- resizeMode: 'cover',
- },
- layoutContent: {
- flex: 1,
- paddingRight: 10,
- paddingTop: 10,
- paddingBottom: 10,
- },
- layoutButton: {
- paddingRight: 10,
- },
- details: {
- paddingLeft: 54,
- paddingRight: 10,
- paddingBottom: 10,
- },
- pill: {
- borderRadius: 4,
- paddingHorizontal: 6,
- paddingVertical: 2,
- },
- btn: {
- paddingVertical: 7,
- borderRadius: 50,
- marginLeft: 6,
- paddingHorizontal: 14,
- },
-})
diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx
index 472d2688c7..b56fa6c75f 100644
--- a/src/view/com/lists/MyLists.tsx
+++ b/src/view/com/lists/MyLists.tsx
@@ -4,7 +4,6 @@ import {
FlatList as RNFlatList,
RefreshControl,
StyleProp,
- StyleSheet,
View,
ViewStyle,
} from 'react-native'
@@ -18,10 +17,13 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
+import {isWeb} from 'platform/detection'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
import {EmptyState} from 'view/com/util/EmptyState'
+import {atoms as a, useTheme} from '#/alf'
+import * as ListCard from '#/components/ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List} from '../util/List'
-import {ListCard} from './ListCard'
const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'}
@@ -41,8 +43,10 @@ export function MyLists({
testID?: string
}) {
const pal = usePalette('default')
+ const t = useTheme()
const {track} = useAnalytics()
const {_} = useLingui()
+ const moderationOpts = useModerationOpts()
const [isPTRing, setIsPTRing] = React.useState(false)
const {data, isFetching, isFetched, isError, error, refetch} =
useMyListsQuery(filter)
@@ -53,7 +57,7 @@ export function MyLists({
if (isError && isEmpty) {
items = items.concat([ERROR_ITEM])
}
- if (!isFetched && isFetching) {
+ if ((!isFetched && isFetching) || !moderationOpts) {
items = items.concat([LOADING])
} else if (isEmpty) {
items = items.concat([EMPTY])
@@ -61,7 +65,7 @@ export function MyLists({
items = items.concat(data)
}
return items
- }, [isError, isEmpty, isFetched, isFetching, data])
+ }, [isError, isEmpty, isFetched, isFetching, moderationOpts, data])
// events
// =
@@ -85,7 +89,6 @@ export function MyLists({
if (item === EMPTY) {
return (
)
} else if (item === LOADING) {
return (
-
+
)
@@ -109,15 +111,18 @@ export function MyLists({
return renderItem ? (
renderItem(item, index)
) : (
-
+
+
+
)
},
- [error, onRefresh, renderItem, _],
+ [renderItem, t.atoms.border_contrast_low, _, error, onRefresh],
)
if (inline) {
@@ -166,10 +171,3 @@ export function MyLists({
)
}
}
-
-const styles = StyleSheet.create({
- item: {
- paddingHorizontal: 18,
- paddingVertical: 4,
- },
-})
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index e4294eaa5f..b591b4b7fd 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -25,7 +25,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
-import {useGate} from '#/lib/statsig/statsig'
import {parseTenorGif} from '#/lib/strings/embed-player'
import {logger} from '#/logger'
import {FeedNotification} from '#/state/queries/notifications/feed'
@@ -87,7 +86,6 @@ let FeedItem = ({
const pal = usePalette('default')
const {_} = useLingui()
const t = useTheme()
- const gate = useGate()
const [isAuthorsExpanded, setAuthorsExpanded] = useState(false)
const itemHref = useMemo(() => {
if (item.type === 'post-like' || item.type === 'repost') {
@@ -207,7 +205,7 @@ let FeedItem = ({
}
}
- if (isFollowBack && gate('ungroup_follow_backs')) {
+ if (isFollowBack) {
action = _(msg`followed you back`)
} else {
action = _(msg`followed you`)
@@ -255,6 +253,7 @@ let FeedItem = ({
borderColor: pal.colors.unreadNotifBorder,
},
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
+ a.overflow_hidden,
]}
href={itemHref}
noFeedback
@@ -547,7 +546,7 @@ function ExpandedAuthorsList({
}, [heightInterp, visible])
return (
-
+
{visible &&
authors.map(author => (
{
return item._reactKey
}
-export function PostThread({
- uri,
- onCanReply,
- onPressReply,
-}: {
- uri: string | undefined
- onCanReply: (canReply: boolean) => void
- onPressReply: () => unknown
-}) {
+export function PostThread({uri}: {uri: string | undefined}) {
const {hasSession, currentAccount} = useSession()
const {_} = useLingui()
const t = useTheme()
@@ -163,12 +160,22 @@ export function PostThread({
return cache
}, [thread, moderationOpts])
+ const [justPostedUris, setJustPostedUris] = React.useState(
+ () => new Set(),
+ )
+
const skeleton = React.useMemo(() => {
const threadViewPrefs = preferences?.threadViewPrefs
if (!threadViewPrefs || !thread) return null
return createThreadSkeleton(
- sortThread(thread, threadViewPrefs, threadModerationCache, currentDid),
+ sortThread(
+ thread,
+ threadViewPrefs,
+ threadModerationCache,
+ currentDid,
+ justPostedUris,
+ ),
!!currentDid,
treeView,
threadModerationCache,
@@ -181,6 +188,7 @@ export function PostThread({
treeView,
threadModerationCache,
hiddenRepliesState,
+ justPostedUris,
])
const error = React.useMemo(() => {
@@ -210,14 +218,6 @@ export function PostThread({
return null
}, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
- useEffect(() => {
- if (error) {
- onCanReply(false)
- } else if (rootPost) {
- onCanReply(!rootPost.viewer?.replyDisabled)
- }
- }, [rootPost, onCanReply, error])
-
// construct content
const posts = React.useMemo(() => {
if (!skeleton) return []
@@ -313,6 +313,38 @@ export function PostThread({
setMaxReplies(prev => prev + 50)
}, [isFetching, maxReplies, posts.length])
+ const onPostReply = React.useCallback(
+ (postUri: string | undefined) => {
+ refetch()
+ if (postUri) {
+ setJustPostedUris(set => {
+ const nextSet = new Set(set)
+ nextSet.add(postUri)
+ return nextSet
+ })
+ }
+ },
+ [refetch],
+ )
+
+ const {openComposer} = useComposerControls()
+ const onPressReply = React.useCallback(() => {
+ if (thread?.type !== 'post') {
+ return
+ }
+ openComposer({
+ replyTo: {
+ uri: thread.post.uri,
+ cid: thread.post.cid,
+ text: thread.record.text,
+ author: thread.post.author,
+ embed: thread.post.embed,
+ },
+ onPost: onPostReply,
+ })
+ }, [openComposer, thread, onPostReply])
+
+ const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
const hasParents =
skeleton?.highlightedPost?.type === 'post' &&
(skeleton.highlightedPost.ctx.isParentLoading ||
@@ -324,7 +356,9 @@ export function PostThread({
if (item === REPLY_PROMPT && hasSession) {
return (
- {!isMobile && }
+ {!isMobile && (
+
+ )}
)
} else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
@@ -406,7 +440,7 @@ export function PostThread({
HiddenRepliesState.ShowAndOverridePostHider &&
item.ctx.depth > 0
}
- onPostReply={refetch}
+ onPostReply={onPostReply}
hideTopBorder={index === 0 && !item.ctx.isParentLoading}
/>
@@ -473,10 +507,30 @@ export function PostThread({
sideBorders={false}
/>
+ {isMobile && canReply && hasSession && (
+
+ )}
)
}
+function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
+ const safeAreaInsets = useSafeAreaInsets()
+ const fabMinimalShellTransform = useMinimalShellFabTransform()
+ return (
+
+
+
+ )
+}
+
function isThreadPost(v: unknown): v is ThreadPost {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
}
@@ -622,3 +676,12 @@ function hasBranchingReplies(node?: ThreadNode) {
}
return true
}
+
+const styles = StyleSheet.create({
+ prompt: {
+ // @ts-ignore web-only
+ position: isWeb ? 'fixed' : 'absolute',
+ left: 0,
+ right: 0,
+ },
+})
diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx
similarity index 94%
rename from src/view/com/composer/Prompt.tsx
rename to src/view/com/post-thread/PostThreadComposePrompt.tsx
index 20637c7e9f..62b28cc759 100644
--- a/src/view/com/composer/Prompt.tsx
+++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx
@@ -10,7 +10,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
-export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
+export function PostThreadComposePrompt({
+ onPressCompose,
+}: {
+ onPressCompose: () => void
+}) {
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const pal = usePalette('default')
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 63dac300a8..0ff3601434 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -75,7 +75,7 @@ export function PostThreadItem({
showParentReplyLine?: boolean
hasPrecedingItem: boolean
overrideBlur: boolean
- onPostReply: () => void
+ onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean
}) {
const postShadowed = usePostShadow(post)
@@ -169,7 +169,7 @@ let PostThreadItemLoaded = ({
showParentReplyLine?: boolean
hasPrecedingItem: boolean
overrideBlur: boolean
- onPostReply: () => void
+ onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean
}): React.ReactNode => {
const pal = usePalette('default')
@@ -660,7 +660,7 @@ function ExpandedPostDetails({
a.flex_row,
a.align_center,
a.flex_wrap,
- a.gap_sm,
+ a.gap_xs,
s.mt2,
s.mb10,
]}>
@@ -742,6 +742,7 @@ const styles = StyleSheet.create({
flexWrap: 'wrap',
paddingBottom: 4,
paddingRight: 10,
+ overflow: 'hidden',
},
postTextLargeContainer: {
paddingHorizontal: 0,
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index ade0e7f10f..8121b8abcc 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -12,17 +12,17 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
+import {MAX_POST_LINES} from '#/lib/constants'
+import {usePalette} from '#/lib/hooks/usePalette'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {makeProfileLink} from '#/lib/routes/links'
+import {countLines} from '#/lib/strings/helpers'
+import {colors, s} from '#/lib/styles'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {precacheProfile} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
-import {MAX_POST_LINES} from 'lib/constants'
-import {usePalette} from 'lib/hooks/usePalette'
-import {makeProfileLink} from 'lib/routes/links'
-import {countLines} from 'lib/strings/helpers'
-import {colors, s} from 'lib/styles'
-import {precacheProfile} from 'state/queries/profile'
import {AviFollowButton} from '#/view/com/posts/AviFollowButton'
import {atoms as a} from '#/alf'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
@@ -280,6 +280,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
+ overflow: 'hidden',
},
replyLine: {
position: 'absolute',
diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx
index f7141ee421..00428cbe61 100644
--- a/src/view/com/posts/AviFollowButton.tsx
+++ b/src/view/com/posts/AviFollowButton.tsx
@@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native'
import {createHitslop} from '#/lib/constants'
import {NavigationProp} from '#/lib/routes/types'
-import {useGate} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session'
@@ -37,7 +36,6 @@ export function AviFollowButton({
profile: profile,
logContext: 'AvatarButton',
})
- const gate = useGate()
const {currentAccount, hasSession} = useSession()
const navigation = useNavigation()
@@ -80,7 +78,7 @@ export function AviFollowButton({
},
]
- return hasSession && gate('show_avi_follow_button') ? (
+ return hasSession ? (
{children}
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index ef46333193..a179484000 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -349,8 +349,7 @@ let Feed = ({
const shouldShow =
(interstitial.type === feedInterstitialType &&
gate('suggested_feeds_interstitial')) ||
- (interstitial.type === followInterstitialType &&
- gate('suggested_follows_interstitial')) ||
+ interstitial.type === followInterstitialType ||
interstitial.type === progressGuideInterstitialType
if (shouldShow) {
@@ -481,9 +480,7 @@ let Feed = ({
// -prf
return
}
- return (
-
- )
+ return
} else {
return null
}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 6660a8d9d6..0fef4c5a83 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -1,4 +1,4 @@
-import React, {memo, useMemo, useState} from 'react'
+import React, {memo, useId, useMemo, useState} from 'react'
import {StyleSheet, View} from 'react-native'
import {
AppBskyActorDefs,
@@ -63,6 +63,7 @@ interface FeedItemProps {
feedContext: string | undefined
hideTopBorder?: boolean
isParentBlocked?: boolean
+ isParentNotFound?: boolean
}
export function FeedItem({
@@ -78,6 +79,7 @@ export function FeedItem({
isThreadParent,
hideTopBorder,
isParentBlocked,
+ isParentNotFound,
}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
const postShadowed = usePostShadow(post)
const richText = useMemo(
@@ -109,6 +111,7 @@ export function FeedItem({
isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder}
isParentBlocked={isParentBlocked}
+ isParentNotFound={isParentNotFound}
/>
)
}
@@ -129,6 +132,7 @@ let FeedItemInner = ({
isThreadParent,
hideTopBorder,
isParentBlocked,
+ isParentNotFound,
}: FeedItemProps & {
richText: RichTextAPI
post: Shadow
@@ -137,7 +141,6 @@ let FeedItemInner = ({
const {openComposer} = useComposerControls()
const pal = usePalette('default')
const {_} = useLingui()
- const gate = useGate()
const href = useMemo(() => {
const urip = new AtUri(post.uri)
@@ -345,9 +348,14 @@ let FeedItemInner = ({
postHref={href}
onOpenAuthor={onOpenAuthor}
/>
- {showReplyTo && (parentAuthor || isParentBlocked) && (
-
- )}
+ {showReplyTo &&
+ (parentAuthor || isParentBlocked || isParentNotFound) && (
+
+ )}
- {gate('video_debug') && (
-
- )}
+
Reply to a blocked post
+ } else if (notFound) {
+ label = Reply to an unknown post
} else if (profile != null) {
const isMe = profile.did === currentAccount?.did
if (isMe) {
@@ -501,6 +511,19 @@ function ReplyToLabel({
)
}
+function VideoDebug() {
+ const gate = useGate()
+ const id = useId()
+
+ if (!gate('video_debug')) return null
+
+ return (
+
+ )
+}
+
const styles = StyleSheet.create({
outer: {
paddingLeft: 10,
@@ -544,6 +567,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
flexWrap: 'wrap',
paddingBottom: 2,
+ overflow: 'hidden',
},
contentHiderChild: {
marginTop: 6,
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index fcd1ec3b18..9676eff1f6 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -36,6 +36,7 @@ let FeedSlice = ({
isThreadChild={isThreadChildAt(slice.items, 0)}
hideTopBorder={hideTopBorder}
isParentBlocked={slice.items[0].isParentBlocked}
+ isParentNotFound={slice.items[0].isParentNotFound}
/>
>
@@ -90,6 +93,7 @@ let FeedSlice = ({
isThreadChildAt(slice.items, i) && slice.items.length === i + 1
}
isParentBlocked={slice.items[i].isParentBlocked}
+ isParentNotFound={slice.items[i].isParentNotFound}
hideTopBorder={hideTopBorder && i === 0}
/>
))}
diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx
index 82750959d6..83c79ab7cd 100644
--- a/src/view/com/testing/TestCtrls.e2e.tsx
+++ b/src/view/com/testing/TestCtrls.e2e.tsx
@@ -20,7 +20,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
export function TestCtrls() {
const queryClient = useQueryClient()
- const {logout, login} = useSessionApi()
+ const {logoutEveryAccount, login} = useSessionApi()
const {openModal} = useModalControls()
const onboardingDispatch = useOnboardingDispatch()
const {setShowLoggedOut} = useLoggedOutViewControls()
@@ -60,7 +60,7 @@ export function TestCtrls() {
/>
logout('Settings')}
+ onPress={() => logoutEveryAccount('Settings')}
accessibilityRole="button"
style={BTN}
/>
diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx
index 221879df79..fa2553d384 100644
--- a/src/view/com/util/AccountDropdownBtn.tsx
+++ b/src/view/com/util/AccountDropdownBtn.tsx
@@ -4,26 +4,27 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
-import {s} from 'lib/styles'
-import {usePalette} from 'lib/hooks/usePalette'
-import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
-import * as Toast from '../../com/util/Toast'
-import {useSessionApi, SessionAccount} from '#/state/session'
-import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {SessionAccount, useSessionApi} from '#/state/session'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+import {useDialogControl} from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+import * as Toast from '../../com/util/Toast'
+import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
export function AccountDropdownBtn({account}: {account: SessionAccount}) {
const pal = usePalette('default')
const {removeAccount} = useSessionApi()
+ const removePromptControl = useDialogControl()
const {_} = useLingui()
const items: DropdownItem[] = [
{
label: _(msg`Remove account`),
- onPress: () => {
- removeAccount(account)
- Toast.show(_(msg`Account removed from quick access`))
- },
+ onPress: removePromptControl.open,
icon: {
ios: {
name: 'trash',
@@ -34,17 +35,32 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) {
},
]
return (
-
-
-
-
-
+ <>
+
+
+
+
+
+ {
+ removeAccount(account)
+ Toast.show(_(msg`Account removed from quick access`))
+ }}
+ confirmButtonCta={_(msg`Remove`)}
+ confirmButtonColor="negative"
+ />
+ >
)
}
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 95168e8b3c..b1567c2c69 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -91,11 +91,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
{!isAndroid && (
-
+
·
)}
@@ -104,7 +100,6 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
{
if (finalShape === 'square') {
@@ -223,10 +221,7 @@ let UserAvatar = ({
style={aviStyle}
resizeMode="cover"
source={{
- uri: hackModifyThumbnailPath(
- avatar,
- size < 90 && gate('small_avi_thumb'),
- ),
+ uri: hackModifyThumbnailPath(avatar, size < 90),
}}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
/>
@@ -236,10 +231,7 @@ let UserAvatar = ({
style={aviStyle}
contentFit="cover"
source={{
- uri: hackModifyThumbnailPath(
- avatar,
- size < 90 && gate('small_avi_thumb'),
- ),
+ uri: hackModifyThumbnailPath(avatar, size < 90),
}}
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
/>
diff --git a/src/view/com/util/post-embeds/ListEmbed.tsx b/src/view/com/util/post-embeds/ListEmbed.tsx
deleted file mode 100644
index fc5ad270fc..0000000000
--- a/src/view/com/util/post-embeds/ListEmbed.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ListCard} from 'view/com/lists/ListCard'
-import {AppBskyGraphDefs} from '@atproto/api'
-import {s} from 'lib/styles'
-
-export function ListEmbed({
- item,
- style,
-}: {
- item: AppBskyGraphDefs.ListView
- style?: StyleProp
-}) {
- const pal = usePalette('default')
-
- return (
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- borderRadius: 8,
- },
- card: {
- borderTopWidth: 0,
- borderRadius: 8,
- },
-})
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index 887efac1ab..4e2909f40b 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,6 +1,6 @@
-import React from 'react'
+import React, {useCallback, useState} from 'react'
import {View} from 'react-native'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
@@ -8,13 +8,23 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
+import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoView} from './ActiveVideoContext'
+import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({source}: {source: string}) {
const t = useTheme()
const {active, setActive} = useActiveVideoView({source})
const {_} = useLingui()
+ const [key, setKey] = useState(0)
+ const renderError = useCallback(
+ (error: unknown) => (
+ setKey(key + 1)} />
+ ),
+ [key],
+ )
+
return (
- {
- if (isActive) {
- setActive()
- }
- }}>
- {active ? (
-
- ) : (
-
- )}
-
+
+ {
+ if (isActive) {
+ setActive()
+ }
+ }}>
+ {active ? (
+
+ ) : (
+
+ )}
+
+
)
}
+
+function VideoError({retry}: {error: unknown; retry: () => void}) {
+ return (
+
+
+
+ An error occurred while loading the video. Please try again later.
+
+
+
+
+ )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
index 70d887283e..5803b836df 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
@@ -1,17 +1,15 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
+import {Trans} from '@lingui/macro'
import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
import {atoms as a, useTheme} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
-import {Text} from '#/components/Typography'
import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoView} from './ActiveVideoContext'
+import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({source}: {source: string}) {
const t = useTheme()
@@ -138,32 +136,11 @@ function ViewportObserver({
}
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
- const t = useTheme()
- const {_} = useLingui()
-
const isHLS = error instanceof HLSUnsupportedError
return (
-
-
+
+
{isHLS ? (
Your browser does not support the video format. Please try a
@@ -174,19 +151,8 @@ function VideoError({error, retry}: {error: unknown; retry: () => void}) {
An error occurred while loading the video. Please try again later.
)}
-
- {!isHLS && (
-
- )}
-
+
+ {!isHLS && }
+
)
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
index cc356fb069..fa49438763 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -1,27 +1,72 @@
-import React, {useEffect, useRef, useState} from 'react'
+import React, {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
+import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useIsFocused} from '@react-navigation/native'
-import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext'
-import {android, atoms as a} from '#/alf'
+import {HITSLOP_30} from '#/lib/constants'
+import {useAppState} from '#/lib/hooks/useAppState'
+import {logger} from '#/logger'
+import {useVideoPlayer} from '#/view/com/util/post-embeds/VideoPlayerContext'
+import {android, atoms as a, useTheme} from '#/alf'
+import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
+import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {Text} from '#/components/Typography'
+import {
+ AudioCategory,
+ PlatformInfo,
+} from '../../../../../../modules/expo-bluesky-swiss-army'
export function VideoEmbedInnerNative() {
const player = useVideoPlayer()
const ref = useRef(null)
+ const isScreenFocused = useIsFocused()
+ const isAppFocused = useAppState()
+
+ useEffect(() => {
+ try {
+ if (isAppFocused === 'active' && isScreenFocused && !player.playing) {
+ PlatformInfo.setAudioCategory(AudioCategory.Ambient)
+ PlatformInfo.setAudioActive(false)
+ player.muted = true
+ player.play()
+ } else if (player.playing) {
+ player.pause()
+ }
+ } catch (err) {
+ logger.error(
+ 'Failed to play/pause while backgrounding/switching screens',
+ {safeMessage: err},
+ )
+ }
+ }, [isAppFocused, player, isScreenFocused])
+
+ const enterFullscreen = useCallback(() => {
+ ref.current?.enterFullscreen()
+ }, [])
return (
-
+
{
+ PlatformInfo.setAudioCategory(AudioCategory.Playback)
+ PlatformInfo.setAudioActive(true)
+ player.muted = false
+ }}
+ onExitFullscreen={() => {
+ PlatformInfo.setAudioCategory(AudioCategory.Ambient)
+ PlatformInfo.setAudioActive(false)
+ player.muted = true
+ if (!player.playing) player.play()
+ }}
/>
- ref.current?.enterFullscreen()}
- />
+
)
}
@@ -33,6 +78,9 @@ function Controls({
player: VideoPlayer
enterFullscreen: () => void
}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const [isMuted, setIsMuted] = useState(player.muted)
const [duration, setDuration] = useState(() => Math.floor(player.duration))
const [currentTime, setCurrentTime] = useState(() =>
Math.floor(player.currentTime),
@@ -47,50 +95,121 @@ function Controls({
// duration gets reset to 0 on loop
if (player.duration) setDuration(Math.floor(player.duration))
setCurrentTime(Math.floor(player.currentTime))
+
// how often should we update the time?
// 1000 gets out of sync with the video time
}, 250)
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const sub = player.addListener('volumeChange', ({isMuted}) => {
+ setIsMuted(isMuted)
+ })
+
return () => {
clearInterval(interval)
+ sub.remove()
}
}, [player])
- if (isNaN(timeRemaining)) {
- return null
- }
+ const onPressFullscreen = useCallback(() => {
+ switch (player.status) {
+ case 'idle':
+ case 'loading':
+ case 'readyToPlay': {
+ if (!player.playing) player.play()
+ enterFullscreen()
+ break
+ }
+ case 'error': {
+ player.replay()
+ break
+ }
+ }
+ }, [player, enterFullscreen])
+
+ const toggleMuted = useCallback(() => {
+ const muted = !player.muted
+ // We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when
+ // the video is muted, and vice versa.
+ const mix = !muted
+ const category = muted ? AudioCategory.Ambient : AudioCategory.Playback
+
+ PlatformInfo.setAudioCategory(category)
+ PlatformInfo.setAudioActive(mix)
+ player.muted = muted
+ }, [player])
+
+ // show countdown when:
+ // 1. timeRemaining is a number - was seeing NaNs
+ // 2. duration is greater than 0 - means metadata has loaded
+ // 3. we're less than 5 second into the video
+ const showTime = !isNaN(timeRemaining) && duration > 0 && currentTime <= 5
return (
-
+
+ {minutes}:{seconds}
+
+
+ )}
+
+ {duration > 0 && (
+
-
- {minutes}:{seconds}
-
-
-
+ right: 5,
+ minHeight: 20,
+ justifyContent: 'center',
+ }}>
+
+ {isMuted ? (
+
+ ) : (
+
+ )}
+
+
+ )}
)
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
index 59da5be42a..2760c7fafd 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
@@ -1,3 +1,3 @@
export function VideoEmbedInnerNative() {
- throw new Error('VideoEmbedInnerNative may not be used on native.')
+ throw new Error('VideoEmbedInnerNative may not be used on web.')
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx
new file mode 100644
index 0000000000..1b46163cce
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoFallback.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text as TypoText} from '#/components/Typography'
+
+export function Container({children}: {children: React.ReactNode}) {
+ const t = useTheme()
+ return (
+
+ {children}
+
+ )
+}
+
+export function Text({children}: {children: React.ReactNode}) {
+ const t = useTheme()
+ return (
+
+ {children}
+
+ )
+}
+
+export function RetryButton({onPress}: {onPress: () => void}) {
+ const {_} = useLingui()
+
+ return (
+
+ )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx
new file mode 100644
index 0000000000..e2e24ed367
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.native.tsx
@@ -0,0 +1,3 @@
+export function Controls() {
+ throw new Error('VideoWebControls may not be used on native.')
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
index 11e0867e43..7caaf3abf7 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
@@ -1,7 +1,51 @@
-import React from 'react'
+import React, {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from 'react'
+import {Pressable, View} from 'react-native'
+import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
import type Hls from 'hls.js'
-export function Controls({}: {
+import {isIPhoneWeb} from 'platform/detection'
+import {
+ useAutoplayDisabled,
+ useSetSubtitlesEnabled,
+ useSubtitlesEnabled,
+} from 'state/preferences'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Button} from '#/components/Button'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {
+ ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
+ ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
+} from '#/components/icons/ArrowsDiagonal'
+import {
+ CC_Filled_Corner0_Rounded as CCActiveIcon,
+ CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
+} from '#/components/icons/CC'
+import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
+import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
+import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
+import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function Controls({
+ videoRef,
+ hlsRef,
+ active,
+ setActive,
+ focused,
+ setFocused,
+ onScreen,
+ fullscreenRef,
+ hasSubtitleTrack,
+}: {
videoRef: React.RefObject
hlsRef: React.RefObject
active: boolean
@@ -11,6 +55,533 @@ export function Controls({}: {
onScreen: boolean
fullscreenRef: React.RefObject
hasSubtitleTrack: boolean
-}): React.ReactElement {
- throw new Error('Web-only component')
+}) {
+ const {
+ play,
+ pause,
+ playing,
+ muted,
+ toggleMute,
+ togglePlayPause,
+ currentTime,
+ duration,
+ buffering,
+ error,
+ canPlay,
+ } = useVideoUtils(videoRef)
+ const t = useTheme()
+ const {_} = useLingui()
+ const subtitlesEnabled = useSubtitlesEnabled()
+ const setSubtitlesEnabled = useSetSubtitlesEnabled()
+ const {
+ state: hovered,
+ onIn: onMouseEnter,
+ onOut: onMouseLeave,
+ } = useInteractionState()
+ const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
+ const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
+ const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
+
+ const onKeyDown = useCallback(() => {
+ setInteractingViaKeypress(true)
+ }, [])
+
+ useEffect(() => {
+ if (interactingViaKeypress) {
+ document.addEventListener('click', () => setInteractingViaKeypress(false))
+ return () => {
+ document.removeEventListener('click', () =>
+ setInteractingViaKeypress(false),
+ )
+ }
+ }
+ }, [interactingViaKeypress])
+
+ // pause + unfocus when another video is active
+ useEffect(() => {
+ if (!active) {
+ pause()
+ setFocused(false)
+ }
+ }, [active, pause, setFocused])
+
+ // autoplay/pause based on visibility
+ const autoplayDisabled = useAutoplayDisabled()
+ useEffect(() => {
+ if (active && !autoplayDisabled) {
+ if (onScreen) {
+ play()
+ } else {
+ pause()
+ }
+ }
+ }, [onScreen, pause, active, play, autoplayDisabled])
+
+ // use minimal quality when not focused
+ useEffect(() => {
+ if (!hlsRef.current) return
+ if (focused) {
+ // auto decide quality based on network conditions
+ hlsRef.current.autoLevelCapping = -1
+ } else {
+ hlsRef.current.autoLevelCapping = 0
+ }
+ }, [hlsRef, focused])
+
+ useEffect(() => {
+ if (!hlsRef.current) return
+ if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
+ hlsRef.current.subtitleTrack = 0
+ } else {
+ hlsRef.current.subtitleTrack = -1
+ }
+ }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
+
+ // clicking on any button should focus the player, if it's not already focused
+ const drawFocus = useCallback(() => {
+ if (!active) {
+ setActive()
+ }
+ setFocused(true)
+ }, [active, setActive, setFocused])
+
+ const onPressEmptySpace = useCallback(() => {
+ if (!focused) {
+ drawFocus()
+ } else {
+ togglePlayPause()
+ }
+ }, [togglePlayPause, drawFocus, focused])
+
+ const onPressPlayPause = useCallback(() => {
+ drawFocus()
+ togglePlayPause()
+ }, [drawFocus, togglePlayPause])
+
+ const onPressSubtitles = useCallback(() => {
+ drawFocus()
+ setSubtitlesEnabled(!subtitlesEnabled)
+ }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
+
+ const onPressMute = useCallback(() => {
+ drawFocus()
+ toggleMute()
+ }, [drawFocus, toggleMute])
+
+ const onPressFullscreen = useCallback(() => {
+ drawFocus()
+ toggleFullscreen()
+ }, [drawFocus, toggleFullscreen])
+
+ const showControls =
+ (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
+
+ return (
+ {
+ evt.stopPropagation()
+ setInteractingViaKeypress(false)
+ }}
+ onMouseEnter={onMouseEnter}
+ onMouseLeave={onMouseLeave}
+ onFocus={onFocus}
+ onBlur={onBlur}
+ onKeyDown={onKeyDown}>
+
+
+
+
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+ {hasSubtitleTrack && (
+
+ )}
+
+ {!isIPhoneWeb && (
+
+ )}
+
+ {(showControls || !focused) && (
+
+ {duration > 0 && (
+
+ )}
+
+ )}
+ {(buffering || error) && (
+
+ {buffering && }
+ {error && (
+
+ An error occurred
+
+ )}
+
+ )}
+
+ )
+}
+
+const btnProps = {
+ variant: 'ghost',
+ shape: 'round',
+ size: 'medium',
+ style: a.p_2xs,
+ hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
+} as const
+
+function formatTime(time: number) {
+ if (isNaN(time)) {
+ return '--'
+ }
+
+ time = Math.round(time)
+
+ const minutes = Math.floor(time / 60)
+ const seconds = String(time % 60).padStart(2, '0')
+
+ return `${minutes}:${seconds}`
+}
+
+function useVideoUtils(ref: React.RefObject) {
+ const [playing, setPlaying] = useState(false)
+ const [muted, setMuted] = useState(true)
+ const [currentTime, setCurrentTime] = useState(0)
+ const [duration, setDuration] = useState(0)
+ const [buffering, setBuffering] = useState(false)
+ const [error, setError] = useState(false)
+ const [canPlay, setCanPlay] = useState(false)
+ const playWhenReadyRef = useRef(false)
+
+ useEffect(() => {
+ if (!ref.current) return
+
+ let bufferingTimeout: ReturnType | undefined
+
+ function round(num: number) {
+ return Math.round(num * 100) / 100
+ }
+
+ // Initial values
+ setCurrentTime(round(ref.current.currentTime) || 0)
+ setDuration(round(ref.current.duration) || 0)
+ setMuted(ref.current.muted)
+ setPlaying(!ref.current.paused)
+
+ const handleTimeUpdate = () => {
+ if (!ref.current) return
+ setCurrentTime(round(ref.current.currentTime) || 0)
+ }
+
+ const handleDurationChange = () => {
+ if (!ref.current) return
+ setDuration(round(ref.current.duration) || 0)
+ }
+
+ const handlePlay = () => {
+ setPlaying(true)
+ }
+
+ const handlePause = () => {
+ setPlaying(false)
+ }
+
+ const handleVolumeChange = () => {
+ if (!ref.current) return
+ setMuted(ref.current.muted)
+ }
+
+ const handleError = () => {
+ setError(true)
+ }
+
+ const handleCanPlay = () => {
+ setBuffering(false)
+ setCanPlay(true)
+
+ if (!ref.current) return
+ if (playWhenReadyRef.current) {
+ ref.current.play()
+ playWhenReadyRef.current = false
+ }
+ }
+
+ const handleCanPlayThrough = () => {
+ setBuffering(false)
+ }
+
+ const handleWaiting = () => {
+ if (bufferingTimeout) clearTimeout(bufferingTimeout)
+ bufferingTimeout = setTimeout(() => {
+ setBuffering(true)
+ }, 200) // Delay to avoid frequent buffering state changes
+ }
+
+ const handlePlaying = () => {
+ if (bufferingTimeout) clearTimeout(bufferingTimeout)
+ setBuffering(false)
+ setError(false)
+ }
+
+ const handleSeeking = () => {
+ setBuffering(true)
+ }
+
+ const handleSeeked = () => {
+ setBuffering(false)
+ }
+
+ const handleStalled = () => {
+ if (bufferingTimeout) clearTimeout(bufferingTimeout)
+ bufferingTimeout = setTimeout(() => {
+ setBuffering(true)
+ }, 200) // Delay to avoid frequent buffering state changes
+ }
+
+ const handleEnded = () => {
+ setPlaying(false)
+ setBuffering(false)
+ setError(false)
+ }
+
+ const abortController = new AbortController()
+
+ ref.current.addEventListener('timeupdate', handleTimeUpdate, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('durationchange', handleDurationChange, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('play', handlePlay, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('pause', handlePause, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('volumechange', handleVolumeChange, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('error', handleError, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('canplay', handleCanPlay, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('waiting', handleWaiting, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('playing', handlePlaying, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('seeking', handleSeeking, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('seeked', handleSeeked, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('stalled', handleStalled, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('ended', handleEnded, {
+ signal: abortController.signal,
+ })
+
+ return () => {
+ abortController.abort()
+ clearTimeout(bufferingTimeout)
+ }
+ }, [ref])
+
+ const play = useCallback(() => {
+ if (!ref.current) return
+
+ if (ref.current.ended) {
+ ref.current.currentTime = 0
+ }
+
+ if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
+ playWhenReadyRef.current = true
+ } else {
+ const promise = ref.current.play()
+ if (promise !== undefined) {
+ promise.catch(err => {
+ console.error('Error playing video:', err)
+ })
+ }
+ }
+ }, [ref])
+
+ const pause = useCallback(() => {
+ if (!ref.current) return
+
+ ref.current.pause()
+ playWhenReadyRef.current = false
+ }, [ref])
+
+ const togglePlayPause = useCallback(() => {
+ if (!ref.current) return
+
+ if (ref.current.paused) {
+ play()
+ } else {
+ pause()
+ }
+ }, [ref, play, pause])
+
+ const mute = useCallback(() => {
+ if (!ref.current) return
+
+ ref.current.muted = true
+ }, [ref])
+
+ const unmute = useCallback(() => {
+ if (!ref.current) return
+
+ ref.current.muted = false
+ }, [ref])
+
+ const toggleMute = useCallback(() => {
+ if (!ref.current) return
+
+ ref.current.muted = !ref.current.muted
+ }, [ref])
+
+ return {
+ play,
+ pause,
+ togglePlayPause,
+ duration,
+ currentTime,
+ playing,
+ muted,
+ mute,
+ unmute,
+ toggleMute,
+ buffering,
+ error,
+ canPlay,
+ }
+}
+
+function fullscreenSubscribe(onChange: () => void) {
+ document.addEventListener('fullscreenchange', onChange)
+ return () => document.removeEventListener('fullscreenchange', onChange)
+}
+
+function useFullscreen(ref: React.RefObject) {
+ const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
+ Boolean(document.fullscreenElement),
+ )
+
+ const toggleFullscreen = useCallback(() => {
+ if (isFullscreen) {
+ document.exitFullscreen()
+ } else {
+ if (!ref.current) return
+ ref.current.requestFullscreen()
+ }
+ }, [isFullscreen, ref])
+
+ return [isFullscreen, toggleFullscreen] as const
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx
deleted file mode 100644
index 7caaf3abf7..0000000000
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx
+++ /dev/null
@@ -1,587 +0,0 @@
-import React, {
- useCallback,
- useEffect,
- useRef,
- useState,
- useSyncExternalStore,
-} from 'react'
-import {Pressable, View} from 'react-native'
-import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import type Hls from 'hls.js'
-
-import {isIPhoneWeb} from 'platform/detection'
-import {
- useAutoplayDisabled,
- useSetSubtitlesEnabled,
- useSubtitlesEnabled,
-} from 'state/preferences'
-import {atoms as a, useTheme, web} from '#/alf'
-import {Button} from '#/components/Button'
-import {useInteractionState} from '#/components/hooks/useInteractionState'
-import {
- ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
- ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
-} from '#/components/icons/ArrowsDiagonal'
-import {
- CC_Filled_Corner0_Rounded as CCActiveIcon,
- CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
-} from '#/components/icons/CC'
-import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
-import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
-import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
-import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
-import {Loader} from '#/components/Loader'
-import {Text} from '#/components/Typography'
-
-export function Controls({
- videoRef,
- hlsRef,
- active,
- setActive,
- focused,
- setFocused,
- onScreen,
- fullscreenRef,
- hasSubtitleTrack,
-}: {
- videoRef: React.RefObject
- hlsRef: React.RefObject
- active: boolean
- setActive: () => void
- focused: boolean
- setFocused: (focused: boolean) => void
- onScreen: boolean
- fullscreenRef: React.RefObject
- hasSubtitleTrack: boolean
-}) {
- const {
- play,
- pause,
- playing,
- muted,
- toggleMute,
- togglePlayPause,
- currentTime,
- duration,
- buffering,
- error,
- canPlay,
- } = useVideoUtils(videoRef)
- const t = useTheme()
- const {_} = useLingui()
- const subtitlesEnabled = useSubtitlesEnabled()
- const setSubtitlesEnabled = useSetSubtitlesEnabled()
- const {
- state: hovered,
- onIn: onMouseEnter,
- onOut: onMouseLeave,
- } = useInteractionState()
- const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
- const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
- const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
-
- const onKeyDown = useCallback(() => {
- setInteractingViaKeypress(true)
- }, [])
-
- useEffect(() => {
- if (interactingViaKeypress) {
- document.addEventListener('click', () => setInteractingViaKeypress(false))
- return () => {
- document.removeEventListener('click', () =>
- setInteractingViaKeypress(false),
- )
- }
- }
- }, [interactingViaKeypress])
-
- // pause + unfocus when another video is active
- useEffect(() => {
- if (!active) {
- pause()
- setFocused(false)
- }
- }, [active, pause, setFocused])
-
- // autoplay/pause based on visibility
- const autoplayDisabled = useAutoplayDisabled()
- useEffect(() => {
- if (active && !autoplayDisabled) {
- if (onScreen) {
- play()
- } else {
- pause()
- }
- }
- }, [onScreen, pause, active, play, autoplayDisabled])
-
- // use minimal quality when not focused
- useEffect(() => {
- if (!hlsRef.current) return
- if (focused) {
- // auto decide quality based on network conditions
- hlsRef.current.autoLevelCapping = -1
- } else {
- hlsRef.current.autoLevelCapping = 0
- }
- }, [hlsRef, focused])
-
- useEffect(() => {
- if (!hlsRef.current) return
- if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
- hlsRef.current.subtitleTrack = 0
- } else {
- hlsRef.current.subtitleTrack = -1
- }
- }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
-
- // clicking on any button should focus the player, if it's not already focused
- const drawFocus = useCallback(() => {
- if (!active) {
- setActive()
- }
- setFocused(true)
- }, [active, setActive, setFocused])
-
- const onPressEmptySpace = useCallback(() => {
- if (!focused) {
- drawFocus()
- } else {
- togglePlayPause()
- }
- }, [togglePlayPause, drawFocus, focused])
-
- const onPressPlayPause = useCallback(() => {
- drawFocus()
- togglePlayPause()
- }, [drawFocus, togglePlayPause])
-
- const onPressSubtitles = useCallback(() => {
- drawFocus()
- setSubtitlesEnabled(!subtitlesEnabled)
- }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
-
- const onPressMute = useCallback(() => {
- drawFocus()
- toggleMute()
- }, [drawFocus, toggleMute])
-
- const onPressFullscreen = useCallback(() => {
- drawFocus()
- toggleFullscreen()
- }, [drawFocus, toggleFullscreen])
-
- const showControls =
- (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
-
- return (
- {
- evt.stopPropagation()
- setInteractingViaKeypress(false)
- }}
- onMouseEnter={onMouseEnter}
- onMouseLeave={onMouseLeave}
- onFocus={onFocus}
- onBlur={onBlur}
- onKeyDown={onKeyDown}>
-
-
-
-
-
- {formatTime(currentTime)} / {formatTime(duration)}
-
- {hasSubtitleTrack && (
-
- )}
-
- {!isIPhoneWeb && (
-
- )}
-
- {(showControls || !focused) && (
-
- {duration > 0 && (
-
- )}
-
- )}
- {(buffering || error) && (
-
- {buffering && }
- {error && (
-
- An error occurred
-
- )}
-
- )}
-
- )
-}
-
-const btnProps = {
- variant: 'ghost',
- shape: 'round',
- size: 'medium',
- style: a.p_2xs,
- hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
-} as const
-
-function formatTime(time: number) {
- if (isNaN(time)) {
- return '--'
- }
-
- time = Math.round(time)
-
- const minutes = Math.floor(time / 60)
- const seconds = String(time % 60).padStart(2, '0')
-
- return `${minutes}:${seconds}`
-}
-
-function useVideoUtils(ref: React.RefObject) {
- const [playing, setPlaying] = useState(false)
- const [muted, setMuted] = useState(true)
- const [currentTime, setCurrentTime] = useState(0)
- const [duration, setDuration] = useState(0)
- const [buffering, setBuffering] = useState(false)
- const [error, setError] = useState(false)
- const [canPlay, setCanPlay] = useState(false)
- const playWhenReadyRef = useRef(false)
-
- useEffect(() => {
- if (!ref.current) return
-
- let bufferingTimeout: ReturnType | undefined
-
- function round(num: number) {
- return Math.round(num * 100) / 100
- }
-
- // Initial values
- setCurrentTime(round(ref.current.currentTime) || 0)
- setDuration(round(ref.current.duration) || 0)
- setMuted(ref.current.muted)
- setPlaying(!ref.current.paused)
-
- const handleTimeUpdate = () => {
- if (!ref.current) return
- setCurrentTime(round(ref.current.currentTime) || 0)
- }
-
- const handleDurationChange = () => {
- if (!ref.current) return
- setDuration(round(ref.current.duration) || 0)
- }
-
- const handlePlay = () => {
- setPlaying(true)
- }
-
- const handlePause = () => {
- setPlaying(false)
- }
-
- const handleVolumeChange = () => {
- if (!ref.current) return
- setMuted(ref.current.muted)
- }
-
- const handleError = () => {
- setError(true)
- }
-
- const handleCanPlay = () => {
- setBuffering(false)
- setCanPlay(true)
-
- if (!ref.current) return
- if (playWhenReadyRef.current) {
- ref.current.play()
- playWhenReadyRef.current = false
- }
- }
-
- const handleCanPlayThrough = () => {
- setBuffering(false)
- }
-
- const handleWaiting = () => {
- if (bufferingTimeout) clearTimeout(bufferingTimeout)
- bufferingTimeout = setTimeout(() => {
- setBuffering(true)
- }, 200) // Delay to avoid frequent buffering state changes
- }
-
- const handlePlaying = () => {
- if (bufferingTimeout) clearTimeout(bufferingTimeout)
- setBuffering(false)
- setError(false)
- }
-
- const handleSeeking = () => {
- setBuffering(true)
- }
-
- const handleSeeked = () => {
- setBuffering(false)
- }
-
- const handleStalled = () => {
- if (bufferingTimeout) clearTimeout(bufferingTimeout)
- bufferingTimeout = setTimeout(() => {
- setBuffering(true)
- }, 200) // Delay to avoid frequent buffering state changes
- }
-
- const handleEnded = () => {
- setPlaying(false)
- setBuffering(false)
- setError(false)
- }
-
- const abortController = new AbortController()
-
- ref.current.addEventListener('timeupdate', handleTimeUpdate, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('durationchange', handleDurationChange, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('play', handlePlay, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('pause', handlePause, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('volumechange', handleVolumeChange, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('error', handleError, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('canplay', handleCanPlay, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('waiting', handleWaiting, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('playing', handlePlaying, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('seeking', handleSeeking, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('seeked', handleSeeked, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('stalled', handleStalled, {
- signal: abortController.signal,
- })
- ref.current.addEventListener('ended', handleEnded, {
- signal: abortController.signal,
- })
-
- return () => {
- abortController.abort()
- clearTimeout(bufferingTimeout)
- }
- }, [ref])
-
- const play = useCallback(() => {
- if (!ref.current) return
-
- if (ref.current.ended) {
- ref.current.currentTime = 0
- }
-
- if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
- playWhenReadyRef.current = true
- } else {
- const promise = ref.current.play()
- if (promise !== undefined) {
- promise.catch(err => {
- console.error('Error playing video:', err)
- })
- }
- }
- }, [ref])
-
- const pause = useCallback(() => {
- if (!ref.current) return
-
- ref.current.pause()
- playWhenReadyRef.current = false
- }, [ref])
-
- const togglePlayPause = useCallback(() => {
- if (!ref.current) return
-
- if (ref.current.paused) {
- play()
- } else {
- pause()
- }
- }, [ref, play, pause])
-
- const mute = useCallback(() => {
- if (!ref.current) return
-
- ref.current.muted = true
- }, [ref])
-
- const unmute = useCallback(() => {
- if (!ref.current) return
-
- ref.current.muted = false
- }, [ref])
-
- const toggleMute = useCallback(() => {
- if (!ref.current) return
-
- ref.current.muted = !ref.current.muted
- }, [ref])
-
- return {
- play,
- pause,
- togglePlayPause,
- duration,
- currentTime,
- playing,
- muted,
- mute,
- unmute,
- toggleMute,
- buffering,
- error,
- canPlay,
- }
-}
-
-function fullscreenSubscribe(onChange: () => void) {
- document.addEventListener('fullscreenchange', onChange)
- return () => document.removeEventListener('fullscreenchange', onChange)
-}
-
-function useFullscreen(ref: React.RefObject) {
- const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
- Boolean(document.fullscreenElement),
- )
-
- const toggleFullscreen = useCallback(() => {
- if (isFullscreen) {
- document.exitFullscreen()
- } else {
- if (!ref.current) return
- ref.current.requestFullscreen()
- }
- }, [isFullscreen, ref])
-
- return [isFullscreen, toggleFullscreen] as const
-}
diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
index 473343ca4b..95511099e4 100644
--- a/src/view/com/util/post-embeds/VideoPlayerContext.tsx
+++ b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
@@ -2,6 +2,12 @@ import React, {useContext} from 'react'
import type {VideoPlayer} from 'expo-video'
import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
+import {logger} from '#/logger'
+import {
+ AudioCategory,
+ PlatformInfo,
+} from '../../../../../modules/expo-bluesky-swiss-army'
+
const VideoPlayerContext = React.createContext(null)
export function VideoPlayerProvider({
@@ -13,8 +19,16 @@ export function VideoPlayerProvider({
}) {
// eslint-disable-next-line @typescript-eslint/no-shadow
const player = useExpoVideoPlayer(source, player => {
- player.loop = true
- player.play()
+ try {
+ PlatformInfo.setAudioCategory(AudioCategory.Ambient)
+ PlatformInfo.setAudioActive(false)
+
+ player.loop = true
+ player.muted = true
+ player.play()
+ } catch (err) {
+ logger.error('Failed to init video player', {safeMessage: err})
+ }
})
return (
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 0462212fbd..9c13644834 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -25,13 +25,13 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
+import * as ListCard from '#/components/ListCard'
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
-import {ListEmbed} from './ListEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
type Embed =
@@ -203,10 +203,20 @@ function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) {
const moderation = React.useMemo(() => {
return moderationOpts ? moderateUserList(view, moderationOpts) : undefined
}, [view, moderationOpts])
+ const t = useTheme()
return (
-
+
+
+
)
}
diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx
index 2a4477532d..5cb5c6a39f 100644
--- a/src/view/screens/AccessibilitySettings.tsx
+++ b/src/view/screens/AccessibilitySettings.tsx
@@ -80,7 +80,7 @@ export function AccessibilitySettingsScreen({}: Props) {
style={s.flex1}
contentContainerStyle={[
s.flex1,
- {paddingBottom: 200},
+ {paddingBottom: 100},
isMobile && pal.viewLight,
]}>
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 6ee8b3ada6..9a47007c4b 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -7,6 +7,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
+import {useGate} from '#/lib/statsig/statsig'
import {emitSoftReset} from '#/state/events'
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
@@ -88,6 +89,7 @@ function HomeScreenReady({
const selectedFeed = allFeeds[selectedIndex]
const requestNotificationsPermission = useRequestNotificationsPermission()
const triggerTourIfQueued = useTriggerTourIfQueued(TOURS.HOME)
+ const gate = useGate()
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
useOTAUpdates()
@@ -169,6 +171,10 @@ function HomeScreenReady({
const {isMobile} = useWebMediaQueries()
useFocusEffect(
React.useCallback(() => {
+ if (gate('fixed_bottom_bar')) {
+ // Unnecessary because it's always there.
+ return
+ }
const listener = AppState.addEventListener('change', nextAppState => {
if (nextAppState === 'active') {
if (isMobile && mode.value === 1) {
@@ -181,7 +187,7 @@ function HomeScreenReady({
return () => {
listener.remove()
}
- }, [setMinimalShellMode, mode, isMobile]),
+ }, [setMinimalShellMode, mode, isMobile, gate]),
)
const onPageSelected = React.useCallback(
diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx
index 390d2807b0..0f27db5229 100644
--- a/src/view/screens/LanguageSettings.tsx
+++ b/src/view/screens/LanguageSettings.tsx
@@ -145,6 +145,7 @@ export function LanguageSettingsScreen(_props: Props) {
backgroundColor: pal.viewLight.backgroundColor,
color: pal.text.color,
fontSize: 14,
+ fontFamily: 'inherit',
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
@@ -236,6 +237,7 @@ export function LanguageSettingsScreen(_props: Props) {
backgroundColor: pal.viewLight.backgroundColor,
color: pal.text.color,
fontSize: 14,
+ fontFamily: 'inherit',
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index 89234c46b8..88d0726c12 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -1,39 +1,19 @@
import React from 'react'
-import {StyleSheet, View} from 'react-native'
-import Animated from 'react-native-reanimated'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
-import {useQueryClient} from '@tanstack/react-query'
-import {clamp} from 'lodash'
-import {isWeb} from '#/platform/detection'
-import {
- RQKEY as POST_THREAD_RQKEY,
- ThreadNode,
-} from '#/state/queries/post-thread'
-import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
-import {useComposerControls} from '#/state/shell/composer'
-import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {s} from 'lib/styles'
-import {ComposePrompt} from 'view/com/composer/Prompt'
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
type Props = NativeStackScreenProps
export function PostThreadScreen({route}: Props) {
- const queryClient = useQueryClient()
- const {hasSession} = useSession()
- const fabMinimalShellTransform = useMinimalShellFabTransform()
const setMinimalShellMode = useSetMinimalShellMode()
- const {openComposer} = useComposerControls()
- const safeAreaInsets = useSafeAreaInsets()
+
const {name, rkey} = route.params
- const {isMobile} = useWebMediaQueries()
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
- const [canReply, setCanReply] = React.useState(false)
useFocusEffect(
React.useCallback(() => {
@@ -41,59 +21,11 @@ export function PostThreadScreen({route}: Props) {
}, [setMinimalShellMode]),
)
- const onPressReply = React.useCallback(() => {
- if (!uri) {
- return
- }
- const thread = queryClient.getQueryData(POST_THREAD_RQKEY(uri))
- if (thread?.type !== 'post') {
- return
- }
- openComposer({
- replyTo: {
- uri: thread.post.uri,
- cid: thread.post.cid,
- text: thread.record.text,
- author: thread.post.author,
- embed: thread.post.embed,
- },
- onPost: () =>
- queryClient.invalidateQueries({
- queryKey: POST_THREAD_RQKEY(uri),
- }),
- })
- }, [openComposer, queryClient, uri])
-
return (
-
+
- {isMobile && canReply && hasSession && (
-
-
-
- )}
)
}
-
-const styles = StyleSheet.create({
- prompt: {
- // @ts-ignore web-only
- position: isWeb ? 'fixed' : 'absolute',
- left: 0,
- right: 0,
- },
-})
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index bf13791ae6..0c2c6405fc 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -32,6 +32,7 @@ import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {
useAddSavedFeedsMutation,
usePreferencesQuery,
+ UsePreferencesQueryResponse,
useRemoveFeedMutation,
useUpdateSavedFeedsMutation,
} from '#/state/queries/preferences'
@@ -67,9 +68,10 @@ import {LoadingScreen} from 'view/com/util/LoadingScreen'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {CenteredView} from 'view/com/util/Views'
+import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
-import {ScreenHider} from '#/components/moderation/ScreenHider'
+import * as Hider from '#/components/moderation/Hider'
import * as Prompt from '#/components/Prompt'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {RichText} from '#/components/RichText'
@@ -88,6 +90,7 @@ export function ProfileListScreen(props: Props) {
const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
)
+ const {data: preferences} = usePreferencesQuery()
const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
const moderationOpts = useModerationOpts()
@@ -110,12 +113,13 @@ export function ProfileListScreen(props: Props) {
)
}
- return resolvedUri && list && moderationOpts ? (
+ return resolvedUri && list && moderationOpts && preferences ? (
) : (
@@ -127,27 +131,32 @@ function ProfileListScreenLoaded({
uri,
list,
moderationOpts,
+ preferences,
}: Props & {
uri: string
list: AppBskyGraphDefs.ListView
moderationOpts: ModerationOpts
+ preferences: UsePreferencesQueryResponse
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const {openComposer} = useComposerControls()
const setMinimalShellMode = useSetMinimalShellMode()
+ const {currentAccount} = useSession()
const {rkey} = route.params
const feedSectionRef = React.useRef(null)
const aboutSectionRef = React.useRef(null)
const {openModal} = useModalControls()
- const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
+ const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST
const isScreenFocused = useIsFocused()
+ const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1
+ const isOwner = currentAccount?.did === list.creator.did
const moderation = React.useMemo(() => {
return moderateUserList(list, moderationOpts)
}, [list, moderationOpts])
- useSetTitle(list.name)
+ useSetTitle(isHidden ? _(msg`List Hidden`) : list.name)
useFocusEffect(
useCallback(() => {
@@ -179,34 +188,75 @@ function ProfileListScreenLoaded({
)
const renderHeader = useCallback(() => {
- return
- }, [rkey, list])
+ return
+ }, [rkey, list, preferences])
if (isCurateList) {
return (
-
+
+
+
+
+
+
+
+ {({headerHeight, scrollElRef, isFocused}) => (
+
+ )}
+ {({headerHeight, scrollElRef}) => (
+
+ )}
+
+ openComposer({})}
+ icon={
+
+ }
+ accessibilityRole="button"
+ accessibilityLabel={_(msg`New post`)}
+ accessibilityHint=""
+ />
+
+
+
+ )
+ }
+ return (
+
+
+
+
+
- {({headerHeight, scrollElRef, isFocused}) => (
-
- )}
+ renderHeader={renderHeader}>
{({headerHeight, scrollElRef}) => (
@@ -227,47 +277,20 @@ function ProfileListScreenLoaded({
accessibilityHint=""
/>
-
- )
- }
- return (
-
-
-
- {({headerHeight, scrollElRef}) => (
-
- )}
-
- openComposer({})}
- icon={
-
- }
- accessibilityRole="button"
- accessibilityLabel={_(msg`New post`)}
- accessibilityHint=""
- />
-
-
+
+
)
}
-function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
+function Header({
+ rkey,
+ list,
+ preferences,
+}: {
+ rkey: string
+ list: AppBskyGraphDefs.ListView
+ preferences: UsePreferencesQueryResponse
+}) {
const pal = usePalette('default')
const palInverted = usePalette('inverted')
const {_} = useLingui()
@@ -283,7 +306,6 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
const isBlocking = !!list.viewer?.blocked
const isMuting = !!list.viewer?.muted
const isOwner = list.creator.did === currentAccount?.did
- const {data: preferences} = usePreferencesQuery()
const {track} = useAnalytics()
const playHaptic = useHaptics()
@@ -644,7 +666,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
cid: list.cid,
}}
/>
- {isCurateList || isPinned ? (
+ {isCurateList ? (
)
@@ -565,7 +561,7 @@ export function Explore() {
}
}
},
- [t, moderationOpts, gate],
+ [t, moderationOpts],
)
return (
@@ -575,7 +571,7 @@ export function Explore() {
keyExtractor={item => item.key}
// @ts-ignore web only -prf
desktopFixedHeight
- contentContainerStyle={{paddingBottom: 200}}
+ contentContainerStyle={{paddingBottom: 100}}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
/>
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 0eef5cbd66..30d16506e0 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -783,7 +783,7 @@ let SearchInputBox = ({
}}>
maxLength) {
- return text.substring(0, maxLength) + '...'
- }
- return text
-}
-
function SearchHistory({
searchHistory,
selectedProfiles,
@@ -965,8 +958,10 @@ function SearchHistory({
style={styles.profileAvatar as StyleProp}
accessibilityIgnoresInvertColors
/>
-
- {truncateText(profile.displayName || '', 12)}
+
+ {profile.displayName || profile.handle}
-
- {isCurrentAccount ? (
- {
- if (isNative) {
- logout('Settings')
- resetToTab('HomeTab')
- } else {
- navigate('Home').then(() => {
- logout('Settings')
- })
- }
- }}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Sign out`)}
- accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}
- activeOpacity={0.8}>
-
- Sign out
-
-
- ) : (
-
- )}
+
)
@@ -173,6 +147,7 @@ export function SettingsScreen({}: Props) {
const {accounts, currentAccount} = useSession()
const {mutate: clearPreferences} = useClearPreferencesMutation()
const {setShowLoggedOut} = useLoggedOutViewControls()
+ const {logoutEveryAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const exportCarControl = useDialogControl()
const birthdayControl = useDialogControl()
@@ -237,6 +212,10 @@ export function SettingsScreen({}: Props) {
openModal({name: 'delete-account'})
}, [openModal])
+ const onPressLogoutEveryAccount = React.useCallback(() => {
+ logoutEveryAccount('Settings')
+ }, [logoutEveryAccount])
+
const onPressResetPreferences = React.useCallback(async () => {
clearPreferences()
}, [clearPreferences])
@@ -325,7 +304,7 @@ export function SettingsScreen({}: Props) {
@@ -394,6 +373,15 @@ export function SettingsScreen({}: Props) {
) : null}
+ {accounts.length > 1 && (
+
+
+ Other accounts
+
+
+
+ )}
+
{accounts
.filter(a => a.did !== currentAccount?.did)
.map(account => (
@@ -422,6 +410,29 @@ export function SettingsScreen({}: Props) {
Add account
+
+
+
+
+
+
+ {accounts.length > 1 ? (
+ Sign out of all accounts
+ ) : (
+ Sign out
+ )}
+
+
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index 282b3ff5c7..71dbe8839d 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -36,7 +36,7 @@ function StorybookInner() {
return (
-
+
{!showContainedList ? (
<>
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 4b765962a6..0e852edd1a 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -33,6 +33,7 @@ import {NavSignupCard} from '#/view/shell/NavSignupCard'
import {formatCountShortOnly} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
+import {atoms as a} from '#/alf'
import {useTheme as useAlfTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {
@@ -96,29 +97,42 @@ let DrawerProfileCard = ({
numberOfLines={1}>
@{account.handle}
-
-
-
- {formatCountShortOnly(profile?.followersCount ?? 0)}
- {' '}
-
- {' '}
- ·{' '}
-
-
- {formatCountShortOnly(profile?.followsCount ?? 0)}
- {' '}
-
-
-
+
+
+
+
+ {formatCountShortOnly(profile?.followersCount ?? 0)}
+ {' '}
+
+
+
+
+ ·
+
+
+
+
+ {formatCountShortOnly(profile?.followsCount ?? 0)}
+ {' '}
+
+
+
+
)
}
@@ -610,7 +624,7 @@ const styles = StyleSheet.create({
backgroundColor: '#1B1919',
},
main: {
- paddingLeft: 20,
+ paddingHorizontal: 20,
paddingTop: 20,
},
smallSpacer: {
@@ -627,14 +641,12 @@ const styles = StyleSheet.create({
},
profileCardFollowers: {
marginTop: 16,
- paddingRight: 10,
},
menuItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
- paddingRight: 10,
},
menuItemIconWrapper: {
width: 24,
diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
index 203e7e5077..7951eef75b 100644
--- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx
+++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
@@ -29,7 +29,6 @@ import {
useLoggedOutView,
useLoggedOutViewControls,
} from '#/state/shell/logged-out'
-import {useGate} from 'lib/statsig/statsig'
import {isNative, isWeb} from 'platform/detection'
import {Deactivated} from '#/screens/Deactivated'
import {Onboarding} from '#/screens/Onboarding'
@@ -51,7 +50,6 @@ function NativeStackNavigator({
screenOptions,
...rest
}: NativeStackNavigatorProps) {
- const gate = useGate()
// --- this is copy and pasted from the original native stack navigator ---
const {state, descriptors, navigation, NavigationContent} =
useNavigationBuilder<
@@ -102,12 +100,7 @@ function NativeStackNavigator({
const {showLoggedOut} = useLoggedOutView()
const {setShowLoggedOut} = useLoggedOutViewControls()
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
- if (
- !hasSession &&
- (!PWI_ENABLED ||
- activeRouteRequiresAuth ||
- (isNative && gate('native_pwi_disabled')))
- ) {
+ if (!hasSession && (!PWI_ENABLED || activeRouteRequiresAuth || isNative)) {
return
}
if (hasSession && currentAccount?.signupQueued) {
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index ed3d8212cf..fb8e6c26cb 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -11,6 +11,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
import {TextLink} from 'view/com/util/Link'
import {Text} from 'view/com/util/text/Text'
+import {atoms as a} from '#/alf'
import {ProgressGuideList} from '#/components/ProgressGuide/List'
import {DesktopFeeds} from './Feeds'
import {DesktopSearch} from './Search'
@@ -56,7 +57,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
paddingTop: hasSession ? 0 : 18,
},
]}>
-
+
{hasSession && (
<>
- ·
+ ·
>
)}
@@ -80,7 +81,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
text={_(msg`Privacy`)}
/>
- ·
+ ·
- ·
+ ·
-
-
-
-
- {query ? (
-
-
-
- Cancel
-
-
-
- ) : undefined}
-
-
-
+
{query !== '' && isActive && moderationOpts && (
{isFetching && !autocompleteData?.length ? (
@@ -262,33 +226,6 @@ const styles = StyleSheet.create({
position: 'relative',
width: 300,
},
- search: {
- paddingHorizontal: 16,
- paddingVertical: 2,
- width: 300,
- borderRadius: 20,
- },
- inputContainer: {
- flexDirection: 'row',
- },
- iconWrapper: {
- position: 'relative',
- top: 2,
- paddingVertical: 7,
- marginRight: 8,
- },
- input: {
- flex: 1,
- fontSize: 18,
- width: '100%',
- paddingTop: 7,
- paddingBottom: 7,
- },
- cancelBtn: {
- paddingRight: 4,
- paddingLeft: 10,
- paddingVertical: 7,
- },
resultsContainer: {
marginTop: 10,
flexDirection: 'column',
@@ -296,8 +233,4 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderRadius: 6,
},
- noResults: {
- textAlign: 'center',
- paddingVertical: 10,
- },
})
diff --git a/yarn.lock b/yarn.lock
index 16547aa3ad..cd0508d6a6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,39 +34,65 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
-"@atproto/api@0.12.29":
- version "0.12.29"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.29.tgz#95a19202c2f0eec4c955909685be11009ba9b9a1"
- integrity sha512-PyzPLjGWR0qNOMrmj3Nt3N5NuuANSgOk/33Bu3j+rFjjPrHvk9CI6iQPU6zuDaDCoyOTRJRafw8X/aMQw+ilgw==
+"@atproto-labs/fetch-node@0.1.0":
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.1.0.tgz#692666d57ec24a7ba0813077a303baccf26108e0"
+ integrity sha512-DUHgaGw8LBqiGg51pUDuWK/alMcmNbpcK7ALzlF2Gw//TNLTsgrj0qY9aEtK+np9rEC+x/o3bN4SGnuQEpgqIg==
+ dependencies:
+ "@atproto-labs/fetch" "0.1.0"
+ "@atproto-labs/pipe" "0.1.0"
+ ipaddr.js "^2.1.0"
+ psl "^1.9.0"
+ undici "^6.14.1"
+
+"@atproto-labs/fetch@0.1.0":
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.1.0.tgz#50a46943fd2f321dd748de28c73ba7cbfa493132"
+ integrity sha512-uirja+uA/C4HNk7vayM+AJqsccxQn2wVziUHxbsjJGt/K6Q8ZOKDaEX2+GrcXvpUVcqUKh+94JFjuzH+CAEUlg==
+ dependencies:
+ "@atproto-labs/pipe" "0.1.0"
+ optionalDependencies:
+ zod "^3.23.8"
+
+"@atproto-labs/pipe@0.1.0":
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/pipe/-/pipe-0.1.0.tgz#c8d86923b6d8e900d39efe6fdcdf0d897c434086"
+ integrity sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==
+
+"@atproto-labs/simple-store-memory@0.1.1":
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz#54526a1f8ec978822be9fad75106ad8b78500dd3"
+ integrity sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==
+ dependencies:
+ "@atproto-labs/simple-store" "0.1.1"
+ lru-cache "^10.2.0"
+
+"@atproto-labs/simple-store@0.1.1":
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
+ integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
+
+"@atproto/api@0.13.0", "@atproto/api@^0.13.0":
+ version "0.13.0"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8"
+ integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA==
dependencies:
"@atproto/common-web" "^0.3.0"
- "@atproto/lexicon" "^0.4.0"
+ "@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
- "@atproto/xrpc" "^0.5.0"
+ "@atproto/xrpc" "^0.6.0"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
-"@atproto/api@^0.12.3":
- version "0.12.3"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.3.tgz#5b7b1c7d4210ee9315961504900c8409395cbb17"
- integrity sha512-y/kGpIEo+mKGQ7VOphpqCAigTI0LZRmDThNChTfSzDKm9TzEobwiw0zUID0Yw6ot1iLLFx3nKURmuZAYlEuobw==
+"@atproto/aws@^0.2.2":
+ version "0.2.2"
+ resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.2.tgz#703e5e06f288bcf61c6d99a990738f1e7299e653"
+ integrity sha512-j7eR7+sQumFsc66/5xyCDez9JtR6dlZc+fOdwdh85nCJD4zmQyU4r1CKrA48wQ3tkzze+ASEb1SgODuIQmIugA==
dependencies:
- "@atproto/common-web" "^0.3.0"
- "@atproto/lexicon" "^0.4.0"
- "@atproto/syntax" "^0.3.0"
- "@atproto/xrpc" "^0.5.0"
- multiformats "^9.9.0"
- tlds "^1.234.0"
-
-"@atproto/aws@^0.2.0":
- version "0.2.0"
- resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.0.tgz#17f3faf744824457cabd62f87be8bf08cacf8029"
- integrity sha512-F09SHiC9CX3ydfrvYZbkpfES48UGCQNnznNVgJ3QyKSN8ON+BoWmGCpAFtn3AWeEoU0w9h0hypNvUm5nORv+5g==
- dependencies:
- "@atproto/common" "^0.4.0"
+ "@atproto/common" "^0.4.1"
"@atproto/crypto" "^0.4.0"
- "@atproto/repo" "^0.4.0"
+ "@atproto/repo" "^0.4.2"
"@aws-sdk/client-cloudfront" "^3.261.0"
"@aws-sdk/client-kms" "^3.196.0"
"@aws-sdk/client-s3" "^3.224.0"
@@ -76,19 +102,19 @@
multiformats "^9.9.0"
uint8arrays "3.0.0"
-"@atproto/bsky@^0.0.45":
- version "0.0.45"
- resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.45.tgz#c3083d8038fe8c5ff921d9bcb0b5a043cc840827"
- integrity sha512-osWeigdYzQH2vZki+eszCR8ta9zdUB4om79aFmnE+zvxw7HFduwAAbcHf6kmmiLCfaOWvCsYb1wS2i3IC66TAg==
+"@atproto/bsky@^0.0.74":
+ version "0.0.74"
+ resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.74.tgz#b735af6ded16778604378710a2e871350c29570a"
+ integrity sha512-vyukmlBamoET0sZnDMOeTGAkQNV7KbHg65uIQ6OX4/QGynyaQP8SvSF0OsEBzBqOraxV1w9WT8AZrUbyl3uvIg==
dependencies:
- "@atproto/api" "^0.12.3"
- "@atproto/common" "^0.4.0"
+ "@atproto/api" "^0.13.0"
+ "@atproto/common" "^0.4.1"
"@atproto/crypto" "^0.4.0"
"@atproto/identity" "^0.4.0"
- "@atproto/lexicon" "^0.4.0"
- "@atproto/repo" "^0.4.0"
+ "@atproto/lexicon" "^0.4.1"
+ "@atproto/repo" "^0.4.2"
"@atproto/syntax" "^0.3.0"
- "@atproto/xrpc-server" "^0.5.1"
+ "@atproto/xrpc-server" "^0.6.1"
"@bufbuild/protobuf" "^1.5.0"
"@connectrpc/connect" "^1.1.4"
"@connectrpc/connect-express" "^1.1.4"
@@ -105,19 +131,20 @@
multiformats "^9.9.0"
p-queue "^6.6.2"
pg "^8.10.0"
- pino "^8.15.0"
+ pino "^8.21.0"
pino-http "^8.2.1"
sharp "^0.32.6"
+ statsig-node "^5.23.1"
structured-headers "^1.0.1"
typed-emitter "^2.1.0"
uint8arrays "3.0.0"
-"@atproto/bsync@^0.0.3":
- version "0.0.3"
- resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.3.tgz#2b0b8ef3686cf177846a80088317f2e89d1bf88f"
- integrity sha512-tJRwNgXzfNV57lzgWPvjtb1OMlMJH9SpsMeYhIii16zcaFUWwsb474BicKpkGRT+iCvtYzBT6gWlZE2Ijnhf7w==
+"@atproto/bsync@^0.0.5":
+ version "0.0.5"
+ resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.5.tgz#bf2fa45e4595fda12addcd6784314e4dbe409046"
+ integrity sha512-xCCMHy14y4tQoXiGrfd0XjSnc4q7I9bUNqju9E8jrP95QTDedH1FQgybStbUIbHt0eEqY5v9E7iZBH3n7Kiz7A==
dependencies:
- "@atproto/common" "^0.4.0"
+ "@atproto/common" "^0.4.1"
"@atproto/syntax" "^0.3.0"
"@bufbuild/protobuf" "^1.5.0"
"@connectrpc/connect" "^1.1.4"
@@ -158,17 +185,17 @@
pino "^8.6.1"
zod "^3.14.2"
-"@atproto/common@^0.4.0":
- version "0.4.0"
- resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.0.tgz#d77696c7eb545426df727837d9ee333b429fe7ef"
- integrity sha512-yOXuPlCjT/OK9j+neIGYn9wkxx/AlxQSucysAF0xgwu0Ji8jAtKBf9Jv6R5ObYAjAD/kVUvEYumle+Yq/R9/7g==
+"@atproto/common@^0.4.1":
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.1.tgz#ca6fce47001ce8d031acd3fb4942fbfd81f72c43"
+ integrity sha512-uL7kQIcBTbvkBDNfxMXL6lBH4fO2DQpHd2BryJxMtbw/4iEPKe9xBYApwECHhEIk9+zhhpTRZ15FJ3gxTXN82Q==
dependencies:
"@atproto/common-web" "^0.3.0"
"@ipld/dag-cbor" "^7.0.3"
cbor-x "^1.5.1"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
- pino "^8.15.0"
+ pino "^8.21.0"
"@atproto/crypto@0.1.0":
version "0.1.0"
@@ -190,22 +217,22 @@
"@noble/hashes" "^1.3.1"
uint8arrays "3.0.0"
-"@atproto/dev-env@^0.3.5":
- version "0.3.5"
- resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.5.tgz#cd13313dbc52131731d039a1d22808ee8193505d"
- integrity sha512-dqRNihzX1xIHbWPHmfYsliUUXyZn5FFhCeButrGie5soQmHA4okQJTB1XWDly3mdHLjUM90g+5zjRSAKoui77Q==
+"@atproto/dev-env@^0.3.39":
+ version "0.3.39"
+ resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.39.tgz#f498f087d4da43d5f86805c07d5f2b781e60fd6f"
+ integrity sha512-rIeUO99DL8/gRKYEAkAFuTn77y8letEbKMXnfpsVX2YHD89VRdDyMxkYzRu2+31UjtGv62I+qTLLKQS4EcFItA==
dependencies:
- "@atproto/api" "^0.12.3"
- "@atproto/bsky" "^0.0.45"
- "@atproto/bsync" "^0.0.3"
+ "@atproto/api" "^0.13.0"
+ "@atproto/bsky" "^0.0.74"
+ "@atproto/bsync" "^0.0.5"
"@atproto/common-web" "^0.3.0"
"@atproto/crypto" "^0.4.0"
"@atproto/identity" "^0.4.0"
- "@atproto/lexicon" "^0.4.0"
- "@atproto/ozone" "^0.1.7"
- "@atproto/pds" "^0.4.14"
+ "@atproto/lexicon" "^0.4.1"
+ "@atproto/ozone" "^0.1.36"
+ "@atproto/pds" "^0.4.48"
"@atproto/syntax" "^0.3.0"
- "@atproto/xrpc-server" "^0.5.1"
+ "@atproto/xrpc-server" "^0.6.1"
"@did-plc/lib" "^0.0.1"
"@did-plc/server" "^0.0.1"
axios "^0.27.2"
@@ -224,30 +251,79 @@
"@atproto/crypto" "^0.4.0"
axios "^0.27.2"
-"@atproto/lexicon@^0.4.0":
- version "0.4.0"
- resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576"
- integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==
+"@atproto/jwk-jose@0.1.2":
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.2.tgz#236eadb740b498689d9a912d1254aa9ff58890a1"
+ integrity sha512-lDwc/6lLn2aZ/JpyyggyjLFsJPMntrVzryyGUx5aNpuTS8SIuc4Ky0REhxqfLopQXJJZCuRRjagHG3uP05/moQ==
+ dependencies:
+ "@atproto/jwk" "0.1.1"
+ jose "^5.2.0"
+
+"@atproto/jwk@0.1.1":
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.1.1.tgz#15bcad4a1778eeb20c82108e0ec55fef45cd07b6"
+ integrity sha512-6h/bj1APUk7QcV9t/oA6+9DB5NZx9SZru9x+/pV5oHFI9Xz4ZuM5+dq1PfsJV54pZyqdnZ6W6M717cxoC7q7og==
+ dependencies:
+ multiformats "^9.9.0"
+ zod "^3.23.8"
+
+"@atproto/lexicon@^0.4.1":
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0"
+ integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/syntax" "^0.3.0"
iso-datestring-validator "^2.2.2"
multiformats "^9.9.0"
- zod "^3.21.4"
+ zod "^3.23.8"
-"@atproto/ozone@^0.1.7":
- version "0.1.7"
- resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.7.tgz#248d88e1acfe56936651754975472d03d047d689"
- integrity sha512-vvaV0MFynOzZJcL8m8mEW21o1FFIkP+wHTXEC9LJrL3h03+PMaby8Ujmif6WX5eikhfxvr9xsU/Jxbi/iValuQ==
+"@atproto/oauth-provider@^0.1.2":
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.1.2.tgz#a576a4c7795c7938a994e76192c19a2e73ffcddf"
+ integrity sha512-z1YKK0XLDfSDtLP5ntPCviEtajvUHbI4TwzYQ5X9CAL9PoXjqhQg0U/csg1wGDs8qkbphF9gni9M2stlpH7H0g==
dependencies:
- "@atproto/api" "^0.12.3"
- "@atproto/common" "^0.4.0"
+ "@atproto-labs/fetch" "0.1.0"
+ "@atproto-labs/fetch-node" "0.1.0"
+ "@atproto-labs/pipe" "0.1.0"
+ "@atproto-labs/simple-store" "0.1.1"
+ "@atproto-labs/simple-store-memory" "0.1.1"
+ "@atproto/jwk" "0.1.1"
+ "@atproto/jwk-jose" "0.1.2"
+ "@atproto/oauth-types" "0.1.2"
+ "@hapi/accept" "^6.0.3"
+ "@hapi/bourne" "^3.0.0"
+ cookie "^0.6.0"
+ http-errors "^2.0.0"
+ jose "^5.2.0"
+ oidc-token-hash "^5.0.3"
+ psl "^1.9.0"
+ zod "^3.23.8"
+ optionalDependencies:
+ ioredis "^5.3.2"
+ keygrip "^1.1.0"
+
+"@atproto/oauth-types@0.1.2":
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.1.2.tgz#d6c497c8e5f88f1875c630adde4ed9c5d8a8b4f4"
+ integrity sha512-yySPPTLxteFJ3O3xVWEhvBFx7rczgo4LK2nQNeqAPMZdYd5dpgvuZZ88nQQge074BfuOc0MWTnr0kPdxQMjjPw==
+ dependencies:
+ "@atproto/jwk" "0.1.1"
+ zod "^3.23.8"
+
+"@atproto/ozone@^0.1.36":
+ version "0.1.36"
+ resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.36.tgz#6a1a71fdff3ff486c5951a9e491e954b51703d53"
+ integrity sha512-BQThLU5RFG+/bZli/fj5YrFU8jW5rkium7aplfJX2eHkV6huJnBU5DcgracjH2paPGC5L/zjYtibz5spqatKAg==
+ dependencies:
+ "@atproto/api" "^0.13.0"
+ "@atproto/common" "^0.4.1"
"@atproto/crypto" "^0.4.0"
"@atproto/identity" "^0.4.0"
- "@atproto/lexicon" "^0.4.0"
+ "@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
- "@atproto/xrpc" "^0.5.0"
- "@atproto/xrpc-server" "^0.5.1"
+ "@atproto/xrpc" "^0.6.0"
+ "@atproto/xrpc-server" "^0.6.1"
"@did-plc/lib" "^0.0.1"
axios "^1.6.7"
compression "^1.7.4"
@@ -255,30 +331,34 @@
express "^4.17.2"
http-terminator "^3.2.0"
kysely "^0.22.0"
+ lande "^1.0.10"
multiformats "^9.9.0"
p-queue "^6.6.2"
pg "^8.10.0"
pino-http "^8.2.1"
+ structured-headers "^1.0.1"
typed-emitter "^2.1.0"
uint8arrays "3.0.0"
-"@atproto/pds@^0.4.14":
- version "0.4.14"
- resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.14.tgz#5b55ef307323bda712f2ddaba5c1fff7740ed91b"
- integrity sha512-rqVcvtw5oMuuJIpWZbSSTSx19+JaZyUcg9OEjdlUmyEpToRN88zTEQySEksymrrLQkW/LPRyWGd7WthbGEuEfQ==
+"@atproto/pds@^0.4.48":
+ version "0.4.48"
+ resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.48.tgz#34f29846a0585f5cc33f1685eb75ad730b7dcb9f"
+ integrity sha512-B5FpmECkGtA0EyhiB5rfhmQArmGekqqyzFnPlNpO5vOUrTTVKc9mgGfHLVJtrnwDUfGAuIgpigqZ8HgwS0DnMA==
dependencies:
- "@atproto/api" "^0.12.3"
- "@atproto/aws" "^0.2.0"
- "@atproto/common" "^0.4.0"
+ "@atproto-labs/fetch-node" "0.1.0"
+ "@atproto/api" "^0.13.0"
+ "@atproto/aws" "^0.2.2"
+ "@atproto/common" "^0.4.1"
"@atproto/crypto" "^0.4.0"
"@atproto/identity" "^0.4.0"
- "@atproto/lexicon" "^0.4.0"
- "@atproto/repo" "^0.4.0"
+ "@atproto/lexicon" "^0.4.1"
+ "@atproto/oauth-provider" "^0.1.2"
+ "@atproto/repo" "^0.4.2"
"@atproto/syntax" "^0.3.0"
- "@atproto/xrpc" "^0.5.0"
- "@atproto/xrpc-server" "^0.5.1"
+ "@atproto/xrpc" "^0.6.0"
+ "@atproto/xrpc-server" "^0.6.1"
"@did-plc/lib" "^0.0.4"
- better-sqlite3 "^9.4.0"
+ better-sqlite3 "^10.0.0"
bytes "^3.1.2"
compression "^1.7.4"
cors "^2.8.5"
@@ -297,41 +377,42 @@
nodemailer "^6.8.0"
nodemailer-html-to-text "^3.2.0"
p-queue "^6.6.2"
- pino "^8.15.0"
+ pino "^8.21.0"
pino-http "^8.2.1"
sharp "^0.32.6"
typed-emitter "^2.1.0"
uint8arrays "3.0.0"
- zod "^3.21.4"
+ zod "^3.23.8"
-"@atproto/repo@^0.4.0":
- version "0.4.0"
- resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.4.0.tgz#e5d3195a8e4233c9bf060737b18ddee905af2d9a"
- integrity sha512-LB0DF/D8r8hB+qiGB0sWZuq7TSJYbWel+t572aCrLeCOmbRgnLkGPLUTOOUvLFYv8xz1BPZTbI8hy/vcUV79VA==
+"@atproto/repo@^0.4.2":
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.4.2.tgz#311eef52ef5df0b6f969fb4b329935a32db05313"
+ integrity sha512-6hEGA3BmasPCoBGaIN/jKAjKJidCf+z8exkx/77V3WB7TboucSLHn/8gg+Xf03U7bJd6mn3F0YmPaRfJwqIT8w==
dependencies:
- "@atproto/common" "^0.4.0"
+ "@atproto/common" "^0.4.1"
"@atproto/common-web" "^0.3.0"
"@atproto/crypto" "^0.4.0"
- "@atproto/lexicon" "^0.4.0"
+ "@atproto/lexicon" "^0.4.1"
"@ipld/car" "^3.2.3"
"@ipld/dag-cbor" "^7.0.0"
multiformats "^9.9.0"
uint8arrays "3.0.0"
- zod "^3.21.4"
+ zod "^3.23.8"
"@atproto/syntax@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
-"@atproto/xrpc-server@^0.5.1":
- version "0.5.1"
- resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.5.1.tgz#f63c86ba60bd5b9c5a641ea57191ff83d9db41fd"
- integrity sha512-SXU6dscVe5iYxPeV79QIFs/yEEu7LLOzyHGoHG1kSNO6DjwxXTdcWOc8GSYGV6H+7VycOoPZPkyD9q4teJlj/w==
+"@atproto/xrpc-server@^0.6.1":
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.6.1.tgz#c8c75065ab6bc1a7f5c121b558acb5213f2afda6"
+ integrity sha512-Qm0aJC1LbYYHaRGWoh0D2iG48VwRha1T1NEP/D5UkD4GzfjT8m5PDiZBtcyspJD/BEC7UYX9/BhMYCoZLQMYcA==
dependencies:
- "@atproto/common" "^0.4.0"
+ "@atproto/common" "^0.4.1"
"@atproto/crypto" "^0.4.0"
- "@atproto/lexicon" "^0.4.0"
+ "@atproto/lexicon" "^0.4.1"
+ "@atproto/xrpc" "^0.6.0"
cbor-x "^1.5.1"
express "^4.17.2"
http-errors "^2.0.0"
@@ -339,15 +420,15 @@
rate-limiter-flexible "^2.4.1"
uint8arrays "3.0.0"
ws "^8.12.0"
- zod "^3.21.4"
+ zod "^3.23.8"
-"@atproto/xrpc@^0.5.0":
- version "0.5.0"
- resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032"
- integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
+"@atproto/xrpc@^0.6.0":
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
+ integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
dependencies:
- "@atproto/lexicon" "^0.4.0"
- zod "^3.21.4"
+ "@atproto/lexicon" "^0.4.1"
+ zod "^3.23.8"
"@aws-crypto/crc32@3.0.0":
version "3.0.0"
@@ -4001,6 +4082,31 @@
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
+"@hapi/accept@^6.0.3":
+ version "6.0.3"
+ resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.3.tgz#eef0800a4f89cd969da8e5d0311dc877c37279ab"
+ integrity sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==
+ dependencies:
+ "@hapi/boom" "^10.0.1"
+ "@hapi/hoek" "^11.0.2"
+
+"@hapi/boom@^10.0.1":
+ version "10.0.1"
+ resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.1.tgz#ebb14688275ae150aa6af788dbe482e6a6062685"
+ integrity sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==
+ dependencies:
+ "@hapi/hoek" "^11.0.2"
+
+"@hapi/bourne@^3.0.0":
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7"
+ integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==
+
+"@hapi/hoek@^11.0.2":
+ version "11.0.4"
+ resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-11.0.4.tgz#42a7f244fd3dd777792bfb74b8c6340ae9182f37"
+ integrity sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==
+
"@hapi/hoek@^9.0.0":
version "9.3.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"
@@ -9453,10 +9559,10 @@ better-opn@~3.0.2:
dependencies:
open "^8.0.4"
-better-sqlite3@^9.4.0:
- version "9.4.5"
- resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-9.4.5.tgz#1d3422443a9924637cb06cc3ccc941b2ae932c65"
- integrity sha512-uFVyoyZR9BNcjSca+cp3MWCv6upAv+tbMC4SWM51NIMhoQOm4tjIkyxFO/ZsYdGAF61WJBgdzyJcz4OokJi0gQ==
+better-sqlite3@^10.0.0:
+ version "10.1.0"
+ resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-10.1.0.tgz#8dc07e496fc014a7cd2211f79e591f6ba92838e8"
+ integrity sha512-hqpHJaCfKEZFaAWdMh6crdzRWyzQzfP6Ih8TYI0vFn01a6ZTDSbJIMXN+6AMBaBOh99DzUy8l3PsV9R3qnJDng==
dependencies:
bindings "^1.5.0"
prebuild-install "^7.1.1"
@@ -10305,6 +10411,11 @@ cookie@0.5.0:
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
+cookie@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
+ integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
+
copy-webpack-plugin@^10.2.0:
version "10.2.4"
resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-10.2.4.tgz#6c854be3fdaae22025da34b9112ccf81c63308fe"
@@ -12302,10 +12413,10 @@ expo-updates@~0.25.14:
ignore "^5.3.1"
resolve-from "^5.0.0"
-expo-video@^1.1.10:
- version "1.1.10"
- resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.1.10.tgz#b47c0d40c21f401236639424bd25d70c09316b7b"
- integrity sha512-k9ecpgtwAK8Ut8enm8Jv398XkB/uVOyLLqk80M/d8pH9EN5CVrBQ7iEzWlR3quvVUFM7Uf5wRukJ4hk3mZ8NCg==
+expo-video@^1.2.4:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.2.4.tgz#787342aded4295a1b6864f59227d178b93e1bb53"
+ integrity sha512-pBK9mt7vYAbuPQjCSQxHQ7xrNjbmRheJep7JIStEg57O183/JRfP2blKuXniiSt1HBdZYPdoQnGRa3jGMXB9pg==
expo-web-browser@~13.0.3:
version "13.0.3"
@@ -13779,6 +13890,11 @@ ip-regex@^2.1.0:
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"
integrity sha512-58yWmlHpp7VYfcdTwMTvwMmqx/Elfxjd9RXTDyMsbL7lLWmhMylLEqiYVLKuLzOZqVgiWXD9MfR62Vv89VRxkw==
+ip3country@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/ip3country/-/ip3country-5.0.0.tgz#f1394b050c51ba9c10cc691c8eb240bba3d7177a"
+ integrity sha512-lcFLMFU4eO1Z7tIpbVFZkaZ5ltqpeaRx7L9NsAbA9uA7/O/rj3RF8+evE5gDitooaTTIqjdzZrenFO/OOxQ2ew==
+
ipaddr.js@1.9.1, ipaddr.js@^1.9.0:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -13789,6 +13905,11 @@ ipaddr.js@^2.0.1:
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.1.0.tgz#2119bc447ff8c257753b196fc5f1ce08a4cdf39f"
integrity sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==
+ipaddr.js@^2.1.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8"
+ integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==
+
is-arguments@^1.0.4:
version "1.1.1"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
@@ -15351,6 +15472,11 @@ jose@^5.0.1:
resolved "https://registry.yarnpkg.com/jose/-/jose-5.1.3.tgz#303959d85c51b5cb14725f930270b72be56abdca"
integrity sha512-GPExOkcMsCLBTi1YetY2LmkoY559fss0+0KVa6kOfb2YFe84nAM7Nm/XzuZozah4iHgmBGrCOHL5/cy670SBRw==
+jose@^5.2.0:
+ version "5.6.3"
+ resolved "https://registry.yarnpkg.com/jose/-/jose-5.6.3.tgz#415688bc84875461c86dfe271ea6029112a23e27"
+ integrity sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==
+
js-base64@^3.7.2:
version "3.7.5"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.5.tgz#21e24cf6b886f76d6f5f165bfcd69cc55b9e3fca"
@@ -15603,6 +15729,13 @@ key-encoder@^2.0.3:
bn.js "^4.11.8"
elliptic "^6.4.1"
+keygrip@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
+ integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==
+ dependencies:
+ tsscmp "1.0.6"
+
kind-of@^6.0.2:
version "6.0.3"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
@@ -16776,6 +16909,13 @@ node-fetch@^2.2.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.12, nod
dependencies:
whatwg-url "^5.0.0"
+node-fetch@^2.6.13:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
+ integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
+ dependencies:
+ whatwg-url "^5.0.0"
+
node-forge@^1, node-forge@^1.2.1, node-forge@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3"
@@ -17024,6 +17164,11 @@ obuf@^1.0.0, obuf@^1.1.2:
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
+oidc-token-hash@^5.0.3:
+ version "5.0.3"
+ resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz#9a229f0a1ce9d4fc89bcaee5478c97a889e7b7b6"
+ integrity sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==
+
on-exit-leak-free@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz#5c703c968f7e7f851885f6459bf8a8a57edc9cc4"
@@ -17567,18 +17712,18 @@ pinkie@^2.0.0:
resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
integrity sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==
-pino-abstract-transport@v1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz#cc0d6955fffcadb91b7b49ef220a6cc111d48bb3"
- integrity sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==
+pino-abstract-transport@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz#97f9f2631931e242da531b5c66d3079c12c9d1b5"
+ integrity sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==
dependencies:
readable-stream "^4.0.0"
split2 "^4.0.0"
-pino-abstract-transport@v1.1.0:
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.1.0.tgz#083d98f966262164504afb989bccd05f665937a8"
- integrity sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==
+pino-abstract-transport@v1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.0.0.tgz#cc0d6955fffcadb91b7b49ef220a6cc111d48bb3"
+ integrity sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==
dependencies:
readable-stream "^4.0.0"
split2 "^4.0.0"
@@ -17615,22 +17760,22 @@ pino@^8.0.0, pino@^8.11.0, pino@^8.6.1:
sonic-boom "^3.1.0"
thread-stream "^2.0.0"
-pino@^8.15.0:
- version "8.15.1"
- resolved "https://registry.yarnpkg.com/pino/-/pino-8.15.1.tgz#04b815ff7aa4e46b1bbab88d8010aaa2b17eaba4"
- integrity sha512-Cp4QzUQrvWCRJaQ8Lzv0mJzXVk4z2jlq8JNKMGaixC2Pz5L4l2p95TkuRvYbrEbe85NQsDKrAd4zalf7Ml6WiA==
+pino@^8.21.0:
+ version "8.21.0"
+ resolved "https://registry.yarnpkg.com/pino/-/pino-8.21.0.tgz#e1207f3675a2722940d62da79a7a55a98409f00d"
+ integrity sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==
dependencies:
atomic-sleep "^1.0.0"
fast-redact "^3.1.1"
on-exit-leak-free "^2.1.0"
- pino-abstract-transport v1.1.0
+ pino-abstract-transport "^1.2.0"
pino-std-serializers "^6.0.0"
- process-warning "^2.0.0"
+ process-warning "^3.0.0"
quick-format-unescaped "^4.0.3"
real-require "^0.2.0"
safe-stable-stringify "^2.3.1"
- sonic-boom "^3.1.0"
- thread-stream "^2.0.0"
+ sonic-boom "^3.7.0"
+ thread-stream "^2.6.0"
pirates@^4.0.1, pirates@^4.0.4, pirates@^4.0.5:
version "4.0.6"
@@ -18392,6 +18537,11 @@ process-warning@^2.0.0:
resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.2.0.tgz#008ec76b579820a8e5c35d81960525ca64feb626"
integrity sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==
+process-warning@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-3.0.0.tgz#96e5b88884187a1dce6f5c3166d611132058710b"
+ integrity sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==
+
process@^0.11.10:
version "0.11.10"
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
@@ -20229,6 +20379,13 @@ sonic-boom@^3.1.0:
dependencies:
atomic-sleep "^1.0.0"
+sonic-boom@^3.7.0:
+ version "3.8.1"
+ resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-3.8.1.tgz#d5ba8c4e26d6176c9a1d14d549d9ff579a163422"
+ integrity sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==
+ dependencies:
+ atomic-sleep "^1.0.0"
+
source-list-map@^2.0.0, source-list-map@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
@@ -20419,6 +20576,16 @@ statsig-js@4.45.1:
js-sha256 "^0.10.1"
uuid "^8.3.2"
+statsig-node@^5.23.1:
+ version "5.25.1"
+ resolved "https://registry.yarnpkg.com/statsig-node/-/statsig-node-5.25.1.tgz#6d8ea9ecaad6c09250e5ff7d33eda9fd0f9c05f4"
+ integrity sha512-K8+1psxFVdFr5LyXwDotJqBl7uKt8vbZO2e/9zzbLI4yDOuLDoItG5Ju5QAR0oUfEdEAANOzwV2yA052Wrc/Xw==
+ dependencies:
+ ip3country "^5.0.0"
+ node-fetch "^2.6.13"
+ ua-parser-js "^1.0.2"
+ uuid "^8.3.2"
+
statsig-react-native-expo@^4.6.1:
version "4.6.1"
resolved "https://registry.yarnpkg.com/statsig-react-native-expo/-/statsig-react-native-expo-4.6.1.tgz#0bdf49fee7112f7f28bff2405f4ba0c1727bb3d6"
@@ -21052,6 +21219,13 @@ thread-stream@^2.0.0:
dependencies:
real-require "^0.2.0"
+thread-stream@^2.6.0:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-2.7.0.tgz#d8a8e1b3fd538a6cca8ce69dbe5d3d097b601e11"
+ integrity sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==
+ dependencies:
+ real-require "^0.2.0"
+
throat@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b"
@@ -21251,6 +21425,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
+tsscmp@1.0.6:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
+ integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
+
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
@@ -21388,6 +21567,11 @@ ua-parser-js@^0.7.33:
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.35.tgz#8bda4827be4f0b1dda91699a29499575a1f1d307"
integrity sha512-veRf7dawaj9xaWEu9HoTVn5Pggtc/qj+kqTOFvNiN1l0YdxwC1kvel57UCjThjGa3BHBihE8/UJAHI+uQHmd/g==
+ua-parser-js@^1.0.2:
+ version "1.0.38"
+ resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.38.tgz#66bb0c4c0e322fe48edfe6d446df6042e62f25e2"
+ integrity sha512-Aq5ppTOfvrCMgAPneW1HfWj66Xi7XL+/mIy996R1/CLS/rcyJQm6QZdsKrUeivDFQ+Oc9Wyuwor8Ze8peEoUoQ==
+
ua-parser-js@^1.0.35:
version "1.0.35"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.35.tgz#c4ef44343bc3db0a3cbefdf21822f1b1fc1ab011"
@@ -21432,6 +21616,11 @@ undici@^5.28.2:
dependencies:
"@fastify/busboy" "^2.0.0"
+undici@^6.14.1:
+ version "6.19.5"
+ resolved "https://registry.yarnpkg.com/undici/-/undici-6.19.5.tgz#5829101361b583b53206e81579f4df71c56d6be8"
+ integrity sha512-LryC15SWzqQsREHIOUybavaIHF5IoL0dJ9aWWxL/PgT1KfqAW5225FZpDUFlt9xiDMS2/S7DOKhFWA7RLksWdg==
+
unfetch@^3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-3.1.2.tgz#dc271ef77a2800768f7b459673c5604b5101ef77"
@@ -22603,7 +22792,7 @@ zod-validation-error@^3.0.3:
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af"
integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==
-zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.21.4, zod@^3.22.4:
+zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.21.4, zod@^3.22.4, zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==