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/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..a9532cd9c6
--- /dev/null
+++ b/assets/icons/arrowsDiagonalIn_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..9b92e533eb
--- /dev/null
+++ b/assets/icons/arrowsDiagonalIn_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..9987b34406
--- /dev/null
+++ b/assets/icons/arrowsDiagonalOut_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..36d8e1d67c
--- /dev/null
+++ b/assets/icons/arrowsDiagonalOut_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/cc_filled_stroke2_corner0_rounded.svg b/assets/icons/cc_filled_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..58823ca80d
--- /dev/null
+++ b/assets/icons/cc_filled_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/cc_stroke2_corner0_rounded.svg b/assets/icons/cc_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..fcda1570f9
--- /dev/null
+++ b/assets/icons/cc_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/pause_filled_corner0_rounded.svg b/assets/icons/pause_filled_corner0_rounded.svg
new file mode 100644
index 0000000000..0037701f90
--- /dev/null
+++ b/assets/icons/pause_filled_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/pause_filled_corner2_rounded.svg b/assets/icons/pause_filled_corner2_rounded.svg
new file mode 100644
index 0000000000..98726d873e
--- /dev/null
+++ b/assets/icons/pause_filled_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/pause_stroke2_corner0_rounded.svg b/assets/icons/pause_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..d2735ed2bd
--- /dev/null
+++ b/assets/icons/pause_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/pause_stroke2_corner2_rounded.svg b/assets/icons/pause_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..3a8c0b4379
--- /dev/null
+++ b/assets/icons/pause_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/play_filled_corner0_rounded.svg b/assets/icons/play_filled_corner0_rounded.svg
new file mode 100644
index 0000000000..7bee1ae9a3
--- /dev/null
+++ b/assets/icons/play_filled_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/play_stroke2_corner0_rounded.svg b/assets/icons/play_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..d7321b9b7b
--- /dev/null
+++ b/assets/icons/play_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
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/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go
index 908486aa7e..d9235afdee 100644
--- a/bskyweb/cmd/bskyweb/main.go
+++ b/bskyweb/cmd/bskyweb/main.go
@@ -41,10 +41,10 @@ func run(args []string) {
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
},
&cli.StringFlag{
- Name: "ogcard-host",
- Usage: "scheme, hostname, and port of ogcard service",
+ Name: "ogcard-host",
+ Usage: "scheme, hostname, and port of ogcard service",
Required: false,
- EnvVars: []string{"OGCARD_HOST"},
+ EnvVars: []string{"OGCARD_HOST"},
},
&cli.StringFlag{
Name: "http-address",
@@ -67,6 +67,13 @@ func run(args []string) {
Required: false,
EnvVars: []string{"DEBUG"},
},
+ &cli.StringFlag{
+ Name: "basic-auth-password",
+ Usage: "optional password to restrict access to web interface",
+ Required: false,
+ Value: "",
+ EnvVars: []string{"BASIC_AUTH_PASSWORD"},
+ },
},
},
}
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index a91a1ccbf5..203ed62f4e 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "crypto/subtle"
"errors"
"fmt"
"io/fs"
@@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error {
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
+ basicAuthPassword := cctx.String("basic-auth-password")
// Echo
e := echo.New()
@@ -140,6 +142,18 @@ func serve(cctx *cli.Context) error {
},
}))
+ // optional password gating of entire web interface
+ if basicAuthPassword != "" {
+ e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
+ // Be careful to use constant time comparison to prevent timing attacks
+ if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 &&
+ subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 {
+ return true, nil
+ }
+ return false, nil
+ }))
+ }
+
// redirect trailing slash to non-trailing slash.
// all of our current endpoints have no trailing slash.
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
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/jestSetup.js b/jest/jestSetup.js
index a6b7c24f69..a68c1dc4bf 100644
--- a/jest/jestSetup.js
+++ b/jest/jestSetup.js
@@ -95,3 +95,16 @@ jest.mock('expo-application', () => ({
nativeApplicationVersion: '1.0.0',
nativeBuildVersion: '1',
}))
+
+jest.mock('expo-modules-core', () => ({
+ requireNativeModule: jest.fn().mockImplementation(moduleName => {
+ if (moduleName === 'ExpoPlatformInfo') {
+ return {
+ getIsReducedMotionEnabled: () => false,
+ }
+ }
+ }),
+ requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
+ return () => null
+ }),
+}))
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/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt
new file mode 100644
index 0000000000..189796f817
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/platforminfo/ExpoPlatformInfoModule.kt
@@ -0,0 +1,24 @@
+package expo.modules.blueskyswissarmy.platforminfo
+
+import android.provider.Settings
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoPlatformInfoModule : Module() {
+ override fun definition() =
+ ModuleDefinition {
+ Name("ExpoPlatformInfo")
+
+ // See https://github.com/software-mansion/react-native-reanimated/blob/7df5fd57d608fe25724608835461cd925ff5151d/packages/react-native-reanimated/android/src/main/java/com/swmansion/reanimated/nativeProxy/NativeProxyCommon.java#L242
+ Function("getIsReducedMotionEnabled") {
+ val resolver = appContext.reactContext?.contentResolver ?: return@Function false
+ val scale = Settings.Global.getString(resolver, Settings.Global.TRANSITION_ANIMATION_SCALE) ?: return@Function false
+
+ try {
+ return@Function scale.toFloat() == 0f
+ } catch (_: Error) {
+ return@Function false
+ }
+ }
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt
new file mode 100644
index 0000000000..ddbb05cde3
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/ExpoBlueskyVisibilityViewModule.kt
@@ -0,0 +1,23 @@
+package expo.modules.blueskyswissarmy.visibilityview
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoBlueskyVisibilityViewModule : Module() {
+ override fun definition() =
+ ModuleDefinition {
+ Name("ExpoBlueskyVisibilityView")
+
+ AsyncFunction("updateActiveViewAsync") {
+ VisibilityViewManager.updateActiveView()
+ }
+
+ View(VisibilityView::class) {
+ Events(arrayOf("onChangeStatus"))
+
+ Prop("enabled") { view: VisibilityView, prop: Boolean ->
+ view.isViewEnabled = prop
+ }
+ }
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt
new file mode 100644
index 0000000000..a55ab80d5a
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityView.kt
@@ -0,0 +1,63 @@
+package expo.modules.blueskyswissarmy.visibilityview
+
+import android.content.Context
+import android.graphics.Rect
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.viewevent.EventDispatcher
+import expo.modules.kotlin.views.ExpoView
+
+class VisibilityView(
+ context: Context,
+ appContext: AppContext,
+) : ExpoView(context, appContext) {
+ var isViewEnabled: Boolean = false
+
+ private val onChangeStatus by EventDispatcher()
+
+ private var isCurrentlyActive = false
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ VisibilityViewManager.addView(this)
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ VisibilityViewManager.removeView(this)
+ }
+
+ fun setIsCurrentlyActive(isActive: Boolean) {
+ if (isCurrentlyActive == isActive) {
+ return
+ }
+
+ this.isCurrentlyActive = isActive
+ this.onChangeStatus(
+ mapOf(
+ "isActive" to isActive,
+ ),
+ )
+ }
+
+ fun getPositionOnScreen(): Rect? {
+ if (!this.isShown) {
+ return null
+ }
+
+ val screenPosition = intArrayOf(0, 0)
+ this.getLocationInWindow(screenPosition)
+ return Rect(
+ screenPosition[0],
+ screenPosition[1],
+ screenPosition[0] + this.width,
+ screenPosition[1] + this.height,
+ )
+ }
+
+ fun isViewableEnough(): Boolean {
+ val positionOnScreen = this.getPositionOnScreen() ?: return false
+ val visibleArea = positionOnScreen.width() * positionOnScreen.height()
+ val totalArea = this.width * this.height
+ return visibleArea >= 0.5 * totalArea
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt
new file mode 100644
index 0000000000..ec1e49816a
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/visibilityview/VisibilityViewManager.kt
@@ -0,0 +1,82 @@
+package expo.modules.blueskyswissarmy.visibilityview
+
+import android.graphics.Rect
+
+class VisibilityViewManager {
+ companion object {
+ private val views = HashMap()
+ private var currentlyActiveView: VisibilityView? = null
+ private var prevCount = 0
+
+ fun addView(view: VisibilityView) {
+ this.views[view.id] = view
+
+ if (this.prevCount == 0) {
+ this.updateActiveView()
+ }
+ this.prevCount = this.views.count()
+ }
+
+ fun removeView(view: VisibilityView) {
+ this.views.remove(view.id)
+ this.prevCount = this.views.count()
+ }
+
+ fun updateActiveView() {
+ var activeView: VisibilityView? = null
+ val count = this.views.count()
+
+ if (count == 1) {
+ val view = this.views.values.first()
+ if (view.isViewableEnough()) {
+ activeView = view
+ }
+ } else if (count > 1) {
+ val views = this.views.values
+ var mostVisibleView: VisibilityView? = null
+ var mostVisiblePosition: Rect? = null
+
+ views.forEach { view ->
+ if (!view.isViewableEnough()) {
+ return
+ }
+
+ val position = view.getPositionOnScreen() ?: return@forEach
+ val topY = position.centerY() - (position.height() / 2)
+
+ if (topY >= 150) {
+ if (mostVisiblePosition == null) {
+ mostVisiblePosition = position
+ }
+
+ if (position.centerY() <= mostVisiblePosition!!.centerY()) {
+ mostVisibleView = view
+ mostVisiblePosition = position
+ }
+ }
+ }
+
+ activeView = mostVisibleView
+ }
+
+ if (activeView == this.currentlyActiveView) {
+ return
+ }
+
+ this.clearActiveView()
+ if (activeView != null) {
+ this.setActiveView(activeView)
+ }
+ }
+
+ private fun clearActiveView() {
+ this.currentlyActiveView?.setIsCurrentlyActive(false)
+ this.currentlyActiveView = null
+ }
+
+ private fun setActiveView(view: VisibilityView) {
+ view.setIsCurrentlyActive(true)
+ this.currentlyActiveView = view
+ }
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json
index 1111f8a0be..4cdc11e993 100644
--- a/modules/expo-bluesky-swiss-army/expo-module.config.json
+++ b/modules/expo-bluesky-swiss-army/expo-module.config.json
@@ -1,12 +1,19 @@
{
"platforms": ["ios", "tvos", "android", "web"],
"ios": {
- "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"]
+ "modules": [
+ "ExpoBlueskySharedPrefsModule",
+ "ExpoBlueskyReferrerModule",
+ "ExpoBlueskyVisibilityViewModule",
+ "ExpoPlatformInfoModule"
+ ]
},
"android": {
"modules": [
"expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
- "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
+ "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule",
+ "expo.modules.blueskyswissarmy.visibilityview.ExpoBlueskyVisibilityViewModule",
+ "expo.modules.blueskyswissarmy.platforminfo.ExpoPlatformInfoModule"
]
}
}
diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts
index 89cea00a28..2cf4f36c52 100644
--- a/modules/expo-bluesky-swiss-army/index.ts
+++ b/modules/expo-bluesky-swiss-army/index.ts
@@ -1,4 +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 {Referrer, SharedPrefs}
+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
new file mode 100644
index 0000000000..02bf5c6628
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift
@@ -0,0 +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/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift
new file mode 100644
index 0000000000..ec12a84af7
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/Visibility/ExpoBlueskyVisibilityViewModule.swift
@@ -0,0 +1,21 @@
+import ExpoModulesCore
+
+public class ExpoBlueskyVisibilityViewModule: Module {
+ public func definition() -> ModuleDefinition {
+ Name("ExpoBlueskyVisibilityView")
+
+ AsyncFunction("updateActiveViewAsync") {
+ VisibilityViewManager.shared.updateActiveView()
+ }
+
+ View(VisibilityView.self) {
+ Events([
+ "onChangeStatus"
+ ])
+
+ Prop("enabled") { (view: VisibilityView, prop: Bool) in
+ view.enabled = prop
+ }
+ }
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift
new file mode 100644
index 0000000000..ae8e168681
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisibilityViewManager.swift
@@ -0,0 +1,86 @@
+import Foundation
+
+class VisibilityViewManager {
+ static let shared = VisibilityViewManager()
+
+ private let views = NSHashTable(options: .weakMemory)
+ private var currentlyActiveView: VisibilityView?
+ private var screenHeight: CGFloat = UIScreen.main.bounds.height
+ private var prevCount = 0
+
+ func addView(_ view: VisibilityView) {
+ self.views.add(view)
+
+ if self.prevCount == 0 {
+ self.updateActiveView()
+ }
+ self.prevCount = self.views.count
+ }
+
+ func removeView(_ view: VisibilityView) {
+ self.views.remove(view)
+ self.prevCount = self.views.count
+ }
+
+ func updateActiveView() {
+ DispatchQueue.main.async {
+ var activeView: VisibilityView?
+
+ if self.views.count == 1 {
+ let view = self.views.allObjects[0]
+ if view.isViewableEnough() {
+ activeView = view
+ }
+ } else if self.views.count > 1 {
+ let views = self.views.allObjects
+ var mostVisibleView: VisibilityView?
+ var mostVisiblePosition: CGRect?
+
+ views.forEach { view in
+ if !view.isViewableEnough() {
+ return
+ }
+
+ guard let position = view.getPositionOnScreen() else {
+ return
+ }
+
+ if position.minY >= 150 {
+ if mostVisiblePosition == nil {
+ mostVisiblePosition = position
+ }
+
+ if let unwrapped = mostVisiblePosition,
+ position.minY <= unwrapped.minY {
+ mostVisibleView = view
+ mostVisiblePosition = position
+ }
+ }
+ }
+
+ activeView = mostVisibleView
+ }
+
+ if activeView == self.currentlyActiveView {
+ return
+ }
+
+ self.clearActiveView()
+ if let view = activeView {
+ self.setActiveView(view)
+ }
+ }
+ }
+
+ private func clearActiveView() {
+ if let currentlyActiveView = self.currentlyActiveView {
+ currentlyActiveView.setIsCurrentlyActive(isActive: false)
+ self.currentlyActiveView = nil
+ }
+ }
+
+ private func setActiveView(_ view: VisibilityView) {
+ view.setIsCurrentlyActive(isActive: true)
+ self.currentlyActiveView = view
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift
new file mode 100644
index 0000000000..fd99ee4938
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/Visibility/VisiblityView.swift
@@ -0,0 +1,69 @@
+import ExpoModulesCore
+
+class VisibilityView: ExpoView {
+ var enabled = false {
+ didSet {
+ if enabled {
+ VisibilityViewManager.shared.removeView(self)
+ }
+ }
+ }
+
+ private let onChangeStatus = EventDispatcher()
+ private var isCurrentlyActiveView = false
+
+ required init(appContext: AppContext? = nil) {
+ super.init(appContext: appContext)
+ }
+
+ public override func willMove(toWindow newWindow: UIWindow?) {
+ super.willMove(toWindow: newWindow)
+
+ if !self.enabled {
+ return
+ }
+
+ if newWindow == nil {
+ VisibilityViewManager.shared.removeView(self)
+ } else {
+ VisibilityViewManager.shared.addView(self)
+ }
+ }
+
+ func setIsCurrentlyActive(isActive: Bool) {
+ if isCurrentlyActiveView == isActive {
+ return
+ }
+ self.isCurrentlyActiveView = isActive
+ self.onChangeStatus([
+ "isActive": isActive
+ ])
+ }
+}
+
+// 🚨 DANGER 🚨
+// These functions need to be called from the main thread. Xcode will warn you if you call one of them
+// off the main thread, so pay attention!
+extension UIView {
+ func getPositionOnScreen() -> CGRect? {
+ if let window = self.window {
+ return self.convert(self.bounds, to: window)
+ }
+ return nil
+ }
+
+ func isViewableEnough() -> Bool {
+ guard let window = self.window else {
+ return false
+ }
+
+ let viewFrameOnScreen = self.convert(self.bounds, to: window)
+ let screenBounds = window.bounds
+ let intersection = viewFrameOnScreen.intersection(screenBounds)
+
+ let viewHeight = viewFrameOnScreen.height
+ let intersectionHeight = intersection.height
+
+ return intersectionHeight >= 0.5 * viewHeight
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts
new file mode 100644
index 0000000000..b515206d9f
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts
@@ -0,0 +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
new file mode 100644
index 0000000000..81f8c45f4d
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.ts
@@ -0,0 +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
new file mode 100644
index 0000000000..61412753c9
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/PlatformInfo/index.web.ts
@@ -0,0 +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/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx
new file mode 100644
index 0000000000..9d0e8cf220
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx
@@ -0,0 +1,39 @@
+import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
+import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
+
+import {VisibilityViewProps} from './types'
+const NativeView: React.ComponentType<{
+ onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
+ children: React.ReactNode
+ enabled: Boolean
+ style: StyleProp
+}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
+
+const NativeModule = requireNativeModule('ExpoBlueskyVisibilityView')
+
+export async function updateActiveViewAsync() {
+ await NativeModule.updateActiveViewAsync()
+}
+
+export default function VisibilityView({
+ children,
+ onChangeStatus: onChangeStatusOuter,
+ enabled,
+}: VisibilityViewProps) {
+ const onChangeStatus = React.useCallback(
+ (e: {nativeEvent: {isActive: boolean}}) => {
+ onChangeStatusOuter(e.nativeEvent.isActive)
+ },
+ [onChangeStatusOuter],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx
new file mode 100644
index 0000000000..8b4f1928cb
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx
@@ -0,0 +1,10 @@
+import {NotImplementedError} from '../NotImplemented'
+import {VisibilityViewProps} from './types'
+
+export async function updateActiveViewAsync() {
+ throw new NotImplementedError()
+}
+
+export default function VisibilityView({children}: VisibilityViewProps) {
+ return children
+}
diff --git a/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts
new file mode 100644
index 0000000000..312acf2d29
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts
@@ -0,0 +1,6 @@
+import React from 'react'
+export interface VisibilityViewProps {
+ children: React.ReactNode
+ onChangeStatus: (isActive: boolean) => void
+ enabled: boolean
+}
diff --git a/package.json b/package.json
index 3d053bc83b..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.26",
+ "@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/patches/react-native-reanimated+3.11.0.patch b/patches/react-native-reanimated+3.11.0.patch
index 9147cf08ef..a79a0ac085 100644
--- a/patches/react-native-reanimated+3.11.0.patch
+++ b/patches/react-native-reanimated+3.11.0.patch
@@ -207,31 +207,3 @@ index 88b3fdf..2488ebc 100644
const { layout, entering, exiting, sharedTransitionTag } = this.props;
if (
-diff --git a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
-index ac9be5d..86d4605 100644
---- a/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
-+++ b/node_modules/react-native-reanimated/lib/module/reanimated2/index.js
-@@ -47,4 +47,5 @@ export { LayoutAnimationConfig } from './component/LayoutAnimationConfig';
- export { PerformanceMonitor } from './component/PerformanceMonitor';
- export { startMapper, stopMapper } from './mappers';
- export { startScreenTransition, finishScreenTransition, ScreenTransition } from './screenTransition';
-+export { isReducedMotion } from './PlatformChecker';
- //# sourceMappingURL=index.js.map
-diff --git a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
-index f01dc57..161ef22 100644
---- a/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
-+++ b/node_modules/react-native-reanimated/lib/typescript/reanimated2/index.d.ts
-@@ -36,3 +36,4 @@ export type { FlatListPropsWithLayout } from './component/FlatList';
- export { startMapper, stopMapper } from './mappers';
- export { startScreenTransition, finishScreenTransition, ScreenTransition, } from './screenTransition';
- export type { AnimatedScreenTransition, GoBackGesture, ScreenTransitionConfig, } from './screenTransition';
-+export { isReducedMotion } from './PlatformChecker';
-diff --git a/node_modules/react-native-reanimated/src/reanimated2/index.ts b/node_modules/react-native-reanimated/src/reanimated2/index.ts
-index 5885fa1..a3c693f 100644
---- a/node_modules/react-native-reanimated/src/reanimated2/index.ts
-+++ b/node_modules/react-native-reanimated/src/reanimated2/index.ts
-@@ -284,3 +284,4 @@ export type {
- GoBackGesture,
- ScreenTransitionConfig,
- } from './screenTransition';
-+export { isReducedMotion } from './PlatformChecker';
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/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index 2e8724143d..e37d2c3e0e 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -92,14 +92,16 @@ function getRank(seenPost: SeenPost): string {
tier = 'a'
} else if (seenPost.feedContext?.startsWith('cluster')) {
tier = 'b'
- } else if (seenPost.feedContext?.startsWith('ntpc')) {
+ } else if (seenPost.feedContext === 'popcluster') {
tier = 'c'
- } else if (seenPost.feedContext?.startsWith('t-')) {
+ } else if (seenPost.feedContext?.startsWith('ntpc')) {
tier = 'd'
- } else if (seenPost.feedContext === 'nettop') {
+ } else if (seenPost.feedContext?.startsWith('t-')) {
tier = 'e'
- } else {
+ } else if (seenPost.feedContext === 'nettop') {
tier = 'f'
+ } else {
+ tier = 'g'
}
let score = Math.round(
Math.log(
@@ -131,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/Lists.tsx b/src/components/Lists.tsx
index e706e101f5..beeb554763 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
if (!gtTablet) return null
return (
-
- {title}
+
+ {title}
{subtitle ? (
{subtitle}
diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx
index 9fc91f6815..1ddcd583fc 100644
--- a/src/components/Menu/context.tsx
+++ b/src/components/Menu/context.tsx
@@ -1,8 +1,12 @@
import React from 'react'
-import type {ContextType} from '#/components/Menu/types'
+import type {ContextType, ItemContextType} from '#/components/Menu/types'
export const Context = React.createContext({
// @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/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/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 7ddb79434a..c2b80ca042 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -1,4 +1,5 @@
import {
+ AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
@@ -6,50 +7,146 @@ import {
} from '@atproto/api'
import {isPostInLanguage} from '../../locale/helpers'
+import {FALLBACK_MARKER_POST} from './feed/home'
import {ReasonFeedSource} from './feed/types'
+
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
export type FeedTunerFn = (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
+ dryRun: boolean,
) => FeedViewPostsSlice[]
type FeedSliceItem = {
post: AppBskyFeedDefs.PostView
- reply?: AppBskyFeedDefs.ReplyRef
+ record: AppBskyFeedPost.Record
+ parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
+ isParentBlocked: boolean
+ isParentNotFound: boolean
}
-function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem {
- return {
- post: feedViewPost.post,
- reply: feedViewPost.reply,
- }
+type AuthorContext = {
+ author: AppBskyActorDefs.ProfileViewBasic
+ parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
+ grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
+ rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
}
export class FeedViewPostsSlice {
_reactKey: string
_feedPost: FeedViewPost
items: FeedSliceItem[]
+ isIncompleteThread: boolean
+ isFallbackMarker: boolean
+ isOrphan: boolean
+ rootUri: string
constructor(feedPost: FeedViewPost) {
+ const {post, reply, reason} = feedPost
+ this.items = []
+ this.isIncompleteThread = false
+ this.isFallbackMarker = false
+ this.isOrphan = false
+ if (AppBskyFeedDefs.isPostView(reply?.root)) {
+ this.rootUri = reply.root.uri
+ } else {
+ this.rootUri = post.uri
+ }
this._feedPost = feedPost
- this._reactKey = `slice-${feedPost.post.uri}-${
- feedPost.reason?.indexedAt || feedPost.post.indexedAt
+ this._reactKey = `slice-${post.uri}-${
+ feedPost.reason?.indexedAt || post.indexedAt
}`
- this.items = [toSliceItem(feedPost)]
- }
-
- get uri() {
- return this._feedPost.post.uri
- }
-
- get isThread() {
- return (
- this.items.length > 1 &&
- this.items.every(
- item => item.post.author.did === this.items[0].post.author.did,
- )
+ if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {
+ this.isFallbackMarker = true
+ return
+ }
+ if (
+ !AppBskyFeedPost.isRecord(post.record) ||
+ !AppBskyFeedPost.validateRecord(post.record).success
+ ) {
+ return
+ }
+ 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
+ }
+ this.items.push({
+ post,
+ record: post.record,
+ parentAuthor,
+ isParentBlocked,
+ isParentNotFound,
+ })
+ if (!reply || reason) {
+ return
+ }
+ if (
+ !AppBskyFeedDefs.isPostView(parent) ||
+ !AppBskyFeedPost.isRecord(parent.record) ||
+ !AppBskyFeedPost.validateRecord(parent.record).success
+ ) {
+ 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(
+ 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, and we need this for thread
+ // de-deduping
+ }
+ if (
+ !AppBskyFeedDefs.isPostView(root) ||
+ !AppBskyFeedPost.isRecord(root.record) ||
+ !AppBskyFeedPost.validateRecord(root.record).success
+ ) {
+ this.isOrphan = true
+ return
+ }
+ if (root.uri === parent.uri) {
+ return
+ }
+ this.items.unshift({
+ post: root,
+ record: root.record,
+ isParentBlocked: false,
+ isParentNotFound: false,
+ parentAuthor: undefined,
+ })
+ if (parent.record.reply?.parent.uri !== root.uri) {
+ this.isIncompleteThread = true
+ }
}
get isQuotePost() {
@@ -90,204 +187,171 @@ export class FeedViewPostsSlice {
return !!this.items.find(item => item.post.uri === uri)
}
- isNextInThread(uri: string) {
- return this.items[this.items.length - 1].post.uri === uri
- }
-
- insert(item: FeedViewPost) {
- const selfReplyUri = getSelfReplyUri(item)
- const i = this.items.findIndex(item2 => item2.post.uri === selfReplyUri)
- if (i !== -1) {
- this.items.splice(i + 1, 0, item)
- } else {
- this.items.push(item)
- }
- }
-
- flattenReplyParent() {
- if (this.items[0].reply) {
- const reply = this.items[0].reply
- if (AppBskyFeedDefs.isPostView(reply.parent)) {
- this.items.splice(0, 0, {post: reply.parent})
- }
- }
- }
-
- isFollowingAllAuthors(userDid: string) {
+ getAuthors(): AuthorContext {
const feedPost = this._feedPost
- const authors = [feedPost.post.author]
+ let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author
+ let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
+ let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
+ let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
if (feedPost.reply) {
if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
- authors.push(feedPost.reply.parent.author)
+ parentAuthor = feedPost.reply.parent.author
}
if (feedPost.reply.grandparentAuthor) {
- authors.push(feedPost.reply.grandparentAuthor)
+ grandparentAuthor = feedPost.reply.grandparentAuthor
}
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
- authors.push(feedPost.reply.root.author)
+ rootAuthor = feedPost.reply.root.author
}
}
- return authors.every(a => a.did === userDid || a.viewer?.following)
+ return {
+ author,
+ parentAuthor,
+ grandparentAuthor,
+ rootAuthor,
+ }
}
}
export class FeedTuner {
seenKeys: Set = new Set()
seenUris: Set = new Set()
+ seenRootUris: Set = new Set()
constructor(public tunerFns: FeedTunerFn[]) {}
- reset() {
- this.seenKeys.clear()
- this.seenUris.clear()
- }
-
tune(
feed: FeedViewPost[],
- {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = {
+ {dryRun}: {dryRun: boolean} = {
dryRun: false,
- maintainOrder: false,
},
): FeedViewPostsSlice[] {
- let slices: FeedViewPostsSlice[] = []
+ let slices: FeedViewPostsSlice[] = feed
+ .map(item => new FeedViewPostsSlice(item))
+ .filter(s => s.items.length > 0 || s.isFallbackMarker)
- // remove posts that are replies, but which don't have the parent
- // hydrated. this means the parent was either deleted or blocked
- feed = feed.filter(item => {
- if (
- AppBskyFeedPost.isRecord(item.post.record) &&
- item.post.record.reply &&
- !item.reply
- ) {
+ // run the custom tuners
+ for (const tunerFn of this.tunerFns) {
+ slices = tunerFn(this, slices.slice(), dryRun)
+ }
+
+ slices = slices.filter(slice => {
+ if (this.seenKeys.has(slice._reactKey)) {
return false
}
+ // Some feeds, like Following, dedupe by thread, so you only see the most recent reply.
+ // However, we don't want per-thread dedupe for author feeds (where we need to show every post)
+ // or for feedgens (where we want to let the feed serve multiple replies if it chooses to).
+ // To avoid showing the same context (root and/or parent) more than once, we do last resort
+ // per-post deduplication. It hides already seen posts as long as this doesn't break the thread.
+ for (let i = 0; i < slice.items.length; i++) {
+ const item = slice.items[i]
+ if (this.seenUris.has(item.post.uri)) {
+ if (i === 0) {
+ // Omit contiguous seen leading items.
+ // For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]
+ // would turn into [A -> B -> C], [D -> E], [F].
+ slice.items.splice(0, 1)
+ i--
+ }
+ if (i === slice.items.length - 1) {
+ // If the last item in the slice was already seen, omit the whole slice.
+ // This means we'd miss its parents, but the user can "show more" to see them.
+ // For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]
+ // would get collapsed into [A ... E -> F], with B/C/D considered seen.
+ return false
+ }
+ } else {
+ if (!dryRun) {
+ this.seenUris.add(item.post.uri)
+ }
+ }
+ }
+ if (!dryRun) {
+ this.seenKeys.add(slice._reactKey)
+ }
return true
})
- if (maintainOrder) {
- slices = feed.map(item => new FeedViewPostsSlice(item))
- } else {
- // arrange the posts into thread slices
- for (let i = feed.length - 1; i >= 0; i--) {
- const item = feed[i]
-
- const selfReplyUri = getSelfReplyUri(item)
- if (selfReplyUri) {
- const index = slices.findIndex(slice =>
- slice.isNextInThread(selfReplyUri),
- )
-
- if (index !== -1) {
- const parent = slices[index]
-
- parent.insert(item)
-
- // If our slice isn't currently on the top, reinsert it to the top.
- if (index !== 0) {
- slices.splice(index, 1)
- slices.unshift(parent)
- }
-
- continue
- }
- }
-
- slices.unshift(new FeedViewPostsSlice(item))
- }
- }
-
- // run the custom tuners
- for (const tunerFn of this.tunerFns) {
- slices = tunerFn(this, slices.slice())
- }
-
- // remove any items already "seen"
- const soonToBeSeenUris: Set = new Set()
- for (let i = slices.length - 1; i >= 0; i--) {
- if (!slices[i].isThread && this.seenUris.has(slices[i].uri)) {
- slices.splice(i, 1)
- } else {
- for (const item of slices[i].items) {
- soonToBeSeenUris.add(item.post.uri)
- }
- }
- }
-
- // turn non-threads with reply parents into threads
- for (const slice of slices) {
- if (!slice.isThread && !slice.reason && slice.items[0].reply) {
- const reply = slice.items[0].reply
- if (
- AppBskyFeedDefs.isPostView(reply.parent) &&
- !this.seenUris.has(reply.parent.uri) &&
- !soonToBeSeenUris.has(reply.parent.uri)
- ) {
- const uri = reply.parent.uri
- slice.flattenReplyParent()
- soonToBeSeenUris.add(uri)
- }
- }
- }
-
- if (!dryRun) {
- slices = slices.filter(slice => {
- if (this.seenKeys.has(slice._reactKey)) {
- return false
- }
- for (const item of slice.items) {
- this.seenUris.add(item.post.uri)
- }
- this.seenKeys.add(slice._reactKey)
- return true
- })
- }
-
return slices
}
- static removeReplies(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
- for (let i = slices.length - 1; i >= 0; i--) {
- if (slices[i].isReply) {
- slices.splice(i, 1)
- }
- }
- return slices
- }
-
- static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
- for (let i = slices.length - 1; i >= 0; i--) {
- if (slices[i].isRepost) {
- slices.splice(i, 1)
- }
- }
- return slices
- }
-
- static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
- for (let i = slices.length - 1; i >= 0; i--) {
- if (slices[i].isQuotePost) {
- slices.splice(i, 1)
- }
- }
- return slices
- }
-
- static dedupReposts(
+ static removeReplies(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
- ): FeedViewPostsSlice[] {
- // remove duplicates caused by reposts
+ _dryRun: boolean,
+ ) {
for (let i = 0; i < slices.length; i++) {
- const item1 = slices[i]
- for (let j = i + 1; j < slices.length; j++) {
- const item2 = slices[j]
- if (item2.isThread) {
- // dont dedup items that are rendering in a thread as this can cause rendering errors
- continue
- }
- if (item1.containsUri(item2.items[0].post.uri)) {
- slices.splice(j, 1)
- j--
+ const slice = slices[i]
+ if (
+ slice.isReply &&
+ !slice.isRepost &&
+ // This is not perfect but it's close as we can get to
+ // detecting threads without having to peek ahead.
+ !areSameAuthor(slice.getAuthors())
+ ) {
+ slices.splice(i, 1)
+ i--
+ }
+ }
+ return slices
+ }
+
+ static removeReposts(
+ tuner: FeedTuner,
+ slices: FeedViewPostsSlice[],
+ _dryRun: boolean,
+ ) {
+ for (let i = 0; i < slices.length; i++) {
+ if (slices[i].isRepost) {
+ slices.splice(i, 1)
+ i--
+ }
+ }
+ return slices
+ }
+
+ static removeQuotePosts(
+ tuner: FeedTuner,
+ slices: FeedViewPostsSlice[],
+ _dryRun: boolean,
+ ) {
+ for (let i = 0; i < slices.length; i++) {
+ if (slices[i].isQuotePost) {
+ slices.splice(i, 1)
+ i--
+ }
+ }
+ return slices
+ }
+
+ static removeOrphans(
+ tuner: FeedTuner,
+ slices: FeedViewPostsSlice[],
+ _dryRun: boolean,
+ ) {
+ for (let i = 0; i < slices.length; i++) {
+ if (slices[i].isOrphan) {
+ slices.splice(i, 1)
+ i--
+ }
+ }
+ return slices
+ }
+
+ static dedupThreads(
+ tuner: FeedTuner,
+ slices: FeedViewPostsSlice[],
+ dryRun: boolean,
+ ): FeedViewPostsSlice[] {
+ for (let i = 0; i < slices.length; i++) {
+ const rootUri = slices[i].rootUri
+ if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {
+ slices.splice(i, 1)
+ i--
+ } else {
+ if (!dryRun) {
+ tuner.seenRootUris.add(rootUri)
}
}
}
@@ -298,15 +362,17 @@ export class FeedTuner {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
+ _dryRun: boolean,
): FeedViewPostsSlice[] => {
- for (let i = slices.length - 1; i >= 0; i--) {
+ for (let i = 0; i < slices.length; i++) {
const slice = slices[i]
if (
slice.isReply &&
!slice.isRepost &&
- !slice.isFollowingAllAuthors(userDid)
+ !shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
) {
slices.splice(i, 1)
+ i--
}
}
return slices
@@ -324,6 +390,7 @@ export class FeedTuner {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
+ _dryRun: boolean,
): FeedViewPostsSlice[] => {
const candidateSlices = slices.slice()
@@ -332,7 +399,7 @@ export class FeedTuner {
return slices
}
- for (let i = slices.length - 1; i >= 0; i--) {
+ for (let i = 0; i < slices.length; i++) {
let hasPreferredLang = false
for (const item of slices[i].items) {
if (isPostInLanguage(item.post, preferredLangsCode2)) {
@@ -358,16 +425,66 @@ export class FeedTuner {
}
}
-function getSelfReplyUri(item: FeedViewPost): string | undefined {
- if (item.reply) {
- if (
- AppBskyFeedDefs.isPostView(item.reply.parent) &&
- !AppBskyFeedDefs.isReasonRepost(item.reason) // don't thread reposted self-replies
- ) {
- return item.reply.parent.author.did === item.post.author.did
- ? item.reply.parent.uri
- : undefined
- }
+function areSameAuthor(authors: AuthorContext): boolean {
+ const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
+ const authorDid = author.did
+ if (parentAuthor && parentAuthor.did !== authorDid) {
+ return false
}
- return undefined
+ if (grandparentAuthor && grandparentAuthor.did !== authorDid) {
+ return false
+ }
+ if (rootAuthor && rootAuthor.did !== authorDid) {
+ return false
+ }
+ return true
+}
+
+function shouldDisplayReplyInFollowing(
+ authors: AuthorContext,
+ userDid: string,
+): boolean {
+ const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
+ if (!isSelfOrFollowing(author, userDid)) {
+ // Only show replies from self or people you follow.
+ return false
+ }
+ if (
+ (!parentAuthor || parentAuthor.did === author.did) &&
+ (!rootAuthor || rootAuthor.did === author.did) &&
+ (!grandparentAuthor || grandparentAuthor.did === author.did)
+ ) {
+ // Always show self-threads.
+ return true
+ }
+ // From this point on we need at least one more reason to show it.
+ if (
+ parentAuthor &&
+ parentAuthor.did !== author.did &&
+ isSelfOrFollowing(parentAuthor, userDid)
+ ) {
+ return true
+ }
+ if (
+ grandparentAuthor &&
+ grandparentAuthor.did !== author.did &&
+ isSelfOrFollowing(grandparentAuthor, userDid)
+ ) {
+ return true
+ }
+ if (
+ rootAuthor &&
+ rootAuthor.did !== author.did &&
+ isSelfOrFollowing(rootAuthor, userDid)
+ ) {
+ return true
+ }
+ return false
+}
+
+function isSelfOrFollowing(
+ profile: AppBskyActorDefs.ProfileViewBasic,
+ userDid: string,
+) {
+ return Boolean(profile.did === userDid || profile.viewer?.following)
}
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/feed/merge.ts b/src/lib/api/feed/merge.ts
index 86db1b98fa..b41e82fb06 100644
--- a/src/lib/api/feed/merge.ts
+++ b/src/lib/api/feed/merge.ts
@@ -193,12 +193,6 @@ class MergeFeedSource {
return this.hasMore && this.queue.length === 0
}
- reset() {
- this.cursor = undefined
- this.queue = []
- this.hasMore = true
- }
-
take(n: number): AppBskyFeedDefs.FeedViewPost[] {
return this.queue.splice(0, n)
}
@@ -232,11 +226,6 @@ class MergeFeedSource {
class MergeFeedSource_Following extends MergeFeedSource {
tuner = new FeedTuner(this.feedTuners)
- reset() {
- super.reset()
- this.tuner.reset()
- }
-
async fetchNext(n: number) {
return this._fetchNextInner(n)
}
@@ -249,7 +238,6 @@ class MergeFeedSource_Following extends MergeFeedSource {
// run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(res.data.feed, {
dryRun: false,
- maintainOrder: true,
})
res.data.feed = slices.map(slice => slice._feedPost)
return res
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/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/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts
index be9014029c..01ffbe5cf6 100644
--- a/src/lib/moderation/useModerationCauseDescription.ts
+++ b/src/lib/moderation/useModerationCauseDescription.ts
@@ -126,7 +126,7 @@ export function useModerationCauseDescription(
}
}
if (def.identifier === 'porn' || def.identifier === 'sexual') {
- strings.name = 'Adult Content'
+ strings.name = _(msg`Adult Content`)
}
return {
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 159061eac9..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': {
@@ -159,6 +160,7 @@ export type LogEvents = {
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
+ | 'ProfileHeaderSuggestedFollows'
}
'profile:unfollow': {
logContext:
@@ -173,6 +175,7 @@ export type LogEvents = {
| 'AvatarButton'
| 'StarterPackProfilesList'
| 'FeedInterstitial'
+ | 'ProfileHeaderSuggestedFollows'
}
'chat:create': {
logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
@@ -211,6 +214,14 @@ export type LogEvents = {
'feed:interstitial:profileCard:press': {}
'feed:interstitial:feedCard:press': {}
+ 'profile:header:suggestedFollowsCard:press': {}
+
+ 'debug:followingPrefs': {
+ followingShowRepliesFromPref: 'all' | 'following' | 'off'
+ followingRepliesMinLikePref: number
+ }
+ 'debug:followingDisplayed': {}
+
'test:all:always': {}
'test:all:sometimes': {}
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 58a60232be..0f92cd14a2 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,17 +1,10 @@
export type Gate =
// Keep this alphabetic please.
| '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/platform/detection.ts b/src/platform/detection.ts
index 0c0360a82a..c62ae71aae 100644
--- a/src/platform/detection.ts
+++ b/src/platform/detection.ts
@@ -1,5 +1,4 @@
import {Platform} from 'react-native'
-import {isReducedMotion} from 'react-native-reanimated'
import {getLocales} from 'expo-localization'
import {fixLegacyLanguageCode} from '#/locale/helpers'
@@ -15,11 +14,10 @@ export const isMobileWeb =
isWeb &&
// @ts-ignore we know window exists -prf
global.window.matchMedia(isMobileWebMediaQuery)?.matches
+export const isIPhoneWeb = isWeb && /iPhone/.test(navigator.userAgent)
export const deviceLocales = dedupArray(
getLocales?.()
.map?.(locale => fixLegacyLanguageCode(locale.languageCode))
.filter(code => typeof code === 'string'),
) as string[]
-
-export const prefersReducedMotion = isReducedMotion()
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/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index 11b951e99d..c0e78e9789 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -387,9 +387,6 @@ export function MessagesList({
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
- // Prevents wrong position in Firefox when sending a message
- // as well as scroll getting stuck on Chome when scrolling upwards.
- disableContainStyle={true}
disableVirtualization={true}
style={animatedListStyle}
// The extra two items account for the header and the footer components
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index b275b31910..2b6353b276 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -157,7 +157,7 @@ let ProfileHeaderStandard = ({
hideBackButton={hideBackButton}
isPlaceholderProfile={isPlaceholderProfile}>
{(isScrolledDown || hasNew) && (
()
@@ -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/a11y.tsx b/src/state/a11y.tsx
index aefcfd1ec4..08948267c0 100644
--- a/src/state/a11y.tsx
+++ b/src/state/a11y.tsx
@@ -1,8 +1,8 @@
import React from 'react'
import {AccessibilityInfo} from 'react-native'
-import {isReducedMotion} from 'react-native-reanimated'
import {isWeb} from '#/platform/detection'
+import {PlatformInfo} from '../../modules/expo-bluesky-swiss-army'
const Context = React.createContext({
reduceMotionEnabled: false,
@@ -15,7 +15,7 @@ export function useA11y() {
export function Provider({children}: React.PropsWithChildren<{}>) {
const [reduceMotionEnabled, setReduceMotionEnabled] = React.useState(() =>
- isReducedMotion(),
+ PlatformInfo.getIsReducedMotionEnabled(),
)
const [screenReaderEnabled, setScreenReaderEnabled] = React.useState(false)
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 59b4bf78a4..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(
() =>
@@ -123,7 +107,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
toString({
item: postItem.uri,
event: 'app.bsky.feed.defs#interactionSeen',
- feedContext: postItem.feedContext,
+ feedContext: slice.feedContext,
}),
)
sendToFeed()
diff --git a/src/state/invites.tsx b/src/state/invites.tsx
index 6a0d1b5900..0d40caf258 100644
--- a/src/state/invites.tsx
+++ b/src/state/invites.tsx
@@ -1,4 +1,5 @@
import React from 'react'
+
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['invites']
@@ -35,8 +36,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('invites'))
+ return persisted.onUpdate('invites', nextInvites => {
+ setState(nextInvites)
})
}, [setState])
diff --git a/src/state/persisted/__tests__/fixtures.ts b/src/state/persisted/__tests__/fixtures.ts
deleted file mode 100644
index ac8f7c8d1d..0000000000
--- a/src/state/persisted/__tests__/fixtures.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import type {LegacySchema} from '#/state/persisted/legacy'
-
-export const ALICE_DID = 'did:plc:ALICE_DID'
-export const BOB_DID = 'did:plc:BOB_DID'
-
-export const LEGACY_DATA_DUMP: LegacySchema = {
- session: {
- data: {
- service: 'https://bsky.social/',
- did: ALICE_DID,
- },
- accounts: [
- {
- service: 'https://bsky.social',
- did: ALICE_DID,
- refreshJwt: 'refreshJwt',
- accessJwt: 'accessJwt',
- handle: 'alice.test',
- email: 'alice@bsky.test',
- displayName: 'Alice',
- aviUrl: 'avi',
- emailConfirmed: true,
- },
- {
- service: 'https://bsky.social',
- did: BOB_DID,
- refreshJwt: 'refreshJwt',
- accessJwt: 'accessJwt',
- handle: 'bob.test',
- email: 'bob@bsky.test',
- displayName: 'Bob',
- aviUrl: 'avi',
- emailConfirmed: true,
- },
- ],
- },
- me: {
- did: ALICE_DID,
- handle: 'alice.test',
- displayName: 'Alice',
- description: '',
- avatar: 'avi',
- },
- onboarding: {step: 'Home'},
- shell: {colorMode: 'system'},
- preferences: {
- primaryLanguage: 'en',
- contentLanguages: ['en'],
- postLanguage: 'en',
- postLanguageHistory: ['en', 'en', 'ja', 'pt', 'de', 'en'],
- contentLabels: {
- nsfw: 'warn',
- nudity: 'warn',
- suggestive: 'warn',
- gore: 'warn',
- hate: 'hide',
- spam: 'hide',
- impersonation: 'warn',
- },
- savedFeeds: ['feed_a', 'feed_b', 'feed_c'],
- pinnedFeeds: ['feed_a', 'feed_b'],
- requireAltTextEnabled: false,
- },
- invitedUsers: {seenDids: [], copiedInvites: []},
- mutedThreads: {uris: []},
- reminders: {},
-}
diff --git a/src/state/persisted/__tests__/index.test.ts b/src/state/persisted/__tests__/index.test.ts
deleted file mode 100644
index 90c5e0e4ec..0000000000
--- a/src/state/persisted/__tests__/index.test.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import {jest, expect, test, afterEach} from '@jest/globals'
-import AsyncStorage from '@react-native-async-storage/async-storage'
-
-import {defaults} from '#/state/persisted/schema'
-import {migrate} from '#/state/persisted/legacy'
-import * as store from '#/state/persisted/store'
-import * as persisted from '#/state/persisted'
-
-const write = jest.mocked(store.write)
-const read = jest.mocked(store.read)
-
-jest.mock('#/logger')
-jest.mock('#/state/persisted/legacy', () => ({
- migrate: jest.fn(),
-}))
-jest.mock('#/state/persisted/store', () => ({
- write: jest.fn(),
- read: jest.fn(),
-}))
-
-afterEach(() => {
- jest.useFakeTimers()
- jest.clearAllMocks()
- AsyncStorage.clear()
-})
-
-test('init: fresh install, no migration', async () => {
- await persisted.init()
-
- expect(migrate).toHaveBeenCalledTimes(1)
- expect(read).toHaveBeenCalledTimes(1)
- expect(write).toHaveBeenCalledWith(defaults)
-
- // default value
- expect(persisted.get('colorMode')).toBe('system')
-})
-
-test('init: fresh install, migration ran', async () => {
- read.mockResolvedValueOnce(defaults)
-
- await persisted.init()
-
- expect(migrate).toHaveBeenCalledTimes(1)
- expect(read).toHaveBeenCalledTimes(1)
- expect(write).not.toHaveBeenCalled()
-
- // default value
- expect(persisted.get('colorMode')).toBe('system')
-})
diff --git a/src/state/persisted/__tests__/migrate.test.ts b/src/state/persisted/__tests__/migrate.test.ts
deleted file mode 100644
index 97767e2732..0000000000
--- a/src/state/persisted/__tests__/migrate.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import {jest, expect, test, afterEach} from '@jest/globals'
-import AsyncStorage from '@react-native-async-storage/async-storage'
-
-import {defaults, schema} from '#/state/persisted/schema'
-import {transform, migrate} from '#/state/persisted/legacy'
-import * as store from '#/state/persisted/store'
-import {logger} from '#/logger'
-import * as fixtures from '#/state/persisted/__tests__/fixtures'
-
-const write = jest.mocked(store.write)
-const read = jest.mocked(store.read)
-
-jest.mock('#/logger')
-jest.mock('#/state/persisted/store', () => ({
- write: jest.fn(),
- read: jest.fn(),
-}))
-
-afterEach(() => {
- jest.clearAllMocks()
- AsyncStorage.clear()
-})
-
-test('migrate: fresh install', async () => {
- await migrate()
-
- expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
- expect(read).toHaveBeenCalledTimes(1)
- expect(logger.debug).toHaveBeenCalledWith(
- 'persisted state: no migration needed',
- )
-})
-
-test('migrate: fresh install, existing new storage', async () => {
- read.mockResolvedValueOnce(defaults)
-
- await migrate()
-
- expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
- expect(read).toHaveBeenCalledTimes(1)
- expect(logger.debug).toHaveBeenCalledWith(
- 'persisted state: no migration needed',
- )
-})
-
-test('migrate: fresh install, AsyncStorage error', async () => {
- const prevGetItem = AsyncStorage.getItem
-
- const error = new Error('test error')
-
- AsyncStorage.getItem = jest.fn(() => {
- throw error
- })
-
- await migrate()
-
- expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
- expect(logger.error).toHaveBeenCalledWith(error, {
- message: 'persisted state: error migrating legacy storage',
- })
-
- AsyncStorage.getItem = prevGetItem
-})
-
-test('migrate: has legacy data', async () => {
- await AsyncStorage.setItem('root', JSON.stringify(fixtures.LEGACY_DATA_DUMP))
-
- await migrate()
-
- expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
- expect(logger.debug).toHaveBeenCalledWith(
- 'persisted state: migrated legacy storage',
- )
-})
-
-test('migrate: has legacy data, fails validation', async () => {
- const legacy = fixtures.LEGACY_DATA_DUMP
- // @ts-ignore
- legacy.shell.colorMode = 'invalid'
- await AsyncStorage.setItem('root', JSON.stringify(legacy))
-
- await migrate()
-
- const transformed = transform(legacy)
- const validate = schema.safeParse(transformed)
-
- expect(write).not.toHaveBeenCalled()
- expect(logger.error).toHaveBeenCalledWith(
- 'persisted state: legacy data failed validation',
- // @ts-ignore
- {message: validate.error},
- )
-})
diff --git a/src/state/persisted/__tests__/schema.test.ts b/src/state/persisted/__tests__/schema.test.ts
deleted file mode 100644
index c78a2c27cb..0000000000
--- a/src/state/persisted/__tests__/schema.test.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import {expect, test} from '@jest/globals'
-
-import {transform} from '#/state/persisted/legacy'
-import {defaults, schema} from '#/state/persisted/schema'
-import * as fixtures from '#/state/persisted/__tests__/fixtures'
-
-test('defaults', () => {
- expect(() => schema.parse(defaults)).not.toThrow()
-})
-
-test('transform', () => {
- const data = transform({})
- expect(() => schema.parse(data)).not.toThrow()
-})
-
-test('transform: legacy fixture', () => {
- const data = transform(fixtures.LEGACY_DATA_DUMP)
- expect(() => schema.parse(data)).not.toThrow()
- expect(data.session.currentAccount?.did).toEqual(fixtures.ALICE_DID)
- expect(data.session.accounts.length).toEqual(2)
-})
diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts
index 5fe0f9bd0a..6f4beae2ca 100644
--- a/src/state/persisted/index.ts
+++ b/src/state/persisted/index.ts
@@ -1,97 +1,86 @@
-import EventEmitter from 'eventemitter3'
+import AsyncStorage from '@react-native-async-storage/async-storage'
-import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
-import {migrate} from '#/state/persisted/legacy'
-import {defaults, Schema} from '#/state/persisted/schema'
-import * as store from '#/state/persisted/store'
+import {
+ defaults,
+ Schema,
+ tryParse,
+ tryStringify,
+} from '#/state/persisted/schema'
+import {PersistedApi} from './types'
+
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'
-const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
-const UPDATE_EVENT = 'BSKY_UPDATE'
+const BSKY_STORAGE = 'BSKY_STORAGE'
let _state: Schema = defaults
-const _emitter = new EventEmitter()
-/**
- * Initializes and returns persisted data state, so that it can be passed to
- * the Provider.
- */
export async function init() {
- logger.debug('persisted state: initializing')
-
- broadcast.onmessage = onBroadcastMessage
-
- try {
- await migrate() // migrate old store
- const stored = await store.read() // check for new store
- if (!stored) {
- logger.debug('persisted state: initializing default storage')
- await store.write(defaults) // opt: init new store
- }
- _state = stored || defaults // return new store
- logger.debug('persisted state: initialized')
- } catch (e) {
- logger.error('persisted state: failed to load root state from storage', {
- message: e,
- })
- // AsyncStorage failure, but we can still continue in memory
- return defaults
+ const stored = await readFromStorage()
+ if (stored) {
+ _state = stored
}
}
+init satisfies PersistedApi['init']
export function get(key: K): Schema[K] {
return _state[key]
}
+get satisfies PersistedApi['get']
export async function write(
key: K,
value: Schema[K],
): Promise {
+ _state = {
+ ..._state,
+ [key]: value,
+ }
+ await writeToStorage(_state)
+}
+write satisfies PersistedApi['write']
+
+export function onUpdate(
+ _key: K,
+ _cb: (v: Schema[K]) => void,
+): () => void {
+ return () => {}
+}
+onUpdate satisfies PersistedApi['onUpdate']
+
+export async function clearStorage() {
try {
- _state[key] = value
- await store.write(_state)
- // must happen on next tick, otherwise the tab will read stale storage data
- setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0)
- logger.debug(`persisted state: wrote root state to storage`, {
- updatedKey: key,
- })
- } catch (e) {
- logger.error(`persisted state: failed writing root state to storage`, {
- message: e,
- })
+ await AsyncStorage.removeItem(BSKY_STORAGE)
+ } catch (e: any) {
+ logger.error(`persisted store: failed to clear`, {message: e.toString()})
}
}
+clearStorage satisfies PersistedApi['clearStorage']
-export function onUpdate(cb: () => void): () => void {
- _emitter.addListener('update', cb)
- return () => _emitter.removeListener('update', cb)
-}
-
-async function onBroadcastMessage({data}: MessageEvent) {
- // validate event
- if (typeof data === 'object' && data.event === UPDATE_EVENT) {
+async function writeToStorage(value: Schema) {
+ const rawData = tryStringify(value)
+ if (rawData) {
try {
- // read next state, possibly updated by another tab
- const next = await store.read()
-
- if (next) {
- logger.debug(`persisted state: handling update from broadcast channel`)
- _state = next
- _emitter.emit('update')
- } else {
- logger.error(
- `persisted state: handled update update from broadcast channel, but found no data`,
- )
- }
+ await AsyncStorage.setItem(BSKY_STORAGE, rawData)
} catch (e) {
- logger.error(
- `persisted state: failed handling update from broadcast channel`,
- {
- message: e,
- },
- )
+ logger.error(`persisted state: failed writing root state to storage`, {
+ message: e,
+ })
}
}
}
+
+async function readFromStorage(): Promise {
+ let rawData: string | null = null
+ try {
+ rawData = await AsyncStorage.getItem(BSKY_STORAGE)
+ } catch (e) {
+ logger.error(`persisted state: failed reading root state from storage`, {
+ message: e,
+ })
+ }
+ if (rawData) {
+ return tryParse(rawData)
+ }
+}
diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts
new file mode 100644
index 0000000000..7521776bc0
--- /dev/null
+++ b/src/state/persisted/index.web.ts
@@ -0,0 +1,148 @@
+import EventEmitter from 'eventemitter3'
+
+import BroadcastChannel from '#/lib/broadcast'
+import {logger} from '#/logger'
+import {
+ defaults,
+ Schema,
+ tryParse,
+ tryStringify,
+} from '#/state/persisted/schema'
+import {PersistedApi} from './types'
+
+export type {PersistedAccount, Schema} from '#/state/persisted/schema'
+export {defaults} from '#/state/persisted/schema'
+
+const BSKY_STORAGE = 'BSKY_STORAGE'
+
+const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL')
+const UPDATE_EVENT = 'BSKY_UPDATE'
+
+let _state: Schema = defaults
+const _emitter = new EventEmitter()
+
+export async function init() {
+ broadcast.onmessage = onBroadcastMessage
+ const stored = readFromStorage()
+ if (stored) {
+ _state = stored
+ }
+}
+init satisfies PersistedApi['init']
+
+export function get(key: K): Schema[K] {
+ return _state[key]
+}
+get satisfies PersistedApi['get']
+
+export async function write(
+ key: K,
+ value: Schema[K],
+): Promise {
+ const next = readFromStorage()
+ if (next) {
+ // The storage could have been updated by a different tab before this tab is notified.
+ // Make sure this write is applied on top of the latest data in the storage as long as it's valid.
+ _state = next
+ // Don't fire the update listeners yet to avoid a loop.
+ // If there was a change, we'll receive the broadcast event soon enough which will do that.
+ }
+ try {
+ if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
+ // Fast path for updates that are guaranteed to be noops.
+ // This is good mostly because it avoids useless broadcasts to other tabs.
+ return
+ }
+ } catch (e) {
+ // Ignore and go through the normal path.
+ }
+ _state = {
+ ..._state,
+ [key]: value,
+ }
+ writeToStorage(_state)
+ broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
+ broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
+}
+write satisfies PersistedApi['write']
+
+export function onUpdate(
+ key: K,
+ cb: (v: Schema[K]) => void,
+): () => void {
+ const listener = () => cb(get(key))
+ _emitter.addListener('update', listener) // Backcompat while upgrading
+ _emitter.addListener('update:' + key, listener)
+ return () => {
+ _emitter.removeListener('update', listener) // Backcompat while upgrading
+ _emitter.removeListener('update:' + key, listener)
+ }
+}
+onUpdate satisfies PersistedApi['onUpdate']
+
+export async function clearStorage() {
+ try {
+ localStorage.removeItem(BSKY_STORAGE)
+ } catch (e: any) {
+ // Expected on the web in private mode.
+ }
+}
+clearStorage satisfies PersistedApi['clearStorage']
+
+async function onBroadcastMessage({data}: MessageEvent) {
+ if (
+ typeof data === 'object' &&
+ (data.event === UPDATE_EVENT || // Backcompat while upgrading
+ data.event?.type === UPDATE_EVENT)
+ ) {
+ // read next state, possibly updated by another tab
+ const next = readFromStorage()
+ if (next === _state) {
+ return
+ }
+ if (next) {
+ _state = next
+ if (typeof data.event.key === 'string') {
+ _emitter.emit('update:' + data.event.key)
+ } else {
+ _emitter.emit('update') // Backcompat while upgrading
+ }
+ } else {
+ logger.error(
+ `persisted state: handled update update from broadcast channel, but found no data`,
+ )
+ }
+ }
+}
+
+function writeToStorage(value: Schema) {
+ const rawData = tryStringify(value)
+ if (rawData) {
+ try {
+ localStorage.setItem(BSKY_STORAGE, rawData)
+ } catch (e) {
+ // Expected on the web in private mode.
+ }
+ }
+}
+
+let lastRawData: string | undefined
+let lastResult: Schema | undefined
+function readFromStorage(): Schema | undefined {
+ let rawData: string | null = null
+ try {
+ rawData = localStorage.getItem(BSKY_STORAGE)
+ } catch (e) {
+ // Expected on the web in private mode.
+ }
+ if (rawData) {
+ if (rawData === lastRawData) {
+ return lastResult
+ } else {
+ const result = tryParse(rawData)
+ lastRawData = rawData
+ lastResult = result
+ return result
+ }
+ }
+}
diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts
deleted file mode 100644
index ca7967cd2e..0000000000
--- a/src/state/persisted/legacy.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-import AsyncStorage from '@react-native-async-storage/async-storage'
-
-import {logger} from '#/logger'
-import {defaults, Schema, schema} from '#/state/persisted/schema'
-import {read, write} from '#/state/persisted/store'
-
-/**
- * The shape of the serialized data from our legacy Mobx store.
- */
-export type LegacySchema = {
- shell: {
- colorMode: 'system' | 'light' | 'dark'
- }
- session: {
- data: {
- service: string
- did: `did:plc:${string}`
- } | null
- accounts: {
- service: string
- did: `did:plc:${string}`
- refreshJwt: string
- accessJwt: string
- handle: string
- email: string
- displayName: string
- aviUrl: string
- emailConfirmed: boolean
- }[]
- }
- me: {
- did: `did:plc:${string}`
- handle: string
- displayName: string
- description: string
- avatar: string
- }
- onboarding: {
- step: string
- }
- preferences: {
- primaryLanguage: string
- contentLanguages: string[]
- postLanguage: string
- postLanguageHistory: string[]
- contentLabels: {
- nsfw: string
- nudity: string
- suggestive: string
- gore: string
- hate: string
- spam: string
- impersonation: string
- }
- savedFeeds: string[]
- pinnedFeeds: string[]
- requireAltTextEnabled: boolean
- }
- invitedUsers: {
- seenDids: string[]
- copiedInvites: string[]
- }
- mutedThreads: {uris: string[]}
- reminders: {lastEmailConfirm?: string}
-}
-
-const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
-
-export function transform(legacy: Partial): Schema {
- return {
- colorMode: legacy.shell?.colorMode || defaults.colorMode,
- darkTheme: defaults.darkTheme,
- session: {
- accounts: legacy.session?.accounts || defaults.session.accounts,
- currentAccount:
- legacy.session?.accounts?.find(
- a => a.did === legacy.session?.data?.did,
- ) || defaults.session.currentAccount,
- },
- reminders: {
- lastEmailConfirm:
- legacy.reminders?.lastEmailConfirm ||
- defaults.reminders.lastEmailConfirm,
- },
- languagePrefs: {
- primaryLanguage:
- legacy.preferences?.primaryLanguage ||
- defaults.languagePrefs.primaryLanguage,
- contentLanguages:
- legacy.preferences?.contentLanguages ||
- defaults.languagePrefs.contentLanguages,
- postLanguage:
- legacy.preferences?.postLanguage || defaults.languagePrefs.postLanguage,
- postLanguageHistory:
- legacy.preferences?.postLanguageHistory ||
- defaults.languagePrefs.postLanguageHistory,
- appLanguage:
- legacy.preferences?.primaryLanguage ||
- defaults.languagePrefs.appLanguage,
- },
- requireAltTextEnabled:
- legacy.preferences?.requireAltTextEnabled ||
- defaults.requireAltTextEnabled,
- mutedThreads: legacy.mutedThreads?.uris || defaults.mutedThreads,
- invites: {
- copiedInvites:
- legacy.invitedUsers?.copiedInvites || defaults.invites.copiedInvites,
- },
- onboarding: {
- step: legacy.onboarding?.step || defaults.onboarding.step,
- },
- hiddenPosts: defaults.hiddenPosts,
- externalEmbeds: defaults.externalEmbeds,
- lastSelectedHomeFeed: defaults.lastSelectedHomeFeed,
- pdsAddressHistory: defaults.pdsAddressHistory,
- disableHaptics: defaults.disableHaptics,
- }
-}
-
-/**
- * Migrates legacy persisted state to new store if new store doesn't exist in
- * local storage AND old storage exists.
- */
-export async function migrate() {
- logger.debug('persisted state: check need to migrate')
-
- try {
- const rawLegacyData = await AsyncStorage.getItem(
- DEPRECATED_ROOT_STATE_STORAGE_KEY,
- )
- const newData = await read()
- const alreadyMigrated = Boolean(newData)
-
- if (!alreadyMigrated && rawLegacyData) {
- logger.debug('persisted state: migrating legacy storage')
-
- const legacyData = JSON.parse(rawLegacyData)
- const newData = transform(legacyData)
- const validate = schema.safeParse(newData)
-
- if (validate.success) {
- await write(newData)
- logger.debug('persisted state: migrated legacy storage')
- } else {
- logger.error('persisted state: legacy data failed validation', {
- message: validate.error,
- })
- }
- } else {
- logger.debug('persisted state: no migration needed')
- }
- } catch (e: any) {
- logger.error(e, {
- message: 'persisted state: error migrating legacy storage',
- })
- }
-}
-
-export async function clearLegacyStorage() {
- try {
- await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY)
- } catch (e: any) {
- logger.error(`persisted legacy store: failed to clear`, {
- message: e.toString(),
- })
- }
-}
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 88fc370a6f..331a111a2e 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -1,6 +1,8 @@
import {z} from 'zod'
-import {deviceLocales, prefersReducedMotion} from '#/platform/detection'
+import {logger} from '#/logger'
+import {deviceLocales} from '#/platform/detection'
+import {PlatformInfo} from '../../../modules/expo-bluesky-swiss-army'
const externalEmbedOptions = ['show', 'hide'] as const
@@ -42,7 +44,7 @@ const currentAccountSchema = accountSchema.extend({
})
export type PersistedCurrentAccount = z.infer
-export const schema = z.object({
+const schema = z.object({
colorMode: z.enum(['system', 'light', 'dark']),
darkTheme: z.enum(['dim', 'dark']).optional(),
session: z.object({
@@ -89,6 +91,7 @@ export const schema = z.object({
disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(),
hasCheckedForStarterPack: z.boolean().optional(),
+ subtitlesEnabled: z.boolean().optional(),
/** @deprecated */
mutedThreads: z.array(z.string()),
})
@@ -128,7 +131,48 @@ export const defaults: Schema = {
lastSelectedHomeFeed: undefined,
pdsAddressHistory: [],
disableHaptics: false,
- disableAutoplay: prefersReducedMotion,
+ disableAutoplay: PlatformInfo.getIsReducedMotionEnabled(),
kawaii: false,
hasCheckedForStarterPack: false,
+ subtitlesEnabled: true,
+}
+
+export function tryParse(rawData: string): Schema | undefined {
+ let objData
+ try {
+ objData = JSON.parse(rawData)
+ } catch (e) {
+ logger.error('persisted state: failed to parse root state from storage', {
+ message: e,
+ })
+ }
+ if (!objData) {
+ return undefined
+ }
+ const parsed = schema.safeParse(objData)
+ if (parsed.success) {
+ return objData
+ } else {
+ const errors =
+ parsed.error?.errors?.map(e => ({
+ code: e.code,
+ // @ts-ignore exists on some types
+ expected: e?.expected,
+ path: e.path?.join('.'),
+ })) || []
+ logger.error(`persisted store: data failed validation on read`, {errors})
+ return undefined
+ }
+}
+
+export function tryStringify(value: Schema): string | undefined {
+ try {
+ schema.parse(value)
+ return JSON.stringify(value)
+ } catch (e) {
+ logger.error(`persisted state: failed stringifying root state`, {
+ message: e,
+ })
+ return undefined
+ }
}
diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts
deleted file mode 100644
index f740126c45..0000000000
--- a/src/state/persisted/store.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import AsyncStorage from '@react-native-async-storage/async-storage'
-
-import {logger} from '#/logger'
-import {Schema, schema} from '#/state/persisted/schema'
-
-const BSKY_STORAGE = 'BSKY_STORAGE'
-
-export async function write(value: Schema) {
- schema.parse(value)
- await AsyncStorage.setItem(BSKY_STORAGE, JSON.stringify(value))
-}
-
-export async function read(): Promise {
- const rawData = await AsyncStorage.getItem(BSKY_STORAGE)
- const objData = rawData ? JSON.parse(rawData) : undefined
-
- // new user
- if (!objData) return undefined
-
- // existing user, validate
- const parsed = schema.safeParse(objData)
-
- if (parsed.success) {
- return objData
- } else {
- const errors =
- parsed.error?.errors?.map(e => ({
- code: e.code,
- // @ts-ignore exists on some types
- expected: e?.expected,
- path: e.path?.join('.'),
- })) || []
- logger.error(`persisted store: data failed validation on read`, {errors})
- return undefined
- }
-}
-
-export async function clear() {
- try {
- await AsyncStorage.removeItem(BSKY_STORAGE)
- } catch (e: any) {
- logger.error(`persisted store: failed to clear`, {message: e.toString()})
- }
-}
diff --git a/src/state/persisted/types.ts b/src/state/persisted/types.ts
new file mode 100644
index 0000000000..fd39079bf8
--- /dev/null
+++ b/src/state/persisted/types.ts
@@ -0,0 +1,12 @@
+import type {Schema} from './schema'
+
+export type PersistedApi = {
+ init(): Promise
+ get(key: K): Schema[K]
+ write(key: K, value: Schema[K]): Promise
+ onUpdate(
+ key: K,
+ cb: (v: Schema[K]) => void,
+ ): () => void
+ clearStorage: () => Promise
+}
diff --git a/src/state/preferences/alt-text-required.tsx b/src/state/preferences/alt-text-required.tsx
index 642e790fbc..0ddc173ea3 100644
--- a/src/state/preferences/alt-text-required.tsx
+++ b/src/state/preferences/alt-text-required.tsx
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('requireAltTextEnabled'))
- })
+ return persisted.onUpdate(
+ 'requireAltTextEnabled',
+ nextRequireAltTextEnabled => {
+ setState(nextRequireAltTextEnabled)
+ },
+ )
}, [setStateWrapped])
return (
diff --git a/src/state/preferences/autoplay.tsx b/src/state/preferences/autoplay.tsx
index d5aa049f36..141c8161ef 100644
--- a/src/state/preferences/autoplay.tsx
+++ b/src/state/preferences/autoplay.tsx
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(Boolean(persisted.get('disableAutoplay')))
+ return persisted.onUpdate('disableAutoplay', nextDisableAutoplay => {
+ setState(Boolean(nextDisableAutoplay))
})
}, [setStateWrapped])
diff --git a/src/state/preferences/disable-haptics.tsx b/src/state/preferences/disable-haptics.tsx
index af2c55a182..367d4f7db4 100644
--- a/src/state/preferences/disable-haptics.tsx
+++ b/src/state/preferences/disable-haptics.tsx
@@ -24,8 +24,8 @@ export function Provider({children}: {children: React.ReactNode}) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(Boolean(persisted.get('disableHaptics')))
+ return persisted.onUpdate('disableHaptics', nextDisableHaptics => {
+ setState(Boolean(nextDisableHaptics))
})
}, [setStateWrapped])
diff --git a/src/state/preferences/external-embeds-prefs.tsx b/src/state/preferences/external-embeds-prefs.tsx
index 9ace5d940f..04afb89dd7 100644
--- a/src/state/preferences/external-embeds-prefs.tsx
+++ b/src/state/preferences/external-embeds-prefs.tsx
@@ -35,8 +35,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('externalEmbeds'))
+ return persisted.onUpdate('externalEmbeds', nextExternalEmbeds => {
+ setState(nextExternalEmbeds)
})
}, [setStateWrapped])
diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx
index d816bde649..b6f14fae7b 100644
--- a/src/state/preferences/feed-tuners.tsx
+++ b/src/state/preferences/feed-tuners.tsx
@@ -19,20 +19,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
}
}
if (feedDesc.startsWith('feedgen')) {
- return [
- FeedTuner.dedupReposts,
- FeedTuner.preferredLangOnly(langPrefs.contentLanguages),
- ]
+ return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
}
if (feedDesc.startsWith('list')) {
- const feedTuners = []
-
+ let feedTuners = []
if (feedDesc.endsWith('|as_following')) {
// Same as Following tuners below, copypaste for now.
+ feedTuners.push(FeedTuner.removeOrphans)
if (preferences?.feedViewPrefs.hideReposts) {
feedTuners.push(FeedTuner.removeReposts)
- } else {
- feedTuners.push(FeedTuner.dedupReposts)
}
if (preferences?.feedViewPrefs.hideReplies) {
feedTuners.push(FeedTuner.removeReplies)
@@ -46,18 +41,15 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
if (preferences?.feedViewPrefs.hideQuotePosts) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
- } else {
- feedTuners.push(FeedTuner.dedupReposts)
+ feedTuners.push(FeedTuner.dedupThreads)
}
return feedTuners
}
if (feedDesc === 'following') {
- const feedTuners = []
+ const feedTuners = [FeedTuner.removeOrphans]
if (preferences?.feedViewPrefs.hideReposts) {
feedTuners.push(FeedTuner.removeReposts)
- } else {
- feedTuners.push(FeedTuner.dedupReposts)
}
if (preferences?.feedViewPrefs.hideReplies) {
feedTuners.push(FeedTuner.removeReplies)
@@ -71,6 +63,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
if (preferences?.feedViewPrefs.hideQuotePosts) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
+ feedTuners.push(FeedTuner.dedupThreads)
return feedTuners
}
diff --git a/src/state/preferences/hidden-posts.tsx b/src/state/preferences/hidden-posts.tsx
index 2c6a373e15..510af713d3 100644
--- a/src/state/preferences/hidden-posts.tsx
+++ b/src/state/preferences/hidden-posts.tsx
@@ -44,8 +44,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('hiddenPosts'))
+ return persisted.onUpdate('hiddenPosts', nextHiddenPosts => {
+ setState(nextHiddenPosts)
})
}, [setStateWrapped])
diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx
index 73c4bbbe78..76c854105e 100644
--- a/src/state/preferences/in-app-browser.tsx
+++ b/src/state/preferences/in-app-browser.tsx
@@ -34,8 +34,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('useInAppBrowser'))
+ return persisted.onUpdate('useInAppBrowser', nextUseInAppBrowser => {
+ setState(nextUseInAppBrowser)
})
}, [setStateWrapped])
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index e6b53d5be0..c7eaf27261 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -9,6 +9,7 @@ import {Provider as InAppBrowserProvider} from './in-app-browser'
import {Provider as KawaiiProvider} from './kawaii'
import {Provider as LanguagesProvider} from './languages'
import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
+import {Provider as SubtitlesProvider} from './subtitles'
import {Provider as UsedStarterPacksProvider} from './used-starter-packs'
export {
@@ -24,6 +25,7 @@ export {
export * from './hidden-posts'
export {useLabelDefinitions} from './label-defs'
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
+export {useSetSubtitlesEnabled, useSubtitlesEnabled} from './subtitles'
export function Provider({children}: React.PropsWithChildren<{}>) {
return (
@@ -36,7 +38,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
- {children}
+
+ {children}
+
diff --git a/src/state/preferences/kawaii.tsx b/src/state/preferences/kawaii.tsx
index 4aa95ef8b0..4216891648 100644
--- a/src/state/preferences/kawaii.tsx
+++ b/src/state/preferences/kawaii.tsx
@@ -21,8 +21,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('kawaii'))
+ return persisted.onUpdate('kawaii', nextKawaii => {
+ setState(nextKawaii)
})
}, [setStateWrapped])
diff --git a/src/state/preferences/languages.tsx b/src/state/preferences/languages.tsx
index b7494c1f93..5093cd725d 100644
--- a/src/state/preferences/languages.tsx
+++ b/src/state/preferences/languages.tsx
@@ -43,8 +43,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('languagePrefs'))
+ return persisted.onUpdate('languagePrefs', nextLanguagePrefs => {
+ setState(nextLanguagePrefs)
})
}, [setStateWrapped])
diff --git a/src/state/preferences/large-alt-badge.tsx b/src/state/preferences/large-alt-badge.tsx
index b3d597c5cb..9d2c9fa54e 100644
--- a/src/state/preferences/large-alt-badge.tsx
+++ b/src/state/preferences/large-alt-badge.tsx
@@ -26,9 +26,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('largeAltBadgeEnabled'))
- })
+ return persisted.onUpdate(
+ 'largeAltBadgeEnabled',
+ nextLargeAltBadgeEnabled => {
+ setState(nextLargeAltBadgeEnabled)
+ },
+ )
}, [setStateWrapped])
return (
diff --git a/src/state/preferences/subtitles.tsx b/src/state/preferences/subtitles.tsx
new file mode 100644
index 0000000000..e0e89feb16
--- /dev/null
+++ b/src/state/preferences/subtitles.tsx
@@ -0,0 +1,42 @@
+import React from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = boolean
+type SetContext = (v: boolean) => void
+
+const stateContext = React.createContext(
+ Boolean(persisted.defaults.subtitlesEnabled),
+)
+const setContext = React.createContext((_: boolean) => {})
+
+export function Provider({children}: {children: React.ReactNode}) {
+ const [state, setState] = React.useState(
+ Boolean(persisted.get('subtitlesEnabled')),
+ )
+
+ const setStateWrapped = React.useCallback(
+ (subtitlesEnabled: persisted.Schema['subtitlesEnabled']) => {
+ setState(Boolean(subtitlesEnabled))
+ persisted.write('subtitlesEnabled', subtitlesEnabled)
+ },
+ [setState],
+ )
+
+ React.useEffect(() => {
+ return persisted.onUpdate('subtitlesEnabled', nextSubtitlesEnabled => {
+ setState(Boolean(nextSubtitlesEnabled))
+ })
+ }, [setStateWrapped])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export const useSubtitlesEnabled = () => React.useContext(stateContext)
+export const useSetSubtitlesEnabled = () => React.useContext(setContext)
diff --git a/src/state/preferences/used-starter-packs.tsx b/src/state/preferences/used-starter-packs.tsx
index 8d5d9e8283..e4de479d55 100644
--- a/src/state/preferences/used-starter-packs.tsx
+++ b/src/state/preferences/used-starter-packs.tsx
@@ -19,9 +19,12 @@ export function Provider({children}: {children: React.ReactNode}) {
}
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('hasCheckedForStarterPack'))
- })
+ return persisted.onUpdate(
+ 'hasCheckedForStarterPack',
+ nextHasCheckedForStarterPack => {
+ setState(nextHasCheckedForStarterPack)
+ },
+ )
}, [])
return (
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/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 65467e8023..ee3e2c14d2 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -77,22 +77,23 @@ export interface FeedPostSliceItem {
uri: string
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
- reason?:
- | AppBskyFeedDefs.ReasonRepost
- | ReasonFeedSource
- | {[k: string]: unknown; $type: string}
- feedContext: string | undefined
moderation: ModerationDecision
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
isParentBlocked?: boolean
+ isParentNotFound?: boolean
}
export interface FeedPostSlice {
_isFeedPostSlice: boolean
_reactKey: string
- rootUri: string
- isThread: boolean
items: FeedPostSliceItem[]
+ isIncompleteThread: boolean
+ isFallbackMarker: boolean
+ feedContext: string | undefined
+ reason?:
+ | AppBskyFeedDefs.ReasonRepost
+ | ReasonFeedSource
+ | {[k: string]: unknown; $type: string}
}
export interface FeedPageUnselected {
@@ -313,53 +314,23 @@ export function usePostFeedQuery(
const feedPostSlice: FeedPostSlice = {
_reactKey: slice._reactKey,
_isFeedPostSlice: true,
- rootUri: slice.uri,
- isThread:
- slice.items.length > 1 &&
- slice.items.every(
- item =>
- item.post.author.did ===
- slice.items[0].post.author.did,
- ),
- items: slice.items
- .map((item, i) => {
- if (
- AppBskyFeedPost.isRecord(item.post.record) &&
- AppBskyFeedPost.validateRecord(item.post.record)
- .success
- ) {
- const parent = item.reply?.parent
- let parentAuthor:
- | AppBskyActorDefs.ProfileViewBasic
- | undefined
- if (AppBskyFeedDefs.isPostView(parent)) {
- parentAuthor = parent.author
- }
- if (!parentAuthor) {
- parentAuthor =
- slice.items[i + 1]?.reply?.grandparentAuthor
- }
- const replyRef = item.reply
- const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
- replyRef?.parent,
- )
-
- const feedPostSliceItem: FeedPostSliceItem = {
- _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
- uri: item.post.uri,
- post: item.post,
- record: item.post.record,
- reason: slice.reason,
- feedContext: slice.feedContext,
- moderation: moderations[i],
- parentAuthor,
- isParentBlocked,
- }
- return feedPostSliceItem
- }
- return undefined
- })
- .filter(n => !!n),
+ isIncompleteThread: slice.isIncompleteThread,
+ isFallbackMarker: slice.isFallbackMarker,
+ feedContext: slice.feedContext,
+ reason: slice.reason,
+ items: slice.items.map((item, i) => {
+ const feedPostSliceItem: FeedPostSliceItem = {
+ _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
+ uri: item.post.uri,
+ post: item.post,
+ record: item.record,
+ moderation: moderations[i],
+ parentAuthor: item.parentAuthor,
+ isParentBlocked: item.isParentBlocked,
+ isParentNotFound: item.isParentNotFound,
+ }
+ return feedPostSliceItem
+ }),
}
return feedPostSlice
})
@@ -442,7 +413,6 @@ export async function pollLatest(page: FeedPage | undefined) {
if (post) {
const slices = page.tuner.tune([post], {
dryRun: true,
- maintainOrder: true,
})
if (slices[0]) {
return true
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index db85e8a177..fd419d1c44 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -136,6 +136,8 @@ export function sortThread(
node: ThreadNode,
opts: UsePreferencesQueryResponse['threadViewPrefs'],
modCache: ThreadModerationCache,
+ currentDid: string | undefined,
+ justPostedUris: Set,
): ThreadNode {
if (node.type !== 'post') {
return node
@@ -149,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) {
@@ -159,6 +175,16 @@ export function sortThread(
return 1 // op's own reply
}
+ const aIsBySelf = a.post.author.did === currentDid
+ const bIsBySelf = b.post.author.did === currentDid
+ if (aIsBySelf && bIsBySelf) {
+ return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
+ } else if (aIsBySelf) {
+ return -1 // current account's reply
+ } else if (bIsBySelf) {
+ return 1 // current account's reply
+ }
+
const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
if (aBlur !== bBlur) {
@@ -195,7 +221,9 @@ export function sortThread(
}
return b.post.indexedAt.localeCompare(a.post.indexedAt)
})
- node.replies.forEach(reply => sortThread(reply, opts, modCache))
+ 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/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts
index a1244721a2..f5d51a974a 100644
--- a/src/state/queries/suggested-follows.ts
+++ b/src/state/queries/suggested-follows.ts
@@ -106,6 +106,7 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery({
+ gcTime: 0,
queryKey: suggestedFollowsByActorQueryKey(did),
queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts
index 4d7f7995c5..cf741b2510 100644
--- a/src/state/queries/video/video-upload.ts
+++ b/src/state/queries/video/video-upload.ts
@@ -2,10 +2,11 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
-import {CompressedVideo} from 'lib/media/video/compress'
-import {UploadVideoResponse} from 'lib/media/video/types'
-import {createVideoEndpointUrl} from 'state/queries/video/util'
-import {useSession} from 'state/session'
+import {CompressedVideo} from '#/lib/media/video/compress'
+import {UploadVideoResponse} from '#/lib/media/video/types'
+import {createVideoEndpointUrl} from '#/state/queries/video/util'
+import {useAgent, useSession} from '#/state/session'
+
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
export const useUploadVideoMutation = ({
@@ -18,6 +19,7 @@ export const useUploadVideoMutation = ({
setProgress: (progress: number) => void
}) => {
const {currentAccount} = useSession()
+ const agent = useAgent()
return useMutation({
mutationFn: async (video: CompressedVideo) => {
@@ -26,6 +28,17 @@ export const useUploadVideoMutation = ({
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
})
+ // a logged-in agent should have this set, but we'll check just in case
+ if (!agent.pdsUrl) {
+ throw new Error('Agent does not have a PDS URL')
+ }
+
+ const {data: serviceAuth} =
+ await agent.api.com.atproto.server.getServiceAuth({
+ aud: `did:web:${agent.pdsUrl.hostname}`,
+ lxm: 'com.atproto.repo.uploadBlob',
+ })
+
const uploadTask = createUploadTask(
uri,
video.uri,
@@ -33,13 +46,12 @@ export const useUploadVideoMutation = ({
headers: {
'dev-key': UPLOAD_HEADER,
'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
+ Authorization: `Bearer ${serviceAuth.token}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
},
- p => {
- setProgress(p.totalBytesSent / p.totalBytesExpectedToSend)
- },
+ p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend),
)
const res = await uploadTask.uploadAsync()
diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts
index b5b9e93bf9..b9b0bacfac 100644
--- a/src/state/queries/video/video-upload.web.ts
+++ b/src/state/queries/video/video-upload.web.ts
@@ -1,10 +1,11 @@
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
-import {CompressedVideo} from 'lib/media/video/compress'
-import {UploadVideoResponse} from 'lib/media/video/types'
-import {createVideoEndpointUrl} from 'state/queries/video/util'
-import {useSession} from 'state/session'
+import {CompressedVideo} from '#/lib/media/video/compress'
+import {UploadVideoResponse} from '#/lib/media/video/types'
+import {createVideoEndpointUrl} from '#/state/queries/video/util'
+import {useAgent, useSession} from '#/state/session'
+
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
export const useUploadVideoMutation = ({
@@ -17,6 +18,7 @@ export const useUploadVideoMutation = ({
setProgress: (progress: number) => void
}) => {
const {currentAccount} = useSession()
+ const agent = useAgent()
return useMutation({
mutationFn: async (video: CompressedVideo) => {
@@ -25,6 +27,17 @@ export const useUploadVideoMutation = ({
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
})
+ // a logged-in agent should have this set, but we'll check just in case
+ if (!agent.pdsUrl) {
+ throw new Error('Agent does not have a PDS URL')
+ }
+
+ const {data: serviceAuth} =
+ await agent.api.com.atproto.server.getServiceAuth({
+ aud: `did:web:${agent.pdsUrl.hostname}`,
+ lxm: 'com.atproto.repo.uploadBlob',
+ })
+
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
const xhr = new XMLHttpRequest()
@@ -53,6 +66,7 @@ export const useUploadVideoMutation = ({
xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type?
// @TODO remove this header for prod
xhr.setRequestHeader('dev-key', UPLOAD_HEADER)
+ xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
xhr.send(bytes)
})) as UploadVideoResponse
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 3aac19025d..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],
@@ -185,8 +204,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}, [state])
React.useEffect(() => {
- return persisted.onUpdate(() => {
- const synced = persisted.get('session')
+ return persisted.onUpdate('session', nextSession => {
+ const synced = nextSession
addSessionDebugLog({type: 'persisted:receive', data: synced})
dispatch({
type: 'synced-accounts',
@@ -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/color-mode.tsx b/src/state/shell/color-mode.tsx
index f3339d2406..47b936c0bb 100644
--- a/src/state/shell/color-mode.tsx
+++ b/src/state/shell/color-mode.tsx
@@ -1,4 +1,5 @@
import React from 'react'
+
import * as persisted from '#/state/persisted'
type StateContext = {
@@ -43,10 +44,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- setColorMode(persisted.get('colorMode'))
- setDarkTheme(persisted.get('darkTheme'))
+ const unsub1 = persisted.onUpdate('darkTheme', nextDarkTheme => {
+ setDarkTheme(nextDarkTheme)
})
+ const unsub2 = persisted.onUpdate('colorMode', nextColorMode => {
+ setColorMode(nextColorMode)
+ })
+ return () => {
+ unsub1()
+ unsub2()
+ }
}, [])
return (
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/onboarding.tsx b/src/state/shell/onboarding.tsx
index 6a18b461f9..d3a8fec466 100644
--- a/src/state/shell/onboarding.tsx
+++ b/src/state/shell/onboarding.tsx
@@ -1,6 +1,7 @@
import React from 'react'
-import * as persisted from '#/state/persisted'
+
import {track} from '#/lib/analytics/analytics'
+import * as persisted from '#/state/persisted'
export const OnboardingScreenSteps = {
Welcome: 'Welcome',
@@ -81,13 +82,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
React.useEffect(() => {
- return persisted.onUpdate(() => {
- const next = persisted.get('onboarding').step
+ return persisted.onUpdate('onboarding', nextOnboarding => {
+ const next = nextOnboarding.step
// TODO we've introduced a footgun
if (state.step !== next) {
dispatch({
type: 'set',
- step: persisted.get('onboarding').step as OnboardingStep,
+ step: nextOnboarding.step as OnboardingStep,
})
}
})
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/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 82755de1d3..bd39ddd843 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -194,6 +194,7 @@ export function Feed({
initialNumToRender={initialNumToRender}
windowSize={11}
sideBorders={false}
+ removeClippedSubviews={true}
/>
)
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
-}) {
- const {hasSession} = useSession()
+export function PostThread({uri}: {uri: string | undefined}) {
+ const {hasSession, currentAccount} = useSession()
const {_} = useLingui()
const t = useTheme()
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
@@ -154,6 +151,7 @@ export function PostThread({
// On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
const [deferParents, setDeferParents] = React.useState(isNative)
+ const currentDid = currentAccount?.did
const threadModerationCache = React.useMemo(() => {
const cache: ThreadModerationCache = new WeakMap()
if (thread && moderationOpts) {
@@ -162,13 +160,23 @@ 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),
- hasSession,
+ sortThread(
+ thread,
+ threadViewPrefs,
+ threadModerationCache,
+ currentDid,
+ justPostedUris,
+ ),
+ !!currentDid,
treeView,
threadModerationCache,
hiddenRepliesState !== HiddenRepliesState.Hide,
@@ -176,10 +184,11 @@ export function PostThread({
}, [
thread,
preferences?.threadViewPrefs,
- hasSession,
+ currentDid,
treeView,
threadModerationCache,
hiddenRepliesState,
+ justPostedUris,
])
const error = React.useMemo(() => {
@@ -209,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 []
@@ -312,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 ||
@@ -323,7 +356,9 @@ export function PostThread({
if (item === REPLY_PROMPT && hasSession) {
return (
- {!isMobile && }
+ {!isMobile && (
+
+ )}
)
} else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
@@ -405,7 +440,7 @@ export function PostThread({
HiddenRepliesState.ShowAndOverridePostHider &&
item.ctx.depth > 0
}
- onPostReply={refetch}
+ onPostReply={onPostReply}
hideTopBorder={index === 0 && !item.ctx.isParentLoading}
/>
@@ -472,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'
}
@@ -621,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 1215f70465..3b3f54aec8 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')
@@ -687,7 +687,7 @@ function ExpandedPostDetails({
a.flex_row,
a.align_center,
a.flex_wrap,
- a.gap_sm,
+ a.gap_xs,
s.mt2,
s.mb10,
]}>
@@ -769,6 +769,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 7623ff37e3..a179484000 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -14,7 +14,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
-import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
@@ -181,6 +180,7 @@ let Feed = ({
ListHeaderComponent?: () => JSX.Element
extraData?: any
savedFeedConfig?: AppBskyActorDefs.SavedFeed
+ outsideHeaderOffset?: number
}): React.ReactNode => {
const theme = useTheme()
const {track} = useAnalytics()
@@ -212,8 +212,9 @@ let Feed = ({
isFetchingNextPage,
fetchNextPage,
} = usePostFeedQuery(feed, feedParams, opts)
- if (data?.pages[0]) {
- lastFetchRef.current = data?.pages[0].fetchedAt
+ const lastFetchedAt = data?.pages[0].fetchedAt
+ if (lastFetchedAt) {
+ lastFetchRef.current = lastFetchedAt
}
const isEmpty = React.useMemo(
() => !isFetching && !data?.pages?.some(page => page.slices.length),
@@ -348,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) {
@@ -358,7 +358,7 @@ let Feed = ({
...interstitial,
params: {variant},
// overwrite key with unique value
- key: [interstitial.type, variant].join(':'),
+ key: [interstitial.type, variant, lastFetchedAt].join(':'),
}
if (arr.length > interstitial.slot) {
@@ -374,6 +374,7 @@ let Feed = ({
isFetched,
isError,
isEmpty,
+ lastFetchedAt,
data,
feedUri,
feedIsDiscover,
@@ -472,16 +473,14 @@ let Feed = ({
} else if (item.type === progressGuideInterstitialType) {
return
} else if (item.type === 'slice') {
- if (item.slice.rootUri === FALLBACK_MARKER_POST.post.uri) {
+ if (item.slice.isFallbackMarker) {
// HACK
// tell the user we fell back to discover
// see home.ts (feed api) for more info
// -prf
return
}
- return (
-
- )
+ return
} else {
return null
}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 9ddc54a989..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,10 +348,13 @@ let FeedItemInner = ({
postHref={href}
onOpenAuthor={onOpenAuthor}
/>
- {!isThreadChild &&
- showReplyTo &&
- (parentAuthor || isParentBlocked) && (
-
+ {showReplyTo &&
+ (parentAuthor || isParentBlocked || isParentNotFound) && (
+
)}
- {__DEV__ && gate('videos') && (
-
- )}
+
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) {
@@ -503,13 +511,25 @@ function ReplyToLabel({
)
}
+function VideoDebug() {
+ const gate = useGate()
+ const id = useId()
+
+ if (!gate('video_debug')) return null
+
+ return (
+
+ )
+}
+
const styles = StyleSheet.create({
outer: {
paddingLeft: 10,
paddingRight: 15,
// @ts-ignore web only -prf
cursor: 'pointer',
- overflow: 'hidden',
},
replyLine: {
width: 2,
@@ -547,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 8d707d78ea..9676eff1f6 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -18,7 +18,7 @@ let FeedSlice = ({
slice: FeedPostSlice
hideTopBorder?: boolean
}): React.ReactNode => {
- if (slice.isThread && slice.items.length > 3) {
+ if (slice.isIncompleteThread && slice.items.length >= 3) {
const beforeLast = slice.items.length - 2
const last = slice.items.length - 1
return (
@@ -27,42 +27,48 @@ let FeedSlice = ({
key={slice.items[0]._reactKey}
post={slice.items[0].post}
record={slice.items[0].record}
- reason={slice.items[0].reason}
- feedContext={slice.items[0].feedContext}
+ reason={slice.reason}
+ feedContext={slice.feedContext}
parentAuthor={slice.items[0].parentAuthor}
- showReplyTo={true}
+ showReplyTo={false}
moderation={slice.items[0].moderation}
isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)}
hideTopBorder={hideTopBorder}
isParentBlocked={slice.items[0].isParentBlocked}
+ isParentNotFound={slice.items[0].isParentNotFound}
/>
-
+
>
@@ -76,8 +82,8 @@ let FeedSlice = ({
key={item._reactKey}
post={slice.items[i].post}
record={slice.items[i].record}
- reason={slice.items[i].reason}
- feedContext={slice.items[i].feedContext}
+ reason={i === 0 ? slice.reason : undefined}
+ feedContext={slice.feedContext}
moderation={slice.items[i].moderation}
parentAuthor={slice.items[i].parentAuthor}
showReplyTo={i === 0}
@@ -87,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}
/>
))}
@@ -96,12 +103,12 @@ let FeedSlice = ({
FeedSlice = memo(FeedSlice)
export {FeedSlice}
-function ViewFullThread({slice}: {slice: FeedPostSlice}) {
+function ViewFullThread({uri}: {uri: string}) {
const pal = usePalette('default')
const itemHref = React.useMemo(() => {
- const urip = new AtUri(slice.rootUri)
+ const urip = new AtUri(uri)
return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey)
- }, [slice.rootUri])
+ }, [uri])
return (
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
index c7df4d75be..356b3f09cf 100644
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
@@ -1,32 +1,60 @@
import React from 'react'
-import {Pressable, ScrollView, StyleSheet, View} from 'react-native'
-import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
+import {ScrollView, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {logEvent} from '#/lib/statsig/statsig'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
-import {useAnalytics} from 'lib/analytics/analytics'
-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 {isWeb} from 'platform/detection'
-import {Button} from 'view/com/util/forms/Button'
-import {Link} from 'view/com/util/Link'
-import {Text} from 'view/com/util/text/Text'
-import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
-import * as Toast from '../util/Toast'
+import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import * as ProfileCard from '#/components/ProfileCard'
+import {Text} from '#/components/Typography'
-const OUTER_PADDING = 10
-const INNER_PADDING = 14
-const TOTAL_HEIGHT = 250
+const OUTER_PADDING = a.p_md.padding
+const INNER_PADDING = a.p_lg.padding
+const TOTAL_HEIGHT = 232
+const MOBILE_CARD_WIDTH = 300
+
+function CardOuter({
+ children,
+ style,
+}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
+ const t = useTheme()
+ return (
+
+ {children}
+
+ )
+}
+
+export function SuggestedFollowPlaceholder() {
+ const t = useTheme()
+ return (
+
+
+
+
+
+
+
+
+ )
+}
export function ProfileHeaderSuggestedFollows({
actorDid,
@@ -35,47 +63,55 @@ export function ProfileHeaderSuggestedFollows({
actorDid: string
requestDismiss: () => void
}) {
- const pal = usePalette('default')
- const {isLoading, data} = useSuggestedFollowsByActorQuery({
- did: actorDid,
- })
+ const t = useTheme()
+ const {_} = useLingui()
+ const {isLoading: isSuggestionsLoading, data} =
+ useSuggestedFollowsByActorQuery({
+ did: actorDid,
+ })
+ const moderationOpts = useModerationOpts()
+ const isLoading = isSuggestionsLoading || !moderationOpts
+
return (
+ style={[
+ t.atoms.bg_contrast_25,
+ {
+ height: '100%',
+ paddingTop: INNER_PADDING / 2,
+ },
+ ]}>
-
- Suggested for you
+ style={[
+ a.flex_row,
+ a.justify_between,
+ a.align_center,
+ a.pt_xs,
+ {
+ paddingBottom: INNER_PADDING / 2,
+ paddingLeft: INNER_PADDING,
+ paddingRight: INNER_PADDING / 2,
+ },
+ ]}>
+
+ Similar accounts
-
-
-
+ label={_(msg`Dismiss`)}
+ size="xsmall"
+ variant="ghost"
+ color="secondary"
+ shape="round">
+
+
- {isLoading ? (
- <>
-
-
-
-
-
-
- >
- ) : data ? (
- data.suggestions
- .filter(s => (s.associated?.labeler ? false : true))
- .map(profile => (
-
- ))
- ) : (
-
- )}
+ snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap}
+ decelerationRate="fast">
+
+ {isLoading ? (
+ <>
+
+
+
+
+
+ >
+ ) : data ? (
+ data.suggestions
+ .filter(s => (s.associated?.labeler ? false : true))
+ .map(profile => (
+ {
+ logEvent('profile:header:suggestedFollowsCard:press', {})
+ }}
+ style={[a.flex_1]}>
+ {({hovered, pressed}) => (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ ))
+ ) : (
+
+ )}
+
)
}
-
-function SuggestedFollowSkeleton() {
- const pal = usePalette('default')
- return (
-
-
-
-
-
-
- )
-}
-
-function SuggestedFollow({
- profile: profileUnshadowed,
-}: {
- profile: AppBskyActorDefs.ProfileView
-}) {
- const {track} = useAnalytics()
- const pal = usePalette('default')
- const {_} = useLingui()
- const moderationOpts = useModerationOpts()
- const profile = useProfileShadow(profileUnshadowed)
- const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
- profile,
- 'ProfileHeaderSuggestedFollows',
- )
-
- const onPressFollow = React.useCallback(async () => {
- try {
- track('ProfileHeader:SuggestedFollowFollowed')
- await queueFollow()
- } catch (e: any) {
- if (e?.name !== 'AbortError') {
- Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
- }
- }
- }, [queueFollow, track, _])
-
- const onPressUnfollow = React.useCallback(async () => {
- try {
- await queueUnfollow()
- } catch (e: any) {
- if (e?.name !== 'AbortError') {
- Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
- }
- }
- }, [queueUnfollow, _])
-
- if (!moderationOpts) {
- return null
- }
- const moderation = moderateProfile(profile, moderationOpts)
- const following = profile.viewer?.following
- return (
-
-
-
-
-
-
- {sanitizeDisplayName(
- profile.displayName || sanitizeHandle(profile.handle),
- moderation.ui('displayName'),
- )}
-
-
- {sanitizeHandle(profile.handle, '@')}
-
-
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- suggestedFollowCardOuter: {
- marginHorizontal: INNER_PADDING / 2,
- paddingTop: 10,
- paddingBottom: 12,
- paddingHorizontal: 10,
- borderRadius: 8,
- width: 130,
- alignItems: 'center',
- overflow: 'hidden',
- flexShrink: 1,
- },
-})
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/List.tsx b/src/view/com/util/List.tsx
index e1a10e4741..c62ac5ed1e 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -5,7 +5,9 @@ import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {usePalette} from '#/lib/hooks/usePalette'
import {useScrollHandlers} from '#/lib/ScrollContext'
+import {useDedupe} from 'lib/hooks/useDedupe'
import {addStyle} from 'lib/styles'
+import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {FlatList_INTERNAL} from './Views'
export type ListMethods = FlatList_INTERNAL
@@ -28,8 +30,6 @@ export type ListProps = Omit<
// Web only prop to contain the scroll to the container rather than the window
disableFullWindowScroll?: boolean
sideBorders?: boolean
- // Web only prop to disable a perf optimization (which would otherwise be on).
- disableContainStyle?: boolean
}
export type ListRef = React.MutableRefObject
@@ -49,6 +49,7 @@ function ListImpl(
) {
const isScrolledDown = useSharedValue(false)
const pal = usePalette('default')
+ const dedupe = useDedupe()
function handleScrolledDownChange(didScrollDown: boolean) {
onScrolledDownChange?.(didScrollDown)
@@ -79,6 +80,8 @@ function ListImpl(
runOnJS(handleScrolledDownChange)(didScrollDown)
}
}
+
+ runOnJS(dedupe)(updateActiveViewAsync)
},
// Note: adding onMomentumBegin here makes simulator scroll
// lag on Android. So either don't add it, or figure out why.
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 5aa699356d..5f89cfbbc9 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -4,11 +4,10 @@ import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/rean
import {batchedUpdates} from '#/lib/batchedUpdates'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {useScrollHandlers} from '#/lib/ScrollContext'
-import {isSafari} from 'lib/browser'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {addStyle} from 'lib/styles'
+import {addStyle} from '#/lib/styles'
export type ListMethods = any // TODO: Better types.
export type ListProps = Omit<
@@ -26,8 +25,6 @@ export type ListProps = Omit<
// Web only prop to contain the scroll to the container rather than the window
disableFullWindowScroll?: boolean
sideBorders?: boolean
- // Web only prop to disable a perf optimization (which would otherwise be on).
- disableContainStyle?: boolean
}
export type ListRef = React.MutableRefObject // TODO: Better types.
@@ -60,7 +57,6 @@ function ListImpl(
extraData,
style,
sideBorders = true,
- disableContainStyle,
...props
}: ListProps,
ref: React.Ref,
@@ -364,7 +360,6 @@ function ListImpl(
renderItem={renderItem}
extraData={extraData}
onItemSeen={onItemSeen}
- disableContainStyle={disableContainStyle}
/>
)
})}
@@ -442,7 +437,6 @@ let Row = function RowImpl({
renderItem,
extraData: _unused,
onItemSeen,
- disableContainStyle,
}: {
item: ItemT
index: number
@@ -452,7 +446,6 @@ let Row = function RowImpl({
| ((data: {index: number; item: any; separators: any}) => React.ReactNode)
extraData: any
onItemSeen: ((item: any) => void) | undefined
- disableContainStyle?: boolean
}): React.ReactNode {
const rowRef = React.useRef(null)
const intersectionTimeout = React.useRef(undefined)
@@ -501,11 +494,8 @@ let Row = function RowImpl({
return null
}
- const shouldDisableContainStyle = disableContainStyle || isSafari
return (
-
+
{renderItem({item, index, separators: null as any})}
)
@@ -576,10 +566,6 @@ const styles = StyleSheet.create({
marginLeft: 'auto',
marginRight: 'auto',
},
- contain: {
- // @ts-ignore web only
- contain: 'layout paint',
- },
minHeightViewport: {
// @ts-ignore web only
minHeight: '100vh',
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/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoContext.tsx
index 6804436a7e..d18dfc0908 100644
--- a/src/view/com/util/post-embeds/ActiveVideoContext.tsx
+++ b/src/view/com/util/post-embeds/ActiveVideoContext.tsx
@@ -1,37 +1,103 @@
-import React, {useCallback, useId, useMemo, useState} from 'react'
+import React, {
+ useCallback,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import {useWindowDimensions} from 'react-native'
+import {isNative} from '#/platform/detection'
import {VideoPlayerProvider} from './VideoPlayerContext'
const ActiveVideoContext = React.createContext<{
activeViewId: string | null
setActiveView: (viewId: string, src: string) => void
+ sendViewPosition: (viewId: string, y: number) => void
} | null>(null)
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
const [activeViewId, setActiveViewId] = useState(null)
+ const activeViewLocationRef = useRef(Infinity)
const [source, setSource] = useState(null)
+ const {height: windowHeight} = useWindowDimensions()
+
+ // minimising re-renders by using refs
+ const manuallySetRef = useRef(false)
+ const activeViewIdRef = useRef(activeViewId)
+ useEffect(() => {
+ activeViewIdRef.current = activeViewId
+ }, [activeViewId])
+
+ const setActiveView = useCallback(
+ (viewId: string, src: string) => {
+ setActiveViewId(viewId)
+ setSource(src)
+ manuallySetRef.current = true
+ // we don't know the exact position, but it's definitely on screen
+ // so just guess that it's in the middle. Any value is fine
+ // so long as it's not offscreen
+ activeViewLocationRef.current = windowHeight / 2
+ },
+ [windowHeight],
+ )
+
+ const sendViewPosition = useCallback(
+ (viewId: string, y: number) => {
+ if (isNative) return
+
+ if (viewId === activeViewIdRef.current) {
+ activeViewLocationRef.current = y
+ } else {
+ if (
+ distanceToIdealPosition(y) <
+ distanceToIdealPosition(activeViewLocationRef.current)
+ ) {
+ // if the old view was manually set, only usurp if the old view is offscreen
+ if (
+ manuallySetRef.current &&
+ withinViewport(activeViewLocationRef.current)
+ ) {
+ return
+ }
+
+ setActiveViewId(viewId)
+ activeViewLocationRef.current = y
+ manuallySetRef.current = false
+ }
+ }
+
+ function distanceToIdealPosition(yPos: number) {
+ return Math.abs(yPos - windowHeight / 2.5)
+ }
+
+ function withinViewport(yPos: number) {
+ return yPos > 0 && yPos < windowHeight
+ }
+ },
+ [windowHeight],
+ )
const value = useMemo(
() => ({
activeViewId,
- setActiveView: (viewId: string, src: string) => {
- setActiveViewId(viewId)
- setSource(src)
- },
+ setActiveView,
+ sendViewPosition,
}),
- [activeViewId],
+ [activeViewId, setActiveView, sendViewPosition],
)
return (
-
+
{children}
)
}
-export function useActiveVideoView() {
+export function useActiveVideoView({source}: {source: string}) {
const context = React.useContext(ActiveVideoContext)
if (!context) {
throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
@@ -41,7 +107,12 @@ export function useActiveVideoView() {
return {
active: context.activeViewId === id,
setActive: useCallback(
- (source: string) => context.setActiveView(id, source),
+ () => context.setActiveView(id, source),
+ [context, id, source],
+ ),
+ currentActiveView: context.activeViewId,
+ sendPosition: useCallback(
+ (y: number) => context.sendViewPosition(id, y),
[context, id],
),
}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index 5e5293a553..4e2909f40b 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,20 +1,29 @@
-import React, {useCallback} 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'
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 {VideoEmbedInner} from './VideoEmbedInner'
+import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({source}: {source: string}) {
const t = useTheme()
- const {active, setActive} = useActiveVideoView()
+ const {active, setActive} = useActiveVideoView({source})
const {_} = useLingui()
- const onPress = useCallback(() => setActive(source), [setActive, source])
+ const [key, setKey] = useState(0)
+ const renderError = useCallback(
+ (error: unknown) => (
+ setKey(key + 1)} />
+ ),
+ [key],
+ )
return (
- {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
new file mode 100644
index 0000000000..5803b836df
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
@@ -0,0 +1,158 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react'
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+ HLSUnsupportedError,
+ VideoEmbedInnerWeb,
+} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
+import {atoms as a, useTheme} from '#/alf'
+import {ErrorBoundary} from '../ErrorBoundary'
+import {useActiveVideoView} from './ActiveVideoContext'
+import * as VideoFallback from './VideoEmbedInner/VideoFallback'
+
+export function VideoEmbed({source}: {source: string}) {
+ const t = useTheme()
+ const ref = useRef(null)
+ const {active, setActive, sendPosition, currentActiveView} =
+ useActiveVideoView({source})
+ const [onScreen, setOnScreen] = useState(false)
+
+ useEffect(() => {
+ if (!ref.current) return
+ const observer = new IntersectionObserver(
+ entries => {
+ const entry = entries[0]
+ if (!entry) return
+ setOnScreen(entry.isIntersecting)
+ sendPosition(
+ entry.boundingClientRect.y + entry.boundingClientRect.height / 2,
+ )
+ },
+ {threshold: 0.5},
+ )
+ observer.observe(ref.current)
+ return () => observer.disconnect()
+ }, [sendPosition])
+
+ const [key, setKey] = useState(0)
+ const renderError = useCallback(
+ (error: unknown) => (
+ setKey(key + 1)} />
+ ),
+ [key],
+ )
+
+ return (
+
+ evt.stopPropagation()}>
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * Renders a 100vh tall div and watches it with an IntersectionObserver to
+ * send the position of the div when it's near the screen.
+ */
+function ViewportObserver({
+ children,
+ sendPosition,
+ isAnyViewActive,
+}: {
+ children: React.ReactNode
+ sendPosition: (position: number) => void
+ isAnyViewActive?: boolean
+}) {
+ const ref = useRef(null)
+ const [nearScreen, setNearScreen] = useState(false)
+
+ // Send position when scrolling. This is done with an IntersectionObserver
+ // observing a div of 100vh height
+ useEffect(() => {
+ if (!ref.current) return
+ const observer = new IntersectionObserver(
+ entries => {
+ const entry = entries[0]
+ if (!entry) return
+ const position =
+ entry.boundingClientRect.y + entry.boundingClientRect.height / 2
+ sendPosition(position)
+ setNearScreen(entry.isIntersecting)
+ },
+ {threshold: Array.from({length: 101}, (_, i) => i / 100)},
+ )
+ observer.observe(ref.current)
+ return () => observer.disconnect()
+ }, [sendPosition])
+
+ // In case scrolling hasn't started yet, send up the position
+ useEffect(() => {
+ if (ref.current && !isAnyViewActive) {
+ const rect = ref.current.getBoundingClientRect()
+ const position = rect.y + rect.height / 2
+ sendPosition(position)
+ }
+ }, [isAnyViewActive, sendPosition])
+
+ return (
+
+ {nearScreen && children}
+
+
+ )
+}
+
+function VideoError({error, retry}: {error: unknown; retry: () => void}) {
+ const isHLS = error instanceof HLSUnsupportedError
+
+ return (
+
+
+ {isHLS ? (
+
+ Your browser does not support the video format. Please try a
+ different browser.
+
+ ) : (
+
+ An error occurred while loading the video. Please try again later.
+
+ )}
+
+ {!isHLS && }
+
+ )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx
deleted file mode 100644
index ef06787097..0000000000
--- a/src/view/com/util/post-embeds/VideoEmbedInner.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react'
-import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
-import Animated, {
- measure,
- runOnJS,
- useAnimatedRef,
- useFrameCallback,
- useSharedValue,
-} from 'react-native-reanimated'
-import {VideoPlayer, VideoView} from 'expo-video'
-
-import {atoms as a} from '#/alf'
-import {Text} from '#/components/Typography'
-import {useVideoPlayer} from './VideoPlayerContext'
-
-export const VideoEmbedInner = ({}: {source: string}) => {
- const player = useVideoPlayer()
- const aref = useAnimatedRef()
- const {height: windowHeight} = useWindowDimensions()
- const hasLeftView = useSharedValue(false)
- const ref = useRef(null)
-
- const onEnterView = useCallback(() => {
- if (player.status === 'readyToPlay') {
- player.play()
- }
- }, [player])
-
- const onLeaveView = useCallback(() => {
- player.pause()
- }, [player])
-
- const enterFullscreen = useCallback(() => {
- if (ref.current) {
- ref.current.enterFullscreen()
- }
- }, [])
-
- useFrameCallback(() => {
- const measurement = measure(aref)
-
- if (measurement) {
- if (hasLeftView.value) {
- // Check if the video is in view
- if (
- measurement.pageY >= 0 &&
- measurement.pageY + measurement.height <= windowHeight
- ) {
- runOnJS(onEnterView)()
- hasLeftView.value = false
- }
- } else {
- // Check if the video is out of view
- if (
- measurement.pageY + measurement.height < 0 ||
- measurement.pageY > windowHeight
- ) {
- runOnJS(onLeaveView)()
- hasLeftView.value = true
- }
- }
- }
- })
-
- return (
-
-
-
-
- )
-}
-
-function VideoControls({
- player,
- enterFullscreen,
-}: {
- player: VideoPlayer
- enterFullscreen: () => void
-}) {
- const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime))
-
- useEffect(() => {
- const interval = setInterval(() => {
- setCurrentTime(Math.floor(player.duration - player.currentTime))
- // how often should we update the time?
- // 1000 gets out of sync with the video time
- }, 250)
-
- return () => {
- clearInterval(interval)
- }
- }, [player])
-
- const minutes = Math.floor(currentTime / 60)
- const seconds = String(currentTime % 60).padStart(2, '0')
-
- return (
-
-
-
- {minutes}:{seconds}
-
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- timeContainer: {
- backgroundColor: 'rgba(0, 0, 0, 0.75)',
- borderRadius: 6,
- paddingHorizontal: 6,
- paddingVertical: 3,
- position: 'absolute',
- left: 5,
- bottom: 5,
- },
- timeElapsed: {
- color: 'white',
- fontSize: 12,
- fontWeight: 'bold',
- },
-})
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
deleted file mode 100644
index cb02743c6f..0000000000
--- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import React, {useEffect, useRef} from 'react'
-import Hls from 'hls.js'
-
-import {atoms as a} from '#/alf'
-
-export const VideoEmbedInner = ({source}: {source: string}) => {
- const ref = useRef(null)
-
- // Use HLS.js to play HLS video
- useEffect(() => {
- if (ref.current) {
- if (ref.current.canPlayType('application/vnd.apple.mpegurl')) {
- ref.current.src = source
- } else if (Hls.isSupported()) {
- var hls = new Hls()
- hls.loadSource(source)
- hls.attachMedia(ref.current)
- } else {
- // TODO: fallback
- }
- }
- }, [source])
-
- useEffect(() => {
- if (ref.current) {
- const observer = new IntersectionObserver(
- ([entry]) => {
- if (ref.current) {
- if (entry.isIntersecting) {
- if (ref.current.paused) {
- ref.current.play()
- }
- } else {
- if (!ref.current.paused) {
- ref.current.pause()
- }
- }
- }
- },
- {threshold: 0},
- )
-
- observer.observe(ref.current)
-
- return () => {
- observer.disconnect()
- }
- }
- }, [])
-
- return
-}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
new file mode 100644
index 0000000000..fa49438763
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -0,0 +1,215 @@
+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 {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()
+ }}
+ />
+
+
+ )
+}
+
+function Controls({
+ player,
+ enterFullscreen,
+}: {
+ 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),
+ )
+
+ const timeRemaining = duration - currentTime
+ const minutes = Math.floor(timeRemaining / 60)
+ const seconds = String(timeRemaining % 60).padStart(2, '0')
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ // 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])
+
+ 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 (
+
+ {showTime && (
+
+
+ {minutes}:{seconds}
+
+
+ )}
+
+ {duration > 0 && (
+
+
+ {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
new file mode 100644
index 0000000000..2760c7fafd
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
@@ -0,0 +1,3 @@
+export function VideoEmbedInnerNative() {
+ throw new Error('VideoEmbedInnerNative may not be used on web.')
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
new file mode 100644
index 0000000000..8664aae142
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx
@@ -0,0 +1,3 @@
+export function VideoEmbedInnerWeb() {
+ throw new Error('VideoEmbedInnerWeb may not be used on native.')
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
new file mode 100644
index 0000000000..c0021d9bb7
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -0,0 +1,99 @@
+import React, {useEffect, useRef, useState} from 'react'
+import {View} from 'react-native'
+import Hls from 'hls.js'
+
+import {atoms as a} from '#/alf'
+import {Controls} from './VideoWebControls'
+
+export function VideoEmbedInnerWeb({
+ source,
+ active,
+ setActive,
+ onScreen,
+}: {
+ source: string
+ active?: boolean
+ setActive?: () => void
+ onScreen?: boolean
+}) {
+ if (active == null || setActive == null || onScreen == null) {
+ throw new Error(
+ 'active, setActive, and onScreen are required VideoEmbedInner props on web.',
+ )
+ }
+
+ const containerRef = useRef(null)
+ const ref = useRef(null)
+ const [focused, setFocused] = useState(false)
+ const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
+
+ const hlsRef = useRef(undefined)
+
+ useEffect(() => {
+ if (!ref.current) return
+ if (!Hls.isSupported()) throw new HLSUnsupportedError()
+
+ const hls = new Hls({capLevelToPlayerSize: true})
+ hlsRef.current = hls
+
+ hls.attachMedia(ref.current)
+ hls.loadSource(source)
+
+ // initial value, later on it's managed by Controls
+ hls.autoLevelCapping = 0
+
+ hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
+ if (data.subtitleTracks.length > 0) {
+ setHasSubtitleTrack(true)
+ }
+ })
+
+ return () => {
+ hlsRef.current = undefined
+ hls.detachMedia()
+ hls.destroy()
+ }
+ }, [source])
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+export class HLSUnsupportedError extends Error {
+ constructor() {
+ super('HLS is not supported')
+ }
+}
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
new file mode 100644
index 0000000000..7caaf3abf7
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx
@@ -0,0 +1,587 @@
+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 bc5d9d370c..95511099e4 100644
--- a/src/view/com/util/post-embeds/VideoPlayerContext.tsx
+++ b/src/view/com/util/post-embeds/VideoPlayerContext.tsx
@@ -1,29 +1,35 @@
-import React, {useContext, useEffect} from 'react'
+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({
- viewId,
source,
children,
}: {
- viewId: string | null
source: string
children: React.ReactNode
}) {
// 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)
- // make sure we're playing every time the viewId changes
- // this means the video is different
- useEffect(() => {
- player.play()
- }, [viewId, player])
+ 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/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/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index 65cbb7374e..5bf9e8a160 100644
--- a/src/view/screens/AppPasswords.tsx
+++ b/src/view/screens/AppPasswords.tsx
@@ -268,7 +268,7 @@ function AppPassword({
size={14}
/>
- Allows access to direct messages
+ Allows access to direct messages
)}
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index f7cecd872d..9a47007c4b 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -7,9 +7,10 @@ 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 {FeedParams} from '#/state/queries/post-feed'
+import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSession} from '#/state/session'
@@ -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()
@@ -108,6 +110,30 @@ function HomeScreenReady({
}
}, [selectedIndex])
+ // Temporary, remove when finished debugging
+ const debugHasLoggedFollowingPrefs = React.useRef(false)
+ const debugLogFollowingPrefs = React.useCallback(
+ (feed: FeedDescriptor) => {
+ if (debugHasLoggedFollowingPrefs.current) return
+ if (feed !== 'following') return
+ logEvent('debug:followingPrefs', {
+ followingShowRepliesFromPref: preferences.feedViewPrefs.hideReplies
+ ? 'off'
+ : preferences.feedViewPrefs.hideRepliesByUnfollowed
+ ? 'following'
+ : 'all',
+ followingRepliesMinLikePref:
+ preferences.feedViewPrefs.hideRepliesByLikeCount,
+ })
+ debugHasLoggedFollowingPrefs.current = true
+ },
+ [
+ preferences.feedViewPrefs.hideReplies,
+ preferences.feedViewPrefs.hideRepliesByLikeCount,
+ preferences.feedViewPrefs.hideRepliesByUnfollowed,
+ ],
+ )
+
const {hasSession} = useSession()
const setMinimalShellMode = useSetMinimalShellMode()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
@@ -136,6 +162,7 @@ function HomeScreenReady({
feedUrl: selectedFeed,
reason: 'focus',
})
+ debugLogFollowingPrefs(selectedFeed)
}
}),
)
@@ -144,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) {
@@ -156,7 +187,7 @@ function HomeScreenReady({
return () => {
listener.remove()
}
- }, [setMinimalShellMode, mode, isMobile]),
+ }, [setMinimalShellMode, mode, isMobile, gate]),
)
const onPageSelected = React.useCallback(
@@ -182,8 +213,9 @@ function HomeScreenReady({
feedUrl: feed,
reason,
})
+ debugLogFollowingPrefs(feed)
},
- [allFeeds],
+ [allFeeds, debugLogFollowingPrefs],
)
const onPressSelected = 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/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx
index 5510fbee25..650fd43548 100644
--- a/src/view/screens/Search/Explore.tsx
+++ b/src/view/screens/Search/Explore.tsx
@@ -10,7 +10,6 @@ import {
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -293,7 +292,6 @@ export function Explore() {
error: feedsError,
fetchNextPage: fetchNextFeedsPage,
} = useGetPopularFeedsQuery({limit: 10})
- const gate = useGate()
const isLoadingMoreProfiles = isFetchingNextProfilesPage && !isLoadingProfiles
const onLoadMoreProfiles = React.useCallback(async () => {
@@ -499,9 +497,7 @@ export function Explore() {
profile={item.profile}
noBg
noBorder
- showKnownFollowers={gate(
- 'explore_page_profile_card_social_proof',
- )}
+ showKnownFollowers
/>
)
@@ -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
-
-
- ) : (
-
- )}
+
)
@@ -174,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()
@@ -238,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])
@@ -299,10 +277,6 @@ export function SettingsScreen({}: Props) {
await clearStorage()
Toast.show(_(msg`Storage cleared, you need to restart the app now.`))
}, [_])
- const clearAllLegacyStorage = React.useCallback(async () => {
- await clearLegacyStorage()
- Toast.show(_(msg`Legacy storage cleared, you need to restart the app now.`))
- }, [_])
const deactivateAccountControl = useDialogControl()
const onPressDeactivateAccount = React.useCallback(() => {
@@ -330,7 +304,7 @@ export function SettingsScreen({}: Props) {
@@ -399,6 +373,15 @@ export function SettingsScreen({}: Props) {
) : null}
+ {accounts.length > 1 && (
+
+
+ Other accounts
+
+
+
+ )}
+
{accounts
.filter(a => a.did !== currentAccount?.did)
.map(account => (
@@ -427,6 +410,29 @@ export function SettingsScreen({}: Props) {
Add account
+
+
+
+
+
+
+ {accounts.length > 1 ? (
+ Sign out of all accounts
+ ) : (
+ Sign out
+ )}
+
+
@@ -863,18 +869,6 @@ export function SettingsScreen({}: Props) {
Reset onboarding state
-
-
-
- Clear all legacy storage data (restart after this)
-
-
-
()
const [shouldRenderUnmountTest, setShouldRenderUnmountTest] =
React.useState(false)
const unmountTestInterval = React.useRef()
@@ -147,6 +150,22 @@ export function Dialogs() {
Open Shared Prefs Tester
+
+
This is a prompt
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 6fa8805125..cd0508d6a6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,39 +34,65 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
-"@atproto/api@^0.12.26":
- version "0.12.26"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca"
- integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA==
+"@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==