diff --git a/.eslintrc.js b/.eslintrc.js
index 9d2b7bbb1a..2d5f2822ac 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -71,6 +71,19 @@ module.exports = {
'simple-import-sort/exports': 'warn',
// TODO: Reenable when we figure out why it gets stuck on CI.
// 'react-compiler/react-compiler': 'error',
+ 'no-restricted-imports': [
+ 'error',
+ {
+ paths: [
+ {
+ name: '@atproto/api',
+ importNames: ['moderatePost'],
+ message:
+ 'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.',
+ },
+ ],
+ },
+ ],
},
ignorePatterns: [
'**/__mocks__/*.ts',
diff --git a/__e2e__/flows/feed-reorder.yml b/__e2e__/flows/feed-reorder.yml
index 449df065d7..2b892b533e 100644
--- a/__e2e__/flows/feed-reorder.yml
+++ b/__e2e__/flows/feed-reorder.yml
@@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Pin alice's feed
+- extendedWaitUntil:
+ visible:
+ id: "viewHeaderDrawerBtn"
- tapOn:
- id: "bottomBarProfileBtn"
+ id: "viewHeaderDrawerBtn"
+- tapOn:
+ id: "profileCardButton"
- swipe:
from:
id: "profilePager-selector"
diff --git a/__e2e__/flows/home-screen.yml b/__e2e__/flows/home-screen.yml
index c8d83fb1fa..799a20214c 100644
--- a/__e2e__/flows/home-screen.yml
+++ b/__e2e__/flows/home-screen.yml
@@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eSignInAlice"
+- extendedWaitUntil:
+ visible:
+ text: "Feeds ✨"
- tapOn:
label: "Can go to feeds page using feeds button in tab bar"
text: "Feeds ✨"
@@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
- tapOn:
label: "Can like posts"
id: "likeBtn"
-- assertVisible:
- id: "likeCount"
- text: "1"
- tapOn:
id: "likeBtn"
-- assertNotVisible:
- id: "likeCount"
- tapOn:
label: "Can repost posts"
id: "repostBtn"
- tapOn: "Repost"
-- assertVisible:
- id: "repostCount"
- text: "1"
- tapOn:
id: "repostBtn"
- tapOn: "Remove repost"
-- assertNotVisible:
- id: "repostCount"
- tapOn:
label: "Can delete posts"
diff --git a/__e2e__/flows/profile-screen-edit.yml b/__e2e__/flows/profile-screen-edit.yml
index 288a5d4f6d..251eca3581 100644
--- a/__e2e__/flows/profile-screen-edit.yml
+++ b/__e2e__/flows/profile-screen-edit.yml
@@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
# Navigate to my profile
+- extendedWaitUntil:
+ visible:
+ id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarProfileBtn"
diff --git a/__e2e__/flows/profile-screen.yml b/__e2e__/flows/profile-screen.yml
index 7d2d43deea..b9f95aca26 100644
--- a/__e2e__/flows/profile-screen.yml
+++ b/__e2e__/flows/profile-screen.yml
@@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Navigate to another user profile
+- extendedWaitUntil:
+ visible:
+ id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- tapOn:
diff --git a/__e2e__/flows/thread-screen.yml b/__e2e__/flows/thread-screen.yml
index fdc732596b..9120f4f689 100644
--- a/__e2e__/flows/thread-screen.yml
+++ b/__e2e__/flows/thread-screen.yml
@@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
# Navigate to thread
+- extendedWaitUntil:
+ visible: "Thread root"
- tapOn: "Thread root"
- assertVisible: "Thread reply"
@@ -33,18 +35,10 @@ appId: xyz.blueskyweb.app
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
-- assertVisible:
- id: "likeCount"
- childOf:
- id: "postThreadItem-by-carla.test"
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
-- assertNotVisible:
- id: "likeCount"
- childOf:
- id: "postThreadItem-by-carla.test"
# Can repost the root post
- tapOn:
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/app.config.js b/app.config.js
index 1a33e0f5c6..5ed01eb4ce 100644
--- a/app.config.js
+++ b/app.config.js
@@ -191,7 +191,7 @@ module.exports = function (config) {
'expo-build-properties',
{
ios: {
- deploymentTarget: '14.0',
+ deploymentTarget: '15.1',
newArchEnabled: true,
},
android: {
diff --git a/assets/icons/crop_stroke2_corner0_rounded.svg b/assets/icons/crop_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..118d148f3c
--- /dev/null
+++ b/assets/icons/crop_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/eye_stroke2_corner0_rounded.svg b/assets/icons/eye_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..035daa6e1c
--- /dev/null
+++ b/assets/icons/eye_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
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/play_filled_corner2_rounded.svg b/bskyembed/assets/play_filled_corner2_rounded.svg
new file mode 100644
index 0000000000..48da4add65
--- /dev/null
+++ b/bskyembed/assets/play_filled_corner2_rounded.svg
@@ -0,0 +1 @@
+
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..e269b1c953 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.6",
"@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8",
@@ -22,7 +22,7 @@
"eslint-plugin-simple-import-sort": "^12.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
- "typescript": "^4.0.5",
+ "typescript": "^5.5.4",
"vite": "^5.2.8",
"vite-tsconfig-paths": "^4.3.2"
}
diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx
index 1dadfee38e..3b4f5e77d1 100644
--- a/bskyembed/src/components/embed.tsx
+++ b/bskyembed/src/components/embed.tsx
@@ -3,15 +3,20 @@ import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
+ AppBskyEmbedVideo,
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 playIcon from '../../assets/play_filled_corner2_rounded.svg'
+import starterPackIcon from '../../assets/starterPack.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils'
import {Link} from './link'
@@ -105,7 +110,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
+ // Case 4: Video
+ if (AppBskyEmbedVideo.isView(content)) {
+ return
+ }
+
+ // Case 5: Record with media
if (
AppBskyEmbedRecordWithMedia.isView(content) &&
AppBskyEmbedRecord.isViewRecord(content.record.record)
@@ -182,7 +191,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 +324,7 @@ function ExternalEmbed({
)
}
-function GenericWithImage({
+function GenericWithImageEmbed({
title,
subtitle,
href,
@@ -350,3 +360,89 @@ function GenericWithImage({
)
}
+
+// just the thumbnail and a play button
+function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
+ let aspectRatio = 1
+
+ if (content.aspectRatio) {
+ const {width, height} = content.aspectRatio
+ aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
+ }
+
+ return (
+
+

+
+

+
+
+ )
+}
+
+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}`
+}
+
+function clamp(num: number, min: number, max: number) {
+ return Math.max(min, Math.min(num, max))
+}
diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx
index d23c84cbfb..4db5eeb45e 100644
--- a/bskyembed/src/components/post.tsx
+++ b/bskyembed/src/components/post.tsx
@@ -11,7 +11,7 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg'
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
import {CONTENT_LABELS} from '../labels'
-import {getRkey, niceDate} from '../utils'
+import {getRkey, niceDate, prettyNumber} from '../utils'
import {Container} from './container'
import {Embed} from './embed'
import {Link} from './link'
@@ -78,7 +78,7 @@ export function Post({thread}: Props) {
- {post.likeCount}
+ {prettyNumber(post.likeCount)}
)}
@@ -86,7 +86,7 @@ export function Post({thread}: Props) {
- {post.repostCount}
+ {prettyNumber(post.repostCount)}
)}
@@ -97,7 +97,7 @@ export function Post({thread}: Props) {
{post.replyCount
- ? `Read ${post.replyCount} ${
+ ? `Read ${prettyNumber(post.replyCount)} ${
post.replyCount > 1 ? 'replies' : 'reply'
} on Bluesky`
: `View on Bluesky`}
@@ -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/src/utils.ts b/bskyembed/src/utils.ts
index 1f6fd5061c..cfa4a525bf 100644
--- a/bskyembed/src/utils.ts
+++ b/bskyembed/src/utils.ts
@@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri)
return at.rkey
}
+
+const formatter = new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ maximumFractionDigits: 1,
+ roundingMode: 'trunc',
+})
+
+export function prettyNumber(number: number) {
+ return formatter.format(number)
+}
diff --git a/bskyembed/tsconfig.json b/bskyembed/tsconfig.json
index 44c516ed11..b3b6055ccd 100644
--- a/bskyembed/tsconfig.json
+++ b/bskyembed/tsconfig.json
@@ -20,5 +20,5 @@
"jsxFragmentFactory": "Fragment",
"downlevelIteration": true
},
- "include": ["src"]
+ "include": ["src", "vite.config.ts"]
}
diff --git a/bskyembed/tsconfig.snippet.json b/bskyembed/tsconfig.snippet.json
index a6b6071dd6..fee21964a1 100644
--- a/bskyembed/tsconfig.snippet.json
+++ b/bskyembed/tsconfig.snippet.json
@@ -6,5 +6,5 @@
"strict": true,
"outDir": "dist"
},
- "include": ["snippet"],
+ "include": ["snippet"]
}
diff --git a/bskyembed/yarn.lock b/bskyembed/yarn.lock
index 60efe36845..46c8519ba1 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.6":
+ version "0.13.6"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5"
+ integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==
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.1"
+ 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.1":
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
+ integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
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==
@@ -4003,10 +4024,10 @@ typed-array-length@^1.0.6:
is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0"
-typescript@^4.0.5:
- version "4.9.5"
- resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
- integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
+typescript@^5.5.4:
+ version "5.5.4"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
+ integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
uint8arrays@3.0.0:
version "3.0.0"
@@ -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 d9235afdee..3f46c4b00b 100644
--- a/bskyweb/cmd/bskyweb/main.go
+++ b/bskyweb/cmd/bskyweb/main.go
@@ -60,6 +60,12 @@ func run(args []string) {
Value: "",
EnvVars: []string{"LINK_HOST"},
},
+ &cli.StringFlag{
+ Name: "ipcc-host",
+ Usage: "scheme, hostname, and port of ipcc service",
+ Value: "https://localhost:8730",
+ EnvVars: []string{"IPCC_HOST"},
+ },
&cli.BoolFlag{
Name: "debug",
Usage: "Enable debug mode",
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index fdef01ce78..afd9247ace 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -1,12 +1,17 @@
package main
import (
+ "bytes"
"context"
"crypto/subtle"
+ "crypto/tls"
+ "encoding/base64"
+ "encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
+ "net/netip"
"net/url"
"os"
"os/signal"
@@ -41,6 +46,7 @@ type Config struct {
appviewHost string
ogcardHost string
linkHost string
+ ipccHost string
}
func serve(cctx *cli.Context) error {
@@ -49,6 +55,7 @@ func serve(cctx *cli.Context) error {
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
+ ipccHost := cctx.String("ipcc-host")
basicAuthPassword := cctx.String("basic-auth-password")
// Echo
@@ -91,6 +98,7 @@ func serve(cctx *cli.Context) error {
appviewHost: appviewHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
+ ipccHost: ipccHost,
},
}
@@ -255,11 +263,15 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost)
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
+ e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric)
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
+ // ipcc
+ e.GET("/ipcc", server.WebIpCC)
+
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
@@ -519,3 +531,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
data["requestHost"] = req.Host
return c.Render(http.StatusOK, "profile.html", data)
}
+
+type IPCCRequest struct {
+ IP string `json:"ip"`
+}
+type IPCCResponse struct {
+ CC string `json:"countryCode"`
+}
+
+func (srv *Server) WebIpCC(c echo.Context) error {
+ realIP := c.RealIP()
+ addr, err := netip.ParseAddr(realIP)
+ if err != nil {
+ log.Warnf("could not parse IP %q %s", realIP, err)
+ return c.JSON(400, IPCCResponse{})
+ }
+ var request []byte
+ if addr.Is4() {
+ ip4 := addr.As4()
+ var dest [8]byte
+ base64.StdEncoding.Encode(dest[:], ip4[:])
+ request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
+ } else if addr.Is6() {
+ ip6 := addr.As16()
+ var dest [24]byte
+ base64.StdEncoding.Encode(dest[:], ip6[:])
+ request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
+ }
+
+ ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost)
+ if err != nil {
+ log.Errorf("ipcc misconfigured bad url %s", err)
+ return c.JSON(500, IPCCResponse{})
+ }
+ ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
+ ipccUrl := ipccUrlBuilder.String()
+ cl := http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ InsecureSkipVerify: true,
+ },
+ },
+ }
+ postBodyReader := bytes.NewReader(request)
+ response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
+ if err != nil {
+ log.Warnf("ipcc backend error %s", err)
+ return c.JSON(500, IPCCResponse{})
+ }
+ defer response.Body.Close()
+ dec := json.NewDecoder(response.Body)
+ var outResponse IPCCResponse
+ err = dec.Decode(&outResponse)
+ if err != nil {
+ log.Warnf("ipcc bad response %s", err)
+ return c.JSON(500, IPCCResponse{})
+ }
+ return c.JSON(200, outResponse)
+}
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index cb2caed443..c248027982 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -253,6 +253,11 @@
from { opacity: 1; }
to { opacity: 0; }
}
+
+ .force-no-clicks > *,
+ .force-no-clicks * {
+ pointer-events: none !important;
+ }
{% include "scripts.html" %}
diff --git a/index.js b/index.js
index 7630d0538a..2f13ce1ea1 100644
--- a/index.js
+++ b/index.js
@@ -1,14 +1,11 @@
import 'react-native-gesture-handler' // must be first
-import {LogBox} from 'react-native'
-
import '#/platform/polyfills'
-import {IS_TEST} from '#/env'
+
+import {LogBox} from 'react-native'
import {registerRootComponent} from 'expo'
-import {doPolyfill} from '#/lib/api/api-polyfill'
import App from '#/App'
-
-doPolyfill()
+import {IS_TEST} from '#/env'
if (IS_TEST) {
LogBox.ignoreAllLogs() // suppress all logs in tests
diff --git a/index.web.js b/index.web.js
index 9623734512..be75bc772e 100644
--- a/index.web.js
+++ b/index.web.js
@@ -1,9 +1,8 @@
import '#/platform/markBundleStartTime'
-
import '#/platform/polyfills'
+
import {registerRootComponent} from 'expo'
-import {doPolyfill} from '#/lib/api/api-polyfill'
+
import App from '#/App'
-doPolyfill()
registerRootComponent(App)
diff --git a/jest/test-pds.ts b/jest/test-pds.ts
index 2fe623ca98..962bb7b48e 100644
--- a/jest/test-pds.ts
+++ b/jest/test-pds.ts
@@ -79,32 +79,33 @@ export async function createServer(
plc: {port: port2},
})
+ // DISABLED - looks like dev-env added this and now it conflicts
// add the test mod authority
- const agent = new BskyAgent({service: pdsUrl})
- const res = await agent.api.com.atproto.server.createAccount({
- email: 'mod-authority@test.com',
- handle: 'mod-authority.test',
- password: 'hunter2',
- })
- agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
- await agent.api.app.bsky.actor.profile.create(
- {repo: res.data.did},
- {
- displayName: 'Dev-env Moderation',
- description: `The pretend version of mod.bsky.app`,
- },
- )
+ // const agent = new BskyAgent({service: pdsUrl})
+ // const res = await agent.api.com.atproto.server.createAccount({
+ // email: 'mod-authority@test.com',
+ // handle: 'mod-authority.test',
+ // password: 'hunter2',
+ // })
+ // agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
+ // await agent.api.app.bsky.actor.profile.create(
+ // {repo: res.data.did},
+ // {
+ // displayName: 'Dev-env Moderation',
+ // description: `The pretend version of mod.bsky.app`,
+ // },
+ // )
- await agent.api.app.bsky.labeler.service.create(
- {repo: res.data.did, rkey: 'self'},
- {
- policies: {
- labelValues: ['!hide', '!warn'],
- labelValueDefinitions: [],
- },
- createdAt: new Date().toISOString(),
- },
- )
+ // await agent.api.app.bsky.labeler.service.create(
+ // {repo: res.data.did, rkey: 'self'},
+ // {
+ // policies: {
+ // labelValues: ['!hide', '!warn'],
+ // labelValueDefinitions: [],
+ // },
+ // createdAt: new Date().toISOString(),
+ // },
+ // )
const pic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
@@ -156,7 +157,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 +333,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/lingui.config.js b/lingui.config.js
index 14a94b5ded..796969ab54 100644
--- a/lingui.config.js
+++ b/lingui.config.js
@@ -14,6 +14,7 @@ module.exports = {
'ja',
'ko',
'pt-BR',
+ 'ru',
'tr',
'uk',
'zh-CN',
diff --git a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift
index 7fd60e5fa2..02bf5c6628 100644
--- a/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift
+++ b/modules/expo-bluesky-swiss-army/ios/PlatformInfo/ExpoPlatformInfoModule.swift
@@ -1,6 +1,9 @@
import ExpoModulesCore
public class ExpoPlatformInfoModule: Module {
+ private var prevAudioActive: Bool?
+ private var prevAudioCategory: AVAudioSession.Category?
+
public func definition() -> ModuleDefinition {
Name("ExpoPlatformInfo")
@@ -10,33 +13,34 @@ public class ExpoPlatformInfoModule: Module {
Function("setAudioCategory") { (audioCategoryString: String) in
let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
- try? AVAudioSession.sharedInstance().setCategory(audioCategory)
+ if audioCategory == self.prevAudioCategory {
+ return
+ }
+ self.prevAudioCategory = audioCategory
+ DispatchQueue.global(qos: .background).async {
+ try? AVAudioSession.sharedInstance().setCategory(audioCategory)
+ }
}
Function("setAudioActive") { (active: Bool) in
- var categoryOptions: AVAudioSession.CategoryOptions
- let currentCategory = AVAudioSession.sharedInstance().category
-
- if active {
- categoryOptions = [.mixWithOthers]
- try? AVAudioSession.sharedInstance().setActive(true)
- } else {
- categoryOptions = [.duckOthers]
- try? AVAudioSession
- .sharedInstance()
- .setActive(
- false,
- options: [.notifyOthersOnDeactivation]
- )
+ 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]
+ )
+ }
}
-
- try? AVAudioSession
- .sharedInstance()
- .setCategory(
- currentCategory,
- mode: .default,
- options: categoryOptions
- )
}
}
}
diff --git a/package.json b/package.json
index 164c86b360..77c92414fb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
- "version": "1.90.0",
+ "version": "1.91.0",
"private": true,
"engines": {
"node": ">=18"
@@ -32,7 +32,7 @@
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json",
- "e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
+ "e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"e2e:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:run": "maestro test __e2e__",
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
- "@atproto/api": "0.12.29",
+ "@atproto/api": "0.13.5",
"@bam.tech/react-native-image-resizer": "^3.0.10",
"@braintree/sanitize-url": "^6.0.2",
"@gorhom/bottom-sheet": "5.0.0-alpha.11",
@@ -136,8 +136,8 @@
"expo-splash-screen": "~0.27.4",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.4",
- "expo-task-manager": "~11.8.2",
- "expo-updates": "~0.25.21",
+ "expo-task-manager": "~11.8.1",
+ "expo-updates": "~0.25.14",
"expo-video": "^1.2.4",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
@@ -176,8 +176,9 @@
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.16.2",
"react-native-get-random-values": "~1.11.0",
- "react-native-image-crop-picker": "0.41.2",
- "react-native-keyboard-controller": "^1.12.7",
+ "react-native-image-crop-picker": "0.40.3",
+ "react-native-ios-context-menu": "^1.15.3",
+ "react-native-keyboard-controller": "^1.12.1",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3",
"react-native-progress": "bluesky-social/react-native-progress",
@@ -197,7 +198,6 @@
"react-responsive": "^9.0.2",
"react-textarea-autosize": "^8.5.3",
"rn-fetch-blob": "^0.12.0",
- "rn-tourguide": "bluesky-social/rn-tourguide",
"sentry-expo": "~7.0.1",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
@@ -206,7 +206,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 60fe47f91a..c176cbe282 100644
--- a/patches/expo-modules-core+1.12.11.patch
+++ b/patches/expo-modules-core+1.12.11.patch
@@ -1,5 +1,5 @@
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 @@
@@ -10,3 +10,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.2.4.patch b/patches/expo-video+1.2.4.patch
deleted file mode 100644
index 918c8a8d25..0000000000
--- a/patches/expo-video+1.2.4.patch
+++ /dev/null
@@ -1,135 +0,0 @@
-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
deleted file mode 100644
index 689cf9a926..0000000000
--- a/patches/expo-video+1.2.4.patch.md
+++ /dev/null
@@ -1,6 +0,0 @@
-## 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/plugins/starterPackAppClipExtension/withXcodeTarget.js b/plugins/starterPackAppClipExtension/withXcodeTarget.js
index 61d5f81b07..c14d27291a 100644
--- a/plugins/starterPackAppClipExtension/withXcodeTarget.js
+++ b/plugins/starterPackAppClipExtension/withXcodeTarget.js
@@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => {
buildSettingsObj.SWIFT_VERSION = '5.0'
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
- buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
+ buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
}
}
diff --git a/scripts/post-web-build.js b/scripts/post-web-build.js
index 5db3788545..baaa7cb8b7 100644
--- a/scripts/post-web-build.js
+++ b/scripts/post-web-build.js
@@ -2,7 +2,6 @@ const path = require('path')
const fs = require('fs')
const projectRoot = path.join(__dirname, '..')
-const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js')
const templateFile = path.join(
projectRoot,
'bskyweb',
@@ -10,18 +9,18 @@ const templateFile = path.join(
'scripts.html',
)
-const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js'))
-jsFiles.sort((a, b) => {
- // make sure main is written last
- if (a.startsWith('main')) return 1
- if (b.startsWith('main')) return -1
- return a.localeCompare(b)
-})
+const {entrypoints} = require(path.join(
+ projectRoot,
+ 'web-build/asset-manifest.json',
+))
-console.log(`Found ${jsFiles.length} js files in web-build`)
+console.log(`Found ${entrypoints.length} entrypoints`)
console.log(`Writing ${templateFile}`)
-const outputFile = jsFiles
- .map(name => ``)
+const outputFile = entrypoints
+ .map(name => {
+ const file = path.basename(name)
+ return ``
+ })
.join('\n')
fs.writeFileSync(templateFile, outputFile)
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 8e7c53b93b..780d4058f9 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -50,16 +50,17 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
+import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
-import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
+import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
+import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
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'
@@ -105,52 +106,50 @@ function InnerApp() {
}, [_])
return (
-
-
-
-
-
-
-
-
-
-
- {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
@@ -159,7 +158,7 @@ function App() {
React.useEffect(() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
- PlatformInfo.setAudioActive(true)
+ PlatformInfo.setAudioActive(false)
initPersistedState().then(() => setReady(true))
}, [])
@@ -175,25 +174,30 @@ function App() {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index df6fbf2449..3017a3a264 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -39,15 +39,16 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
-import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
+import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
+import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
+import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
-import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
function InnerApp() {
@@ -105,19 +106,19 @@ function InnerApp() {
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
@@ -153,25 +154,27 @@ function App() {
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
)
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 79856879c3..2beba4f9dc 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -15,10 +15,12 @@ import {
StackActions,
} from '@react-navigation/native'
-import {timeout} from 'lib/async/timeout'
-import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
-import {usePalette} from 'lib/hooks/usePalette'
-import {buildStateObject} from 'lib/routes/helpers'
+import {init as initAnalytics} from '#/lib/analytics/analytics'
+import {timeout} from '#/lib/async/timeout'
+import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
+import {buildStateObject} from '#/lib/routes/helpers'
import {
AllNavigatorParams,
BottomTabNavigatorParams,
@@ -28,20 +30,62 @@ import {
MyProfileTabNavigatorParams,
NotificationsTabNavigatorParams,
SearchTabNavigatorParams,
-} from 'lib/routes/types'
-import {RouteParams, State} from 'lib/routes/types'
-import {bskyTitle} from 'lib/strings/headings'
-import {isAndroid, isNative, isWeb} from 'platform/detection'
+} from '#/lib/routes/types'
+import {RouteParams, State} from '#/lib/routes/types'
+import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
+import {bskyTitle} from '#/lib/strings/headings'
+import {isAndroid, isNative, isWeb} from '#/platform/detection'
+import {useModalControls} from '#/state/modals'
+import {useUnreadNotifications} from '#/state/queries/notifications/unread'
+import {useSession} from '#/state/session'
+import {
+ shouldRequestEmailConfirmation,
+ snoozeEmailConfirmationPrompt,
+} from '#/state/shell/reminders'
+import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings'
+import {AppPasswords} from '#/view/screens/AppPasswords'
+import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
+import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
+import {DebugModScreen} from '#/view/screens/DebugMod'
+import {FeedsScreen} from '#/view/screens/Feeds'
+import {HomeScreen} from '#/view/screens/Home'
+import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings'
+import {ListsScreen} from '#/view/screens/Lists'
+import {LogScreen} from '#/view/screens/Log'
+import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
+import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
+import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
+import {NotFoundScreen} from '#/view/screens/NotFound'
+import {NotificationsScreen} from '#/view/screens/Notifications'
+import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings'
+import {PostThreadScreen} from '#/view/screens/PostThread'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
-import {AppPasswords} from 'view/screens/AppPasswords'
-import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
-import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
-import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
-import {PreferencesThreads} from 'view/screens/PreferencesThreads'
-import {SavedFeeds} from 'view/screens/SavedFeeds'
+import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed'
+import {PreferencesThreads} from '#/view/screens/PreferencesThreads'
+import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
+import {ProfileScreen} from '#/view/screens/Profile'
+import {ProfileFeedScreen} from '#/view/screens/ProfileFeed'
+import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
+import {ProfileFollowersScreen} from '#/view/screens/ProfileFollowers'
+import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows'
+import {ProfileListScreen} from '#/view/screens/ProfileList'
+import {SavedFeeds} from '#/view/screens/SavedFeeds'
+import {SearchScreen} from '#/view/screens/Search'
+import {SettingsScreen} from '#/view/screens/Settings'
+import {Storybook} from '#/view/screens/Storybook'
+import {SupportScreen} from '#/view/screens/Support'
+import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
+import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
+import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag'
+import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
+import {MessagesScreen} from '#/screens/Messages/List'
+import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
+import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
+import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
+import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
@@ -50,51 +94,8 @@ import {
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
+import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
-import {init as initAnalytics} from './lib/analytics/analytics'
-import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
-import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
-import {router} from './routes'
-import {MessagesConversationScreen} from './screens/Messages/Conversation'
-import {MessagesScreen} from './screens/Messages/List'
-import {MessagesSettingsScreen} from './screens/Messages/Settings'
-import {useModalControls} from './state/modals'
-import {useUnreadNotifications} from './state/queries/notifications/unread'
-import {useSession} from './state/session'
-import {
- shouldRequestEmailConfirmation,
- snoozeEmailConfirmationPrompt,
-} from './state/shell/reminders'
-import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
-import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
-import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
-import {DebugModScreen} from './view/screens/DebugMod'
-import {FeedsScreen} from './view/screens/Feeds'
-import {HomeScreen} from './view/screens/Home'
-import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
-import {ListsScreen} from './view/screens/Lists'
-import {LogScreen} from './view/screens/Log'
-import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
-import {NotFoundScreen} from './view/screens/NotFound'
-import {NotificationsScreen} from './view/screens/Notifications'
-import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
-import {PostLikedByScreen} from './view/screens/PostLikedBy'
-import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
-import {PostThreadScreen} from './view/screens/PostThread'
-import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
-import {ProfileScreen} from './view/screens/Profile'
-import {ProfileFeedScreen} from './view/screens/ProfileFeed'
-import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
-import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
-import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
-import {ProfileListScreen} from './view/screens/ProfileList'
-import {SearchScreen} from './view/screens/Search'
-import {SettingsScreen} from './view/screens/Settings'
-import {Storybook} from './view/screens/Storybook'
-import {SupportScreen} from './view/screens/Support'
-import {TermsOfServiceScreen} from './view/screens/TermsOfService'
-import {BottomBar} from './view/shell/bottom-bar/BottomBar'
-import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
const navigationRef = createNavigationContainerRef()
@@ -212,6 +213,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
title: title(msg`Post by @${route.params.name}`),
})}
/>
+ PostQuotesScreen}
+ options={({route}) => ({
+ title: title(msg`Post by @${route.params.name}`),
+ })}
+ />
ProfileFeedScreen}
@@ -482,6 +490,7 @@ function MyProfileTabNavigator() {
getComponent={() => ProfileScreen}
initialParams={{
name: 'me',
+ hideBackButton: true,
}}
/>
{commonScreens(MyProfileTab as typeof HomeTab)}
@@ -652,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING}
theme={theme}
onStateChange={() => {
- logEvent('router:navigate:sampled', {
- from: prevLoggedRouteName.current,
- })
- prevLoggedRouteName.current = getCurrentRouteName()
+ const routeName = getCurrentRouteName()
+ if (routeName === 'Notifications') {
+ logEvent('router:navigate:notifications:sampled', {})
+ }
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
- logEvent('router:navigate:sampled', {})
}}>
{children}
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index e918e370d8..d2e7ffc2ee 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -1,9 +1,14 @@
-import {Platform, StyleSheet} from 'react-native'
+import {Platform, StyleSheet, ViewStyle} from 'react-native'
import * as tokens from '#/alf/tokens'
import {native, web} from '#/alf/util/platform'
export const atoms = {
+ debug: {
+ borderColor: 'red',
+ borderWidth: 1,
+ },
+
/*
* Positioning
*/
@@ -55,6 +60,19 @@ export const atoms = {
height: '100vh',
}),
+ /**
+ * Used for the outermost components on screens, to ensure that they can fill
+ * the screen and extend beyond.
+ */
+ util_screen_outer: [
+ web({
+ minHeight: '100vh',
+ }),
+ native({
+ height: '100%',
+ }),
+ ] as ViewStyle,
+
/*
* Theme-independent bg colors
*/
@@ -853,6 +871,7 @@ export const atoms = {
mr_auto: {
marginRight: 'auto',
},
+
/*
* Pointer events & user select
*/
@@ -871,6 +890,7 @@ export const atoms = {
user_select_all: {
userSelect: 'all',
},
+
/*
* Text decoration
*/
@@ -880,4 +900,11 @@ export const atoms = {
strike_through: {
textDecorationLine: 'line-through',
},
+
+ /*
+ * Display
+ */
+ hidden: {
+ display: 'none',
+ },
} as const
diff --git a/src/alf/index.tsx b/src/alf/index.tsx
index ade2ce1451..5fa7d3b1a1 100644
--- a/src/alf/index.tsx
+++ b/src/alf/index.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import {Dimensions} from 'react-native'
+import {useMediaQuery} from 'react-responsive'
import {createThemes, defaultTheme} from '#/alf/themes'
import {Theme, ThemeName} from '#/alf/types'
@@ -12,52 +12,15 @@ export * from '#/alf/util/flatten'
export * from '#/alf/util/platform'
export * from '#/alf/util/themeSelector'
-type BreakpointName = keyof typeof breakpoints
-
-/*
- * Breakpoints
- */
-const breakpoints: {
- [key: string]: number
-} = {
- gtPhone: 500,
- gtMobile: 800,
- gtTablet: 1300,
-}
-function getActiveBreakpoints({width}: {width: number}) {
- const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
- breakpoint => width >= breakpoints[breakpoint],
- )
-
- return {
- active: active[active.length - 1],
- gtPhone: active.includes('gtPhone'),
- gtMobile: active.includes('gtMobile'),
- gtTablet: active.includes('gtTablet'),
- }
-}
-
/*
* Context
*/
export const Context = React.createContext<{
themeName: ThemeName
theme: Theme
- breakpoints: {
- active: BreakpointName | undefined
- gtPhone: boolean
- gtMobile: boolean
- gtTablet: boolean
- }
}>({
themeName: 'light',
theme: defaultTheme,
- breakpoints: {
- active: undefined,
- gtPhone: false,
- gtMobile: false,
- gtTablet: false,
- },
})
export function ThemeProvider({
@@ -74,18 +37,6 @@ export function ThemeProvider({
})
}, [])
const theme = themes[themeName]
- const [breakpoints, setBreakpoints] = React.useState(() =>
- getActiveBreakpoints({width: Dimensions.get('window').width}),
- )
-
- React.useEffect(() => {
- const listener = Dimensions.addEventListener('change', ({window}) => {
- const bp = getActiveBreakpoints({width: window.width})
- if (bp.active !== breakpoints.active) setBreakpoints(bp)
- })
-
- return listener.remove
- }, [breakpoints, setBreakpoints])
return (
({
themeName: themeName,
theme: theme,
- breakpoints,
}),
- [theme, themeName, breakpoints],
+ [theme, themeName],
)}>
{children}
@@ -107,5 +57,12 @@ export function useTheme() {
}
export function useBreakpoints() {
- return React.useContext(Context).breakpoints
+ const gtPhone = useMediaQuery({minWidth: 500})
+ const gtMobile = useMediaQuery({minWidth: 800})
+ const gtTablet = useMediaQuery({minWidth: 1300})
+ return {
+ gtPhone,
+ gtMobile,
+ gtTablet,
+ }
}
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 7881fc9b5e..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,
diff --git a/src/components/Error.tsx b/src/components/Error.tsx
index 481532434a..59d219831c 100644
--- a/src/components/Error.tsx
+++ b/src/components/Error.tsx
@@ -2,21 +2,18 @@ import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useNavigation} from '@react-navigation/core'
-import {StackActions} from '@react-navigation/native'
-import {NavigationProp} from 'lib/routes/types'
+import {useGoBack} from 'lib/hooks/useGoBack'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
-import {router} from '#/routes'
export function Error({
title,
message,
onRetry,
- onGoBack: onGoBackProp,
+ onGoBack,
hideBackButton,
sideBorders = true,
}: {
@@ -27,31 +24,10 @@ export function Error({
hideBackButton?: boolean
sideBorders?: boolean
}) {
- const navigation = useNavigation()
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
-
- const canGoBack = navigation.canGoBack()
- const onGoBack = React.useCallback(() => {
- if (onGoBackProp) {
- onGoBackProp()
- return
- }
- if (canGoBack) {
- navigation.goBack()
- } else {
- navigation.navigate('HomeTab')
-
- // Checking the state for routes ensures that web doesn't encounter errors while going back
- if (navigation.getState()?.routes) {
- navigation.dispatch(StackActions.push(...router.matchPath('/')))
- } else {
- navigation.navigate('HomeTab')
- navigation.dispatch(StackActions.popToTop())
- }
- }
- }, [navigation, canGoBack, onGoBackProp])
+ const goBack = useGoBack(onGoBack)
return (
diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx
index e37d2c3e0e..5031f584e5 100644
--- a/src/components/FeedInterstitials.tsx
+++ b/src/components/FeedInterstitials.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
-import {AppBskyFeedDefs, AtUri} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -12,7 +12,9 @@ import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
+import {FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile'
+import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory'
@@ -173,14 +175,58 @@ function useExperimentalSuggestedUsersQuery() {
}
}
-export function SuggestedFollows() {
- const t = useTheme()
- const {_} = useLingui()
+export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
+ const [feedType, feedUri] = feed.split('|')
+ if (feedType === 'author') {
+ return
+ } else {
+ return
+ }
+}
+
+export function SuggestedFollowsProfile({did}: {did: string}) {
+ const {
+ isLoading: isSuggestionsLoading,
+ data,
+ error,
+ } = useSuggestedFollowsByActorQuery({
+ did,
+ })
+ return (
+
+ )
+}
+
+export function SuggestedFollowsHome() {
const {
isLoading: isSuggestionsLoading,
profiles,
error,
} = useExperimentalSuggestedUsersQuery()
+ return (
+
+ )
+}
+
+export function ProfileGrid({
+ isSuggestionsLoading,
+ error,
+ profiles,
+}: {
+ isSuggestionsLoading: boolean
+ profiles: AppBskyActorDefs.ProfileViewDetailed[]
+ error: Error | null
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
const moderationOpts = useModerationOpts()
const navigation = useNavigation()
const {gtMobile} = useBreakpoints()
diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx
index 0ed27cf50f..829f36d471 100644
--- a/src/components/ListCard.tsx
+++ b/src/components/ListCard.tsx
@@ -1,13 +1,20 @@
import React from 'react'
import {View} from 'react-native'
-import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
+import {
+ AppBskyActorDefs,
+ AppBskyGraphDefs,
+ AtUri,
+ moderateUserList,
+ ModerationUI,
+} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
import {precacheList} from 'state/queries/feed'
-import {useTheme} from '#/alf'
-import {atoms as a} from '#/alf'
+import {useSession} from 'state/session'
+import {atoms as a, useTheme} from '#/alf'
import {
Avatar,
Description,
@@ -16,6 +23,7 @@ import {
SaveButton,
} from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link'
+import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography'
/*
@@ -43,6 +51,11 @@ type Props = {
export function Default(props: Props) {
const {view, showPinButton} = props
+ const moderationOpts = useModerationOpts()
+ const moderation = moderationOpts
+ ? moderateUserList(view, moderationOpts)
+ : undefined
+
return (
@@ -52,6 +65,7 @@ export function Default(props: Props) {
title={view.name}
creator={view.creator}
purpose={view.purpose}
+ modUi={moderation?.ui('contentView')}
/>
{showPinButton && view.purpose === CURATELIST && (
@@ -89,18 +103,40 @@ export function TitleAndByline({
title,
creator,
purpose = CURATELIST,
+ modUi,
}: {
title: string
creator?: AppBskyActorDefs.ProfileViewBasic
purpose?: AppBskyGraphDefs.ListView['purpose']
+ modUi?: ModerationUI
}) {
const t = useTheme()
+ const {currentAccount} = useSession()
return (
-
- {title}
-
+
+
+
+ Hidden list
+
+
+
+
+ {title}
+
+
+
+
{creator && (
+}) {
+ let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed
+
+ if (AppBskyEmbedImages.isView(media)) {
+ return (
+
+ {media.images.map(image => (
+
+ ))}
+
+ )
+ } else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
+ let url: URL | undefined
+ try {
+ url = new URL(embed.external.uri)
+ } catch {}
+ if (url) {
+ const {success} = parseTenorGif(url)
+ if (success) {
+ return (
+
+
+
+ )
+ }
+ }
+ } else if (AppBskyEmbedVideo.isView(embed)) {
+ return (
+
+
+
+ )
+ }
+
+ return null
+}
+
+export function Outer({
+ children,
+ style,
+}: {
+ children?: React.ReactNode
+ style?: StyleProp
+}) {
+ return {children}
+}
+
+export function ImageItem({
+ thumbnail,
+ alt,
+ children,
+}: {
+ thumbnail: string
+ alt?: string
+ children?: React.ReactNode
+}) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
+ return (
+
+
+
+
+
+
+ GIF
+
+
+
+ )
+}
+
+export function VideoItem({
+ thumbnail,
+ alt,
+}: {
+ thumbnail?: string
+ alt?: string
+}) {
+ if (!thumbnail) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ altContainer: {
+ backgroundColor: 'rgba(0, 0, 0, 0.75)',
+ borderRadius: 6,
+ paddingHorizontal: 6,
+ paddingVertical: 3,
+ position: 'absolute',
+ right: 5,
+ bottom: 5,
+ zIndex: 2,
+ },
+ alt: {
+ color: 'white',
+ fontSize: 7,
+ fontWeight: 'bold',
+ },
+})
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/Pills.tsx b/src/components/Pills.tsx
index 2fff999373..742a11667c 100644
--- a/src/components/Pills.tsx
+++ b/src/components/Pills.tsx
@@ -13,6 +13,15 @@ import {
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
+export type AppModerationCause =
+ | ModerationCause
+ | {
+ type: 'reply-hidden'
+ source: {type: 'user'; did: string}
+ priority: 6
+ downgraded?: boolean
+ }
+
export type CommonProps = {
size?: 'sm' | 'lg'
}
@@ -40,7 +49,7 @@ export function Row({
}
export type LabelProps = {
- cause: ModerationCause
+ cause: AppModerationCause
disableDetailsDialog?: boolean
noBg?: boolean
} & CommonProps
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index a263d19461..6f6d68049a 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -276,8 +276,8 @@ export function DescriptionPlaceholder() {
export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
- logContext: LogEvents['profile:follow']['logContext'] &
- LogEvents['profile:unfollow']['logContext']
+ logContext: LogEvents['profile:follow:sampled']['logContext'] &
+ LogEvents['profile:unfollow:sampled']['logContext']
} & Partial
export function FollowButton(props: FollowButtonProps) {
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx
index 9280009887..3890790dbe 100644
--- a/src/components/ProfileHoverCard/index.web.tsx
+++ b/src/components/ProfileHoverCard/index.web.tsx
@@ -377,7 +377,7 @@ function Inner({
hide: () => void
}) {
const t = useTheme()
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const {currentAccount} = useSession()
const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts),
@@ -393,8 +393,8 @@ function Inner({
profile.viewer?.blocking ||
profile.viewer?.blockedBy ||
profile.viewer?.blockingByList
- const following = formatCount(profile.followsCount || 0)
- const followers = formatCount(profile.followersCount || 0)
+ const following = formatCount(i18n, profile.followsCount || 0)
+ const followers = formatCount(i18n, profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower',
other: 'followers',
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 315ad0dfda..86cb5c315a 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
-export {useDialogControl as usePromptControl} from '#/components/Dialog'
+export {
+ type DialogControlProps as PromptControlProps,
+ useDialogControl as usePromptControl,
+} from '#/components/Dialog'
const Context = React.createContext<{
titleId: string
@@ -23,7 +26,7 @@ export function Outer({
control,
testID,
}: React.PropsWithChildren<{
- control: Dialog.DialogOuterProps['control']
+ control: Dialog.DialogControlProps
testID?: string
}>) {
const {gtMobile} = useBreakpoints()
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/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx
index 3249f1b32e..a5c7cd1b73 100644
--- a/src/components/StarterPack/Main/ProfilesList.tsx
+++ b/src/components/StarterPack/Main/ProfilesList.tsx
@@ -9,14 +9,15 @@ import {
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
import {isNative, isWeb} from 'platform/detection'
-import {useListMembersQuery} from 'state/queries/list-members'
+import {useAllListMembersQuery} from 'state/queries/list-members'
import {useSession} from 'state/session'
import {List, ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf'
-import {ListMaybePlaceholder} from '#/components/Lists'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
@@ -39,17 +40,20 @@ export const ProfilesList = React.forwardRef(
ref,
) {
const t = useTheme()
- const [initialHeaderHeight] = React.useState(headerHeight)
- const bottomBarOffset = useBottomBarOffset(20)
+ const bottomBarOffset = useBottomBarOffset(300)
+ const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession()
- const {data, refetch, isError} = useListMembersQuery(listUri, 50)
+ const {data, refetch, isError} = useAllListMembersQuery(listUri)
const [isPTRing, setIsPTRing] = React.useState(false)
// The server returns these sorted by descending creation date, so we want to invert
- const profiles = data?.pages
- .flatMap(p => p.items.map(i => i.subject))
- .filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
+
+ const profiles = data
+ ?.filter(
+ p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler,
+ )
+ .map(p => p.subject)
.reverse()
const isOwn = new AtUri(listUri).host === currentAccount?.did
@@ -99,7 +103,11 @@ export const ProfilesList = React.forwardRef(
if (!data) {
return (
-
+
(
ref={scrollElRef}
headerOffset={headerHeight}
ListFooterComponent={
-
+
}
showsVerticalScrollIndicator={false}
desktopFixedHeight
+ initialNumToRender={initialNumToRender}
refreshing={isPTRing}
onRefresh={async () => {
setIsPTRing(true)
diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx
index a8a2e34917..8ce5cbbb13 100644
--- a/src/components/StarterPack/QrCode.tsx
+++ b/src/components/StarterPack/QrCode.tsx
@@ -59,20 +59,24 @@ export const QrCode = React.forwardRef(function QrCode(
-
-
- on
-
-
-
-
-
-
+
+
+ on
+
+
+
+
+
+
+
+
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/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
index 5c657293f5..86fbc8c986 100644
--- a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
+++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
@@ -7,6 +7,7 @@ import {BottomSheetFlatListMethods} from '@gorhom/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
@@ -42,6 +43,7 @@ export function WizardEditListDialog({
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
+ const initialNumToRender = useInitialNumToRender()
const listRef = useRef(null)
@@ -148,6 +150,7 @@ export function WizardEditListDialog({
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag"
removeClippedSubviews={true}
+ initialNumToRender={initialNumToRender}
/>
)
diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx
index 55cf0f02b3..bd308fc73a 100644
--- a/src/components/StarterPack/Wizard/WizardListCard.tsx
+++ b/src/components/StarterPack/Wizard/WizardListCard.tsx
@@ -12,7 +12,7 @@ import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {DISCOVER_FEED_URI} from 'lib/constants'
+import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {useSession} from 'state/session'
@@ -130,7 +130,8 @@ export function WizardProfileCard({
const isMe = profile.did === currentAccount?.did
const included = isMe || state.profiles.some(p => p.did === profile.did)
- const disabled = isMe || (!included && state.profiles.length >= 49)
+ const disabled =
+ isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName)
diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx
index 1ffb4da39d..ab6ef8293c 100644
--- a/src/components/WhoCanReply.tsx
+++ b/src/components/WhoCanReply.tsx
@@ -1,39 +1,34 @@
import React from 'react'
-import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
+import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import {
AppBskyFeedDefs,
- AppBskyFeedGetPostThread,
+ AppBskyFeedPost,
AppBskyGraphDefs,
AtUri,
- BskyAgent,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useQueryClient} from '@tanstack/react-query'
-import {createThreadgate} from '#/lib/api'
-import {until} from '#/lib/async/until'
import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
-import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
-import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {
- ThreadgateSetting,
- threadgateViewToSettings,
+ ThreadgateAllowUISetting,
+ threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate'
-import {useAgent} from '#/state/session'
-import * as Toast from 'view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
+import {
+ PostInteractionSettingsDialog,
+ usePrefetchPostInteractionSettings,
+} from '#/components/dialogs/PostInteractionSettingsDialog'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
+import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
-import {TextLink} from '../view/com/util/Link'
-import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps {
@@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const t = useTheme()
const infoDialogControl = useDialogControl()
const editDialogControl = useDialogControl()
- const agent = useAgent()
- const queryClient = useQueryClient()
- const settings = React.useMemo(
- () => threadgateViewToSettings(post.threadgate),
- [post],
- )
- const isRootPost = !('reply' in post.record)
+ /*
+ * `WhoCanReply` is only used for root posts atm, in case this changes
+ * unexpectedly, we should check to make sure it's for sure the root URI.
+ */
+ const rootUri =
+ AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
+ ? post.record.reply.root.uri
+ : post.uri
+ const settings = React.useMemo(() => {
+ return threadgateViewToAllowUISetting(post.threadgate)
+ }, [post.threadgate])
- if (!isRootPost) {
- return null
- }
- if (!settings.length && !isThreadAuthor) {
- return null
- }
+ const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
+ postUri: post.uri,
+ rootPostUri: rootUri,
+ })
- const isEverybody = settings.length === 0
- const isNobody = !!settings.find(gate => gate.type === 'nobody')
- const description = isEverybody
+ const anyoneCanReply =
+ settings.length === 1 && settings[0].type === 'everybody'
+ const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
+ const description = anyoneCanReply
? _(msg`Everybody can reply`)
- : isNobody
+ : noOneCanReply
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
- const onPressEdit = () => {
+ const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
@@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
}
}
- const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
- if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
- return
- }
- try {
- if (newSettings.length) {
- await createThreadgate(agent, post.uri, newSettings)
- } else {
- await agent.api.com.atproto.repo.deleteRecord({
- repo: agent.session!.did,
- collection: 'app.bsky.feed.threadgate',
- rkey: new AtUri(post.uri).rkey,
- })
- }
- await whenAppViewReady(agent, post.uri, res => {
- const thread = res.data.thread
- if (AppBskyFeedDefs.isThreadViewPost(thread)) {
- const fetchedSettings = threadgateViewToSettings(
- thread.post.threadgate,
- )
- return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
- }
- return false
- })
- Toast.show(_(msg`Thread settings updated`))
- queryClient.invalidateQueries({
- queryKey: [POST_THREAD_RQKEY_ROOT],
- })
- } catch (err) {
- Toast.show(
- _(
- msg`There was an issue. Please check your internet connection and try again.`,
- ),
- 'xmark',
- )
- logger.error('Failed to edit threadgate', {message: err})
- }
- }
-
return (
<>
-
- {isThreadAuthor && (
-
+ ) : (
+
)}
>
@@ -174,7 +148,7 @@ function Icon({
}: {
color: string
width?: number
- settings: ThreadgateSetting[]
+ settings: ThreadgateAllowUISetting[]
}) {
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
@@ -186,79 +160,84 @@ function WhoCanReplyDialog({
control,
post,
settings,
+ embeddingDisabled,
}: {
control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView
- settings: ThreadgateSetting[]
-}) {
- return (
-
-
-
-
- )
-}
-
-function WhoCanReplyDialogInner({
- post,
- settings,
-}: {
- post: AppBskyFeedDefs.PostView
- settings: ThreadgateSetting[]
+ settings: ThreadgateAllowUISetting[]
+ embeddingDisabled: boolean
}) {
const {_} = useLingui()
return (
-
-
-
- Who can reply?
-
-
-
-
+
+
+
+
+
+ Who can interact with this post?
+
+
+
+
+
)
}
function Rules({
post,
settings,
+ embeddingDisabled,
}: {
post: AppBskyFeedDefs.PostView
- settings: ThreadgateSetting[]
+ settings: ThreadgateAllowUISetting[]
+ embeddingDisabled: boolean
}) {
const t = useTheme()
+
return (
-
- {!settings.length ? (
- Everybody can reply
- ) : settings[0].type === 'nobody' ? (
- Replies to this thread are disabled
- ) : (
-
- Only{' '}
- {settings.map((rule, i) => (
- <>
-
-
- >
- ))}{' '}
- can reply
-
+ <>
+
+ {settings[0].type === 'everybody' ? (
+ Everybody can reply to this post.
+ ) : settings[0].type === 'nobody' ? (
+ Replies to this post are disabled.
+ ) : (
+
+ Only{' '}
+ {settings.map((rule, i) => (
+
+
+
+
+ ))}{' '}
+ can reply.
+
+ )}{' '}
+
+ {embeddingDisabled && (
+
+ No one but the author can quote this post.
+
)}
-
+ >
)
}
@@ -267,11 +246,10 @@ function Rule({
post,
lists,
}: {
- rule: ThreadgateSetting
+ rule: ThreadgateAllowUISetting
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
- const t = useTheme()
if (rule.type === 'mention') {
return mentioned users
}
@@ -279,12 +257,12 @@ function Rule({
return (
users followed by{' '}
-
+
+ @{post.author.handle}
+
)
}
@@ -294,12 +272,12 @@ function Rule({
const listUrip = new AtUri(list.uri)
return (
- {' '}
+
+ {list.name}
+ {' '}
members
)
@@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
}
return <>, >
}
-
-async function whenAppViewReady(
- agent: BskyAgent,
- uri: string,
- fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
-) {
- await until(
- 5, // 5 tries
- 1e3, // 1s delay between tries
- fn,
- () =>
- agent.app.bsky.feed.getPostThread({
- uri,
- depth: 0,
- }),
- )
-}
diff --git a/src/components/dialogs/Embed.tsx b/src/components/dialogs/Embed.tsx
index 7d858cae40..f43c3c6fe6 100644
--- a/src/components/dialogs/Embed.tsx
+++ b/src/components/dialogs/Embed.tsx
@@ -43,7 +43,7 @@ function EmbedDialogInner({
timestamp,
}: Omit) {
const t = useTheme()
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const ref = useRef(null)
const [copied, setCopied] = useState(false)
@@ -86,9 +86,9 @@ function EmbedDialogInner({
)} (@${escapeHtml(
postAuthor.handle,
)}) ${escapeHtml(
- niceDate(timestamp),
+ niceDate(i18n, timestamp),
)}`
- }, [postUri, postCid, record, timestamp, postAuthor])
+ }, [i18n, postUri, postCid, record, timestamp, postAuthor])
return (
diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx
new file mode 100644
index 0000000000..a326602b72
--- /dev/null
+++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx
@@ -0,0 +1,538 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+import isEqual from 'lodash.isequal'
+
+import {logger} from '#/logger'
+import {STALE} from '#/state/queries'
+import {useMyListsQuery} from '#/state/queries/my-lists'
+import {
+ createPostgateQueryKey,
+ getPostgateRecord,
+ usePostgateQuery,
+ useWritePostgateMutation,
+} from '#/state/queries/postgate'
+import {
+ createPostgateRecord,
+ embeddingRules,
+} from '#/state/queries/postgate/util'
+import {
+ createThreadgateViewQueryKey,
+ getThreadgateView,
+ ThreadgateAllowUISetting,
+ threadgateViewToAllowUISetting,
+ useSetThreadgateAllowMutation,
+ useThreadgateViewQuery,
+} from '#/state/queries/threadgate'
+import {useAgent, useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import * as Toggle from '#/components/forms/Toggle'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export type PostInteractionSettingsFormProps = {
+ onSave: () => void
+ isSaving?: boolean
+
+ postgate: AppBskyFeedPostgate.Record
+ onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
+
+ threadgateAllowUISettings: ThreadgateAllowUISetting[]
+ onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
+
+ replySettingsDisabled?: boolean
+}
+
+export function PostInteractionSettingsControlledDialog({
+ control,
+ ...rest
+}: PostInteractionSettingsFormProps & {
+ control: Dialog.DialogControlProps
+}) {
+ const {_} = useLingui()
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export type PostInteractionSettingsDialogProps = {
+ control: Dialog.DialogControlProps
+ /**
+ * URI of the post to edit the interaction settings for. Could be a root post
+ * or could be a reply.
+ */
+ postUri: string
+ /**
+ * The URI of the root post in the thread. Used to determine if the viewer
+ * owns the threadgate record and can therefore edit it.
+ */
+ rootPostUri: string
+ /**
+ * Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
+ * happen to have one before opening the settings dialog.
+ */
+ initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
+}
+
+export function PostInteractionSettingsDialog(
+ props: PostInteractionSettingsDialogProps,
+) {
+ return (
+
+
+
+
+ )
+}
+
+export function PostInteractionSettingsDialogControlledInner(
+ props: PostInteractionSettingsDialogProps,
+) {
+ const {_} = useLingui()
+ const {currentAccount} = useSession()
+ const [isSaving, setIsSaving] = React.useState(false)
+
+ const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
+ useThreadgateViewQuery({postUri: props.rootPostUri})
+ const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
+ postUri: props.postUri,
+ })
+
+ const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
+ const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
+
+ const [editedPostgate, setEditedPostgate] =
+ React.useState()
+ const [editedAllowUISettings, setEditedAllowUISettings] =
+ React.useState()
+
+ const isLoading = isLoadingThreadgate || isLoadingPostgate
+ const threadgateView = threadgateViewLoaded || props.initialThreadgateView
+ const isThreadgateOwnedByViewer = React.useMemo(() => {
+ return currentAccount?.did === new AtUri(props.rootPostUri).host
+ }, [props.rootPostUri, currentAccount?.did])
+
+ const postgateValue = React.useMemo(() => {
+ return (
+ editedPostgate || postgate || createPostgateRecord({post: props.postUri})
+ )
+ }, [postgate, editedPostgate, props.postUri])
+ const allowUIValue = React.useMemo(() => {
+ return (
+ editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
+ )
+ }, [threadgateView, editedAllowUISettings])
+
+ const onSave = React.useCallback(async () => {
+ if (!editedPostgate && !editedAllowUISettings) {
+ props.control.close()
+ return
+ }
+
+ setIsSaving(true)
+
+ try {
+ const requests = []
+
+ if (editedPostgate) {
+ requests.push(
+ writePostgateRecord({
+ postUri: props.postUri,
+ postgate: editedPostgate,
+ }),
+ )
+ }
+
+ if (editedAllowUISettings && isThreadgateOwnedByViewer) {
+ requests.push(
+ setThreadgateAllow({
+ postUri: props.rootPostUri,
+ allow: editedAllowUISettings,
+ }),
+ )
+ }
+
+ await Promise.all(requests)
+
+ props.control.close()
+ } catch (e: any) {
+ logger.error(`Failed to save post interaction settings`, {
+ context: 'PostInteractionSettingsDialogControlledInner',
+ safeMessage: e.message,
+ })
+ Toast.show(
+ _(
+ msg`There was an issue. Please check your internet connection and try again.`,
+ ),
+ 'xmark',
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }, [
+ _,
+ props.postUri,
+ props.rootPostUri,
+ props.control,
+ editedPostgate,
+ editedAllowUISettings,
+ setIsSaving,
+ writePostgateRecord,
+ setThreadgateAllow,
+ isThreadgateOwnedByViewer,
+ ])
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export function PostInteractionSettingsForm({
+ onSave,
+ isSaving,
+ postgate,
+ onChangePostgate,
+ threadgateAllowUISettings,
+ onChangeThreadgateAllowUISettings,
+ replySettingsDisabled,
+}: PostInteractionSettingsFormProps) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const control = Dialog.useDialogContext()
+ const {data: lists} = useMyListsQuery('curate')
+ const [quotesEnabled, setQuotesEnabled] = React.useState(
+ !(
+ postgate.embeddingRules &&
+ postgate.embeddingRules.find(
+ v => v.$type === embeddingRules.disableRule.$type,
+ )
+ ),
+ )
+
+ const onPressAudience = (setting: ThreadgateAllowUISetting) => {
+ // remove boolean values
+ let newSelected: ThreadgateAllowUISetting[] =
+ threadgateAllowUISettings.filter(
+ v => v.type !== 'nobody' && v.type !== 'everybody',
+ )
+ // toggle
+ const i = newSelected.findIndex(v => isEqual(v, setting))
+ if (i === -1) {
+ newSelected.push(setting)
+ } else {
+ newSelected.splice(i, 1)
+ }
+
+ onChangeThreadgateAllowUISettings(newSelected)
+ }
+
+ const onChangeQuotesEnabled = React.useCallback(
+ (enabled: boolean) => {
+ setQuotesEnabled(enabled)
+ onChangePostgate(
+ createPostgateRecord({
+ ...postgate,
+ embeddingRules: enabled ? [] : [embeddingRules.disableRule],
+ }),
+ )
+ },
+ [setQuotesEnabled, postgate, onChangePostgate],
+ )
+
+ const noOneCanReply = !!threadgateAllowUISettings.find(
+ v => v.type === 'nobody',
+ )
+
+ return (
+
+
+
+ Post interaction settings
+
+
+
+
+ Customize who can interact with this post.
+
+
+
+
+
+
+ Quote settings
+
+
+
+
+ {quotesEnabled ? (
+ Quote posts enabled
+ ) : (
+ Quote posts disabled
+ )}
+
+
+
+
+
+
+
+ {replySettingsDisabled && (
+
+
+
+
+ Reply settings are chosen by the author of the thread
+
+
+
+ )}
+
+
+
+ Reply settings
+
+
+
+ Allow replies from:
+
+
+
+ v.type === 'everybody')
+ }
+ onPress={() =>
+ onChangeThreadgateAllowUISettings([{type: 'everybody'}])
+ }
+ style={{flex: 1}}
+ disabled={replySettingsDisabled}
+ />
+
+ onChangeThreadgateAllowUISettings([{type: 'nobody'}])
+ }
+ style={{flex: 1}}
+ disabled={replySettingsDisabled}
+ />
+
+
+ {!noOneCanReply && (
+ <>
+
+ Or combine these options:
+
+
+
+ v.type === 'mention',
+ )
+ }
+ onPress={() => onPressAudience({type: 'mention'})}
+ disabled={replySettingsDisabled}
+ />
+ v.type === 'following',
+ )
+ }
+ onPress={() => onPressAudience({type: 'following'})}
+ disabled={replySettingsDisabled}
+ />
+ {lists && lists.length > 0
+ ? lists.map(list => (
+ v.type === 'list' && v.list === list.uri,
+ )
+ }
+ onPress={() =>
+ onPressAudience({type: 'list', list: list.uri})
+ }
+ disabled={replySettingsDisabled}
+ />
+ ))
+ : // No loading states to avoid jumps for the common case (no lists)
+ null}
+
+ >
+ )}
+
+
+
+
+
+
+ )
+}
+
+function Selectable({
+ label,
+ isSelected,
+ onPress,
+ style,
+ disabled,
+}: {
+ label: string
+ isSelected: boolean
+ onPress: () => void
+ style?: StyleProp
+ disabled?: boolean
+}) {
+ const t = useTheme()
+ return (
+
+ )
+}
+
+export function usePrefetchPostInteractionSettings({
+ postUri,
+ rootPostUri,
+}: {
+ postUri: string
+ rootPostUri: string
+}) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return React.useCallback(async () => {
+ try {
+ await Promise.all([
+ queryClient.prefetchQuery({
+ queryKey: createPostgateQueryKey(postUri),
+ queryFn: () => getPostgateRecord({agent, postUri}),
+ staleTime: STALE.SECONDS.THIRTY,
+ }),
+ queryClient.prefetchQuery({
+ queryKey: createThreadgateViewQueryKey(rootPostUri),
+ queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
+ staleTime: STALE.SECONDS.THIRTY,
+ }),
+ ])
+ } catch (e: any) {
+ logger.error(`Failed to prefetch post interaction settings`, {
+ safeMessage: e.message,
+ })
+ }
+ }, [queryClient, agent, postUri, rootPostUri])
+}
diff --git a/src/components/dialogs/ThreadgateEditor.tsx b/src/components/dialogs/ThreadgateEditor.tsx
deleted file mode 100644
index 90483b3adf..0000000000
--- a/src/components/dialogs/ThreadgateEditor.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import isEqual from 'lodash.isequal'
-
-import {useMyListsQuery} from '#/state/queries/my-lists'
-import {ThreadgateSetting} from '#/state/queries/threadgate'
-import {atoms as a, useTheme} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
-import * as Dialog from '#/components/Dialog'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {Text} from '#/components/Typography'
-
-interface ThreadgateEditorDialogProps {
- control: Dialog.DialogControlProps
- threadgate: ThreadgateSetting[]
- onChange?: (v: ThreadgateSetting[]) => void
- onConfirm?: (v: ThreadgateSetting[]) => void
-}
-
-export function ThreadgateEditorDialog({
- control,
- threadgate,
- onChange,
- onConfirm,
-}: ThreadgateEditorDialogProps) {
- return (
-
-
-
-
- )
-}
-
-function DialogContent({
- seedThreadgate,
- onChange,
- onConfirm,
-}: {
- seedThreadgate: ThreadgateSetting[]
- onChange?: (v: ThreadgateSetting[]) => void
- onConfirm?: (v: ThreadgateSetting[]) => void
-}) {
- const {_} = useLingui()
- const control = Dialog.useDialogContext()
- const {data: lists} = useMyListsQuery('curate')
- const [draft, setDraft] = React.useState(seedThreadgate)
-
- const [prevSeedThreadgate, setPrevSeedThreadgate] =
- React.useState(seedThreadgate)
- if (seedThreadgate !== prevSeedThreadgate) {
- // New data flowed from above (e.g. due to update coming through).
- setPrevSeedThreadgate(seedThreadgate)
- setDraft(seedThreadgate) // Reset draft.
- }
-
- function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
- setDraft(nextThreadgate)
- onChange?.(nextThreadgate)
- }
-
- const onPressEverybody = () => {
- updateThreadgate([])
- }
-
- const onPressNobody = () => {
- updateThreadgate([{type: 'nobody'}])
- }
-
- const onPressAudience = (setting: ThreadgateSetting) => {
- // remove nobody
- let newSelected: ThreadgateSetting[] = draft.filter(
- v => v.type !== 'nobody',
- )
- // toggle
- const i = newSelected.findIndex(v => isEqual(v, setting))
- if (i === -1) {
- newSelected.push(setting)
- } else {
- newSelected.splice(i, 1)
- }
- updateThreadgate(newSelected)
- }
-
- const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
- return (
-
-
-
- Choose who can reply
-
-
- Either choose "Everybody" or "Nobody"
-
-
-
- v.type === 'nobody')}
- onPress={onPressNobody}
- style={{flex: 1}}
- />
-
-
- Or combine these options:
-
-
- v.type === 'mention')}
- onPress={() => onPressAudience({type: 'mention'})}
- />
- v.type === 'following')}
- onPress={() => onPressAudience({type: 'following'})}
- />
- {lists && lists.length > 0
- ? lists.map(list => (
- v.type === 'list' && v.list === list.uri)
- }
- onPress={() =>
- onPressAudience({type: 'list', list: list.uri})
- }
- />
- ))
- : // No loading states to avoid jumps for the common case (no lists)
- null}
-
-
-
-
-
- )
-}
-
-function Selectable({
- label,
- isSelected,
- onPress,
- style,
-}: {
- label: string
- isSelected: boolean
- onPress: () => void
- style?: StyleProp
-}) {
- const t = useTheme()
- return (
-
- )
-}
diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx
index 573c24f77d..c5c472cf08 100644
--- a/src/components/dms/MessageItem.tsx
+++ b/src/components/dms/MessageItem.tsx
@@ -11,6 +11,7 @@ import {
ChatBskyConvoDefs,
RichText as RichTextAPI,
} from '@atproto/api'
+import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -153,14 +154,14 @@ let MessageItemMetadata = ({
)
const relativeTimestamp = useCallback(
- (timestamp: string) => {
+ (i18n: I18n, timestamp: string) => {
const date = new Date(timestamp)
const now = new Date()
- const time = new Intl.DateTimeFormat(undefined, {
+ const time = i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
- }).format(date)
+ })
const diff = now.getTime() - date.getTime()
@@ -182,13 +183,13 @@ let MessageItemMetadata = ({
return _(msg`Yesterday, ${time}`)
}
- return new Intl.DateTimeFormat(undefined, {
+ return i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
day: 'numeric',
month: 'numeric',
year: 'numeric',
- }).format(date)
+ })
},
[_],
)
diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx
index aefd62b9ac..3db00aece6 100644
--- a/src/components/dms/MessageItemEmbed.tsx
+++ b/src/components/dms/MessageItemEmbed.tsx
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {AppBskyEmbedRecord} from '@atproto/api'
-import {PostEmbeds} from '#/view/com/util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
let MessageItemEmbed = ({
@@ -14,7 +14,11 @@ let MessageItemEmbed = ({
return (
-
+
)
}
diff --git a/src/components/forms/DateField/index.shared.tsx b/src/components/forms/DateField/index.shared.tsx
index 1f54bdc8be..814bbed7cc 100644
--- a/src/components/forms/DateField/index.shared.tsx
+++ b/src/components/forms/DateField/index.shared.tsx
@@ -1,12 +1,12 @@
import React from 'react'
import {Pressable, View} from 'react-native'
+import {useLingui} from '@lingui/react'
import {android, atoms as a, useTheme, web} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {Text} from '#/components/Typography'
-import {localizeDate} from './utils'
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
// iOS: open a dialog with an inline date picker
@@ -25,6 +25,7 @@ export function DateFieldButton({
isInvalid?: boolean
accessibilityHint?: string
}) {
+ const {i18n} = useLingui()
const t = useTheme()
const {
@@ -91,7 +92,7 @@ export function DateFieldButton({
t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875},
]}>
- {localizeDate(value)}
+ {i18n.date(value, {timeZone: 'UTC'})}
diff --git a/src/components/forms/DateField/utils.ts b/src/components/forms/DateField/utils.ts
index c787272fe8..04bb482ce1 100644
--- a/src/components/forms/DateField/utils.ts
+++ b/src/components/forms/DateField/utils.ts
@@ -1,16 +1,5 @@
-import {getLocales} from 'expo-localization'
-
-const LOCALE = getLocales()[0]
-
// we need the date in the form yyyy-MM-dd to pass to the input
export function toSimpleDateString(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return _date.toISOString().split('T')[0]
}
-
-export function localizeDate(date: Date | string): string {
- const _date = typeof date === 'string' ? new Date(date) : date
- return new Intl.DateTimeFormat(LOCALE.languageTag, {
- timeZone: 'UTC',
- }).format(_date)
-}
diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts
index b0f94133b7..00b70f762e 100644
--- a/src/components/hooks/dates.ts
+++ b/src/components/hooks/dates.ts
@@ -21,6 +21,7 @@ import {
ja,
ko,
ptBR,
+ ru,
tr,
uk,
zhCN,
@@ -47,6 +48,7 @@ const locales: Record = {
ja,
ko,
['pt-BR']: ptBR,
+ ru,
tr,
uk,
['zh-CN']: zhCN,
diff --git a/src/components/hooks/useFollowMethods.ts b/src/components/hooks/useFollowMethods.ts
index d67c3690f9..31a1e43daa 100644
--- a/src/components/hooks/useFollowMethods.ts
+++ b/src/components/hooks/useFollowMethods.ts
@@ -15,8 +15,8 @@ export function useFollowMethods({
logContext,
}: {
profile: Shadow
- logContext: LogEvents['profile:follow']['logContext'] &
- LogEvents['profile:unfollow']['logContext']
+ logContext: LogEvents['profile:follow:sampled']['logContext'] &
+ LogEvents['profile:unfollow:sampled']['logContext']
}) {
const {_} = useLingui()
const requireAuth = useRequireAuth()
diff --git a/src/components/hooks/useInteractionState.ts b/src/components/hooks/useInteractionState.ts
index 653b1c10e6..67042d4a8c 100644
--- a/src/components/hooks/useInteractionState.ts
+++ b/src/components/hooks/useInteractionState.ts
@@ -5,10 +5,10 @@ export function useInteractionState() {
const onIn = React.useCallback(() => {
setState(true)
- }, [setState])
+ }, [])
const onOut = React.useCallback(() => {
setState(false)
- }, [setState])
+ }, [])
return React.useMemo(
() => ({
diff --git a/src/components/hooks/useThrottledValue.ts b/src/components/hooks/useThrottledValue.ts
index 5764c547e4..29a11b9358 100644
--- a/src/components/hooks/useThrottledValue.ts
+++ b/src/components/hooks/useThrottledValue.ts
@@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from 'react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-export function useThrottledValue(value: T, time?: number) {
+export function useThrottledValue(value: T, time: number) {
const pendingValueRef = useRef(value)
const [throttledValue, setThrottledValue] = useState(value)
diff --git a/src/components/icons/Crop.tsx b/src/components/icons/Crop.tsx
new file mode 100644
index 0000000000..4b3fc560f9
--- /dev/null
+++ b/src/components/icons/Crop.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
+})
diff --git a/src/components/icons/Eye.tsx b/src/components/icons/Eye.tsx
new file mode 100644
index 0000000000..afa772e1d7
--- /dev/null
+++ b/src/components/icons/Eye.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
+})
diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx
index 387115d3aa..e83f96f0b1 100644
--- a/src/components/icons/common.tsx
+++ b/src/components/icons/common.tsx
@@ -19,6 +19,7 @@ export const sizes = {
md: 20,
lg: 24,
xl: 28,
+ '2xl': 32,
}
export function useCommonSVGProps(props: Props) {
diff --git a/src/components/intents/IntentDialogs.tsx b/src/components/intents/IntentDialogs.tsx
new file mode 100644
index 0000000000..244850370d
--- /dev/null
+++ b/src/components/intents/IntentDialogs.tsx
@@ -0,0 +1,37 @@
+import React from 'react'
+
+import * as Dialog from '#/components/Dialog'
+import {DialogControlProps} from '#/components/Dialog'
+import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
+
+interface Context {
+ verifyEmailDialogControl: DialogControlProps
+ verifyEmailState: {code: string} | undefined
+ setVerifyEmailState: (state: {code: string} | undefined) => void
+}
+
+const Context = React.createContext({} as Context)
+export const useIntentDialogs = () => React.useContext(Context)
+
+export function Provider({children}: {children: React.ReactNode}) {
+ const verifyEmailDialogControl = Dialog.useDialogControl()
+ const [verifyEmailState, setVerifyEmailState] = React.useState<
+ {code: string} | undefined
+ >()
+
+ const value = React.useMemo(
+ () => ({
+ verifyEmailDialogControl,
+ verifyEmailState,
+ setVerifyEmailState,
+ }),
+ [verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
+ )
+
+ return (
+
+ {children}
+
+
+ )
+}
diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx
new file mode 100644
index 0000000000..4dca8bd904
--- /dev/null
+++ b/src/components/intents/VerifyEmailIntentDialog.tsx
@@ -0,0 +1,140 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAgent, useSession} from 'state/session'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {DialogControlProps} from '#/components/Dialog'
+import {useIntentDialogs} from '#/components/intents/IntentDialogs'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export function VerifyEmailIntentDialog() {
+ const {verifyEmailDialogControl: control} = useIntentDialogs()
+
+ return (
+
+
+
+
+ )
+}
+
+function Inner({control}: {control: DialogControlProps}) {
+ const {_} = useLingui()
+ const {verifyEmailState: state} = useIntentDialogs()
+ const [status, setStatus] = React.useState<
+ 'loading' | 'success' | 'failure' | 'resent'
+ >('loading')
+ const [sending, setSending] = React.useState(false)
+ const agent = useAgent()
+ const {currentAccount} = useSession()
+
+ React.useEffect(() => {
+ ;(async () => {
+ if (!state?.code) {
+ return
+ }
+ try {
+ await agent.com.atproto.server.confirmEmail({
+ email: (currentAccount?.email || '').trim(),
+ token: state.code.trim(),
+ })
+ setStatus('success')
+ } catch (e) {
+ setStatus('failure')
+ }
+ })()
+ }, [agent.com.atproto.server, currentAccount?.email, state?.code])
+
+ const onPressResendEmail = async () => {
+ setSending(true)
+ await agent.com.atproto.server.requestEmailConfirmation()
+ setSending(false)
+ setStatus('resent')
+ }
+
+ return (
+
+
+
+ {status === 'loading' ? (
+
+
+
+ ) : status === 'success' ? (
+ <>
+
+ Email Verified
+
+
+
+ Thanks, you have successfully verified your email address.
+
+
+ >
+ ) : status === 'failure' ? (
+ <>
+
+ Invalid Verification Code
+
+
+
+ The verification code you have provided is invalid. Please make
+ sure that you have used the correct verification link or request
+ a new one.
+
+
+ >
+ ) : (
+ <>
+
+ Email Resent
+
+
+
+ We have sent another verification email to{' '}
+
+ {currentAccount?.email}
+
+ .
+
+
+ >
+ )}
+ {status !== 'loading' ? (
+
+
+ {status === 'failure' ? (
+
+ ) : null}
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/moderation/Hider.tsx b/src/components/moderation/Hider.tsx
new file mode 100644
index 0000000000..39f28b4034
--- /dev/null
+++ b/src/components/moderation/Hider.tsx
@@ -0,0 +1,89 @@
+import React from 'react'
+import {ModerationUI} from '@atproto/api'
+
+import {
+ ModerationCauseDescription,
+ useModerationCauseDescription,
+} from '#/lib/moderation/useModerationCauseDescription'
+import {
+ ModerationDetailsDialog,
+ useModerationDetailsDialogControl,
+} from '#/components/moderation/ModerationDetailsDialog'
+
+type Context = {
+ isContentVisible: boolean
+ setIsContentVisible: (show: boolean) => void
+ info: ModerationCauseDescription
+ showInfoDialog: () => void
+ meta: {
+ isNoPwi: boolean
+ allowOverride: boolean
+ }
+}
+
+const Context = React.createContext({} as Context)
+
+export const useHider = () => React.useContext(Context)
+
+export function Outer({
+ modui,
+ isContentVisibleInitialState,
+ allowOverride,
+ children,
+}: React.PropsWithChildren<{
+ isContentVisibleInitialState?: boolean
+ allowOverride?: boolean
+ modui: ModerationUI | undefined
+}>) {
+ const control = useModerationDetailsDialogControl()
+ const blur = modui?.blurs[0]
+ const [isContentVisible, setIsContentVisible] = React.useState(
+ isContentVisibleInitialState || !blur,
+ )
+ const info = useModerationCauseDescription(blur)
+
+ const meta = {
+ isNoPwi: Boolean(
+ modui?.blurs.find(
+ cause =>
+ cause.type === 'label' &&
+ cause.labelDef.identifier === '!no-unauthenticated',
+ ),
+ ),
+ allowOverride: allowOverride ?? !modui?.noOverride,
+ }
+
+ const showInfoDialog = () => {
+ control.open()
+ }
+
+ const onSetContentVisible = (show: boolean) => {
+ if (!meta.allowOverride) return
+ setIsContentVisible(show)
+ }
+
+ const ctx = {
+ isContentVisible,
+ setIsContentVisible: onSetContentVisible,
+ showInfoDialog,
+ info,
+ meta,
+ }
+
+ return (
+
+ {children}
+
+
+ )
+}
+
+export function Content({children}: {children: React.ReactNode}) {
+ const ctx = useHider()
+ return ctx.isContentVisible ? children : null
+}
+
+export function Mask({children}: {children: React.ReactNode}) {
+ const ctx = useHider()
+ return ctx.isContentVisible ? null : children
+}
diff --git a/src/components/moderation/LabelsOnMe.tsx b/src/components/moderation/LabelsOnMe.tsx
index 95b5853cf8..33ede3ed23 100644
--- a/src/components/moderation/LabelsOnMe.tsx
+++ b/src/components/moderation/LabelsOnMe.tsx
@@ -14,19 +14,18 @@ import {
} from '#/components/moderation/LabelsOnMeDialog'
export function LabelsOnMe({
- details,
+ type,
labels,
size,
style,
}: {
- details: {did: string} | {uri: string; cid: string}
+ type: 'account' | 'content'
labels: ComAtprotoLabelDefs.Label[] | undefined
size?: ButtonSize
style?: StyleProp
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
- const isAccount = 'did' in details
const control = useLabelsOnMeDialogControl()
if (!labels || !currentAccount) {
@@ -39,7 +38,7 @@ export function LabelsOnMe({
return (
-
+
)}
- {images && images?.length > 0 && (
-
- )}
+
)
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index c0e78e9789..3034f02905 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -29,6 +29,10 @@ import {useAgent} from '#/state/session'
import {clamp} from 'lib/numbers'
import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection'
+import {
+ EmojiPicker,
+ EmojiPickerState,
+} from '#/view/com/composer/text-input/web/EmojiPicker.web'
import {List} from 'view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
@@ -97,6 +101,12 @@ export function MessagesList({
startContentOffset: 0,
})
+ const [emojiPickerState, setEmojiPickerState] =
+ React.useState({
+ isOpen: false,
+ pos: {top: 0, left: 0, right: 0, bottom: 0},
+ })
+
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
// the bottom.
@@ -422,13 +432,22 @@ export function MessagesList({
+ setEmbed={setEmbed}
+ openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
>
)}
+ {isWeb && (
+ setEmojiPickerState(prev => ({...prev, isOpen: false}))}
+ />
+ )}
+
{newMessagesPill.show && }
>
)
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx
index 2fd9990c7b..e782395808 100644
--- a/src/screens/Messages/List/index.tsx
+++ b/src/screens/Messages/List/index.tsx
@@ -96,7 +96,7 @@ export function MessagesScreen({navigation, route}: Props) {
)
}, [_, t])
- const initialNumToRender = useInitialNumToRender(80)
+ const initialNumToRender = useInitialNumToRender({minItemHeight: 80})
const [isPTRing, setIsPTRing] = useState(false)
const {
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index 825a0e723d..bc765781af 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -23,6 +23,7 @@ import {useProgressGuideControls} from '#/state/shell/progress-guide'
import {uploadBlob} from 'lib/api'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
+import {getAllListMembers} from 'state/queries/list-members'
import {
useActiveStarterPack,
useSetActiveStarterPack,
@@ -43,7 +44,6 @@ import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
-import {TOURS, useSetQueuedTour} from '#/tours'
export function StepFinished() {
const {_} = useLingui()
@@ -58,7 +58,6 @@ export function StepFinished() {
const activeStarterPack = useActiveStarterPack()
const setActiveStarterPack = useSetActiveStarterPack()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
- const setQueuedTour = useSetQueuedTour()
const {startProgressGuide} = useProgressGuideControls()
const finishOnboarding = React.useCallback(async () => {
@@ -73,18 +72,20 @@ export function StepFinished() {
starterPack: activeStarterPack.uri,
})
starterPack = spRes.data.starterPack
-
- if (starterPack.list) {
- const listRes = await agent.app.bsky.graph.getList({
- list: starterPack.list.uri,
- limit: 50,
- })
- listItems = listRes.data.items
- }
} catch (e) {
logger.error('Failed to fetch starter pack', {safeMessage: e})
// don't tell the user, just get them through onboarding.
}
+ try {
+ if (starterPack?.list) {
+ listItems = await getAllListMembers(agent, starterPack.list.uri)
+ }
+ } catch (e) {
+ logger.error('Failed to fetch starter pack list items', {
+ safeMessage: e,
+ })
+ // don't tell the user, just get them through onboarding.
+ }
}
try {
@@ -186,7 +187,6 @@ export function StepFinished() {
setSaving(false)
setActiveStarterPack(undefined)
setHasCheckedForStarterPack(true)
- setQueuedTour(TOURS.HOME)
startProgressGuide('like-10-and-follow-7')
dispatch({type: 'finish'})
onboardDispatch({type: 'finish'})
@@ -220,7 +220,6 @@ export function StepFinished() {
requestNotificationsPermission,
setActiveStarterPack,
setHasCheckedForStarterPack,
- setQueuedTour,
startProgressGuide,
])
diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx
index ca29b5db9b..ded473ff59 100644
--- a/src/screens/Onboarding/StepInterests/index.tsx
+++ b/src/screens/Onboarding/StepInterests/index.tsx
@@ -6,10 +6,8 @@ import {useQuery} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
-import {useGate} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
@@ -29,23 +27,16 @@ import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
-import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
-const PROMPT_HEIGHT = isWeb ? 42 : 36
-// matches the padding of the OnboardingControls.Portal
-const PROMPT_OFFSET = isWeb ? a.pb_2xl.paddingBottom : a.pb_lg.paddingBottom
-const MIN_INTERESTS = 3
-
export function StepInterests() {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
- const gate = useGate()
const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = React.useContext(Context)
@@ -143,11 +134,6 @@ export function StepInterests() {
track('OnboardingV2:StepInterests:Start')
}, [track])
- const isMinimumInterestsEnabled = gate('onboarding_minimum_interests')
- const meetsMinimumRequirement = isMinimumInterestsEnabled
- ? interests.length >= MIN_INTERESTS
- : true
-
const title = isError ? (
Oh no! Something went wrong.
) : (
@@ -185,13 +171,8 @@ export function StepInterests() {
{title}
{description}
- {isMinimumInterestsEnabled && (
-
- Choose 3 or more:
-
- )}
-
+
{isLoading ? (
) : isError || !data ? (
@@ -267,7 +248,7 @@ export function StepInterests() {
) : (
)}
-
- {!meetsMinimumRequirement && (
-
-
-
-
-
- Choose at least {MIN_INTERESTS - interests.length} more
-
-
-
-
- )}
)
diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts
index b9ecc4b987..14750f34c7 100644
--- a/src/screens/Onboarding/util.ts
+++ b/src/screens/Onboarding/util.ts
@@ -4,6 +4,7 @@ import {
BskyAgent,
} from '@atproto/api'
import {TID} from '@atproto/common-web'
+import chunk from 'lodash.chunk'
import {until} from '#/lib/async/until'
@@ -29,10 +30,13 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
value: r,
}))
- await agent.com.atproto.repo.applyWrites({
- repo: session.did,
- writes: followWrites,
- })
+ const chunks = chunk(followWrites, 50)
+ for (const chunk of chunks) {
+ await agent.com.atproto.repo.applyWrites({
+ repo: session.did,
+ writes: chunk,
+ })
+ }
await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length)
const followUris = new Map()
diff --git a/src/view/screens/PostLikedBy.tsx b/src/screens/Post/PostLikedBy.tsx
similarity index 51%
rename from src/view/screens/PostLikedBy.tsx
rename to src/screens/Post/PostLikedBy.tsx
index 5ff5a1932e..ea522488cc 100644
--- a/src/view/screens/PostLikedBy.tsx
+++ b/src/screens/Post/PostLikedBy.tsx
@@ -1,14 +1,17 @@
import React from 'react'
-import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {makeRecordUri} from '#/lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell'
-import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
-import {makeRecordUri} from 'lib/strings/url-helpers'
-import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
-import {ViewHeader} from '../com/util/ViewHeader'
+import {isWeb} from 'platform/detection'
+import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {atoms as a} from '#/alf'
+import {ListHeaderDesktop} from '#/components/Lists'
type Props = NativeStackScreenProps
export const PostLikedByScreen = ({route}: Props) => {
@@ -24,9 +27,10 @@ export const PostLikedByScreen = ({route}: Props) => {
)
return (
-
-
+
+
+
-
+
)
}
diff --git a/src/screens/Post/PostQuotes.tsx b/src/screens/Post/PostQuotes.tsx
new file mode 100644
index 0000000000..0d59418f15
--- /dev/null
+++ b/src/screens/Post/PostQuotes.tsx
@@ -0,0 +1,36 @@
+import React from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {makeRecordUri} from '#/lib/strings/url-helpers'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {isWeb} from 'platform/detection'
+import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {atoms as a} from '#/alf'
+import {ListHeaderDesktop} from '#/components/Lists'
+
+type Props = NativeStackScreenProps
+export const PostQuotesScreen = ({route}: Props) => {
+ const setMinimalShellMode = useSetMinimalShellMode()
+ const {name, rkey} = route.params
+ const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
+ const {_} = useLingui()
+
+ useFocusEffect(
+ React.useCallback(() => {
+ setMinimalShellMode(false)
+ }, [setMinimalShellMode]),
+ )
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/view/screens/PostRepostedBy.tsx b/src/screens/Post/PostRepostedBy.tsx
similarity index 51%
rename from src/view/screens/PostRepostedBy.tsx
rename to src/screens/Post/PostRepostedBy.tsx
index eaacc67807..f8c058ff79 100644
--- a/src/view/screens/PostRepostedBy.tsx
+++ b/src/screens/Post/PostRepostedBy.tsx
@@ -1,14 +1,17 @@
import React from 'react'
-import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {makeRecordUri} from '#/lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell'
-import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
-import {makeRecordUri} from 'lib/strings/url-helpers'
-import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
-import {ViewHeader} from '../com/util/ViewHeader'
+import {isWeb} from 'platform/detection'
+import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {atoms as a} from '#/alf'
+import {ListHeaderDesktop} from '#/components/Lists'
type Props = NativeStackScreenProps
export const PostRepostedByScreen = ({route}: Props) => {
@@ -24,9 +27,10 @@ export const PostRepostedByScreen = ({route}: Props) => {
)
return (
-
-
+
+
+
-
+
)
}
diff --git a/src/screens/Profile/Header/Metrics.tsx b/src/screens/Profile/Header/Metrics.tsx
index e3537f44be..756eb1f89e 100644
--- a/src/screens/Profile/Header/Metrics.tsx
+++ b/src/screens/Profile/Header/Metrics.tsx
@@ -17,9 +17,9 @@ export function ProfileHeaderMetrics({
profile: Shadow
}) {
const t = useTheme()
- const {_} = useLingui()
- const following = formatCount(profile.followsCount || 0)
- const followers = formatCount(profile.followersCount || 0)
+ const {_, i18n} = useLingui()
+ const following = formatCount(i18n, profile.followsCount || 0)
+ const followers = formatCount(i18n, profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower',
other: 'followers',
@@ -54,7 +54,7 @@ export function ProfileHeaderMetrics({
- {formatCount(profile.postsCount || 0)}{' '}
+ {formatCount(i18n, profile.postsCount || 0)}{' '}
{plural(profile.postsCount || 0, {one: 'post', other: 'posts'})}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 2b6353b276..cf5fcb97e3 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -6,7 +6,6 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -22,10 +21,9 @@ import {useRequireAuth, useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useProfileShadow} from 'state/cache/profile-shadow'
-import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
@@ -58,7 +56,6 @@ let ProfileHeaderStandard = ({
}: Props): React.ReactNode => {
const profile: Shadow =
useProfileShadow(profileUnshadowed)
- const t = useTheme()
const {currentAccount, hasSession} = useSession()
const {_} = useLingui()
const {openModal} = useModalControls()
@@ -67,7 +64,6 @@ let ProfileHeaderStandard = ({
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
- const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileHeader',
@@ -200,32 +196,7 @@ let ProfileHeaderStandard = ({
)
) : !profile.viewer?.blockedBy ? (
<>
- {hasSession && (
- <>
-
-
- setShowSuggestedFollows(!showSuggestedFollows)
- }
- label={_(msg`Show follows similar to ${profile.handle}`)}
- style={{width: 36, height: 36}}>
-
-
- >
- )}
+ {hasSession && }
)}
- {showSuggestedFollows && (
- {
- if (showSuggestedFollows) {
- setShowSuggestedFollows(false)
- } else {
- track('ProfileHeader:SuggestedFollowsOpened')
- setShowSuggestedFollows(true)
- }
- }}
- />
- )}
{isMe ? (
-
+
) : (
)}
diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx
index e7ceaab0ca..fc4eff02c8 100644
--- a/src/screens/Profile/Sections/Feed.tsx
+++ b/src/screens/Profile/Sections/Feed.tsx
@@ -8,6 +8,7 @@ import {isNative} from '#/platform/detection'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from '#/view/com/util/text/Text'
import {Feed} from 'view/com/posts/Feed'
@@ -42,6 +43,10 @@ export const ProfileFeedSection = React.forwardRef<
const queryClient = useQueryClient()
const [hasNew, setHasNew] = React.useState(false)
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
+ const shouldUseAdjustedNumToRender = feed.endsWith('posts_and_author_threads')
+ const adjustedInitialNumToRender = useInitialNumToRender({
+ screenHeightOffset: headerHeight,
+ })
const onScrollToTop = React.useCallback(() => {
scrollElRef.current?.scrollToOffset({
@@ -79,7 +84,9 @@ export const ProfileFeedSection = React.forwardRef<
headerOffset={headerHeight}
renderEndOfFeed={ProfileEndOfFeed}
ignoreFilterFor={ignoreFilterFor}
- outsideHeaderOffset={headerHeight}
+ initialNumToRender={
+ shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
+ }
/>
{(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/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx
index bf35764908..9050ca0610 100644
--- a/src/screens/Signup/StepCaptcha/index.tsx
+++ b/src/screens/Signup/StepCaptcha/index.tsx
@@ -8,7 +8,7 @@ import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {logEvent} from 'lib/statsig/statsig'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
-import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
+import {useSignupContext} from '#/screens/Signup/state'
import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
import {atoms as a, useTheme} from '#/alf'
import {FormError} from '#/components/forms/FormError'
@@ -20,7 +20,6 @@ export function StepCaptcha() {
const {_} = useLingui()
const theme = useTheme()
const {state, dispatch} = useSignupContext()
- const submit = useSubmitSignup({state, dispatch})
const [completed, setCompleted] = React.useState(false)
@@ -42,9 +41,12 @@ export function StepCaptcha() {
(code: string) => {
setCompleted(true)
logEvent('signup:captchaSuccess', {})
- submit(code)
+ dispatch({
+ type: 'submit',
+ task: {verificationCode: code, mutableProcessed: false},
+ })
},
- [submit],
+ [dispatch],
)
const onError = React.useCallback(
diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx
index b443e822a4..0ff0506f4e 100644
--- a/src/screens/Signup/StepHandle.tsx
+++ b/src/screens/Signup/StepHandle.tsx
@@ -7,9 +7,10 @@ import {logEvent} from '#/lib/statsig/statsig'
import {createFullHandle, validateHandle} from '#/lib/strings/handles'
import {useAgent} from '#/state/session'
import {ScreenTransition} from '#/screens/Login/ScreenTransition'
-import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
+import {useSignupContext} from '#/screens/Signup/state'
import {atoms as a, useTheme} from '#/alf'
import * as TextField from '#/components/forms/TextField'
+import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
@@ -20,10 +21,10 @@ export function StepHandle() {
const {_} = useLingui()
const t = useTheme()
const {state, dispatch} = useSignupContext()
- const submit = useSubmitSignup({state, dispatch})
const agent = useAgent()
const handleValueRef = useRef(state.handle)
const [draftValue, setDraftValue] = React.useState(state.handle)
+ const isLoading = useThrottledValue(state.isLoading, 500)
const onNextPress = React.useCallback(async () => {
const handle = handleValueRef.current.trim()
@@ -64,7 +65,10 @@ export function StepHandle() {
})
// phoneVerificationRequired is actually whether a captcha is required
if (!state.serviceDescription?.phoneVerificationRequired) {
- submit()
+ dispatch({
+ type: 'submit',
+ task: {verificationCode: undefined, mutableProcessed: false},
+ })
return
}
dispatch({type: 'next'})
@@ -74,7 +78,6 @@ export function StepHandle() {
state.activeStep,
state.serviceDescription?.phoneVerificationRequired,
state.userDomain,
- submit,
agent,
])
@@ -175,7 +178,7 @@ export function StepHandle() {
)}
void}) {
const {screen} = useAnalytics()
const [state, dispatch] = React.useReducer(reducer, initialState)
const {gtMobile} = useBreakpoints()
+ const submit = useSubmitSignup()
const activeStarterPack = useActiveStarterPack()
const {
@@ -81,6 +83,15 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
}
}, [_, serviceInfo, isError])
+ React.useEffect(() => {
+ if (state.pendingSubmit) {
+ if (!state.pendingSubmit.mutableProcessed) {
+ state.pendingSubmit.mutableProcessed = true
+ submit(state, dispatch)
+ }
+ }
+ }, [state, dispatch, submit])
+
return (
({} as IContext)
export const useSignupContext = () => React.useContext(SignupContext)
-export function useSubmitSignup({
- state,
- dispatch,
-}: {
- state: SignupState
- dispatch: (action: SignupAction) => void
-}) {
+export function useSubmitSignup() {
const {_} = useLingui()
const {createAccount} = useSessionApi()
const onboardingDispatch = useOnboardingDispatch()
return useCallback(
- async (verificationCode?: string) => {
+ async (state: SignupState, dispatch: (action: SignupAction) => void) => {
if (!state.email) {
dispatch({type: 'setStep', value: SignupStep.INFO})
return dispatch({
@@ -212,7 +219,7 @@ export function useSubmitSignup({
}
if (
state.serviceDescription?.phoneVerificationRequired &&
- !verificationCode
+ !state.pendingSubmit?.verificationCode
) {
dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
logger.error('Signup Flow Error', {
@@ -235,7 +242,7 @@ export function useSubmitSignup({
password: state.password,
birthDate: state.dateOfBirth,
inviteCode: state.inviteCode.trim(),
- verificationCode: verificationCode,
+ verificationCode: state.pendingSubmit?.verificationCode,
})
/*
* Must happen last so that if the user has multiple tabs open and
@@ -270,19 +277,6 @@ export function useSubmitSignup({
dispatch({type: 'setIsLoading', value: false})
}
},
- [
- state.email,
- state.password,
- state.handle,
- state.serviceDescription?.phoneVerificationRequired,
- state.serviceUrl,
- state.userDomain,
- state.inviteCode,
- state.dateOfBirth,
- dispatch,
- _,
- onboardingDispatch,
- createAccount,
- ],
+ [_, onboardingDispatch, createAccount],
)
}
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/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx
index 643a722119..5f1d5e0628 100644
--- a/src/screens/StarterPack/StarterPackLandingScreen.tsx
+++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx
@@ -31,10 +31,12 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import * as FeedCard from '#/components/FeedCard'
+import {useRichText} from '#/components/hooks/useRichText'
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
+import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
@@ -82,9 +84,15 @@ export function LandingScreen({
return
}
+ // Just for types, this cannot be hit
+ if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
+ return null
+ }
+
return (
@@ -93,22 +101,25 @@ export function LandingScreen({
function LandingScreenLoaded({
starterPack,
+ starterPackRecord: record,
setScreenState,
// TODO apply this to profile card
moderationOpts,
}: {
starterPack: AppBskyGraphDefs.StarterPackView
+ starterPackRecord: AppBskyGraphStarterpack.Record
setScreenState: (state: LoggedOutScreenState) => void
moderationOpts: ModerationOpts
}) {
- const {record, creator, listItemsSample, feeds} = starterPack
- const {_} = useLingui()
+ const {creator, listItemsSample, feeds} = starterPack
+ const {_, i18n} = useLingui()
const t = useTheme()
const activeStarterPack = useActiveStarterPack()
const setActiveStarterPack = useSetActiveStarterPack()
const {isTabletOrDesktop} = useWebMediaQueries()
const androidDialogControl = useDialogControl()
+ const [descriptionRt] = useRichText(record.description || '')
const [appClipOverlayVisible, setAppClipOverlayVisible] =
React.useState(false)
@@ -147,10 +158,6 @@ function LandingScreenLoaded({
}
}
- if (!AppBskyGraphStarterpack.isRecord(record)) {
- return null
- }
-
return (
{record.description ? (
-
- {record.description}
-
+
) : null}
- {formatCount(JOINED_THIS_WEEK)} joined this week
+
+ {formatCount(i18n, JOINED_THIS_WEEK)} joined this week
+
diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx
index 595e185274..5b267ff272 100644
--- a/src/screens/StarterPack/StarterPackScreen.tsx
+++ b/src/screens/StarterPack/StarterPackScreen.tsx
@@ -32,6 +32,7 @@ import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {isWeb} from 'platform/detection'
import {updateProfileShadow} from 'state/cache/profile-shadow'
import {useModerationOpts} from 'state/preferences/moderation-opts'
+import {getAllListMembers} from 'state/queries/list-members'
import {useResolvedStarterPackShortLink} from 'state/queries/resolve-short-link'
import {useResolveDidQuery} from 'state/queries/resolve-uri'
import {useShortenLink} from 'state/queries/shorten-link'
@@ -327,42 +328,52 @@ function Header({
setIsProcessing(true)
+ let listItems: AppBskyGraphDefs.ListItemView[] = []
try {
- const list = await agent.app.bsky.graph.getList({
- list: starterPack.list.uri,
- })
- const dids = list.data.items
- .filter(
- li =>
- li.subject.did !== currentAccount?.did &&
- !isBlockedOrBlocking(li.subject) &&
- !isMuted(li.subject) &&
- !li.subject.viewer?.following,
- )
- .map(li => li.subject.did)
-
- const followUris = await bulkWriteFollows(agent, dids)
-
- batchedUpdates(() => {
- for (let did of dids) {
- updateProfileShadow(queryClient, did, {
- followingUri: followUris.get(did),
- })
- }
- })
-
- logEvent('starterPack:followAll', {
- logContext: 'StarterPackProfilesList',
- starterPack: starterPack.uri,
- count: dids.length,
- })
- captureAction(ProgressGuideAction.Follow, dids.length)
- Toast.show(_(msg`All accounts have been followed!`))
+ listItems = await getAllListMembers(agent, starterPack.list.uri)
} catch (e) {
- Toast.show(_(msg`An error occurred while trying to follow all`), 'xmark')
- } finally {
setIsProcessing(false)
+ Toast.show(_(msg`An error occurred while trying to follow all`), 'xmark')
+ logger.error('Failed to get list members for starter pack', {
+ safeMessage: e,
+ })
+ return
}
+
+ const dids = listItems
+ .filter(
+ li =>
+ li.subject.did !== currentAccount?.did &&
+ !isBlockedOrBlocking(li.subject) &&
+ !isMuted(li.subject) &&
+ !li.subject.viewer?.following,
+ )
+ .map(li => li.subject.did)
+
+ let followUris: Map
+ try {
+ followUris = await bulkWriteFollows(agent, dids)
+ } catch (e) {
+ setIsProcessing(false)
+ Toast.show(_(msg`An error occurred while trying to follow all`), 'xmark')
+ logger.error('Failed to follow all accounts', {safeMessage: e})
+ }
+
+ setIsProcessing(false)
+ batchedUpdates(() => {
+ for (let did of dids) {
+ updateProfileShadow(queryClient, did, {
+ followingUri: followUris.get(did),
+ })
+ }
+ })
+ Toast.show(_(msg`All accounts have been followed!`))
+ captureAction(ProgressGuideAction.Follow, dids.length)
+ logEvent('starterPack:followAll', {
+ logContext: 'StarterPackProfilesList',
+ starterPack: starterPack.uri,
+ count: dids.length,
+ })
}
if (!AppBskyGraphStarterpack.isRecord(record)) {
diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx
index ba5bb147c8..debb7e23cc 100644
--- a/src/screens/StarterPack/Wizard/State.tsx
+++ b/src/screens/StarterPack/Wizard/State.tsx
@@ -7,6 +7,7 @@ import {
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg} from '@lingui/macro'
+import {STARTER_PACK_MAX_SIZE} from 'lib/constants'
import {useSession} from 'state/session'
import * as Toast from '#/view/com/util/Toast'
@@ -73,9 +74,10 @@ function reducer(state: State, action: Action): State {
updatedState = {...state, description: action.description}
break
case 'AddProfile':
- if (state.profiles.length >= 51) {
+ if (state.profiles.length > STARTER_PACK_MAX_SIZE) {
Toast.show(
- msg`You may only add up to 50 profiles`.message ?? '',
+ msg`You may only add up to ${STARTER_PACK_MAX_SIZE} profiles`
+ .message ?? '',
'info',
)
} else {
@@ -91,8 +93,8 @@ function reducer(state: State, action: Action): State {
}
break
case 'AddFeed':
- if (state.feeds.length >= 50) {
- Toast.show(msg`You may only add up to 50 feeds`.message ?? '', 'info')
+ if (state.feeds.length >= 3) {
+ Toast.show(msg`You may only add up to 3 feeds`.message ?? '', 'info')
} else {
updatedState = {...state, feeds: [...state.feeds, action.feed]}
}
diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx
index 8d9bb165b9..40a4a510b7 100644
--- a/src/screens/StarterPack/Wizard/index.tsx
+++ b/src/screens/StarterPack/Wizard/index.tsx
@@ -20,7 +20,7 @@ import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {logger} from '#/logger'
-import {HITSLOP_10} from 'lib/constants'
+import {HITSLOP_10, STARTER_PACK_MAX_SIZE} from 'lib/constants'
import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name'
import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
import {logEvent} from 'lib/statsig/statsig'
@@ -33,7 +33,7 @@ import {
} from 'lib/strings/starter-pack'
import {isAndroid, isNative, isWeb} from 'platform/detection'
import {useModerationOpts} from 'state/preferences/moderation-opts'
-import {useListMembersQuery} from 'state/queries/list-members'
+import {useAllListMembersQuery} from 'state/queries/list-members'
import {useProfileQuery} from 'state/queries/profile'
import {
useCreateStarterPackMutation,
@@ -78,11 +78,10 @@ export function Wizard({
const listUri = starterPack?.list?.uri
const {
- data: profilesData,
+ data: listItems,
isLoading: isLoadingProfiles,
isError: isErrorProfiles,
- } = useListMembersQuery(listUri, 50)
- const listItems = profilesData?.pages.flatMap(p => p.items)
+ } = useAllListMembersQuery(listUri)
const {
data: profile,
@@ -428,7 +427,8 @@ function Footer({
{items.length > minimumItems && (
- {items.length}/{state.currentStep === 'Profiles' ? 50 : 3}
+ {items.length}/
+ {state.currentStep === 'Profiles' ? STARTER_PACK_MAX_SIZE : 3}
)}
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index 48183739b2..65300a8ef1 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -1,11 +1,16 @@
import {useEffect, useMemo, useState} from 'react'
-import {AppBskyFeedDefs} from '@atproto/api'
+import {
+ AppBskyEmbedRecord,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyFeedDefs,
+} from '@atproto/api'
import {QueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3'
import {batchedUpdates} from '#/lib/batchedUpdates'
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed'
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed'
+import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '../queries/post-quotes'
import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread'
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '../queries/search-posts'
import {castAsShadow, Shadow} from './types'
@@ -15,6 +20,7 @@ export interface PostShadow {
likeUri: string | undefined
repostUri: string | undefined
isDeleted: boolean
+ embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
}
export const POST_TOMBSTONE = Symbol('PostTombstone')
@@ -86,8 +92,21 @@ function mergeShadow(
repostCount = Math.max(0, repostCount)
}
+ let embed: typeof post.embed
+ if ('embed' in shadow) {
+ if (
+ (AppBskyEmbedRecord.isView(post.embed) &&
+ AppBskyEmbedRecord.isView(shadow.embed)) ||
+ (AppBskyEmbedRecordWithMedia.isView(post.embed) &&
+ AppBskyEmbedRecordWithMedia.isView(shadow.embed))
+ ) {
+ embed = shadow.embed
+ }
+ }
+
return castAsShadow({
...post,
+ embed: embed || post.embed,
likeCount: likeCount,
repostCount: repostCount,
viewer: {
@@ -130,4 +149,7 @@ function* findPostsInCache(
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
yield post
}
+ for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
+ yield post
+ }
}
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index dc907664e2..dda7a749e5 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -12,6 +12,7 @@ import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryDat
import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts'
import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '../queries/post-feed'
import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by'
+import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '../queries/post-quotes'
import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '../queries/post-reposted-by'
import {findAllProfilesInQueryData as findAllProfilesInPostThreadQueryData} from '../queries/post-thread'
import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '../queries/profile'
@@ -104,6 +105,7 @@ function* findProfilesInCache(
yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did)
yield* findAllProfilesInPostLikedByQueryData(queryClient, did)
yield* findAllProfilesInPostRepostedByQueryData(queryClient, did)
+ yield* findAllProfilesInPostQuotesQueryData(queryClient, did)
yield* findAllProfilesInProfileQueryData(queryClient, did)
yield* findAllProfilesInProfileFollowersQueryData(queryClient, did)
yield* findAllProfilesInProfileFollowsQueryData(queryClient, did)
diff --git a/src/state/events.ts b/src/state/events.ts
index 1384abdeda..dcd36464ec 100644
--- a/src/state/events.ts
+++ b/src/state/events.ts
@@ -22,6 +22,22 @@ export function listenSessionDropped(fn: () => void): UnlistenFn {
return () => emitter.off('session-dropped', fn)
}
+export function emitNetworkConfirmed() {
+ emitter.emit('network-confirmed')
+}
+export function listenNetworkConfirmed(fn: () => void): UnlistenFn {
+ emitter.on('network-confirmed', fn)
+ return () => emitter.off('network-confirmed', fn)
+}
+
+export function emitNetworkLost() {
+ emitter.emit('network-lost')
+}
+export function listenNetworkLost(fn: () => void): UnlistenFn {
+ emitter.on('network-lost', fn)
+ return () => emitter.off('network-lost', fn)
+}
+
export function emitPostCreated() {
emitter.emit('post-created')
}
diff --git a/src/state/feed-feedback.tsx b/src/state/feed-feedback.tsx
index aab2737e5a..29f328a626 100644
--- a/src/state/feed-feedback.tsx
+++ b/src/state/feed-feedback.tsx
@@ -1,11 +1,10 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
-import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
+import {AppBskyFeedDefs} from '@atproto/api'
import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig'
-import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
import {getFeedPostSlice} from '#/view/com/posts/Feed'
@@ -25,7 +24,6 @@ const stateContext = React.createContext({
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const agent = useAgent()
- const gate = useGate()
const enabled = isDiscoverFeed(feed) && hasSession
const queue = React.useRef>(new Set())
const history = React.useRef<
@@ -49,34 +47,20 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
queue.current.clear()
// Send to the feed
- if (gate('session_withproxy_fix')) {
- agent.app.bsky.feed
- .sendInteractions(
- {interactions},
- {
- encoding: 'application/json',
- headers: {
- // TODO when we start sending to other feeds, we need to grab their DID -prf
- 'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
- },
+ agent.app.bsky.feed
+ .sendInteractions(
+ {interactions},
+ {
+ encoding: 'application/json',
+ headers: {
+ // TODO when we start sending to other feeds, we need to grab their DID -prf
+ 'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
},
- )
- .catch((e: any) => {
- logger.warn('Failed to send feed interactions', {error: e})
- })
- } else {
- const proxyAgent = agent.withProxy(
- // @ts-ignore TODO need to update withProxy() to support this key -prf
- 'bsky_fg',
- // TODO when we start sending to other feeds, we need to grab their DID -prf
- 'did:web:discover.bsky.app',
- ) as BskyAgent
- proxyAgent.app.bsky.feed
- .sendInteractions({interactions})
- .catch((e: any) => {
- logger.warn('Failed to send feed interactions', {error: e})
- })
- }
+ },
+ )
+ .catch((e: any) => {
+ logger.warn('Failed to send feed interactions', {error: e})
+ })
// Send to Statsig
if (aggregatedStats.current === null) {
@@ -84,7 +68,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
}
sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
throttledFlushAggregatedStats()
- }, [agent, gate, throttledFlushAggregatedStats])
+ }, [agent, throttledFlushAggregatedStats])
const sendToFeed = React.useMemo(
() =>
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index 2b6751e890..e5ce19a9ad 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -454,7 +454,8 @@ export function usePinnedFeedsInfos() {
}),
)
- await Promise.allSettled([feedsPromise, ...listsPromises])
+ await feedsPromise // Fail the whole query if it fails.
+ await Promise.allSettled(listsPromises) // Ignore individual failing ones.
// order the feeds/lists in the order they were pinned
const result: SavedFeedSourceInfo[] = []
diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts
index 3131a2ec3b..82c3955187 100644
--- a/src/state/queries/list-members.ts
+++ b/src/state/queries/list-members.ts
@@ -1,9 +1,15 @@
-import {AppBskyActorDefs, AppBskyGraphGetList} from '@atproto/api'
+import {
+ AppBskyActorDefs,
+ AppBskyGraphDefs,
+ AppBskyGraphGetList,
+ BskyAgent,
+} from '@atproto/api'
import {
InfiniteData,
QueryClient,
QueryKey,
useInfiniteQuery,
+ useQuery,
} from '@tanstack/react-query'
import {STALE} from '#/state/queries'
@@ -13,7 +19,9 @@ const PAGE_SIZE = 30
type RQPageParam = string | undefined
const RQKEY_ROOT = 'list-members'
+const RQKEY_ROOT_ALL = 'list-members-all'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
+export const RQKEY_ALL = (uri: string) => [RQKEY_ROOT_ALL, uri]
export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) {
const agent = useAgent()
@@ -40,6 +48,38 @@ export function useListMembersQuery(uri?: string, limit: number = PAGE_SIZE) {
})
}
+export function useAllListMembersQuery(uri?: string) {
+ const agent = useAgent()
+ return useQuery({
+ staleTime: STALE.MINUTES.ONE,
+ queryKey: RQKEY_ALL(uri ?? ''),
+ queryFn: async () => {
+ return getAllListMembers(agent, uri!)
+ },
+ enabled: Boolean(uri),
+ })
+}
+
+export async function getAllListMembers(agent: BskyAgent, uri: string) {
+ let hasMore = true
+ let cursor: string | undefined
+ const listItems: AppBskyGraphDefs.ListItemView[] = []
+ // We want to cap this at 6 pages, just for anything weird happening with the api
+ let i = 0
+ while (hasMore && i < 6) {
+ const res = await agent.app.bsky.graph.getList({
+ list: uri,
+ limit: 50,
+ cursor,
+ })
+ listItems.push(...res.data.items)
+ hasMore = Boolean(res.data.cursor)
+ cursor = res.data.cursor
+ i++
+ }
+ return listItems
+}
+
export async function invalidateListMembersQuery({
queryClient,
uri,
@@ -79,4 +119,20 @@ export function* findAllProfilesInQueryData(
}
}
}
+
+ const allQueryData = queryClient.getQueriesData<
+ AppBskyGraphDefs.ListItemView[]
+ >({
+ queryKey: [RQKEY_ROOT_ALL],
+ })
+ for (const [_queryKey, queryData] of allQueryData) {
+ if (!queryData) {
+ continue
+ }
+ for (const item of queryData) {
+ if (item.subject.did === did) {
+ yield item.subject
+ }
+ }
+ }
}
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
index eeb9c3b381..405cb4ae3e 100644
--- a/src/state/queries/list.ts
+++ b/src/state/queries/list.ts
@@ -17,7 +17,7 @@ import {useAgent, useSession} from '../session'
import {invalidate as invalidateMyLists} from './my-lists'
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
-const RQKEY_ROOT = 'list'
+export const RQKEY_ROOT = 'list'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
export function useListQuery(uri?: string) {
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 997076e819..55e048308d 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -16,7 +16,7 @@
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
*/
-import {useEffect, useRef} from 'react'
+import {useCallback, useEffect, useMemo, useRef} from 'react'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {
InfiniteData,
@@ -27,6 +27,7 @@ import {
} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
+import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
import {
@@ -58,11 +59,18 @@ export function useNotificationFeedQuery(opts?: {
const moderationOpts = useModerationOpts()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false
+ const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris()
// false: force showing all notifications
// undefined: let the server decide
const priority = opts?.overridePriorityNotifications ? false : undefined
+ const selectArgs = useMemo(() => {
+ return {
+ hiddenReplyUris,
+ }
+ }, [hiddenReplyUris])
+
const query = useInfiniteQuery<
FeedPage,
Error,
@@ -101,20 +109,41 @@ export function useNotificationFeedQuery(opts?: {
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled,
- select(data: InfiniteData) {
- // override 'isRead' using the first page's returned seenAt
- // we do this because the `markAllRead()` call above will
- // mark subsequent pages as read prematurely
- const seenAt = data.pages[0]?.seenAt || new Date()
- for (const page of data.pages) {
- for (const item of page.items) {
- item.notification.isRead =
- seenAt > new Date(item.notification.indexedAt)
- }
- }
+ select: useCallback(
+ (data: InfiniteData) => {
+ const {hiddenReplyUris} = selectArgs
- return data
- },
+ // override 'isRead' using the first page's returned seenAt
+ // we do this because the `markAllRead()` call above will
+ // mark subsequent pages as read prematurely
+ const seenAt = data.pages[0]?.seenAt || new Date()
+ for (const page of data.pages) {
+ for (const item of page.items) {
+ item.notification.isRead =
+ seenAt > new Date(item.notification.indexedAt)
+ }
+ }
+
+ data = {
+ ...data,
+ pages: data.pages.map(page => {
+ return {
+ ...page,
+ items: page.items.filter(item => {
+ const isHiddenReply =
+ item.type === 'reply' &&
+ item.subjectUri &&
+ hiddenReplyUris.has(item.subjectUri)
+ return !isHiddenReply
+ }),
+ }
+ }),
+ }
+
+ return data
+ },
+ [selectArgs],
+ ),
})
// The server may end up returning an empty page, a page with too few items,
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 724043e586..ee3e2c14d2 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -80,6 +80,7 @@ export interface FeedPostSliceItem {
moderation: ModerationDecision
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
isParentBlocked?: boolean
+ isParentNotFound?: boolean
}
export interface FeedPostSlice {
@@ -326,6 +327,7 @@ export function usePostFeedQuery(
moderation: moderations[i],
parentAuthor: item.parentAuthor,
isParentBlocked: item.isParentBlocked,
+ isParentNotFound: item.isParentNotFound,
}
return feedPostSliceItem
}),
diff --git a/src/state/queries/post-quotes.ts b/src/state/queries/post-quotes.ts
new file mode 100644
index 0000000000..be51eaab0a
--- /dev/null
+++ b/src/state/queries/post-quotes.ts
@@ -0,0 +1,124 @@
+import {
+ AppBskyActorDefs,
+ AppBskyEmbedRecord,
+ AppBskyFeedDefs,
+ AppBskyFeedGetQuotes,
+ AtUri,
+} from '@atproto/api'
+import {
+ InfiniteData,
+ QueryClient,
+ QueryKey,
+ useInfiniteQuery,
+} from '@tanstack/react-query'
+
+import {useAgent} from '#/state/session'
+import {
+ didOrHandleUriMatches,
+ embedViewRecordToPostView,
+ getEmbeddedPost,
+} from './util'
+
+const PAGE_SIZE = 30
+type RQPageParam = string | undefined
+
+const RQKEY_ROOT = 'post-quotes'
+export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri]
+
+export function usePostQuotesQuery(resolvedUri: string | undefined) {
+ const agent = useAgent()
+ return useInfiniteQuery<
+ AppBskyFeedGetQuotes.OutputSchema,
+ Error,
+ InfiniteData,
+ QueryKey,
+ RQPageParam
+ >({
+ queryKey: RQKEY(resolvedUri || ''),
+ async queryFn({pageParam}: {pageParam: RQPageParam}) {
+ const res = await agent.api.app.bsky.feed.getQuotes({
+ uri: resolvedUri || '',
+ limit: PAGE_SIZE,
+ cursor: pageParam,
+ })
+ return res.data
+ },
+ initialPageParam: undefined,
+ getNextPageParam: lastPage => lastPage.cursor,
+ enabled: !!resolvedUri,
+ select: data => {
+ return {
+ ...data,
+ pages: data.pages.map(page => {
+ return {
+ ...page,
+ posts: page.posts.filter(post => {
+ if (post.embed && AppBskyEmbedRecord.isView(post.embed)) {
+ if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) {
+ return false
+ }
+ }
+ return true
+ }),
+ }
+ }),
+ }
+ },
+ })
+}
+
+export function* findAllProfilesInQueryData(
+ queryClient: QueryClient,
+ did: string,
+): Generator {
+ const queryDatas = queryClient.getQueriesData<
+ InfiniteData
+ >({
+ queryKey: [RQKEY_ROOT],
+ })
+ for (const [_queryKey, queryData] of queryDatas) {
+ if (!queryData?.pages) {
+ continue
+ }
+ for (const page of queryData?.pages) {
+ for (const item of page.posts) {
+ if (item.author.did === did) {
+ yield item.author
+ }
+ const quotedPost = getEmbeddedPost(item.embed)
+ if (quotedPost?.author.did === did) {
+ yield quotedPost.author
+ }
+ }
+ }
+ }
+}
+
+export function* findAllPostsInQueryData(
+ queryClient: QueryClient,
+ uri: string,
+): Generator {
+ const queryDatas = queryClient.getQueriesData<
+ InfiniteData
+ >({
+ queryKey: [RQKEY_ROOT],
+ })
+ const atUri = new AtUri(uri)
+ for (const [_queryKey, queryData] of queryDatas) {
+ if (!queryData?.pages) {
+ continue
+ }
+ for (const page of queryData?.pages) {
+ for (const post of page.posts) {
+ if (didOrHandleUriMatches(atUri, post)) {
+ yield post
+ }
+
+ const quotedPost = getEmbeddedPost(post.embed)
+ if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
+ yield embedViewRecordToPostView(quotedPost)
+ }
+ }
+ }
+ }
+}
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index fd419d1c44..83ca60c2ae 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -13,6 +13,7 @@ import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useAgent} from '#/state/session'
+import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from 'state/queries/post-quotes'
import {
findAllPostsInQueryData as findAllPostsInSearchQueryData,
findAllProfilesInQueryData as findAllProfilesInSearchQueryData,
@@ -84,10 +85,15 @@ export type ThreadNode =
export type ThreadModerationCache = WeakMap
+export type PostThreadQueryData = {
+ thread: ThreadNode
+ threadgate?: AppBskyFeedDefs.ThreadgateView
+}
+
export function usePostThreadQuery(uri: string | undefined) {
const queryClient = useQueryClient()
const agent = useAgent()
- return useQuery({
+ return useQuery({
gcTime: 0,
queryKey: RQKEY(uri || ''),
async queryFn() {
@@ -98,16 +104,21 @@ export function usePostThreadQuery(uri: string | undefined) {
if (res.success) {
const thread = responseToThreadNodes(res.data.thread)
annotateSelfThread(thread)
- return thread
+ return {
+ thread,
+ threadgate: res.data.threadgate as
+ | AppBskyFeedDefs.ThreadgateView
+ | undefined,
+ }
}
- return {type: 'unknown', uri: uri!}
+ return {thread: {type: 'unknown', uri: uri!}}
},
enabled: !!uri,
placeholderData: () => {
if (!uri) return
const post = findPostInQueryData(queryClient, uri)
if (post) {
- return post
+ return {thread: post}
}
return undefined
},
@@ -138,6 +149,7 @@ export function sortThread(
modCache: ThreadModerationCache,
currentDid: string | undefined,
justPostedUris: Set,
+ threadgateRecordHiddenReplies: Set,
): ThreadNode {
if (node.type !== 'post') {
return node
@@ -185,6 +197,14 @@ export function sortThread(
return 1 // current account's reply
}
+ const aHidden = threadgateRecordHiddenReplies.has(a.uri)
+ const bHidden = threadgateRecordHiddenReplies.has(b.uri)
+ if (aHidden && !aIsBySelf && !bHidden) {
+ return 1
+ } else if (bHidden && !bIsBySelf && !aHidden) {
+ return -1
+ }
+
const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
if (aBlur !== bBlur) {
@@ -222,7 +242,14 @@ export function sortThread(
return b.post.indexedAt.localeCompare(a.post.indexedAt)
})
node.replies.forEach(reply =>
- sortThread(reply, opts, modCache, currentDid, justPostedUris),
+ sortThread(
+ reply,
+ opts,
+ modCache,
+ currentDid,
+ justPostedUris,
+ threadgateRecordHiddenReplies,
+ ),
)
}
return node
@@ -359,14 +386,15 @@ export function* findAllPostsInQueryData(
): Generator {
const atUri = new AtUri(uri)
- const queryDatas = queryClient.getQueriesData({
+ const queryDatas = queryClient.getQueriesData({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
continue
}
- for (const item of traverseThread(queryData)) {
+ const {thread} = queryData
+ for (const item of traverseThread(thread)) {
if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) {
const placeholder = threadNodeToPlaceholderThread(item)
if (placeholder) {
@@ -386,6 +414,9 @@ export function* findAllPostsInQueryData(
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
yield postViewToPlaceholderThread(post)
}
+ for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
+ yield postViewToPlaceholderThread(post)
+ }
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
yield postViewToPlaceholderThread(post)
}
@@ -395,14 +426,15 @@ export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator {
- const queryDatas = queryClient.getQueriesData({
+ const queryDatas = queryClient.getQueriesData({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
continue
}
- for (const item of traverseThread(queryData)) {
+ const {thread} = queryData
+ for (const item of traverseThread(thread)) {
if (item.type === 'post' && item.post.author.did === did) {
yield item.post.author
}
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 071a2e91fe..982d224aee 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -73,10 +73,34 @@ export function useGetPost() {
)
}
+export function useGetPosts() {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+ return useCallback(
+ async ({uris}: {uris: string[]}) => {
+ return queryClient.fetchQuery({
+ queryKey: RQKEY(uris.join(',') || ''),
+ async queryFn() {
+ const res = await agent.getPosts({
+ uris,
+ })
+
+ if (res.success) {
+ return res.data.posts
+ } else {
+ throw new Error('useGetPosts failed')
+ }
+ },
+ })
+ },
+ [queryClient, agent],
+ )
+}
+
export function usePostLikeMutationQueue(
post: Shadow,
- logContext: LogEvents['post:like']['logContext'] &
- LogEvents['post:unlike']['logContext'],
+ logContext: LogEvents['post:like:sampled']['logContext'] &
+ LogEvents['post:unlike:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -134,7 +158,7 @@ export function usePostLikeMutationQueue(
}
function usePostLikeMutation(
- logContext: LogEvents['post:like']['logContext'],
+ logContext: LogEvents['post:like:sampled']['logContext'],
post: Shadow,
) {
const {currentAccount} = useSession()
@@ -151,7 +175,7 @@ function usePostLikeMutation(
if (currentAccount) {
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
- logEvent('post:like', {
+ logEvent('post:like:sampled', {
logContext,
doesPosterFollowLiker: postAuthor.viewer
? Boolean(postAuthor.viewer.followedBy)
@@ -176,12 +200,12 @@ function usePostLikeMutation(
}
function usePostUnlikeMutation(
- logContext: LogEvents['post:unlike']['logContext'],
+ logContext: LogEvents['post:unlike:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation({
mutationFn: ({likeUri}) => {
- logEvent('post:unlike', {logContext})
+ logEvent('post:unlike:sampled', {logContext})
return agent.deleteLike(likeUri)
},
onSuccess() {
@@ -192,8 +216,8 @@ function usePostUnlikeMutation(
export function usePostRepostMutationQueue(
post: Shadow,
- logContext: LogEvents['post:repost']['logContext'] &
- LogEvents['post:unrepost']['logContext'],
+ logContext: LogEvents['post:repost:sampled']['logContext'] &
+ LogEvents['post:unrepost:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -249,7 +273,7 @@ export function usePostRepostMutationQueue(
}
function usePostRepostMutation(
- logContext: LogEvents['post:repost']['logContext'],
+ logContext: LogEvents['post:repost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<
@@ -258,7 +282,7 @@ function usePostRepostMutation(
{uri: string; cid: string} // the post's uri and cid
>({
mutationFn: post => {
- logEvent('post:repost', {logContext})
+ logEvent('post:repost:sampled', {logContext})
return agent.repost(post.uri, post.cid)
},
onSuccess() {
@@ -268,12 +292,12 @@ function usePostRepostMutation(
}
function usePostUnrepostMutation(
- logContext: LogEvents['post:unrepost']['logContext'],
+ logContext: LogEvents['post:unrepost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation({
mutationFn: ({repostUri}) => {
- logEvent('post:unrepost', {logContext})
+ logEvent('post:unrepost:sampled', {logContext})
return agent.deleteRepost(repostUri)
},
onSuccess() {
diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts
new file mode 100644
index 0000000000..149b9cbe93
--- /dev/null
+++ b/src/state/queries/postgate/index.ts
@@ -0,0 +1,295 @@
+import React from 'react'
+import {
+ AppBskyEmbedRecord,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyFeedDefs,
+ AppBskyFeedPostgate,
+ AtUri,
+ BskyAgent,
+} from '@atproto/api'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {networkRetry, retry} from '#/lib/async/retry'
+import {logger} from '#/logger'
+import {updatePostShadow} from '#/state/cache/post-shadow'
+import {STALE} from '#/state/queries'
+import {useGetPosts} from '#/state/queries/post'
+import {
+ createMaybeDetachedQuoteEmbed,
+ createPostgateRecord,
+ mergePostgateRecords,
+ POSTGATE_COLLECTION,
+} from '#/state/queries/postgate/util'
+import {useAgent} from '#/state/session'
+
+export async function getPostgateRecord({
+ agent,
+ postUri,
+}: {
+ agent: BskyAgent
+ postUri: string
+}): Promise {
+ const urip = new AtUri(postUri)
+
+ if (!urip.host.startsWith('did:')) {
+ const res = await agent.resolveHandle({
+ handle: urip.host,
+ })
+ urip.host = res.data.did
+ }
+
+ try {
+ const {data} = await retry(
+ 2,
+ e => {
+ /*
+ * If the record doesn't exist, we want to return null instead of
+ * throwing an error. NB: This will also catch reference errors, such as
+ * a typo in the URI.
+ */
+ if (e.message.includes(`Could not locate record:`)) {
+ return false
+ }
+ return true
+ },
+ () =>
+ agent.api.com.atproto.repo.getRecord({
+ repo: urip.host,
+ collection: POSTGATE_COLLECTION,
+ rkey: urip.rkey,
+ }),
+ )
+
+ if (data.value && AppBskyFeedPostgate.isRecord(data.value)) {
+ return data.value
+ } else {
+ return undefined
+ }
+ } catch (e: any) {
+ /*
+ * If the record doesn't exist, we want to return null instead of
+ * throwing an error. NB: This will also catch reference errors, such as
+ * a typo in the URI.
+ */
+ if (e.message.includes(`Could not locate record:`)) {
+ return undefined
+ } else {
+ throw e
+ }
+ }
+}
+
+export async function writePostgateRecord({
+ agent,
+ postUri,
+ postgate,
+}: {
+ agent: BskyAgent
+ postUri: string
+ postgate: AppBskyFeedPostgate.Record
+}) {
+ const postUrip = new AtUri(postUri)
+
+ await networkRetry(2, () =>
+ agent.api.com.atproto.repo.putRecord({
+ repo: agent.session!.did,
+ collection: POSTGATE_COLLECTION,
+ rkey: postUrip.rkey,
+ record: postgate,
+ }),
+ )
+}
+
+export async function upsertPostgate(
+ {
+ agent,
+ postUri,
+ }: {
+ agent: BskyAgent
+ postUri: string
+ },
+ callback: (
+ postgate: AppBskyFeedPostgate.Record | undefined,
+ ) => Promise,
+) {
+ const prev = await getPostgateRecord({
+ agent,
+ postUri,
+ })
+ const next = await callback(prev)
+ if (!next) return
+ await writePostgateRecord({
+ agent,
+ postUri,
+ postgate: next,
+ })
+}
+
+export const createPostgateQueryKey = (postUri: string) => [
+ 'postgate-record',
+ postUri,
+]
+export function usePostgateQuery({postUri}: {postUri: string}) {
+ const agent = useAgent()
+ return useQuery({
+ staleTime: STALE.SECONDS.THIRTY,
+ queryKey: createPostgateQueryKey(postUri),
+ async queryFn() {
+ return (await getPostgateRecord({agent, postUri})) ?? null
+ },
+ })
+}
+
+export function useWritePostgateMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async ({
+ postUri,
+ postgate,
+ }: {
+ postUri: string
+ postgate: AppBskyFeedPostgate.Record
+ }) => {
+ return writePostgateRecord({
+ agent,
+ postUri,
+ postgate,
+ })
+ },
+ onSuccess(_, {postUri}) {
+ queryClient.invalidateQueries({
+ queryKey: createPostgateQueryKey(postUri),
+ })
+ },
+ })
+}
+
+export function useToggleQuoteDetachmentMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ const getPosts = useGetPosts()
+ const prevEmbed = React.useRef()
+
+ return useMutation({
+ mutationFn: async ({
+ post,
+ quoteUri,
+ action,
+ }: {
+ post: AppBskyFeedDefs.PostView
+ quoteUri: string
+ action: 'detach' | 'reattach'
+ }) => {
+ // cache here since post shadow mutates original object
+ prevEmbed.current = post.embed
+
+ if (action === 'detach') {
+ updatePostShadow(queryClient, post.uri, {
+ embed: createMaybeDetachedQuoteEmbed({
+ post,
+ quote: undefined,
+ quoteUri,
+ detached: true,
+ }),
+ })
+ }
+
+ await upsertPostgate({agent, postUri: quoteUri}, async prev => {
+ if (prev) {
+ if (action === 'detach') {
+ return mergePostgateRecords(prev, {
+ detachedEmbeddingUris: [post.uri],
+ })
+ } else if (action === 'reattach') {
+ return {
+ ...prev,
+ detachedEmbeddingUris:
+ prev.detachedEmbeddingUris?.filter(uri => uri !== post.uri) ||
+ [],
+ }
+ }
+ } else {
+ if (action === 'detach') {
+ return createPostgateRecord({
+ post: quoteUri,
+ detachedEmbeddingUris: [post.uri],
+ })
+ }
+ }
+ })
+ },
+ async onSuccess(_data, {post, quoteUri, action}) {
+ if (action === 'reattach') {
+ try {
+ const [quote] = await getPosts({uris: [quoteUri]})
+ updatePostShadow(queryClient, post.uri, {
+ embed: createMaybeDetachedQuoteEmbed({
+ post,
+ quote,
+ quoteUri: undefined,
+ detached: false,
+ }),
+ })
+ } catch (e: any) {
+ // ok if this fails, it's just optimistic UI
+ logger.error(`Postgate: failed to get quote post for re-attachment`, {
+ safeMessage: e.message,
+ })
+ }
+ }
+ },
+ onError(_, {post, action}) {
+ if (action === 'detach' && prevEmbed.current) {
+ // detach failed, add the embed back
+ if (
+ AppBskyEmbedRecord.isView(prevEmbed.current) ||
+ AppBskyEmbedRecordWithMedia.isView(prevEmbed.current)
+ ) {
+ updatePostShadow(queryClient, post.uri, {
+ embed: prevEmbed.current,
+ })
+ }
+ }
+ },
+ onSettled() {
+ prevEmbed.current = undefined
+ },
+ })
+}
+
+export function useToggleQuotepostEnabledMutation() {
+ const agent = useAgent()
+
+ return useMutation({
+ mutationFn: async ({
+ postUri,
+ action,
+ }: {
+ postUri: string
+ action: 'enable' | 'disable'
+ }) => {
+ await upsertPostgate({agent, postUri: postUri}, async prev => {
+ if (prev) {
+ if (action === 'disable') {
+ return mergePostgateRecords(prev, {
+ embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}],
+ })
+ } else if (action === 'enable') {
+ return {
+ ...prev,
+ embeddingRules: [],
+ }
+ }
+ } else {
+ if (action === 'disable') {
+ return createPostgateRecord({
+ post: postUri,
+ embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}],
+ })
+ }
+ }
+ })
+ },
+ })
+}
diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts
new file mode 100644
index 0000000000..96762d38cb
--- /dev/null
+++ b/src/state/queries/postgate/util.ts
@@ -0,0 +1,197 @@
+import {
+ AppBskyEmbedRecord,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyFeedDefs,
+ AppBskyFeedPostgate,
+ AtUri,
+} from '@atproto/api'
+
+export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate'
+
+export function createPostgateRecord(
+ postgate: Partial & {
+ post: AppBskyFeedPostgate.Record['post']
+ },
+): AppBskyFeedPostgate.Record {
+ return {
+ $type: POSTGATE_COLLECTION,
+ createdAt: new Date().toISOString(),
+ post: postgate.post,
+ detachedEmbeddingUris: postgate.detachedEmbeddingUris || [],
+ embeddingRules: postgate.embeddingRules || [],
+ }
+}
+
+export function mergePostgateRecords(
+ prev: AppBskyFeedPostgate.Record,
+ next: Partial,
+) {
+ const detachedEmbeddingUris = Array.from(
+ new Set([
+ ...(prev.detachedEmbeddingUris || []),
+ ...(next.detachedEmbeddingUris || []),
+ ]),
+ )
+ const embeddingRules = [
+ ...(prev.embeddingRules || []),
+ ...(next.embeddingRules || []),
+ ].filter(
+ (rule, i, all) => all.findIndex(_rule => _rule.$type === rule.$type) === i,
+ )
+ return createPostgateRecord({
+ post: prev.post,
+ detachedEmbeddingUris,
+ embeddingRules,
+ })
+}
+
+export function createEmbedViewDetachedRecord({uri}: {uri: string}) {
+ const record: AppBskyEmbedRecord.ViewDetached = {
+ $type: 'app.bsky.embed.record#viewDetached',
+ uri,
+ detached: true,
+ }
+ return {
+ $type: 'app.bsky.embed.record#view',
+ record,
+ }
+}
+
+export function createMaybeDetachedQuoteEmbed({
+ post,
+ quote,
+ quoteUri,
+ detached,
+}:
+ | {
+ post: AppBskyFeedDefs.PostView
+ quote: AppBskyFeedDefs.PostView
+ quoteUri: undefined
+ detached: false
+ }
+ | {
+ post: AppBskyFeedDefs.PostView
+ quote: undefined
+ quoteUri: string
+ detached: true
+ }): AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined {
+ if (AppBskyEmbedRecord.isView(post.embed)) {
+ if (detached) {
+ return createEmbedViewDetachedRecord({uri: quoteUri})
+ } else {
+ return createEmbedRecordView({post: quote})
+ }
+ } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
+ if (detached) {
+ return {
+ ...post.embed,
+ record: createEmbedViewDetachedRecord({uri: quoteUri}),
+ }
+ } else {
+ return createEmbedRecordWithMediaView({post, quote})
+ }
+ }
+}
+
+export function createEmbedViewRecordFromPost(
+ post: AppBskyFeedDefs.PostView,
+): AppBskyEmbedRecord.ViewRecord {
+ return {
+ $type: 'app.bsky.embed.record#viewRecord',
+ uri: post.uri,
+ cid: post.cid,
+ author: post.author,
+ value: post.record,
+ labels: post.labels,
+ replyCount: post.replyCount,
+ repostCount: post.repostCount,
+ likeCount: post.likeCount,
+ quoteCount: post.quoteCount,
+ indexedAt: post.indexedAt,
+ }
+}
+
+export function createEmbedRecordView({
+ post,
+}: {
+ post: AppBskyFeedDefs.PostView
+}): AppBskyEmbedRecord.View {
+ return {
+ $type: 'app.bsky.embed.record#view',
+ record: createEmbedViewRecordFromPost(post),
+ }
+}
+
+export function createEmbedRecordWithMediaView({
+ post,
+ quote,
+}: {
+ post: AppBskyFeedDefs.PostView
+ quote: AppBskyFeedDefs.PostView
+}): AppBskyEmbedRecordWithMedia.View | undefined {
+ if (!AppBskyEmbedRecordWithMedia.isView(post.embed)) return
+ return {
+ ...(post.embed || {}),
+ record: {
+ record: createEmbedViewRecordFromPost(quote),
+ },
+ }
+}
+
+export function getMaybeDetachedQuoteEmbed({
+ viewerDid,
+ post,
+}: {
+ viewerDid: string
+ post: AppBskyFeedDefs.PostView
+}) {
+ if (AppBskyEmbedRecord.isView(post.embed)) {
+ // detached
+ if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) {
+ const urip = new AtUri(post.embed.record.uri)
+ return {
+ embed: post.embed,
+ uri: urip.toString(),
+ isOwnedByViewer: urip.host === viewerDid,
+ isDetached: true,
+ }
+ }
+
+ // post
+ if (AppBskyEmbedRecord.isViewRecord(post.embed.record)) {
+ const urip = new AtUri(post.embed.record.uri)
+ return {
+ embed: post.embed,
+ uri: urip.toString(),
+ isOwnedByViewer: urip.host === viewerDid,
+ isDetached: false,
+ }
+ }
+ } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
+ // detached
+ if (AppBskyEmbedRecord.isViewDetached(post.embed.record.record)) {
+ const urip = new AtUri(post.embed.record.record.uri)
+ return {
+ embed: post.embed,
+ uri: urip.toString(),
+ isOwnedByViewer: urip.host === viewerDid,
+ isDetached: true,
+ }
+ }
+
+ // post
+ if (AppBskyEmbedRecord.isViewRecord(post.embed.record.record)) {
+ const urip = new AtUri(post.embed.record.record.uri)
+ return {
+ embed: post.embed,
+ uri: urip.toString(),
+ isOwnedByViewer: urip.host === viewerDid,
+ isDetached: false,
+ }
+ }
+ }
+}
+
+export const embeddingRules = {
+ disableRule: {$type: 'app.bsky.feed.postgate#disableRule'},
+}
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.ts b/src/state/queries/profile.ts
index 6682cf3c89..532b005cf4 100644
--- a/src/state/queries/profile.ts
+++ b/src/state/queries/profile.ts
@@ -219,8 +219,8 @@ export function useProfileUpdateMutation() {
export function useProfileFollowMutationQueue(
profile: Shadow,
- logContext: LogEvents['profile:follow']['logContext'] &
- LogEvents['profile:unfollow']['logContext'],
+ logContext: LogEvents['profile:follow:sampled']['logContext'] &
+ LogEvents['profile:follow:sampled']['logContext'],
) {
const agent = useAgent()
const queryClient = useQueryClient()
@@ -291,7 +291,7 @@ export function useProfileFollowMutationQueue(
}
function useProfileFollowMutation(
- logContext: LogEvents['profile:follow']['logContext'],
+ logContext: LogEvents['profile:follow:sampled']['logContext'],
profile: Shadow,
) {
const {currentAccount} = useSession()
@@ -306,7 +306,7 @@ function useProfileFollowMutation(
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
captureAction(ProgressGuideAction.Follow)
- logEvent('profile:follow', {
+ logEvent('profile:follow:sampled', {
logContext,
didBecomeMutual: profile.viewer
? Boolean(profile.viewer.followedBy)
@@ -323,12 +323,12 @@ function useProfileFollowMutation(
}
function useProfileUnfollowMutation(
- logContext: LogEvents['profile:unfollow']['logContext'],
+ logContext: LogEvents['profile:unfollow:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation({
mutationFn: async ({followUri}) => {
- logEvent('profile:unfollow', {logContext})
+ logEvent('profile:unfollow:sampled', {logContext})
track('Profile:Unfollow', {username: followUri})
return await agent.deleteFollow(followUri)
},
diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts
index 2cdb6b850e..a3795d7927 100644
--- a/src/state/queries/starter-packs.ts
+++ b/src/state/queries/starter-packs.ts
@@ -16,6 +16,7 @@ import {
useQuery,
useQueryClient,
} from '@tanstack/react-query'
+import chunk from 'lodash.chunk'
import {until} from 'lib/async/until'
import {createStarterPackList} from 'lib/generate-starterpack'
@@ -200,36 +201,40 @@ export function useEditStarterPackMutation({
i.subject.did !== agent.session?.did &&
!profiles.find(p => p.did === i.subject.did && p.did),
)
-
if (removedItems.length !== 0) {
- await agent.com.atproto.repo.applyWrites({
- repo: agent.session!.did,
- writes: removedItems.map(i => ({
- $type: 'com.atproto.repo.applyWrites#delete',
- collection: 'app.bsky.graph.listitem',
- rkey: new AtUri(i.uri).rkey,
- })),
- })
+ const chunks = chunk(removedItems, 50)
+ for (const chunk of chunks) {
+ await agent.com.atproto.repo.applyWrites({
+ repo: agent.session!.did,
+ writes: chunk.map(i => ({
+ $type: 'com.atproto.repo.applyWrites#delete',
+ collection: 'app.bsky.graph.listitem',
+ rkey: new AtUri(i.uri).rkey,
+ })),
+ })
+ }
}
const addedProfiles = profiles.filter(
p => !currentListItems.find(i => i.subject.did === p.did),
)
-
if (addedProfiles.length > 0) {
- await agent.com.atproto.repo.applyWrites({
- repo: agent.session!.did,
- writes: addedProfiles.map(p => ({
- $type: 'com.atproto.repo.applyWrites#create',
- collection: 'app.bsky.graph.listitem',
- value: {
- $type: 'app.bsky.graph.listitem',
- subject: p.did,
- list: currentStarterPack.list?.uri,
- createdAt: new Date().toISOString(),
- },
- })),
- })
+ const chunks = chunk(addedProfiles, 50)
+ for (const chunk of chunks) {
+ await agent.com.atproto.repo.applyWrites({
+ repo: agent.session!.did,
+ writes: chunk.map(p => ({
+ $type: 'com.atproto.repo.applyWrites#create',
+ collection: 'app.bsky.graph.listitem',
+ value: {
+ $type: 'app.bsky.graph.listitem',
+ subject: p.did,
+ list: currentStarterPack.list?.uri,
+ createdAt: new Date().toISOString(),
+ },
+ })),
+ })
+ }
}
const rkey = parseStarterPackUri(currentStarterPack.uri)!.rkey
diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts
deleted file mode 100644
index 8b6aeba6c1..0000000000
--- a/src/state/queries/threadgate.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
-
-export type ThreadgateSetting =
- | {type: 'nobody'}
- | {type: 'mention'}
- | {type: 'following'}
- | {type: 'list'; list: unknown}
-
-export function threadgateViewToSettings(
- threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
-): ThreadgateSetting[] {
- const record =
- threadgate &&
- AppBskyFeedThreadgate.isRecord(threadgate.record) &&
- AppBskyFeedThreadgate.validateRecord(threadgate.record).success
- ? threadgate.record
- : null
- if (!record) {
- return []
- }
- if (!record.allow?.length) {
- return [{type: 'nobody'}]
- }
- const settings: ThreadgateSetting[] = record.allow
- .map(allow => {
- let setting: ThreadgateSetting | undefined
- if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
- setting = {type: 'mention'}
- } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
- setting = {type: 'following'}
- } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
- setting = {type: 'list', list: allow.list}
- }
- return setting
- })
- .filter(n => !!n)
- return settings
-}
diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts
new file mode 100644
index 0000000000..8aa9320814
--- /dev/null
+++ b/src/state/queries/threadgate/index.ts
@@ -0,0 +1,356 @@
+import {
+ AppBskyFeedDefs,
+ AppBskyFeedGetPostThread,
+ AppBskyFeedThreadgate,
+ AtUri,
+ BskyAgent,
+} from '@atproto/api'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {networkRetry, retry} from '#/lib/async/retry'
+import {until} from '#/lib/async/until'
+import {STALE} from '#/state/queries'
+import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread'
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
+import {
+ createThreadgateRecord,
+ mergeThreadgateRecords,
+ threadgateAllowUISettingToAllowRecordValue,
+ threadgateViewToAllowUISetting,
+} from '#/state/queries/threadgate/util'
+import {useAgent} from '#/state/session'
+import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies'
+
+export * from '#/state/queries/threadgate/types'
+export * from '#/state/queries/threadgate/util'
+
+export const threadgateRecordQueryKeyRoot = 'threadgate-record'
+export const createThreadgateRecordQueryKey = (uri: string) => [
+ threadgateRecordQueryKeyRoot,
+ uri,
+]
+
+export function useThreadgateRecordQuery({
+ postUri,
+ initialData,
+}: {
+ postUri?: string
+ initialData?: AppBskyFeedThreadgate.Record
+} = {}) {
+ const agent = useAgent()
+
+ return useQuery({
+ enabled: !!postUri,
+ queryKey: createThreadgateRecordQueryKey(postUri || ''),
+ placeholderData: initialData,
+ staleTime: STALE.MINUTES.ONE,
+ async queryFn() {
+ return getThreadgateRecord({
+ agent,
+ postUri: postUri!,
+ })
+ },
+ })
+}
+
+export const threadgateViewQueryKeyRoot = 'threadgate-view'
+export const createThreadgateViewQueryKey = (uri: string) => [
+ threadgateViewQueryKeyRoot,
+ uri,
+]
+export function useThreadgateViewQuery({
+ postUri,
+ initialData,
+}: {
+ postUri?: string
+ initialData?: AppBskyFeedDefs.ThreadgateView
+} = {}) {
+ const agent = useAgent()
+
+ return useQuery({
+ enabled: !!postUri,
+ queryKey: createThreadgateViewQueryKey(postUri || ''),
+ placeholderData: initialData,
+ staleTime: STALE.MINUTES.ONE,
+ async queryFn() {
+ return getThreadgateView({
+ agent,
+ postUri: postUri!,
+ })
+ },
+ })
+}
+
+export async function getThreadgateView({
+ agent,
+ postUri,
+}: {
+ agent: BskyAgent
+ postUri: string
+}) {
+ const {data} = await agent.app.bsky.feed.getPostThread({
+ uri: postUri!,
+ depth: 0,
+ })
+
+ if (AppBskyFeedDefs.isThreadViewPost(data.thread)) {
+ return data.thread.post.threadgate ?? null
+ }
+
+ return null
+}
+
+export async function getThreadgateRecord({
+ agent,
+ postUri,
+}: {
+ agent: BskyAgent
+ postUri: string
+}): Promise {
+ const urip = new AtUri(postUri)
+
+ if (!urip.host.startsWith('did:')) {
+ const res = await agent.resolveHandle({
+ handle: urip.host,
+ })
+ urip.host = res.data.did
+ }
+
+ try {
+ const {data} = await retry(
+ 2,
+ e => {
+ /*
+ * If the record doesn't exist, we want to return null instead of
+ * throwing an error. NB: This will also catch reference errors, such as
+ * a typo in the URI.
+ */
+ if (e.message.includes(`Could not locate record:`)) {
+ return false
+ }
+ return true
+ },
+ () =>
+ agent.api.com.atproto.repo.getRecord({
+ repo: urip.host,
+ collection: 'app.bsky.feed.threadgate',
+ rkey: urip.rkey,
+ }),
+ )
+
+ if (data.value && AppBskyFeedThreadgate.isRecord(data.value)) {
+ return data.value
+ } else {
+ return null
+ }
+ } catch (e: any) {
+ /*
+ * If the record doesn't exist, we want to return null instead of
+ * throwing an error. NB: This will also catch reference errors, such as
+ * a typo in the URI.
+ */
+ if (e.message.includes(`Could not locate record:`)) {
+ return null
+ } else {
+ throw e
+ }
+ }
+}
+
+export async function writeThreadgateRecord({
+ agent,
+ postUri,
+ threadgate,
+}: {
+ agent: BskyAgent
+ postUri: string
+ threadgate: AppBskyFeedThreadgate.Record
+}) {
+ const postUrip = new AtUri(postUri)
+ const record = createThreadgateRecord({
+ post: postUri,
+ allow: threadgate.allow, // can/should be undefined!
+ hiddenReplies: threadgate.hiddenReplies || [],
+ })
+
+ await networkRetry(2, () =>
+ agent.api.com.atproto.repo.putRecord({
+ repo: agent.session!.did,
+ collection: 'app.bsky.feed.threadgate',
+ rkey: postUrip.rkey,
+ record,
+ }),
+ )
+}
+
+export async function upsertThreadgate(
+ {
+ agent,
+ postUri,
+ }: {
+ agent: BskyAgent
+ postUri: string
+ },
+ callback: (
+ threadgate: AppBskyFeedThreadgate.Record | null,
+ ) => Promise,
+) {
+ const prev = await getThreadgateRecord({
+ agent,
+ postUri,
+ })
+ const next = await callback(prev)
+ if (!next) return
+ await writeThreadgateRecord({
+ agent,
+ postUri,
+ threadgate: next,
+ })
+}
+
+/**
+ * Update the allow list for a threadgate record.
+ */
+export async function updateThreadgateAllow({
+ agent,
+ postUri,
+ allow,
+}: {
+ agent: BskyAgent
+ postUri: string
+ allow: ThreadgateAllowUISetting[]
+}) {
+ return upsertThreadgate({agent, postUri}, async prev => {
+ if (prev) {
+ return {
+ ...prev,
+ allow: threadgateAllowUISettingToAllowRecordValue(allow),
+ }
+ } else {
+ return createThreadgateRecord({
+ post: postUri,
+ allow: threadgateAllowUISettingToAllowRecordValue(allow),
+ })
+ }
+ })
+}
+
+export function useSetThreadgateAllowMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ postUri,
+ allow,
+ }: {
+ postUri: string
+ allow: ThreadgateAllowUISetting[]
+ }) => {
+ return upsertThreadgate({agent, postUri}, async prev => {
+ if (prev) {
+ return {
+ ...prev,
+ allow: threadgateAllowUISettingToAllowRecordValue(allow),
+ }
+ } else {
+ return createThreadgateRecord({
+ post: postUri,
+ allow: threadgateAllowUISettingToAllowRecordValue(allow),
+ })
+ }
+ })
+ },
+ async onSuccess(_, {postUri, allow}) {
+ await until(
+ 5, // 5 tries
+ 1e3, // 1s delay between tries
+ (res: AppBskyFeedGetPostThread.Response) => {
+ const thread = res.data.thread
+ if (AppBskyFeedDefs.isThreadViewPost(thread)) {
+ const fetchedSettings = threadgateViewToAllowUISetting(
+ thread.post.threadgate,
+ )
+ return JSON.stringify(fetchedSettings) === JSON.stringify(allow)
+ }
+ return false
+ },
+ () => {
+ return agent.app.bsky.feed.getPostThread({
+ uri: postUri,
+ depth: 0,
+ })
+ },
+ )
+
+ queryClient.invalidateQueries({
+ queryKey: [postThreadQueryKeyRoot],
+ })
+ queryClient.invalidateQueries({
+ queryKey: [threadgateRecordQueryKeyRoot],
+ })
+ queryClient.invalidateQueries({
+ queryKey: [threadgateViewQueryKeyRoot],
+ })
+ },
+ })
+}
+
+export function useToggleReplyVisibilityMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ const hiddenReplies = useThreadgateHiddenReplyUrisAPI()
+
+ return useMutation({
+ mutationFn: async ({
+ postUri,
+ replyUri,
+ action,
+ }: {
+ postUri: string
+ replyUri: string
+ action: 'hide' | 'show'
+ }) => {
+ if (action === 'hide') {
+ hiddenReplies.addHiddenReplyUri(replyUri)
+ } else if (action === 'show') {
+ hiddenReplies.removeHiddenReplyUri(replyUri)
+ }
+
+ await upsertThreadgate({agent, postUri}, async prev => {
+ if (prev) {
+ if (action === 'hide') {
+ return mergeThreadgateRecords(prev, {
+ hiddenReplies: [replyUri],
+ })
+ } else if (action === 'show') {
+ return {
+ ...prev,
+ hiddenReplies:
+ prev.hiddenReplies?.filter(uri => uri !== replyUri) || [],
+ }
+ }
+ } else {
+ if (action === 'hide') {
+ return createThreadgateRecord({
+ post: postUri,
+ hiddenReplies: [replyUri],
+ })
+ }
+ }
+ })
+ },
+ onSuccess() {
+ queryClient.invalidateQueries({
+ queryKey: [threadgateRecordQueryKeyRoot],
+ })
+ },
+ onError(_, {replyUri, action}) {
+ if (action === 'hide') {
+ hiddenReplies.removeHiddenReplyUri(replyUri)
+ } else if (action === 'show') {
+ hiddenReplies.addHiddenReplyUri(replyUri)
+ }
+ },
+ })
+}
diff --git a/src/state/queries/threadgate/types.ts b/src/state/queries/threadgate/types.ts
new file mode 100644
index 0000000000..0cbea311cb
--- /dev/null
+++ b/src/state/queries/threadgate/types.ts
@@ -0,0 +1,6 @@
+export type ThreadgateAllowUISetting =
+ | {type: 'everybody'}
+ | {type: 'nobody'}
+ | {type: 'mention'}
+ | {type: 'following'}
+ | {type: 'list'; list: unknown}
diff --git a/src/state/queries/threadgate/util.ts b/src/state/queries/threadgate/util.ts
new file mode 100644
index 0000000000..09ae0a0c1f
--- /dev/null
+++ b/src/state/queries/threadgate/util.ts
@@ -0,0 +1,141 @@
+import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
+
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
+
+export function threadgateViewToAllowUISetting(
+ threadgateView: AppBskyFeedDefs.ThreadgateView | undefined,
+): ThreadgateAllowUISetting[] {
+ const threadgate =
+ threadgateView &&
+ AppBskyFeedThreadgate.isRecord(threadgateView.record) &&
+ AppBskyFeedThreadgate.validateRecord(threadgateView.record).success
+ ? threadgateView.record
+ : undefined
+ return threadgateRecordToAllowUISetting(threadgate)
+}
+
+/**
+ * Converts a full {@link AppBskyFeedThreadgate.Record} to a list of
+ * {@link ThreadgateAllowUISetting}, for use by app UI.
+ */
+export function threadgateRecordToAllowUISetting(
+ threadgate: AppBskyFeedThreadgate.Record | undefined,
+): ThreadgateAllowUISetting[] {
+ /*
+ * If `threadgate` doesn't exist (default), or if `threadgate.allow === undefined`, it means
+ * anyone can reply.
+ *
+ * If `threadgate.allow === []` it means no one can reply, and we translate to UI code
+ * here. This was a historical choice, and we have no lexicon representation
+ * for 'replies disabled' other than an empty array.
+ */
+ if (!threadgate || threadgate.allow === undefined) {
+ return [{type: 'everybody'}]
+ }
+ if (threadgate.allow.length === 0) {
+ return [{type: 'nobody'}]
+ }
+
+ const settings: ThreadgateAllowUISetting[] = threadgate.allow
+ .map(allow => {
+ let setting: ThreadgateAllowUISetting | undefined
+ if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
+ setting = {type: 'mention'}
+ } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
+ setting = {type: 'following'}
+ } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
+ setting = {type: 'list', list: allow.list}
+ }
+ return setting
+ })
+ .filter(n => !!n)
+ return settings
+}
+
+/**
+ * Converts an array of {@link ThreadgateAllowUISetting} to the `allow` prop on
+ * {@link AppBskyFeedThreadgate.Record}.
+ *
+ * If the `allow` property on the record is undefined, we infer that to mean
+ * that everyone can reply. If it's an empty array, we infer that to mean that
+ * no one can reply.
+ */
+export function threadgateAllowUISettingToAllowRecordValue(
+ threadgate: ThreadgateAllowUISetting[],
+): AppBskyFeedThreadgate.Record['allow'] {
+ if (threadgate.find(v => v.type === 'everybody')) {
+ return undefined
+ }
+
+ let allow: (
+ | AppBskyFeedThreadgate.MentionRule
+ | AppBskyFeedThreadgate.FollowingRule
+ | AppBskyFeedThreadgate.ListRule
+ )[] = []
+
+ if (!threadgate.find(v => v.type === 'nobody')) {
+ for (const rule of threadgate) {
+ if (rule.type === 'mention') {
+ allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'})
+ } else if (rule.type === 'following') {
+ allow.push({$type: 'app.bsky.feed.threadgate#followingRule'})
+ } else if (rule.type === 'list') {
+ allow.push({
+ $type: 'app.bsky.feed.threadgate#listRule',
+ list: rule.list,
+ })
+ }
+ }
+ }
+
+ return allow
+}
+
+/**
+ * Merges two {@link AppBskyFeedThreadgate.Record} objects, combining their
+ * `allow` and `hiddenReplies` arrays and de-deduplicating them.
+ *
+ * Note: `allow` can be undefined here, be sure you don't accidentally set it
+ * to an empty array. See other comments in this file.
+ */
+export function mergeThreadgateRecords(
+ prev: AppBskyFeedThreadgate.Record,
+ next: Partial,
+): AppBskyFeedThreadgate.Record {
+ // can be undefined if everyone can reply!
+ const allow: AppBskyFeedThreadgate.Record['allow'] | undefined =
+ prev.allow || next.allow
+ ? [...(prev.allow || []), ...(next.allow || [])].filter(
+ (v, i, a) => a.findIndex(t => t.$type === v.$type) === i,
+ )
+ : undefined
+ const hiddenReplies = Array.from(
+ new Set([...(prev.hiddenReplies || []), ...(next.hiddenReplies || [])]),
+ )
+
+ return createThreadgateRecord({
+ post: prev.post,
+ allow, // can be undefined!
+ hiddenReplies,
+ })
+}
+
+/**
+ * Create a new {@link AppBskyFeedThreadgate.Record} object with the given
+ * properties.
+ */
+export function createThreadgateRecord(
+ threadgate: Partial,
+): AppBskyFeedThreadgate.Record {
+ if (!threadgate.post) {
+ throw new Error('Cannot create a threadgate record without a post URI')
+ }
+
+ return {
+ $type: 'app.bsky.feed.threadgate',
+ post: threadgate.post,
+ createdAt: new Date().toISOString(),
+ allow: threadgate.allow, // can be undefined!
+ hiddenReplies: threadgate.hiddenReplies || [],
+ }
+}
diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts
index f733c37886..0d6a8e99ac 100644
--- a/src/state/queries/util.ts
+++ b/src/state/queries/util.ts
@@ -74,6 +74,7 @@ export function embedViewRecordToPostView(
labels: v.labels,
embed: v.embeds?.[0],
likeCount: v.likeCount,
+ quoteCount: v.quoteCount,
replyCount: v.replyCount,
repostCount: v.repostCount,
}
diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts
index a2c739cfde..cefbf94066 100644
--- a/src/state/queries/video/compress-video.ts
+++ b/src/state/queries/video/compress-video.ts
@@ -1,23 +1,31 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {useMutation} from '@tanstack/react-query'
-import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
+import {cancelable} from '#/lib/async/cancelable'
+import {CompressedVideo} from '#/lib/media/video/types'
+import {compressVideo} from 'lib/media/video/compress'
export function useCompressVideoMutation({
onProgress,
onSuccess,
onError,
+ signal,
}: {
onProgress: (progress: number) => void
onError: (e: any) => void
onSuccess: (video: CompressedVideo) => void
+ signal: AbortSignal
}) {
return useMutation({
- mutationFn: async (asset: ImagePickerAsset) => {
- return await compressVideo(asset.uri, {
- onProgress: num => onProgress(trunc2dp(num)),
- })
- },
+ mutationKey: ['video', 'compress'],
+ mutationFn: cancelable(
+ (asset: ImagePickerAsset) =>
+ compressVideo(asset, {
+ onProgress: num => onProgress(trunc2dp(num)),
+ signal,
+ }),
+ signal,
+ ),
onError,
onSuccess,
onMutate: () => {
diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts
index 266d8aee37..2c1298ab63 100644
--- a/src/state/queries/video/util.ts
+++ b/src/state/queries/video/util.ts
@@ -1,10 +1,13 @@
-const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
+import {useMemo} from 'react'
+import {AtpAgent} from '@atproto/api'
+
+import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
export const createVideoEndpointUrl = (
route: string,
params?: Record,
) => {
- const url = new URL(`${UPLOAD_ENDPOINT}`)
+ const url = new URL(VIDEO_SERVICE)
url.pathname = route
if (params) {
for (const key in params) {
@@ -13,3 +16,41 @@ export const createVideoEndpointUrl = (
}
return url.href
}
+
+export function useVideoAgent() {
+ return useMemo(() => {
+ return new AtpAgent({
+ service: VIDEO_SERVICE,
+ })
+ }, [])
+}
+
+export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
+ switch (mimeType) {
+ case 'video/mp4':
+ return 'mp4'
+ case 'video/webm':
+ return 'webm'
+ case 'video/mpeg':
+ return 'mpeg'
+ case 'video/quicktime':
+ return 'mov'
+ default:
+ throw new Error(`Unsupported mime type: ${mimeType}`)
+ }
+}
+
+export function extToMime(ext: string) {
+ switch (ext) {
+ case 'mp4':
+ return 'video/mp4'
+ case 'webm':
+ return 'video/webm'
+ case 'mpeg':
+ return 'video/mpeg'
+ case 'mov':
+ return 'video/quicktime'
+ default:
+ throw new Error(`Unsupported file extension: ${ext}`)
+ }
+}
diff --git a/src/state/queries/video/video-upload.shared.ts b/src/state/queries/video/video-upload.shared.ts
new file mode 100644
index 0000000000..6b633bf213
--- /dev/null
+++ b/src/state/queries/video/video-upload.shared.ts
@@ -0,0 +1,73 @@
+import {useCallback} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {VIDEO_SERVICE_DID} from '#/lib/constants'
+import {UploadLimitError} from '#/lib/media/video/errors'
+import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
+import {useAgent} from '#/state/session'
+import {useVideoAgent} from './util'
+
+export function useServiceAuthToken({
+ aud,
+ lxm,
+ exp,
+}: {
+ aud?: string
+ lxm: string
+ exp?: number
+}) {
+ const agent = useAgent()
+
+ return useCallback(async () => {
+ const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
+
+ if (!pdsAud) {
+ throw new Error('Agent does not have a PDS URL')
+ }
+
+ const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
+ aud: aud ?? pdsAud,
+ lxm,
+ exp,
+ })
+
+ return serviceAuth.token
+ }, [agent, aud, lxm, exp])
+}
+
+export function useVideoUploadLimits() {
+ const agent = useVideoAgent()
+ const getToken = useServiceAuthToken({
+ lxm: 'app.bsky.video.getUploadLimits',
+ aud: VIDEO_SERVICE_DID,
+ })
+ const {_} = useLingui()
+
+ return useCallback(async () => {
+ const {data: limits} = await agent.app.bsky.video
+ .getUploadLimits(
+ {},
+ {headers: {Authorization: `Bearer ${await getToken()}`}},
+ )
+ .catch(err => {
+ if (err instanceof Error) {
+ throw new UploadLimitError(err.message)
+ } else {
+ throw err
+ }
+ })
+
+ if (!limits.canUpload) {
+ if (limits.message) {
+ throw new UploadLimitError(limits.message)
+ } else {
+ throw new UploadLimitError(
+ _(
+ msg`You have temporarily reached the limit for video uploads. Please try again later.`,
+ ),
+ )
+ }
+ }
+ }, [agent, _, getToken])
+}
diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts
index cf741b2510..170b538901 100644
--- a/src/state/queries/video/video-upload.ts
+++ b/src/state/queries/video/video-upload.ts
@@ -1,52 +1,53 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
+import {AppBskyVideoDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
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 {useAgent, useSession} from '#/state/session'
-
-const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
+import {cancelable} from '#/lib/async/cancelable'
+import {ServerError} from '#/lib/media/video/errors'
+import {CompressedVideo} from '#/lib/media/video/types'
+import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
+import {useSession} from '#/state/session'
+import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
onError,
setProgress,
+ signal,
}: {
- onSuccess: (response: UploadVideoResponse) => void
+ onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
onError: (e: any) => void
setProgress: (progress: number) => void
+ signal: AbortSignal
}) => {
const {currentAccount} = useSession()
- const agent = useAgent()
+ const getToken = useServiceAuthToken({
+ lxm: 'com.atproto.repo.uploadBlob',
+ exp: Date.now() / 1000 + 60 * 30, // 30 minutes
+ })
+ const checkLimits = useVideoUploadLimits()
+ const {_} = useLingui()
return useMutation({
- mutationFn: async (video: CompressedVideo) => {
- const uri = createVideoEndpointUrl('/upload', {
+ mutationKey: ['video', 'upload'],
+ mutationFn: cancelable(async (video: CompressedVideo) => {
+ await checkLimits()
+
+ const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
- name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
+ name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
- // 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,
{
headers: {
- 'dev-key': UPLOAD_HEADER,
- 'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
- Authorization: `Bearer ${serviceAuth.token}`,
+ 'content-type': video.mimeType,
+ Authorization: `Bearer ${await getToken()}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
@@ -59,12 +60,16 @@ export const useUploadVideoMutation = ({
throw new Error('No response')
}
- // @TODO rm, useful for debugging/getting video cid
- console.log('[VIDEO]', res.body)
- const responseBody = JSON.parse(res.body) as UploadVideoResponse
- onSuccess(responseBody)
+ const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
+
+ if (!responseBody.jobId) {
+ throw new ServerError(
+ responseBody.error || _(msg`Failed to upload video`),
+ )
+ }
+
return responseBody
- },
+ }, signal),
onError,
onSuccess,
})
diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts
index b9b0bacfac..c93e206030 100644
--- a/src/state/queries/video/video-upload.web.ts
+++ b/src/state/queries/video/video-upload.web.ts
@@ -1,79 +1,85 @@
+import {AppBskyVideoDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
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 {useAgent, useSession} from '#/state/session'
-
-const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
+import {cancelable} from '#/lib/async/cancelable'
+import {ServerError} from '#/lib/media/video/errors'
+import {CompressedVideo} from '#/lib/media/video/types'
+import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
+import {useSession} from '#/state/session'
+import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
onError,
setProgress,
+ signal,
}: {
- onSuccess: (response: UploadVideoResponse) => void
+ onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
onError: (e: any) => void
setProgress: (progress: number) => void
+ signal: AbortSignal
}) => {
const {currentAccount} = useSession()
- const agent = useAgent()
+ const getToken = useServiceAuthToken({
+ lxm: 'com.atproto.repo.uploadBlob',
+ exp: Date.now() / 1000 + 60 * 30, // 30 minutes
+ })
+ const checkLimits = useVideoUploadLimits()
+ const {_} = useLingui()
return useMutation({
- mutationFn: async (video: CompressedVideo) => {
- const uri = createVideoEndpointUrl('/upload', {
+ mutationKey: ['video', 'upload'],
+ mutationFn: cancelable(async (video: CompressedVideo) => {
+ await checkLimits()
+
+ const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
- name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
+ name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
})
- // 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')
+ let bytes = video.bytes
+ if (!bytes) {
+ bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
- 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 token = await getToken()
const xhr = new XMLHttpRequest()
- const res = (await new Promise((resolve, reject) => {
- xhr.upload.addEventListener('progress', e => {
- const progress = e.loaded / e.total
- setProgress(progress)
- })
- xhr.onloadend = () => {
- if (xhr.readyState === 4) {
- const uploadRes = JSON.parse(
- xhr.responseText,
- ) as UploadVideoResponse
- resolve(uploadRes)
- onSuccess(uploadRes)
- } else {
- reject()
- onError(new Error('Failed to upload video'))
+ const res = await new Promise(
+ (resolve, reject) => {
+ xhr.upload.addEventListener('progress', e => {
+ const progress = e.loaded / e.total
+ setProgress(progress)
+ })
+ xhr.onloadend = () => {
+ if (xhr.readyState === 4) {
+ const uploadRes = JSON.parse(
+ xhr.responseText,
+ ) as AppBskyVideoDefs.JobStatus
+ resolve(uploadRes)
+ } else {
+ reject(new ServerError(_(msg`Failed to upload video`)))
+ }
}
- }
- xhr.onerror = () => {
- reject()
- onError(new Error('Failed to upload video'))
- }
- xhr.open('POST', uri)
- 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
+ xhr.onerror = () => {
+ reject(new ServerError(_(msg`Failed to upload video`)))
+ }
+ xhr.open('POST', uri)
+ xhr.setRequestHeader('Content-Type', video.mimeType)
+ xhr.setRequestHeader('Authorization', `Bearer ${token}`)
+ xhr.send(bytes)
+ },
+ )
+
+ if (!res.jobId) {
+ throw new ServerError(res.error || _(msg`Failed to upload video`))
+ }
- // @TODO rm for prod
- console.log('[VIDEO]', res)
return res
- },
+ }, signal),
onError,
onSuccess,
})
diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts
index 295db38b43..95fc0b68bb 100644
--- a/src/state/queries/video/video.ts
+++ b/src/state/queries/video/video.ts
@@ -1,86 +1,121 @@
-import React from 'react'
+import React, {useCallback, useEffect} from 'react'
import {ImagePickerAsset} from 'expo-image-picker'
+import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useQuery} from '@tanstack/react-query'
+import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
+import {AbortError} from '#/lib/async/cancelable'
+import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {logger} from '#/logger'
-import {CompressedVideo} from 'lib/media/video/compress'
-import {VideoTooLargeError} from 'lib/media/video/errors'
-import {JobState, JobStatus} from 'lib/media/video/types'
+import {isWeb} from '#/platform/detection'
+import {
+ ServerError,
+ UploadLimitError,
+ VideoTooLargeError,
+} from 'lib/media/video/errors'
+import {CompressedVideo} from 'lib/media/video/types'
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
-import {createVideoEndpointUrl} from 'state/queries/video/util'
+import {useVideoAgent} from 'state/queries/video/util'
import {useUploadVideoMutation} from 'state/queries/video/video-upload'
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
type Action =
- | {
- type: 'SetStatus'
- status: Status
- }
- | {
- type: 'SetProgress'
- progress: number
- }
- | {
- type: 'SetError'
- error: string | undefined
- }
+ | {type: 'SetStatus'; status: Status}
+ | {type: 'SetProgress'; progress: number}
+ | {type: 'SetError'; error: string | undefined}
| {type: 'Reset'}
| {type: 'SetAsset'; asset: ImagePickerAsset}
+ | {type: 'SetDimensions'; width: number; height: number}
| {type: 'SetVideo'; video: CompressedVideo}
- | {type: 'SetJobStatus'; jobStatus: JobStatus}
+ | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
+ | {type: 'SetComplete'; blobRef: BlobRef}
export interface State {
status: Status
progress: number
asset?: ImagePickerAsset
video: CompressedVideo | null
- jobStatus?: JobStatus
+ jobStatus?: AppBskyVideoDefs.JobStatus
+ blobRef?: BlobRef
error?: string
+ abortController: AbortController
+ pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
}
-function reducer(state: State, action: Action): State {
- let updatedState = state
- if (action.type === 'SetStatus') {
- updatedState = {...state, status: action.status}
- } else if (action.type === 'SetProgress') {
- updatedState = {...state, progress: action.progress}
- } else if (action.type === 'SetError') {
- updatedState = {...state, error: action.error}
- } else if (action.type === 'Reset') {
- updatedState = {
- status: 'idle',
- progress: 0,
- video: null,
+export type VideoUploadDispatch = (action: Action) => void
+
+function reducer(queryClient: QueryClient) {
+ return (state: State, action: Action): State => {
+ let updatedState = state
+ if (action.type === 'SetStatus') {
+ updatedState = {...state, status: action.status}
+ } else if (action.type === 'SetProgress') {
+ updatedState = {...state, progress: action.progress}
+ } else if (action.type === 'SetError') {
+ updatedState = {...state, error: action.error}
+ } else if (action.type === 'Reset') {
+ state.abortController.abort()
+ queryClient.cancelQueries({
+ queryKey: ['video'],
+ })
+ updatedState = {
+ status: 'idle',
+ progress: 0,
+ video: null,
+ blobRef: undefined,
+ abortController: new AbortController(),
+ }
+ } else if (action.type === 'SetAsset') {
+ updatedState = {
+ ...state,
+ asset: action.asset,
+ status: 'compressing',
+ error: undefined,
+ }
+ } else if (action.type === 'SetDimensions') {
+ updatedState = {
+ ...state,
+ asset: state.asset
+ ? {...state.asset, width: action.width, height: action.height}
+ : undefined,
+ }
+ } else if (action.type === 'SetVideo') {
+ updatedState = {...state, video: action.video, status: 'uploading'}
+ } else if (action.type === 'SetJobStatus') {
+ updatedState = {...state, jobStatus: action.jobStatus}
+ } else if (action.type === 'SetComplete') {
+ updatedState = {
+ ...state,
+ pendingPublish: {
+ blobRef: action.blobRef,
+ mutableProcessed: false,
+ },
+ status: 'done',
+ }
}
- } else if (action.type === 'SetAsset') {
- updatedState = {...state, asset: action.asset}
- } else if (action.type === 'SetVideo') {
- updatedState = {...state, video: action.video}
- } else if (action.type === 'SetJobStatus') {
- updatedState = {...state, jobStatus: action.jobStatus}
+ return updatedState
}
- return updatedState
}
export function useUploadVideo({
setStatus,
- onSuccess,
}: {
setStatus: (status: string) => void
onSuccess: () => void
}) {
const {_} = useLingui()
- const [state, dispatch] = React.useReducer(reducer, {
+ const queryClient = useQueryClient()
+ const [state, dispatch] = React.useReducer(reducer(queryClient), {
status: 'idle',
progress: 0,
video: null,
+ abortController: new AbortController(),
})
const {setJobId} = useUploadStatusQuery({
- onStatusChange: (status: JobStatus) => {
+ onStatusChange: (status: AppBskyVideoDefs.JobStatus) => {
// This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
// Leaving it for now though
dispatch({
@@ -89,13 +124,22 @@ export function useUploadVideo({
})
setStatus(status.state.toString())
},
- onSuccess: () => {
+ onSuccess: blobRef => {
dispatch({
- type: 'SetStatus',
- status: 'idle',
+ type: 'SetComplete',
+ blobRef,
})
- onSuccess()
},
+ onError: useCallback(
+ error => {
+ logger.error('Error processing video', {safeMessage: error})
+ dispatch({
+ type: 'SetError',
+ error: _(msg`Video failed to process`),
+ })
+ },
+ [_],
+ ),
})
const {mutate: onVideoCompressed} = useUploadVideoMutation({
@@ -104,26 +148,75 @@ export function useUploadVideo({
type: 'SetStatus',
status: 'processing',
})
- setJobId(response.job_id)
+ setJobId(response.jobId)
},
onError: e => {
- dispatch({
- type: 'SetError',
- error: _(msg`An error occurred while uploading the video.`),
- })
+ if (e instanceof AbortError) {
+ return
+ } else if (e instanceof ServerError || e instanceof UploadLimitError) {
+ let message
+ // https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
+ switch (e.message) {
+ case 'User is not allowed to upload videos':
+ message = _(msg`You are not allowed to upload videos.`)
+ break
+ case 'Uploading is disabled at the moment':
+ message = _(
+ msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
+ )
+ break
+ case "Failed to get user's upload stats":
+ message = _(
+ msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
+ )
+ break
+ case 'User has exceeded daily upload bytes limit':
+ message = _(
+ msg`You've reached your daily limit for video uploads (too many bytes)`,
+ )
+ break
+ case 'User has exceeded daily upload videos limit':
+ message = _(
+ msg`You've reached your daily limit for video uploads (too many videos)`,
+ )
+ break
+ default:
+ message = e.message
+ break
+ }
+ dispatch({
+ type: 'SetError',
+ error: message,
+ })
+ } else {
+ dispatch({
+ type: 'SetError',
+ error: _(msg`An error occurred while uploading the video.`),
+ })
+ }
logger.error('Error uploading video', {safeMessage: e})
},
setProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
+ signal: state.abortController.signal,
})
const {mutate: onSelectVideo} = useCompressVideoMutation({
onProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
+ onSuccess: (video: CompressedVideo) => {
+ dispatch({
+ type: 'SetVideo',
+ video,
+ })
+ onVideoCompressed(video)
+ },
onError: e => {
- if (e instanceof VideoTooLargeError) {
+ if (e instanceof AbortError) {
+ return
+ } else if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 100MB.`),
@@ -131,69 +224,78 @@ export function useUploadVideo({
} else {
dispatch({
type: 'SetError',
- // @TODO better error message from server, left untranslated on purpose
- error: 'An error occurred while compressing the video.',
+ error: _(msg`An error occurred while compressing the video.`),
})
logger.error('Error compressing video', {safeMessage: e})
}
},
- onSuccess: (video: CompressedVideo) => {
- dispatch({
- type: 'SetVideo',
- video,
- })
- dispatch({
- type: 'SetStatus',
- status: 'uploading',
- })
- onVideoCompressed(video)
- },
+ signal: state.abortController.signal,
})
const selectVideo = (asset: ImagePickerAsset) => {
+ // compression step on native converts to mp4, so no need to check there
+ if (isWeb) {
+ const mimeType = getMimeType(asset)
+ if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) {
+ throw new Error(_(msg`Unsupported video type: ${mimeType}`))
+ }
+ }
+
dispatch({
type: 'SetAsset',
asset,
})
- dispatch({
- type: 'SetStatus',
- status: 'compressing',
- })
onSelectVideo(asset)
}
const clearVideo = () => {
- // @TODO cancel any running jobs
dispatch({type: 'Reset'})
}
+ const updateVideoDimensions = useCallback((width: number, height: number) => {
+ dispatch({
+ type: 'SetDimensions',
+ width,
+ height,
+ })
+ }, [])
+
return {
state,
dispatch,
selectVideo,
clearVideo,
+ updateVideoDimensions,
}
}
const useUploadStatusQuery = ({
onStatusChange,
onSuccess,
+ onError,
}: {
- onStatusChange: (status: JobStatus) => void
- onSuccess: () => void
+ onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
+ onSuccess: (blobRef: BlobRef) => void
+ onError: (error: Error) => void
}) => {
+ const videoAgent = useVideoAgent()
const [enabled, setEnabled] = React.useState(true)
const [jobId, setJobId] = React.useState()
- const {isLoading, isError} = useQuery({
- queryKey: ['video-upload'],
+ const {error} = useQuery({
+ queryKey: ['video', 'upload status', jobId],
queryFn: async () => {
- const url = createVideoEndpointUrl(`/job/${jobId}/status`)
- const res = await fetch(url)
- const status = (await res.json()) as JobStatus
- if (status.state === JobState.JOB_STATE_COMPLETED) {
+ if (!jobId) return // this won't happen, can ignore
+
+ const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId})
+ const status = data.jobStatus
+ if (status.state === 'JOB_STATE_COMPLETED') {
setEnabled(false)
- onSuccess()
+ if (!status.blob)
+ throw new Error('Job completed, but did not return a blob')
+ onSuccess(status.blob)
+ } else if (status.state === 'JOB_STATE_FAILED') {
+ throw new Error(status.error ?? 'Job failed to process')
}
onStatusChange(status)
return status
@@ -202,11 +304,31 @@ const useUploadStatusQuery = ({
refetchInterval: 1500,
})
+ useEffect(() => {
+ if (error) {
+ onError(error)
+ setEnabled(false)
+ }
+ }, [error, onError])
+
return {
- isLoading,
- isError,
setJobId: (_jobId: string) => {
setJobId(_jobId)
+ setEnabled(true)
},
}
}
+
+function getMimeType(asset: ImagePickerAsset) {
+ if (isWeb) {
+ const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
+ if (!mimeType) {
+ throw new Error('Could not determine mime type')
+ }
+ return mimeType
+ }
+ if (!asset.mimeType) {
+ throw new Error('Could not determine mime type')
+ }
+ return asset.mimeType
+}
diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts
index 486604169a..3e22c262cb 100644
--- a/src/state/session/__tests__/session-test.ts
+++ b/src/state/session/__tests__/session-test.ts
@@ -27,7 +27,7 @@ describe('session', () => {
`)
const agent = new BskyAgent({service: 'https://alice.com'})
- agent.session = {
+ agent.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -76,7 +76,7 @@ describe('session', () => {
state = run(state, [
{
- type: 'logged-out',
+ type: 'logged-out-every-account',
},
])
// Should keep the account but clear out the tokens.
@@ -118,7 +118,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -166,7 +166,7 @@ describe('session', () => {
`)
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -230,7 +230,7 @@ describe('session', () => {
`)
const agent3 = new BskyAgent({service: 'https://alice.com'})
- agent3.session = {
+ agent3.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -294,7 +294,7 @@ describe('session', () => {
`)
const agent4 = new BskyAgent({service: 'https://jay.com'})
- agent4.session = {
+ agent4.sessionManager.session = {
active: true,
did: 'jay-did',
handle: 'jay.test',
@@ -372,7 +372,7 @@ describe('session', () => {
state = run(state, [
{
// Log everyone out.
- type: 'logged-out',
+ type: 'logged-out-every-account',
},
])
expect(state.accounts.length).toBe(3)
@@ -445,7 +445,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -466,7 +466,7 @@ describe('session', () => {
state = run(state, [
{
- type: 'logged-out',
+ type: 'logged-out-every-account',
},
])
expect(state.accounts.length).toBe(1)
@@ -502,7 +502,7 @@ describe('session', () => {
`)
const agent2 = new BskyAgent({service: 'https://alice.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -553,7 +553,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -598,7 +598,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -606,7 +606,7 @@ describe('session', () => {
refreshJwt: 'alice-refresh-jwt-1',
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -674,11 +674,108 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe(undefined)
})
+ it('can log out of the current account', () => {
+ let state = getInitialState([])
+
+ const agent1 = new BskyAgent({service: 'https://alice.com'})
+ agent1.sessionManager.session = {
+ active: true,
+ did: 'alice-did',
+ handle: 'alice.test',
+ accessJwt: 'alice-access-jwt-1',
+ refreshJwt: 'alice-refresh-jwt-1',
+ }
+ state = run(state, [
+ {
+ type: 'switched-to-account',
+ newAgent: agent1,
+ newAccount: agentToSessionAccountOrThrow(agent1),
+ },
+ ])
+ expect(state.accounts.length).toBe(1)
+ expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
+ expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
+ expect(state.currentAgentState.did).toBe('alice-did')
+
+ const agent2 = new BskyAgent({service: 'https://bob.com'})
+ agent2.sessionManager.session = {
+ active: true,
+ did: 'bob-did',
+ handle: 'bob.test',
+ accessJwt: 'bob-access-jwt-1',
+ refreshJwt: 'bob-refresh-jwt-1',
+ }
+ state = run(state, [
+ {
+ type: 'switched-to-account',
+ newAgent: agent2,
+ newAccount: agentToSessionAccountOrThrow(agent2),
+ },
+ ])
+ expect(state.accounts.length).toBe(2)
+ expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1')
+ expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-1')
+ expect(state.currentAgentState.did).toBe('bob-did')
+
+ state = run(state, [
+ {
+ type: 'logged-out-current-account',
+ },
+ ])
+ expect(state.accounts.length).toBe(2)
+ expect(state.accounts[0].accessJwt).toBe(undefined)
+ expect(state.accounts[0].refreshJwt).toBe(undefined)
+ expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1')
+ expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1')
+ expect(state.currentAgentState.did).toBe(undefined)
+ expect(printState(state)).toMatchInlineSnapshot(`
+ {
+ "accounts": [
+ {
+ "accessJwt": undefined,
+ "active": true,
+ "did": "bob-did",
+ "email": undefined,
+ "emailAuthFactor": false,
+ "emailConfirmed": false,
+ "handle": "bob.test",
+ "pdsUrl": undefined,
+ "refreshJwt": undefined,
+ "service": "https://bob.com/",
+ "signupQueued": false,
+ "status": undefined,
+ },
+ {
+ "accessJwt": "alice-access-jwt-1",
+ "active": true,
+ "did": "alice-did",
+ "email": undefined,
+ "emailAuthFactor": false,
+ "emailConfirmed": false,
+ "handle": "alice.test",
+ "pdsUrl": undefined,
+ "refreshJwt": "alice-refresh-jwt-1",
+ "service": "https://alice.com/",
+ "signupQueued": false,
+ "status": undefined,
+ },
+ ],
+ "currentAgentState": {
+ "agent": {
+ "service": "https://public.api.bsky.app/",
+ },
+ "did": undefined,
+ },
+ "needsPersist": true,
+ }
+ `)
+ })
+
it('updates stored account with refreshed tokens', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -695,7 +792,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -748,7 +845,7 @@ describe('session', () => {
}
`)
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -801,7 +898,7 @@ describe('session', () => {
}
`)
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -859,7 +956,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -876,7 +973,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -907,7 +1004,7 @@ describe('session', () => {
])
expect(lastState === state).toBe(true)
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -931,7 +1028,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -940,7 +1037,7 @@ describe('session', () => {
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -965,7 +1062,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(2)
expect(state.currentAgentState.did).toBe('bob-did')
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice-updated.test',
@@ -1032,7 +1129,7 @@ describe('session', () => {
}
`)
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob-updated.test',
@@ -1099,7 +1196,7 @@ describe('session', () => {
// Ignore other events for inactive agent.
const lastState = state
- agent1.session = undefined
+ agent1.sessionManager.session = undefined
state = run(state, [
{
type: 'received-agent-event',
@@ -1126,7 +1223,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1135,7 +1232,7 @@ describe('session', () => {
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -1162,7 +1259,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('bob-did')
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1184,11 +1281,11 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('bob-did')
})
- it('does soft logout on network error', () => {
+ it('ignores network errors', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1206,7 +1303,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = undefined
+ agent1.sessionManager.session = undefined
state = run(state, [
{
type: 'received-agent-event',
@@ -1217,11 +1314,9 @@ describe('session', () => {
},
])
expect(state.accounts.length).toBe(1)
- // Network error should reset current user but not reset the tokens.
- // TODO: We might want to remove or change this behavior?
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
- expect(state.currentAgentState.did).toBe(undefined)
+ expect(state.currentAgentState.did).toBe('alice-did')
expect(printState(state)).toMatchInlineSnapshot(`
{
"accounts": [
@@ -1242,9 +1337,9 @@ describe('session', () => {
],
"currentAgentState": {
"agent": {
- "service": "https://public.api.bsky.app/",
+ "service": "https://alice.com/",
},
- "did": undefined,
+ "did": "alice-did",
},
"needsPersist": true,
}
@@ -1255,7 +1350,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1273,7 +1368,7 @@ describe('session', () => {
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = undefined
+ agent1.sessionManager.session = undefined
state = run(state, [
{
type: 'received-agent-event',
@@ -1320,7 +1415,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1338,7 +1433,7 @@ describe('session', () => {
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.currentAgentState.did).toBe('alice-did')
- agent1.session = undefined
+ agent1.sessionManager.session = undefined
state = run(state, [
{
type: 'received-agent-event',
@@ -1385,7 +1480,7 @@ describe('session', () => {
let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'})
- agent1.session = {
+ agent1.sessionManager.session = {
active: true,
did: 'alice-did',
handle: 'alice.test',
@@ -1393,7 +1488,7 @@ describe('session', () => {
refreshJwt: 'alice-refresh-jwt-1',
}
const agent2 = new BskyAgent({service: 'https://bob.com'})
- agent2.session = {
+ agent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -1416,7 +1511,7 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('bob-did')
const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'})
- anotherTabAgent1.session = {
+ anotherTabAgent1.sessionManager.session = {
active: true,
did: 'jay-did',
handle: 'jay.test',
@@ -1424,7 +1519,7 @@ describe('session', () => {
refreshJwt: 'jay-refresh-jwt-1',
}
const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'})
- anotherTabAgent2.session = {
+ anotherTabAgent2.sessionManager.session = {
active: true,
did: 'bob-did',
handle: 'bob.test',
@@ -1492,7 +1587,7 @@ describe('session', () => {
`)
const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'})
- anotherTabAgent3.session = {
+ anotherTabAgent3.sessionManager.session = {
active: true,
did: 'clarence-did',
handle: 'clarence.test',
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 4456ab0bf9..8a48cf95e5 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -12,6 +12,7 @@ import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
+import {emitNetworkConfirmed, emitNetworkLost} from '../events'
import {addSessionErrorLog} from './logging'
import {
configureModerationForAccount,
@@ -22,7 +23,7 @@ import {isSessionExpired, isSignupQueued} from './util'
export function createPublicAgent() {
configureModerationForGuest() // Side effect but only relevant for tests
- return new BskyAgent({service: PUBLIC_BSKY_SERVICE})
+ return new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
}
export async function createAgentAndResume(
@@ -33,9 +34,9 @@ export async function createAgentAndResume(
event: AtpSessionEvent,
) => void,
) {
- const agent = new BskyAgent({service: storedAccount.service})
+ const agent = new BskyAppAgent({service: storedAccount.service})
if (storedAccount.pdsUrl) {
- agent.pdsUrl = agent.api.xrpc.uri = new URL(storedAccount.pdsUrl)
+ agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
}
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
const moderation = configureModerationForAccount(agent, storedAccount)
@@ -43,9 +44,8 @@ export async function createAgentAndResume(
if (isSessionExpired(storedAccount)) {
await networkRetry(1, () => agent.resumeSession(prevSession))
} else {
- agent.session = prevSession
+ agent.sessionManager.session = prevSession
if (!storedAccount.signupQueued) {
- // Intentionally not awaited to unblock the UI:
networkRetry(3, () => agent.resumeSession(prevSession)).catch(
(e: any) => {
logger.error(`networkRetry failed to resume session`, {
@@ -60,7 +60,7 @@ export async function createAgentAndResume(
}
}
- return prepareAgent(agent, gates, moderation, onSessionChange)
+ return agent.prepare(gates, moderation, onSessionChange)
}
export async function createAgentAndLogin(
@@ -81,13 +81,13 @@ export async function createAgentAndLogin(
event: AtpSessionEvent,
) => void,
) {
- const agent = new BskyAgent({service})
+ const agent = new BskyAppAgent({service})
await agent.login({identifier, password, authFactorToken})
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const moderation = configureModerationForAccount(agent, account)
- return prepareAgent(agent, moderation, gates, onSessionChange)
+ return agent.prepare(gates, moderation, onSessionChange)
}
export async function createAgentAndCreateAccount(
@@ -116,7 +116,7 @@ export async function createAgentAndCreateAccount(
event: AtpSessionEvent,
) => void,
) {
- const agent = new BskyAgent({service})
+ const agent = new BskyAppAgent({service})
await agent.createAccount({
email,
password,
@@ -174,32 +174,7 @@ export async function createAgentAndCreateAccount(
logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`})
}
- return prepareAgent(agent, gates, moderation, onSessionChange)
-}
-
-async function prepareAgent(
- agent: BskyAgent,
- // Not awaited in the calling code so we can delay blocking on them.
- gates: Promise,
- moderation: Promise,
- onSessionChange: (
- agent: BskyAgent,
- did: string,
- event: AtpSessionEvent,
- ) => void,
-) {
- // There's nothing else left to do, so block on them here.
- await Promise.all([gates, moderation])
-
- // Now the agent is ready.
- const account = agentToSessionAccountOrThrow(agent)
- agent.setPersistSessionHandler(event => {
- onSessionChange(agent, account.did, event)
- if (event !== 'create' && event !== 'update') {
- addSessionErrorLog(account.did, event)
- }
- })
- return {agent, account}
+ return agent.prepare(gates, moderation, onSessionChange)
}
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
@@ -251,3 +226,77 @@ export function sessionAccountToSession(
status: account.status,
}
}
+
+// Not exported. Use factories above to create it.
+let realFetch = globalThis.fetch
+class BskyAppAgent extends BskyAgent {
+ persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
+ undefined
+
+ constructor({service}: {service: string}) {
+ super({
+ service,
+ async fetch(...args) {
+ let success = false
+ try {
+ const result = await realFetch(...args)
+ success = true
+ return result
+ } catch (e) {
+ success = false
+ throw e
+ } finally {
+ if (success) {
+ emitNetworkConfirmed()
+ } else {
+ emitNetworkLost()
+ }
+ }
+ },
+ persistSession: (event: AtpSessionEvent) => {
+ if (this.persistSessionHandler) {
+ this.persistSessionHandler(event)
+ }
+ },
+ })
+ }
+
+ async prepare(
+ // Not awaited in the calling code so we can delay blocking on them.
+ gates: Promise,
+ moderation: Promise,
+ onSessionChange: (
+ agent: BskyAgent,
+ did: string,
+ event: AtpSessionEvent,
+ ) => void,
+ ) {
+ // There's nothing else left to do, so block on them here.
+ await Promise.all([gates, moderation])
+
+ // Now the agent is ready.
+ const account = agentToSessionAccountOrThrow(this)
+ let lastSession = this.sessionManager.session
+ this.persistSessionHandler = event => {
+ if (this.sessionManager.session) {
+ lastSession = this.sessionManager.session
+ } else if (event === 'network-error') {
+ // Put it back, we'll try again later.
+ this.sessionManager.session = lastSession
+ }
+
+ onSessionChange(this, account.did, event)
+ if (event !== 'create' && event !== 'update') {
+ addSessionErrorLog(account.did, event)
+ }
+ }
+ return {account, agent: this}
+ }
+
+ dispose() {
+ this.sessionManager.session = undefined
+ this.persistSessionHandler = undefined
+ }
+}
+
+export type {BskyAppAgent}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 09fcf86642..21fe7f75b9 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -11,6 +11,7 @@ import {IS_DEV} from '#/env'
import {emitSessionDropped} from '../events'
import {
agentToSessionAccount,
+ BskyAppAgent,
createAgentAndCreateAccount,
createAgentAndLogin,
createAgentAndResume,
@@ -34,7 +35,8 @@ const AgentContext = React.createContext(null)
const ApiContext = React.createContext({
createAccount: async () => {},
login: async () => {},
- logout: async () => {},
+ logoutCurrentAccount: async () => {},
+ logoutEveryAccount: async () => {},
resumeSession: async () => {},
removeAccount: () => {},
})
@@ -114,14 +116,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[onAgentSessionChange, cancelPendingTask],
)
- const logout = React.useCallback(
+ const logoutCurrentAccount = React.useCallback<
+ SessionApiContext['logoutEveryAccount']
+ >(
logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask()
dispatch({
- type: 'logged-out',
+ type: 'logged-out-current-account',
})
- logEvent('account:loggedOut', {logContext})
+ logEvent('account:loggedOut', {logContext, scope: 'current'})
+ addSessionDebugLog({type: 'method:end', method: 'logout'})
+ },
+ [cancelPendingTask],
+ )
+
+ const logoutEveryAccount = React.useCallback<
+ SessionApiContext['logoutEveryAccount']
+ >(
+ logContext => {
+ addSessionDebugLog({type: 'method:start', method: 'logout'})
+ cancelPendingTask()
+ dispatch({
+ type: 'logged-out-every-account',
+ })
+ logEvent('account:loggedOut', {logContext, scope: 'every'})
addSessionDebugLog({type: 'method:end', method: 'logout'})
},
[cancelPendingTask],
@@ -202,7 +221,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} else {
const agent = state.currentAgentState.agent as BskyAgent
const prevSession = agent.session
- agent.session = sessionAccountToSession(syncedAccount)
+ agent.sessionManager.session = sessionAccountToSession(syncedAccount)
addSessionDebugLog({
type: 'agent:patch',
agent,
@@ -229,17 +248,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({
createAccount,
login,
- logout,
+ logoutCurrentAccount,
+ logoutEveryAccount,
resumeSession,
removeAccount,
}),
- [createAccount, login, logout, resumeSession, removeAccount],
+ [
+ createAccount,
+ login,
+ logoutCurrentAccount,
+ logoutEveryAccount,
+ resumeSession,
+ removeAccount,
+ ],
)
// @ts-ignore
if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent
- const agent = state.currentAgentState.agent as BskyAgent
+ const agent = state.currentAgentState.agent as BskyAppAgent
const currentAgentRef = React.useRef(agent)
React.useEffect(() => {
if (currentAgentRef.current !== agent) {
@@ -249,8 +276,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
// We never reuse agents so let's fully neutralize the previous one.
// This ensures it won't try to consume any refresh tokens.
- prevAgent.session = undefined
- prevAgent.setPersistSessionHandler(undefined)
+ prevAgent.dispose()
}
}, [agent])
diff --git a/src/state/session/logging.ts b/src/state/session/logging.ts
index b57f1fa0b0..7e1df500be 100644
--- a/src/state/session/logging.ts
+++ b/src/state/session/logging.ts
@@ -56,7 +56,7 @@ type Log =
type: 'agent:patch'
agent: object
prevSession: AtpSessionData | undefined
- nextSession: AtpSessionData
+ nextSession: AtpSessionData | undefined
}
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts
index 0a537b42c6..22ba47162a 100644
--- a/src/state/session/reducer.ts
+++ b/src/state/session/reducer.ts
@@ -42,7 +42,10 @@ export type Action =
accountDid: string
}
| {
- type: 'logged-out'
+ type: 'logged-out-current-account'
+ }
+ | {
+ type: 'logged-out-every-account'
}
| {
type: 'synced-accounts'
@@ -79,12 +82,8 @@ let reducer = (state: State, action: Action): State => {
return state
}
if (sessionEvent === 'network-error') {
- // Don't change stored accounts but kick to the choose account screen.
- return {
- accounts: state.accounts,
- currentAgentState: createPublicAgentState(),
- needsPersist: true,
- }
+ // Assume it's transient.
+ return state
}
const existingAccount = state.accounts.find(a => a.did === accountDid)
if (
@@ -142,7 +141,23 @@ let reducer = (state: State, action: Action): State => {
needsPersist: true,
}
}
- case 'logged-out': {
+ case 'logged-out-current-account': {
+ const {currentAgentState} = state
+ return {
+ accounts: state.accounts.map(a =>
+ a.did === currentAgentState.did
+ ? {
+ ...a,
+ refreshJwt: undefined,
+ accessJwt: undefined,
+ }
+ : a,
+ ),
+ currentAgentState: createPublicAgentState(),
+ needsPersist: true,
+ }
+ }
+ case 'logged-out-every-account': {
return {
accounts: state.accounts.map(a => ({
...a,
diff --git a/src/state/session/types.ts b/src/state/session/types.ts
index d43b57cca9..d32259de9d 100644
--- a/src/state/session/types.ts
+++ b/src/state/session/types.ts
@@ -29,12 +29,12 @@ export type SessionApiContext = {
},
logContext: LogEvents['account:loggedIn']['logContext'],
) => Promise
- /**
- * A full logout. Clears the `currentAccount` from session, AND removes
- * access tokens from all accounts, so that returning as any user will
- * require a full login.
- */
- logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void
+ logoutCurrentAccount: (
+ logContext: LogEvents['account:loggedOut']['logContext'],
+ ) => void
+ logoutEveryAccount: (
+ logContext: LogEvents['account:loggedOut']['logContext'],
+ ) => void
resumeSession: (account: SessionAccount) => Promise
removeAccount: (account: SessionAccount) => void
}
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index e28d6b4ac0..612388ff86 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -5,8 +5,11 @@ import {
AppBskyRichtextFacet,
ModerationDecision,
} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import * as Toast from '#/view/com/util/Toast'
export interface ComposerOptsPostRef {
uri: string
@@ -22,20 +25,16 @@ export interface ComposerOptsQuote {
text: string
facets?: AppBskyRichtextFacet.Main[]
indexedAt: string
- author: {
- did: string
- handle: string
- displayName?: string
- avatar?: string
- }
+ author: AppBskyActorDefs.ProfileViewBasic
embeds?: AppBskyEmbedRecord.ViewRecord['embeds']
}
export interface ComposerOpts {
replyTo?: ComposerOptsPostRef
onPost?: (postUri: string | undefined) => void
quote?: ComposerOptsQuote
+ quoteCount?: number
mention?: string // handle of user to mention
- openPicker?: (pos: DOMRect | undefined) => void
+ openEmojiPicker?: (pos: DOMRect | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number}[]
}
@@ -55,10 +54,25 @@ const controlsContext = React.createContext({
})
export function Provider({children}: React.PropsWithChildren<{}>) {
+ const {_} = useLingui()
const [state, setState] = React.useState()
const openComposer = useNonReactiveCallback((opts: ComposerOpts) => {
- setState(opts)
+ const author = opts.replyTo?.author || opts.quote?.author
+ const isBlocked = Boolean(
+ author &&
+ (author.viewer?.blocking ||
+ author.viewer?.blockedBy ||
+ author.viewer?.blockingByList),
+ )
+ if (isBlocked) {
+ Toast.show(
+ _(msg`Cannot interact with a blocked user`),
+ 'exclamation-circle',
+ )
+ } else {
+ setState(opts)
+ }
})
const closeComposer = useNonReactiveCallback(() => {
diff --git a/src/state/shell/minimal-mode.tsx b/src/state/shell/minimal-mode.tsx
index 69ce13062a..9230339dd0 100644
--- a/src/state/shell/minimal-mode.tsx
+++ b/src/state/shell/minimal-mode.tsx
@@ -6,32 +6,55 @@ import {
withSpring,
} from 'react-native-reanimated'
-type StateContext = SharedValue
+type StateContext = {
+ headerMode: SharedValue
+ footerMode: SharedValue
+}
type SetContext = (v: boolean) => void
const stateContext = React.createContext({
- value: 0,
- addListener() {},
- removeListener() {},
- modify() {},
+ headerMode: {
+ value: 0,
+ addListener() {},
+ removeListener() {},
+ modify() {},
+ },
+ footerMode: {
+ value: 0,
+ addListener() {},
+ removeListener() {},
+ modify() {},
+ },
})
const setContext = React.createContext((_: boolean) => {})
export function Provider({children}: React.PropsWithChildren<{}>) {
- const mode = useSharedValue(0)
+ const headerMode = useSharedValue(0)
+ const footerMode = useSharedValue(0)
const setMode = React.useCallback(
(v: boolean) => {
'worklet'
// Cancel any existing animation
- cancelAnimation(mode)
- mode.value = withSpring(v ? 1 : 0, {
+ cancelAnimation(headerMode)
+ headerMode.value = withSpring(v ? 1 : 0, {
+ overshootClamping: true,
+ })
+ cancelAnimation(footerMode)
+ footerMode.value = withSpring(v ? 1 : 0, {
overshootClamping: true,
})
},
- [mode],
+ [headerMode, footerMode],
+ )
+ const value = React.useMemo(
+ () => ({
+ headerMode,
+ footerMode,
+ }),
+ [headerMode, footerMode],
)
return (
-
+
{children}
)
diff --git a/src/state/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx
new file mode 100644
index 0000000000..60806f5706
--- /dev/null
+++ b/src/state/threadgate-hidden-replies.tsx
@@ -0,0 +1,85 @@
+import React from 'react'
+import {AppBskyFeedThreadgate} from '@atproto/api'
+
+type StateContext = {
+ uris: Set
+ recentlyUnhiddenUris: Set
+}
+type ApiContext = {
+ addHiddenReplyUri: (uri: string) => void
+ removeHiddenReplyUri: (uri: string) => void
+}
+
+const StateContext = React.createContext({
+ uris: new Set(),
+ recentlyUnhiddenUris: new Set(),
+})
+
+const ApiContext = React.createContext({
+ addHiddenReplyUri: () => {},
+ removeHiddenReplyUri: () => {},
+})
+
+export function Provider({children}: {children: React.ReactNode}) {
+ const [uris, setHiddenReplyUris] = React.useState>(new Set())
+ const [recentlyUnhiddenUris, setRecentlyUnhiddenUris] = React.useState<
+ Set
+ >(new Set())
+
+ const stateCtx = React.useMemo(
+ () => ({
+ uris,
+ recentlyUnhiddenUris,
+ }),
+ [uris, recentlyUnhiddenUris],
+ )
+
+ const apiCtx = React.useMemo(
+ () => ({
+ addHiddenReplyUri(uri: string) {
+ setHiddenReplyUris(prev => new Set(prev.add(uri)))
+ setRecentlyUnhiddenUris(prev => {
+ prev.delete(uri)
+ return new Set(prev)
+ })
+ },
+ removeHiddenReplyUri(uri: string) {
+ setHiddenReplyUris(prev => {
+ prev.delete(uri)
+ return new Set(prev)
+ })
+ setRecentlyUnhiddenUris(prev => new Set(prev.add(uri)))
+ },
+ }),
+ [setHiddenReplyUris],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useThreadgateHiddenReplyUris() {
+ return React.useContext(StateContext)
+}
+
+export function useThreadgateHiddenReplyUrisAPI() {
+ return React.useContext(ApiContext)
+}
+
+export function useMergedThreadgateHiddenReplies({
+ threadgateRecord,
+}: {
+ threadgateRecord?: AppBskyFeedThreadgate.Record
+}) {
+ const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris()
+ return React.useMemo(() => {
+ const set = new Set([...(threadgateRecord?.hiddenReplies || []), ...uris])
+ for (const uri of recentlyUnhiddenUris) {
+ set.delete(uri)
+ }
+ return set
+ }, [uris, recentlyUnhiddenUris, threadgateRecord])
+}
diff --git a/src/storage/README.md b/src/storage/README.md
new file mode 100644
index 0000000000..b7d8d35610
--- /dev/null
+++ b/src/storage/README.md
@@ -0,0 +1,62 @@
+# `#/storage`
+
+## Usage
+
+Import the correctly scoped store from `#/storage`. Each instance of `Storage`
+(the base class, not to be used directly), has the following interface:
+
+- `set([...scope, key], value)`
+- `get([...scope, key])`
+- `remove([...scope, key])`
+- `removeMany([...scope], [...keys])`
+
+For example, using our `device` store looks like this, since it's scoped to the
+device (the most base level scope):
+
+```typescript
+import { device } from '#/storage';
+
+device.set(['foobar'], true);
+device.get(['foobar']);
+device.remove(['foobar']);
+device.removeMany([], ['foobar']);
+```
+
+## TypeScript
+
+Stores are strongly typed, and when setting a given value, it will need to
+conform to the schemas defined in `#/storage/schema`. When getting a value, it
+will be returned to you as the type defined in its schema.
+
+## Scoped Stores
+
+Some stores are (or might be) scoped to an account or other identifier. In this
+case, storage instances are created with type-guards, like this:
+
+```typescript
+type AccountSchema = {
+ language: `${string}-${string}`;
+};
+
+type DID = `did:${string}`;
+
+const account = new Storage<
+ [DID],
+ AccountSchema
+>({
+ id: 'account',
+});
+
+account.set(
+ ['did:plc:abc', 'language'],
+ 'en-US',
+);
+
+const language = account.get([
+ 'did:plc:abc',
+ 'language',
+]);
+```
+
+Here, if `['did:plc:abc']` is not supplied along with the key of
+`language`, the `get` will return undefined (and TS will yell at you).
diff --git a/src/storage/__tests__/index.test.ts b/src/storage/__tests__/index.test.ts
new file mode 100644
index 0000000000..e11affa7a8
--- /dev/null
+++ b/src/storage/__tests__/index.test.ts
@@ -0,0 +1,81 @@
+import {beforeEach, expect, jest, test} from '@jest/globals'
+
+import {Storage} from '#/storage'
+
+jest.mock('react-native-mmkv', () => ({
+ MMKV: class MMKVMock {
+ _store = new Map()
+
+ set(key: string, value: unknown) {
+ this._store.set(key, value)
+ }
+
+ getString(key: string) {
+ return this._store.get(key)
+ }
+
+ delete(key: string) {
+ return this._store.delete(key)
+ }
+ },
+}))
+
+type Schema = {
+ boo: boolean
+ str: string | null
+ num: number
+ obj: Record
+}
+
+const scope = `account`
+const store = new Storage<['account'], Schema>({id: 'test'})
+
+beforeEach(() => {
+ store.removeMany([scope], ['boo', 'str', 'num', 'obj'])
+})
+
+test(`stores and retrieves data`, () => {
+ store.set([scope, 'boo'], true)
+ store.set([scope, 'str'], 'string')
+ store.set([scope, 'num'], 1)
+ expect(store.get([scope, 'boo'])).toEqual(true)
+ expect(store.get([scope, 'str'])).toEqual('string')
+ expect(store.get([scope, 'num'])).toEqual(1)
+})
+
+test(`removes data`, () => {
+ store.set([scope, 'boo'], true)
+ expect(store.get([scope, 'boo'])).toEqual(true)
+ store.remove([scope, 'boo'])
+ expect(store.get([scope, 'boo'])).toEqual(undefined)
+})
+
+test(`removes multiple keys at once`, () => {
+ store.set([scope, 'boo'], true)
+ store.set([scope, 'str'], 'string')
+ store.set([scope, 'num'], 1)
+ store.removeMany([scope], ['boo', 'str', 'num'])
+ expect(store.get([scope, 'boo'])).toEqual(undefined)
+ expect(store.get([scope, 'str'])).toEqual(undefined)
+ expect(store.get([scope, 'num'])).toEqual(undefined)
+})
+
+test(`concatenates keys`, () => {
+ store.remove([scope, 'str'])
+ store.set([scope, 'str'], 'concat')
+ // @ts-ignore accessing these properties for testing purposes only
+ expect(store.store.getString(`${scope}${store.sep}str`)).toBeTruthy()
+})
+
+test(`can store falsy values`, () => {
+ store.set([scope, 'str'], null)
+ store.set([scope, 'num'], 0)
+ expect(store.get([scope, 'str'])).toEqual(null)
+ expect(store.get([scope, 'num'])).toEqual(0)
+})
+
+test(`can store objects`, () => {
+ const obj = {foo: true}
+ store.set([scope, 'obj'], obj)
+ expect(store.get([scope, 'obj'])).toEqual(obj)
+})
diff --git a/src/storage/index.ts b/src/storage/index.ts
new file mode 100644
index 0000000000..819ffab7ec
--- /dev/null
+++ b/src/storage/index.ts
@@ -0,0 +1,72 @@
+import {MMKV} from 'react-native-mmkv'
+
+import {Device} from '#/storage/schema'
+
+/**
+ * Generic storage class. DO NOT use this directly. Instead, use the exported
+ * storage instances below.
+ */
+export class Storage {
+ protected sep = ':'
+ protected store: MMKV
+
+ constructor({id}: {id: string}) {
+ this.store = new MMKV({id})
+ }
+
+ /**
+ * Store a value in storage based on scopes and/or keys
+ *
+ * `set([key], value)`
+ * `set([scope, key], value)`
+ */
+ set(
+ scopes: [...Scopes, Key],
+ data: Schema[Key],
+ ): void {
+ // stored as `{ data: }` structure to ease stringification
+ this.store.set(scopes.join(this.sep), JSON.stringify({data}))
+ }
+
+ /**
+ * Get a value from storage based on scopes and/or keys
+ *
+ * `get([key])`
+ * `get([scope, key])`
+ */
+ get(
+ scopes: [...Scopes, Key],
+ ): Schema[Key] | undefined {
+ const res = this.store.getString(scopes.join(this.sep))
+ if (!res) return undefined
+ // parsed from storage structure `{ data: }`
+ return JSON.parse(res).data
+ }
+
+ /**
+ * Remove a value from storage based on scopes and/or keys
+ *
+ * `remove([key])`
+ * `remove([scope, key])`
+ */
+ remove(scopes: [...Scopes, Key]) {
+ this.store.delete(scopes.join(this.sep))
+ }
+
+ /**
+ * Remove many values from the same storage scope by keys
+ *
+ * `removeMany([], [key])`
+ * `removeMany([scope], [key])`
+ */
+ removeMany(scopes: [...Scopes], keys: Key[]) {
+ keys.forEach(key => this.remove([...scopes, key]))
+ }
+}
+
+/**
+ * Device data that's specific to the device and does not vary based on account
+ *
+ * `device.set([key], true)`
+ */
+export const device = new Storage<[], Device>({id: 'device'})
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
new file mode 100644
index 0000000000..6522d75a36
--- /dev/null
+++ b/src/storage/schema.ts
@@ -0,0 +1,4 @@
+/**
+ * Device data that's specific to the device and does not vary based account
+ */
+export type Device = {}
diff --git a/src/tours/Debug.tsx b/src/tours/Debug.tsx
deleted file mode 100644
index ba643a802b..0000000000
--- a/src/tours/Debug.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react'
-import {useTourGuideController} from 'rn-tourguide'
-
-import {Button} from '#/components/Button'
-import {Text} from '#/components/Typography'
-
-export function TourDebugButton() {
- const {start} = useTourGuideController('home')
- return (
- {
- start()
- }}>
- {() => t}
-
- )
-}
diff --git a/src/tours/HomeTour.tsx b/src/tours/HomeTour.tsx
deleted file mode 100644
index d938fe0e02..0000000000
--- a/src/tours/HomeTour.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import React from 'react'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {
- IStep,
- TourGuideZone,
- TourGuideZoneByPosition,
- useTourGuideController,
-} from 'rn-tourguide'
-
-import {DISCOVER_FEED_URI} from '#/lib/constants'
-import {isWeb} from '#/platform/detection'
-import {useSetSelectedFeed} from '#/state/shell/selected-feed'
-import {TOURS} from '.'
-import {useHeaderPosition} from './positioning'
-
-export function HomeTour() {
- const {_} = useLingui()
- const {tourKey, eventEmitter} = useTourGuideController(TOURS.HOME)
- const setSelectedFeed = useSetSelectedFeed()
- const headerPosition = useHeaderPosition()
-
- React.useEffect(() => {
- const handleOnStepChange = (step?: IStep) => {
- if (step?.order === 2) {
- setSelectedFeed('following')
- } else if (step?.order === 3) {
- setSelectedFeed(`feedgen|${DISCOVER_FEED_URI}`)
- }
- }
- eventEmitter?.on('stepChange', handleOnStepChange)
- return () => {
- eventEmitter?.off('stepChange', handleOnStepChange)
- }
- }, [eventEmitter, setSelectedFeed])
-
- return (
- <>
-
-
-
- >
- )
-}
-
-export function HomeTourExploreWrapper({
- children,
-}: React.PropsWithChildren<{}>) {
- const {_} = useLingui()
- const {tourKey} = useTourGuideController(TOURS.HOME)
- return (
-
- {children}
-
- )
-}
diff --git a/src/tours/Tooltip.tsx b/src/tours/Tooltip.tsx
deleted file mode 100644
index e7727763ba..0000000000
--- a/src/tours/Tooltip.tsx
+++ /dev/null
@@ -1,168 +0,0 @@
-import * as React from 'react'
-import {
- AccessibilityInfo,
- findNodeHandle,
- Pressable,
- Text as RNText,
- View,
-} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {FocusScope} from '@tamagui/focus-scope'
-import {IStep, Labels} from 'rn-tourguide'
-
-import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
-import {useA11y} from '#/state/a11y'
-import {Logo} from '#/view/icons/Logo'
-import {atoms as a, useTheme} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
-import {leading, Text} from '#/components/Typography'
-
-const stopPropagation = (e: any) => e.stopPropagation()
-
-export interface TooltipComponentProps {
- isFirstStep?: boolean
- isLastStep?: boolean
- currentStep: IStep
- labels?: Labels
- handleNext?: () => void
- handlePrev?: () => void
- handleStop?: () => void
-}
-
-export function TooltipComponent({
- isLastStep,
- handleNext,
- handleStop,
- currentStep,
- labels,
-}: TooltipComponentProps) {
- const t = useTheme()
- const {_} = useLingui()
- const btnRef = React.useRef(null)
- const textRef = React.useRef(null)
- const {screenReaderEnabled} = useA11y()
- useWebBodyScrollLock(true)
-
- const focusTextNode = () => {
- const node = textRef.current ? findNodeHandle(textRef.current) : undefined
- if (node) {
- AccessibilityInfo.setAccessibilityFocus(node)
- }
- }
-
- // handle initial focus immediately on mount
- React.useLayoutEffect(() => {
- focusTextNode()
- }, [])
-
- // handle focus between steps
- const innerHandleNext = () => {
- handleNext?.()
- setTimeout(() => focusTextNode(), 200)
- }
-
- return (
-
- true}
- onTouchEnd={stopPropagation}
- style={[
- t.atoms.bg,
- a.px_lg,
- a.py_lg,
- a.flex_col,
- a.gap_md,
- a.rounded_sm,
- a.shadow_md,
- {maxWidth: 300},
- ]}>
- {screenReaderEnabled && (
-
- )}
-
-
-
-
- Quick tip
-
-
-
- {currentStep.text}
-
- {!isLastStep ? (
-
- {labels?.next || _(msg`Next`)}
-
- ) : (
-
- {labels?.finish || _(msg`Let's go!`)}
-
- )}
-
- {screenReaderEnabled && (
-
- )}
-
-
- )
-}
diff --git a/src/tours/index.tsx b/src/tours/index.tsx
deleted file mode 100644
index 8d4ca26b8a..0000000000
--- a/src/tours/index.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react'
-import {InteractionManager} from 'react-native'
-import {TourGuideProvider, useTourGuideController} from 'rn-tourguide'
-
-import {useGate} from '#/lib/statsig/statsig'
-import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
-import {HomeTour} from './HomeTour'
-import {TooltipComponent} from './Tooltip'
-
-export enum TOURS {
- HOME = 'home',
-}
-
-type StateContext = TOURS | null
-type SetContext = (v: TOURS | null) => void
-
-const stateContext = React.createContext(null)
-const setContext = React.createContext((_: TOURS | null) => {})
-
-export function Provider({children}: React.PropsWithChildren<{}>) {
- const theme = useColorModeTheme()
- const [state, setState] = React.useState(() => null)
-
- return (
-
-
-
-
- {children}
-
-
-
- )
-}
-
-export function useTriggerTourIfQueued(tour: TOURS) {
- const {start} = useTourGuideController(tour)
- const setQueuedTour = React.useContext(setContext)
- const queuedTour = React.useContext(stateContext)
- const gate = useGate()
-
- return React.useCallback(() => {
- if (queuedTour === tour) {
- setQueuedTour(null)
- InteractionManager.runAfterInteractions(() => {
- if (gate('new_user_guided_tour')) {
- start()
- }
- })
- }
- }, [tour, queuedTour, setQueuedTour, start, gate])
-}
-
-export function useSetQueuedTour() {
- return React.useContext(setContext)
-}
diff --git a/src/tours/positioning.ts b/src/tours/positioning.ts
deleted file mode 100644
index 03d61f53f0..0000000000
--- a/src/tours/positioning.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import {useWindowDimensions} from 'react-native'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
-
-import {useShellLayout} from '#/state/shell/shell-layout'
-
-export function useHeaderPosition() {
- const {headerHeight} = useShellLayout()
- const {width} = useWindowDimensions()
- const insets = useSafeAreaInsets()
-
- return {
- top: insets.top,
- left: 10,
- width: width - 20,
- height: headerHeight.value,
- borderRadiusObject: {
- topLeft: 4,
- topRight: 4,
- bottomLeft: 4,
- bottomRight: 4,
- },
- }
-}
diff --git a/src/tours/positioning.web.ts b/src/tours/positioning.web.ts
deleted file mode 100644
index fd0f7aa714..0000000000
--- a/src/tours/positioning.web.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import {useWindowDimensions} from 'react-native'
-
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
-import {useShellLayout} from '#/state/shell/shell-layout'
-
-export function useHeaderPosition() {
- const {headerHeight} = useShellLayout()
- const winDim = useWindowDimensions()
- const {isMobile} = useWebMediaQueries()
-
- let left = 0
- let width = winDim.width
- if (width > 590 && !isMobile) {
- left = winDim.width / 2 - 295
- width = 590
- }
-
- let offset = isMobile ? 45 : 0
-
- return {
- top: headerHeight.value - offset,
- left,
- width,
- height: 45,
- borderRadiusObject: undefined,
- }
-}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index dba37d82bf..a637b59966 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -1,5 +1,4 @@
import React, {
- Suspense,
useCallback,
useEffect,
useImperativeHandle,
@@ -21,12 +20,19 @@ import {
// @ts-expect-error no type definition
import ProgressCircle from 'react-native-progress/Circle'
import Animated, {
+ Easing,
FadeIn,
FadeOut,
interpolateColor,
+ LayoutAnimationConfig,
+ LinearTransition,
useAnimatedStyle,
+ useDerivedValue,
useSharedValue,
+ withRepeat,
withTiming,
+ ZoomIn,
+ ZoomOut,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
@@ -40,72 +46,81 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {observer} from 'mobx-react-lite'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import * as apilib from '#/lib/api/index'
import {until} from '#/lib/async/until'
+import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
import {
createGIFDescription,
parseAltFromGIFDescription,
} from '#/lib/gif-alt-text'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent, useGate} from '#/lib/statsig/statsig'
+import {cleanError} from '#/lib/strings/errors'
+import {insertMentionAt} from '#/lib/strings/mention-manip'
+import {shortenLinks} from '#/lib/strings/rich-text-manip'
+import {colors, s} from '#/lib/styles'
import {logger} from '#/logger'
+import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
+import {useDialogStateControlContext} from '#/state/dialogs'
import {emitPostCreated} from '#/state/events'
import {useModalControls} from '#/state/modals'
import {useModals} from '#/state/modals'
+import {GalleryModel} from '#/state/models/media/gallery'
import {useRequireAltTextEnabled} from '#/state/preferences'
import {
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
+import {createPostgateRecord} from '#/state/queries/postgate/util'
import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
-import {ThreadgateSetting} from '#/state/queries/threadgate'
-import {useUploadVideo} from '#/state/queries/video/video'
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
+import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
+import {
+ State as VideoUploadState,
+ useUploadVideo,
+ VideoUploadDispatch,
+} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
-import {useAnalytics} from 'lib/analytics/analytics'
-import * as apilib from 'lib/api/index'
-import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
-import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {cleanError} from 'lib/strings/errors'
-import {insertMentionAt} from 'lib/strings/mention-manip'
-import {shortenLinks} from 'lib/strings/rich-text-manip'
-import {colors, s} from 'lib/styles'
-import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
-import {useDialogStateControlContext} from 'state/dialogs'
-import {GalleryModel} from 'state/models/media/gallery'
-import {State as VideoUploadState} from 'state/queries/video/video'
-import {ComposerOpts} from 'state/shell/composer'
-import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
-import {atoms as a, useTheme} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
-import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
-import * as Prompt from '#/components/Prompt'
-import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
-import {Text} from '../util/text/Text'
-import * as Toast from '../util/Toast'
-import {UserAvatar} from '../util/UserAvatar'
-import {CharProgress} from './char-progress/CharProgress'
-import {ExternalEmbed} from './ExternalEmbed'
-import {GifAltText} from './GifAltText'
-import {LabelsBtn} from './labels/LabelsBtn'
-import {Gallery} from './photos/Gallery'
-import {OpenCameraBtn} from './photos/OpenCameraBtn'
-import {SelectGifBtn} from './photos/SelectGifBtn'
-import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
-import {SelectLangBtn} from './select-language/SelectLangBtn'
-import {SuggestedLanguage} from './select-language/SuggestedLanguage'
+import {ComposerOpts} from '#/state/shell/composer'
+import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
+import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
+import {ExternalEmbed} from '#/view/com/composer/ExternalEmbed'
+import {GifAltText} from '#/view/com/composer/GifAltText'
+import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
+import {Gallery} from '#/view/com/composer/photos/Gallery'
+import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
+import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
+import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn'
+import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
+import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
// TODO: Prevent naming components that coincide with RN primitives
// due to linting false positives
-import {TextInput, TextInputRef} from './text-input/TextInput'
-import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
-import {useExternalLinkFetch} from './useExternalLinkFetch'
-import {SelectVideoBtn} from './videos/SelectVideoBtn'
-import {VideoPreview} from './videos/VideoPreview'
-import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
+import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput'
+import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
+import {useExternalLinkFetch} from '#/view/com/composer/useExternalLinkFetch'
+import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
+import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
+import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
+import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
+import {QuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
+import {Text} from '#/view/com/util/text/Text'
+import * as Toast from '#/view/com/util/Toast'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, native, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import * as Prompt from '#/components/Prompt'
+import {Text as NewText} from '#/components/Typography'
type CancelRef = {
onPressCancel: () => void
@@ -116,8 +131,9 @@ export const ComposePost = observer(function ComposePost({
replyTo,
onPost,
quote: initQuote,
+ quoteCount,
mention: initMention,
- openPicker,
+ openEmojiPicker,
text: initText,
imageUris: initImageUris,
cancelRef,
@@ -167,24 +183,35 @@ export const ComposePost = observer(function ComposePost({
initQuote,
)
+ const [videoAltText, setVideoAltText] = useState('')
+ const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
+
const {
selectVideo,
clearVideo,
state: videoUploadState,
+ updateVideoDimensions,
+ dispatch: videoUploadDispatch,
} = useUploadVideo({
- setStatus: (status: string) => setProcessingState(status),
+ setStatus: setProcessingState,
onSuccess: () => {
if (publishOnUpload) {
onPressPublish(true)
}
},
})
+ const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
+
const [publishOnUpload, setPublishOnUpload] = useState(false)
- const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
+ const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
const [extGif, setExtGif] = useState()
const [labels, setLabels] = useState([])
- const [threadgate, setThreadgate] = useState([])
+ const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] =
+ useState(
+ threadgateViewToAllowUISetting(undefined),
+ )
+ const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
const gallery = useMemo(
() => new GalleryModel(initImageUris),
@@ -205,7 +232,12 @@ export const ComposePost = observer(function ComposePost({
)
const onPressCancel = useCallback(() => {
- if (graphemeLength > 0 || !gallery.isEmpty || extGif) {
+ if (
+ graphemeLength > 0 ||
+ !gallery.isEmpty ||
+ extGif ||
+ videoUploadState.status !== 'idle'
+ ) {
closeAllDialogs()
Keyboard.dismiss()
discardPromptControl.open()
@@ -219,6 +251,7 @@ export const ComposePost = observer(function ComposePost({
closeAllDialogs,
discardPromptControl,
onClose,
+ videoUploadState.status,
])
useImperativeHandle(cancelRef, () => ({onPressCancel}))
@@ -288,118 +321,188 @@ export const ComposePost = observer(function ComposePost({
return false
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
- const onPressPublish = async (finishedUploading?: boolean) => {
- if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
- return
- }
+ const onPressPublish = React.useCallback(
+ async (finishedUploading?: boolean) => {
+ if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
+ return
+ }
- if (isAltTextRequiredAndMissing) {
- return
- }
+ if (isAltTextRequiredAndMissing) {
+ return
+ }
- if (
- !finishedUploading &&
- videoUploadState.status !== 'idle' &&
- videoUploadState.asset
- ) {
- setPublishOnUpload(true)
- return
- }
+ if (
+ !finishedUploading &&
+ videoUploadState.asset &&
+ videoUploadState.status !== 'done'
+ ) {
+ setPublishOnUpload(true)
+ return
+ }
- setError('')
+ setError('')
- if (
- richtext.text.trim().length === 0 &&
- gallery.isEmpty &&
- !extLink &&
- !quote
- ) {
- setError(_(msg`Did you want to say anything?`))
- return
- }
- if (extLink?.isLoading) {
- setError(_(msg`Please wait for your link card to finish loading`))
- return
- }
+ if (
+ richtext.text.trim().length === 0 &&
+ gallery.isEmpty &&
+ !extLink &&
+ !quote &&
+ videoUploadState.status === 'idle'
+ ) {
+ setError(_(msg`Did you want to say anything?`))
+ return
+ }
+ if (extLink?.isLoading) {
+ setError(_(msg`Please wait for your link card to finish loading`))
+ return
+ }
- setIsProcessing(true)
+ setIsProcessing(true)
- let postUri
- try {
- postUri = (
- await apilib.post(agent, {
- rawText: richtext.text,
- replyTo: replyTo?.uri,
- images: gallery.images,
- quote,
- extLink,
- labels,
- threadgate,
- onStateChange: setProcessingState,
- langs: toPostLanguages(langPrefs.postLanguage),
- })
- ).uri
+ let postUri
try {
- await whenAppViewReady(agent, postUri, res => {
- const thread = res.data.thread
- return AppBskyFeedDefs.isThreadViewPost(thread)
+ postUri = (
+ await apilib.post(agent, {
+ rawText: richtext.text,
+ replyTo: replyTo?.uri,
+ images: gallery.images,
+ quote,
+ extLink,
+ labels,
+ threadgate: threadgateAllowUISettings,
+ postgate,
+ onStateChange: setProcessingState,
+ langs: toPostLanguages(langPrefs.postLanguage),
+ video: videoUploadState.pendingPublish?.blobRef
+ ? {
+ blobRef: videoUploadState.pendingPublish.blobRef,
+ altText: videoAltText,
+ captions: captions,
+ aspectRatio: videoUploadState.asset
+ ? {
+ width: videoUploadState.asset?.width,
+ height: videoUploadState.asset?.height,
+ }
+ : undefined,
+ }
+ : undefined,
+ })
+ ).uri
+ try {
+ await whenAppViewReady(agent, postUri, res => {
+ const thread = res.data.thread
+ return AppBskyFeedDefs.isThreadViewPost(thread)
+ })
+ } catch (waitErr: any) {
+ logger.error(waitErr, {
+ message: `Waiting for app view failed`,
+ })
+ // Keep going because the post *was* published.
+ }
+ } catch (e: any) {
+ logger.error(e, {
+ message: `Composer: create post failed`,
+ hasImages: gallery.size > 0,
})
- } catch (waitErr: any) {
- logger.error(waitErr, {
- message: `Waiting for app view failed`,
- })
- // Keep going because the post *was* published.
- }
- } catch (e: any) {
- logger.error(e, {
- message: `Composer: create post failed`,
- hasImages: gallery.size > 0,
- })
- if (extLink) {
- setExtLink({
- ...extLink,
- isLoading: true,
- localThumb: undefined,
- } as apilib.ExternalEmbedDraft)
- }
- let err = cleanError(e.message)
- if (err.includes('not locate record')) {
- err = _(
- msg`We're sorry! The post you are replying to has been deleted.`,
- )
- }
- setError(err)
- setIsProcessing(false)
- return
- } finally {
- if (postUri) {
- logEvent('post:create', {
+ if (extLink) {
+ setExtLink({
+ ...extLink,
+ isLoading: true,
+ localThumb: undefined,
+ } as apilib.ExternalEmbedDraft)
+ }
+ let err = cleanError(e.message)
+ if (err.includes('not locate record')) {
+ err = _(
+ msg`We're sorry! The post you are replying to has been deleted.`,
+ )
+ }
+ setError(err)
+ setIsProcessing(false)
+ return
+ } finally {
+ if (postUri) {
+ logEvent('post:create', {
+ imageCount: gallery.size,
+ isReply: replyTo != null,
+ hasLink: extLink != null,
+ hasQuote: quote != null,
+ langs: langPrefs.postLanguage,
+ logContext: 'Composer',
+ })
+ }
+ track('Create Post', {
imageCount: gallery.size,
- isReply: replyTo != null,
- hasLink: extLink != null,
- hasQuote: quote != null,
- langs: langPrefs.postLanguage,
- logContext: 'Composer',
})
+ if (replyTo && replyTo.uri) track('Post:Reply')
+ }
+ if (postUri && !replyTo) {
+ emitPostCreated()
+ }
+ setLangPrefs.savePostLanguageToHistory()
+ if (quote) {
+ // We want to wait for the quote count to update before we call `onPost`, which will refetch data
+ whenAppViewReady(agent, quote.uri, res => {
+ const thread = res.data.thread
+ if (
+ AppBskyFeedDefs.isThreadViewPost(thread) &&
+ thread.post.quoteCount !== quoteCount
+ ) {
+ onPost?.(postUri)
+ return true
+ }
+ return false
+ })
+ } else {
+ onPost?.(postUri)
+ }
+ onClose()
+ Toast.show(
+ replyTo
+ ? _(msg`Your reply has been published`)
+ : _(msg`Your post has been published`),
+ )
+ },
+ [
+ _,
+ agent,
+ captions,
+ extLink,
+ gallery.images,
+ gallery.isEmpty,
+ gallery.size,
+ graphemeLength,
+ isAltTextRequiredAndMissing,
+ isProcessing,
+ labels,
+ langPrefs.postLanguage,
+ onClose,
+ onPost,
+ postgate,
+ quote,
+ quoteCount,
+ replyTo,
+ richtext.text,
+ setExtLink,
+ setLangPrefs,
+ threadgateAllowUISettings,
+ track,
+ videoAltText,
+ videoUploadState.asset,
+ videoUploadState.pendingPublish,
+ videoUploadState.status,
+ ],
+ )
+
+ React.useEffect(() => {
+ if (videoUploadState.pendingPublish && publishOnUpload) {
+ if (!videoUploadState.pendingPublish.mutableProcessed) {
+ videoUploadState.pendingPublish.mutableProcessed = true
+ onPressPublish(true)
}
- track('Create Post', {
- imageCount: gallery.size,
- })
- if (replyTo && replyTo.uri) track('Post:Reply')
}
- if (postUri && !replyTo) {
- emitPostCreated()
- }
- setLangPrefs.savePostLanguageToHistory()
- onPost?.(postUri)
- onClose()
- Toast.show(
- replyTo
- ? _(msg`Your reply has been published`)
- : _(msg`Your post has been published`),
- )
- }
+ }, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish])
const canPost = useMemo(
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
@@ -418,8 +521,8 @@ export const ComposePost = observer(function ComposePost({
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => {
- openPicker?.(textInput.current?.getCursorPosition())
- }, [openPicker])
+ openEmojiPicker?.(textInput.current?.getCursorPosition())
+ }, [openEmojiPicker])
const focusTextInput = useCallback(() => {
textInput.current?.focus()
@@ -480,7 +583,9 @@ export const ComposePost = observer(function ComposePost({
keyboardVerticalOffset={keyboardVerticalOffset}
style={a.flex_1}>
-
+
>
) : (
- <>
+
)}
- >
+
)}
@@ -564,20 +669,15 @@ export const ComposePost = observer(function ComposePost({
)}
- {error !== '' && (
-
-
-
-
- {error}
-
- )}
+ setError('')}
+ videoUploadDispatch={videoUploadDispatch}
+ />
)}
-
-
+
+ {hasVideo && (
+
+ {videoUploadState.asset &&
+ (videoUploadState.status === 'compressing' ? (
+
+ ) : videoUploadState.video ? (
+
+ ) : null)}
+
+
+ )}
+
+
{quote ? (
@@ -643,29 +772,18 @@ export const ComposePost = observer(function ComposePost({
)}
) : null}
- {videoUploadState.status === 'compressing' &&
- videoUploadState.asset ? (
-
- ) : videoUploadState.video ? (
- // remove suspense when we get rid of lazy
-
-
-
- ) : null}
{replyTo ? null : (
)}
@@ -675,15 +793,17 @@ export const ComposePost = observer(function ComposePost({
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
- {videoUploadState.status !== 'idle' ? (
+ {videoUploadState.status !== 'idle' &&
+ videoUploadState.status !== 'done' ? (
) : (
- {gate('videos') && (
+ {gate('video_upload') && (
)}
@@ -872,6 +992,10 @@ const styles = StyleSheet.create({
paddingVertical: 6,
marginLeft: 12,
},
+ postBtnWrapper: {
+ flexDirection: 'row',
+ gap: 14,
+ },
errorLine: {
flexDirection: 'row',
alignItems: 'center',
@@ -932,6 +1056,80 @@ const styles = StyleSheet.create({
},
})
+function ErrorBanner({
+ error: standardError,
+ videoUploadState,
+ clearError,
+ videoUploadDispatch,
+}: {
+ error: string
+ videoUploadState: VideoUploadState
+ clearError: () => void
+ videoUploadDispatch: VideoUploadDispatch
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+
+ const videoError =
+ videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
+ const error = standardError || videoError
+
+ const onClearError = () => {
+ if (standardError) {
+ clearError()
+ } else {
+ videoUploadDispatch({type: 'Reset'})
+ }
+ }
+
+ if (!error) return null
+
+ return (
+
+
+
+
+
+ {error}
+
+
+
+
+
+ {videoError && videoUploadState.jobStatus?.jobId && (
+
+ Job ID: {videoUploadState.jobStatus.jobId}
+
+ )}
+
+
+ )
+}
+
function ToolbarWrapper({
style,
children,
@@ -952,23 +1150,67 @@ function ToolbarWrapper({
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme()
+ const {_} = useLingui()
+ const progress = state.jobStatus?.progress
+ ? state.jobStatus.progress / 100
+ : state.progress
+ const shouldRotate =
+ state.status === 'processing' && (progress === 0 || progress === 1)
+ let wheelProgress = shouldRotate ? 0.33 : progress
- const progress =
- state.status === 'compressing' || state.status === 'uploading'
- ? state.progress
- : state.jobStatus?.progress ?? 100
+ const rotate = useDerivedValue(() => {
+ if (shouldRotate) {
+ return withRepeat(
+ withTiming(360, {
+ duration: 2500,
+ easing: Easing.out(Easing.cubic),
+ }),
+ -1,
+ )
+ }
+ return 0
+ })
+
+ const animatedStyle = useAnimatedStyle(() => {
+ return {
+ transform: [{rotateZ: `${rotate.value}deg`}],
+ }
+ })
+
+ let text = ''
+
+ switch (state.status) {
+ case 'compressing':
+ text = _('Compressing video...')
+ break
+ case 'uploading':
+ text = _('Uploading video...')
+ break
+ case 'processing':
+ text = _('Processing video...')
+ break
+ case 'done':
+ text = _('Video uploaded')
+ break
+ }
+
+ if (state.error) {
+ text = _('Error')
+ wheelProgress = 100
+ }
return (
-
-
- {state.status}
+
+
+
+
+ {text}
)
}
diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
index 7742900a83..57ccc2943a 100644
--- a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
+++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
@@ -25,8 +25,8 @@ export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) {
}}
onPress={onRemove}
accessibilityRole="button"
- accessibilityLabel={_(msg`Remove image preview`)}
- accessibilityHint={_(msg`Removes the image preview`)}
+ accessibilityLabel={_(msg`Remove attachment`)}
+ accessibilityHint={_(msg`Removes the attachment`)}
onAccessibilityEscape={onRemove}>
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index 3c4aaf7388..c477ada065 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder'
import {Text as TiptapText} from '@tiptap/extension-text'
import {generateJSON} from '@tiptap/html'
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
-import EventEmitter from 'eventemitter3'
import {usePalette} from '#/lib/hooks/usePalette'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {blobToDataUri, isUriImage} from 'lib/media/util'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
LinkFacetMatch,
suggestLinkCardUri,
@@ -46,8 +46,6 @@ interface TextInputProps {
onError: (err: string) => void
}
-export const textInputWebEmitter = new EventEmitter()
-
export const TextInput = React.forwardRef(function TextInputImpl(
{
richtext,
diff --git a/src/view/com/composer/text-input/textInputWebEmitter.ts b/src/view/com/composer/text-input/textInputWebEmitter.ts
new file mode 100644
index 0000000000..fb037cac2e
--- /dev/null
+++ b/src/view/com/composer/text-input/textInputWebEmitter.ts
@@ -0,0 +1,3 @@
+import EventEmitter from 'eventemitter3'
+
+export const textInputWebEmitter = new EventEmitter()
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index 1f4178f7f8..ad3bb30eca 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -7,8 +7,8 @@ import {
} from 'react-native'
import Picker from '@emoji-mart/react'
+import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a} from '#/alf'
-import {textInputWebEmitter} from '../TextInput.web'
const HEIGHT_OFFSET = 40
const WIDTH_OFFSET = 100
@@ -26,22 +26,41 @@ export type Emoji = {
unified: string
}
+export interface EmojiPickerPosition {
+ top: number
+ left: number
+ right: number
+ bottom: number
+}
+
export interface EmojiPickerState {
isOpen: boolean
- pos: {top: number; left: number; right: number; bottom: number}
+ pos: EmojiPickerPosition
}
interface IProps {
state: EmojiPickerState
close: () => void
+ /**
+ * If `true`, overrides position and ensures picker is pinned to the top of
+ * the target element.
+ */
+ pinToTop?: boolean
}
-export function EmojiPicker({state, close}: IProps) {
+export function EmojiPicker({state, close, pinToTop}: IProps) {
const {height, width} = useWindowDimensions()
const isShiftDown = React.useRef(false)
const position = React.useMemo(() => {
+ if (pinToTop) {
+ return {
+ top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10,
+ left: state.pos.left,
+ }
+ }
+
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
const fitsAbove = PICKER_HEIGHT < state.pos.top
const placeOnLeft = PICKER_WIDTH < state.pos.left
@@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) {
: undefined,
}
}
- }, [state.pos, height, width])
+ }, [state.pos, height, width, pinToTop])
React.useEffect(() => {
if (!state.isOpen) return
diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
index 6cf2eea2c4..666473afd9 100644
--- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx
+++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
@@ -1,27 +1,33 @@
import React from 'react'
import {Keyboard, StyleProp, ViewStyle} from 'react-native'
import Animated, {AnimatedStyle} from 'react-native-reanimated'
+import {AppBskyFeedPostgate} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
-import {ThreadgateSetting} from '#/state/queries/threadgate'
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {useAnalytics} from 'lib/analytics/analytics'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
-import {ThreadgateEditorDialog} from '#/components/dialogs/ThreadgateEditor'
-import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
export function ThreadgateBtn({
- threadgate,
- onChange,
+ postgate,
+ onChangePostgate,
+ threadgateAllowUISettings,
+ onChangeThreadgateAllowUISettings,
style,
}: {
- threadgate: ThreadgateSetting[]
- onChange: (v: ThreadgateSetting[]) => void
+ postgate: AppBskyFeedPostgate.Record
+ onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
+
+ threadgateAllowUISettings: ThreadgateAllowUISetting[]
+ onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
+
style?: StyleProp>
}) {
const {track} = useAnalytics()
@@ -38,13 +44,15 @@ export function ThreadgateBtn({
control.open()
}
- const isEverybody = threadgate.length === 0
- const isNobody = !!threadgate.find(gate => gate.type === 'nobody')
- const label = isEverybody
- ? _(msg`Everybody can reply`)
- : isNobody
- ? _(msg`Nobody can reply`)
- : _(msg`Some people can reply`)
+ const anyoneCanReply =
+ threadgateAllowUISettings.length === 1 &&
+ threadgateAllowUISettings[0].type === 'everybody'
+ const anyoneCanQuote =
+ !postgate.embeddingRules || postgate.embeddingRules.length === 0
+ const anyoneCanInteract = anyoneCanReply && anyoneCanQuote
+ const label = anyoneCanInteract
+ ? _(msg`Anybody can interact`)
+ : _(msg`Interaction limited`)
return (
<>
@@ -59,16 +67,19 @@ export function ThreadgateBtn({
accessibilityHint={_(
msg`Opens a dialog to choose who can reply to this thread`,
)}>
-
+
{label}
- {
+ control.close()
+ }}
+ postgate={postgate}
+ onChangePostgate={onChangePostgate}
+ threadgateAllowUISettings={threadgateAllowUISettings}
+ onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
/>
>
)
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 2938ea25ac..3175144372 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -1,4 +1,6 @@
import {useEffect, useState} from 'react'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useFetchDid} from '#/state/queries/handle'
@@ -7,6 +9,7 @@ import {useAgent} from '#/state/session'
import * as apilib from 'lib/api/index'
import {POST_IMG_MAX} from 'lib/constants'
import {
+ EmbeddingDisabledError,
getFeedAsEmbed,
getListAsEmbed,
getPostAsQuote,
@@ -28,9 +31,12 @@ import {ComposerOpts} from 'state/shell/composer'
export function useExternalLinkFetch({
setQuote,
+ setError,
}: {
setQuote: (opts: ComposerOpts['quote']) => void
+ setError: (err: string) => void
}) {
+ const {_} = useLingui()
const [extLink, setExtLink] = useState(
undefined,
)
@@ -57,9 +63,13 @@ export function useExternalLinkFetch({
setExtLink(undefined)
},
err => {
- logger.error('Failed to fetch post for quote embedding', {
- message: err.toString(),
- })
+ if (err instanceof EmbeddingDisabledError) {
+ setError(_(msg`This post's author has disabled quote posts.`))
+ } else {
+ logger.error('Failed to fetch post for quote embedding', {
+ message: err.toString(),
+ })
+ }
setExtLink(undefined)
},
)
@@ -170,7 +180,7 @@ export function useExternalLinkFetch({
})
}
return cleanup
- }, [extLink, setQuote, getPost, fetchDid, agent])
+ }, [_, extLink, setQuote, getPost, fetchDid, agent, setError])
return {extLink, setExtLink}
}
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
index 9c528a92e2..6e294ba9c3 100644
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ b/src/view/com/composer/videos/SelectVideoBtn.tsx
@@ -1,4 +1,5 @@
import React, {useCallback} from 'react'
+import {Keyboard} from 'react-native'
import {
ImagePickerAsset,
launchImageLibraryAsync,
@@ -10,40 +11,66 @@ import {useLingui} from '@lingui/react'
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
import {isNative} from '#/platform/detection'
+import {useModalControls} from '#/state/modals'
+import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
+import * as Prompt from '#/components/Prompt'
-const VIDEO_MAX_DURATION = 90
+const VIDEO_MAX_DURATION = 60
type Props = {
onSelectVideo: (video: ImagePickerAsset) => void
disabled?: boolean
+ setError: (error: string) => void
}
-export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
+export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
const {_} = useLingui()
const t = useTheme()
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
+ const control = Prompt.usePromptControl()
+ const {currentAccount} = useSession()
const onPressSelectVideo = useCallback(async () => {
if (isNative && !(await requestVideoAccessIfNeeded())) {
return
}
- const response = await launchImageLibraryAsync({
- exif: false,
- mediaTypes: MediaTypeOptions.Videos,
- videoMaxDuration: VIDEO_MAX_DURATION,
- quality: 1,
- legacy: true,
- preferredAssetRepresentationMode:
- UIImagePickerPreferredAssetRepresentationMode.Current,
- })
- if (response.assets && response.assets.length > 0) {
- onSelectVideo(response.assets[0])
+ if (!currentAccount?.emailConfirmed) {
+ Keyboard.dismiss()
+ control.open()
+ } else {
+ const response = await launchImageLibraryAsync({
+ exif: false,
+ mediaTypes: MediaTypeOptions.Videos,
+ videoMaxDuration: VIDEO_MAX_DURATION,
+ quality: 1,
+ legacy: true,
+ preferredAssetRepresentationMode:
+ UIImagePickerPreferredAssetRepresentationMode.Current,
+ })
+ if (response.assets && response.assets.length > 0) {
+ try {
+ onSelectVideo(response.assets[0])
+ } catch (err) {
+ if (err instanceof Error) {
+ setError(err.message)
+ } else {
+ setError(_(msg`An error occurred while selecting the video`))
+ }
+ }
+ }
}
- }, [onSelectVideo, requestVideoAccessIfNeeded])
+ }, [
+ onSelectVideo,
+ requestVideoAccessIfNeeded,
+ setError,
+ _,
+ control,
+ currentAccount?.emailConfirmed,
+ ])
return (
<>
@@ -62,6 +89,32 @@ export function SelectVideoBtn({onSelectVideo, disabled}: Props) {
style={disabled && t.atoms.text_contrast_low}
/>
+
>
)
}
+
+function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
+ const {_} = useLingui()
+ const {openModal} = useModalControls()
+
+ return (
+ {
+ control.close(() => {
+ openModal({
+ name: 'verify-email',
+ showReminder: false,
+ })
+ })
+ }}
+ />
+ )
+}
diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx
new file mode 100644
index 0000000000..009087452c
--- /dev/null
+++ b/src/view/com/composer/videos/SubtitleDialog.tsx
@@ -0,0 +1,277 @@
+import React, {useCallback, useState} from 'react'
+import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
+import RNPickerSelect from 'react-native-picker-select'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {MAX_ALT_TEXT} from '#/lib/constants'
+import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
+import {LANGUAGES} from '#/locale/languages'
+import {isAndroid, isWeb} from '#/platform/detection'
+import {useLanguagePrefs} from '#/state/preferences'
+import {atoms as a, useTheme, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
+import {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import {Text} from '#/components/Typography'
+import {SubtitleFilePicker} from './SubtitleFilePicker'
+
+interface Props {
+ defaultAltText: string
+ captions: {lang: string; file: File}[]
+ saveAltText: (altText: string) => void
+ setCaptions: React.Dispatch<
+ React.SetStateAction<{lang: string; file: File}[]>
+ >
+}
+
+export function SubtitleDialogBtn(props: Props) {
+ const control = Dialog.useDialogControl()
+ const {_} = useLingui()
+
+ return (
+
+ {
+ if (Keyboard.isVisible()) Keyboard.dismiss()
+ control.open()
+ }}>
+
+
+ {isWeb ? Captions & alt text : Alt text}
+
+
+
+
+
+
+
+ )
+}
+
+function SubtitleDialogInner({
+ defaultAltText,
+ saveAltText,
+ captions,
+ setCaptions,
+}: Props) {
+ const control = Dialog.useDialogContext()
+ const {_} = useLingui()
+ const t = useTheme()
+ const enforceLen = useEnforceMaxGraphemeCount()
+ const {primaryLanguage} = useLanguagePrefs()
+
+ const [altText, setAltText] = useState(defaultAltText)
+
+ const handleSelectFile = useCallback(
+ (file: File) => {
+ setCaptions(subs => [
+ ...subs,
+ {
+ lang: subs.some(s => s.lang === primaryLanguage)
+ ? ''
+ : primaryLanguage,
+ file,
+ },
+ ])
+ },
+ [setCaptions, primaryLanguage],
+ )
+
+ const subtitleMissingLanguage = captions.some(sub => sub.lang === '')
+
+ return (
+
+
+
+ Alt text
+
+
+ setAltText(enforceLen(evt, MAX_ALT_TEXT))}
+ maxLength={MAX_ALT_TEXT * 10}
+ multiline
+ style={{maxHeight: 300}}
+ numberOfLines={3}
+ onKeyPress={({nativeEvent}) => {
+ if (nativeEvent.key === 'Escape') {
+ control.close()
+ }
+ }}
+ />
+
+
+ {isWeb && (
+ <>
+
+
+ Captions (.vtt)
+
+ = 4}
+ />
+
+ {captions.map((subtitle, i) => (
+
+ langCode(lang) === subtitle.lang ||
+ !captions.some(s => s.lang === langCode(lang)),
+ )}
+ style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
+ />
+ ))}
+
+ {subtitleMissingLanguage && (
+
+
+ Ensure you have selected a language for each subtitle file.
+
+
+ )}
+ >
+ )}
+
+
+ {
+ saveAltText(altText)
+ control.close()
+ }}
+ style={a.mt_lg}>
+
+ Done
+
+
+
+
+
+
+ )
+}
+
+function SubtitleFileRow({
+ language,
+ file,
+ otherLanguages,
+ setCaptions,
+ style,
+}: {
+ language: string
+ file: File
+ otherLanguages: {code2: string; code3: string; name: string}[]
+ setCaptions: React.Dispatch<
+ React.SetStateAction<{lang: string; file: File}[]>
+ >
+ style: StyleProp
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+
+ const handleValueChange = useCallback(
+ (lang: string) => {
+ if (lang) {
+ setCaptions(subs =>
+ subs.map(s => (s.lang === language ? {lang, file: s.file} : s)),
+ )
+ }
+ },
+ [setCaptions, language],
+ )
+
+ return (
+
+
+
+ {language === '' ? (
+
+ ) : (
+
+ )}
+
+ {file.name}
+
+ ({
+ label: `${lang.name} (${langCode(lang)})`,
+ value: langCode(lang),
+ }))}
+ style={{viewContainer: {maxWidth: 200, flex: 1}}}
+ />
+
+
+
+
+ setCaptions(subs => subs.filter(s => s.lang !== language))
+ }
+ style={[a.ml_sm]}>
+
+
+
+ )
+}
+
+function langCode(lang: {code2: string; code3: string}) {
+ return lang.code2 || lang.code3
+}
diff --git a/src/view/com/composer/videos/SubtitleFilePicker.native.tsx b/src/view/com/composer/videos/SubtitleFilePicker.native.tsx
new file mode 100644
index 0000000000..f2b9a7b04a
--- /dev/null
+++ b/src/view/com/composer/videos/SubtitleFilePicker.native.tsx
@@ -0,0 +1,3 @@
+export function SubtitleFilePicker() {
+ throw new Error('SubtitleFilePicker is a web-only component')
+}
diff --git a/src/view/com/composer/videos/SubtitleFilePicker.tsx b/src/view/com/composer/videos/SubtitleFilePicker.tsx
new file mode 100644
index 0000000000..9e0fe0aeee
--- /dev/null
+++ b/src/view/com/composer/videos/SubtitleFilePicker.tsx
@@ -0,0 +1,63 @@
+import React, {useRef} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
+
+export function SubtitleFilePicker({
+ onSelectFile,
+ disabled,
+}: {
+ onSelectFile: (file: File) => void
+ disabled?: boolean
+}) {
+ const {_} = useLingui()
+ const ref = useRef(null)
+
+ const handleClick = () => {
+ ref.current?.click()
+ }
+
+ const handlePick = (evt: React.ChangeEvent) => {
+ const selectedFile = evt.target.files?.[0]
+ if (selectedFile) {
+ if (selectedFile.type === 'text/vtt') {
+ onSelectFile(selectedFile)
+ } else {
+ Toast.show(_(msg`Only WebVTT (.vtt) files are supported`))
+ }
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ Select subtitle file (.vtt)
+
+
+
+
+ )
+}
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index 8e2a22852d..60b467d62b 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -1,40 +1,68 @@
/* eslint-disable @typescript-eslint/no-shadow */
import React from 'react'
import {View} from 'react-native'
+import {ImagePickerAsset} from 'expo-image-picker'
import {useVideoPlayer, VideoView} from 'expo-video'
-import {CompressedVideo} from '#/lib/media/video/compress'
+import {CompressedVideo} from '#/lib/media/video/types'
+import {clamp} from '#/lib/numbers'
+import {useAutoplayDisabled} from '#/state/preferences'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function VideoPreview({
+ asset,
video,
clear,
}: {
+ asset: ImagePickerAsset
video: CompressedVideo
+ setDimensions: (width: number, height: number) => void
clear: () => void
}) {
+ const t = useTheme()
+ const autoplayDisabled = useAutoplayDisabled()
const player = useVideoPlayer(video.uri, player => {
player.loop = true
- player.play()
- player.volume = 0
+ player.muted = true
+ if (!autoplayDisabled) {
+ player.play()
+ }
})
+ let aspectRatio = asset.width / asset.height
+
+ if (isNaN(aspectRatio)) {
+ aspectRatio = 16 / 9
+ }
+
+ aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
+
return (
+ {autoplayDisabled && (
+
+
+
+ )}
)
}
diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx
index 223dbd4244..88537956e4 100644
--- a/src/view/com/composer/videos/VideoPreview.web.tsx
+++ b/src/view/com/composer/videos/VideoPreview.web.tsx
@@ -1,27 +1,91 @@
-import React from 'react'
+import React, {useEffect, useRef} from 'react'
import {View} from 'react-native'
+import {ImagePickerAsset} from 'expo-image-picker'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
-import {CompressedVideo} from '#/lib/media/video/compress'
+import {CompressedVideo} from '#/lib/media/video/types'
+import {clamp} from '#/lib/numbers'
+import {useAutoplayDisabled} from '#/state/preferences'
+import * as Toast from '#/view/com/util/Toast'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a} from '#/alf'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
export function VideoPreview({
+ asset,
video,
+ setDimensions,
clear,
}: {
+ asset: ImagePickerAsset
video: CompressedVideo
+ setDimensions: (width: number, height: number) => void
clear: () => void
}) {
+ const ref = useRef(null)
+ const {_} = useLingui()
+ const autoplayDisabled = useAutoplayDisabled()
+
+ useEffect(() => {
+ if (!ref.current) return
+
+ const abortController = new AbortController()
+ const {signal} = abortController
+ ref.current.addEventListener(
+ 'loadedmetadata',
+ function () {
+ setDimensions(this.videoWidth, this.videoHeight)
+ },
+ {signal},
+ )
+ ref.current.addEventListener(
+ 'error',
+ () => {
+ Toast.show(_(msg`Could not process your video`), 'xmark')
+ clear()
+ },
+ {signal},
+ )
+
+ return () => {
+ abortController.abort()
+ }
+ }, [setDimensions, _, clear])
+
+ let aspectRatio = asset.width / asset.height
+
+ if (isNaN(aspectRatio)) {
+ aspectRatio = 16 / 9
+ }
+
+ aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
+
return (
-
+
+ {autoplayDisabled && (
+
+
+
+ )}
)
}
diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
index 1f41736420..ef38e62afe 100644
--- a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
@@ -21,8 +21,8 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) {
}, [])
return (
-
- {thumbnail && (
+ thumbnail && (
+
- )}
-
+
+ )
)
}
diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
index 9b580fdf2a..d4090d8530 100644
--- a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
@@ -1,7 +1,3 @@
-import React from 'react'
-
-export function VideoTranscodeBackdrop({uri}: {uri: string}) {
- return (
-
- )
+export function VideoTranscodeBackdrop() {
+ return null
}
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
index a44b633cd5..f6f0f7ccf9 100644
--- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
@@ -3,32 +3,42 @@ 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 {clamp} from '#/lib/numbers'
+import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
-import {Text} from '#/components/Typography'
+import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
export function VideoTranscodeProgress({
asset,
progress,
+ clear,
}: {
asset: ImagePickerAsset
progress: number
+ clear: () => void
}) {
const t = useTheme()
- const aspectRatio = asset.width / asset.height
+ if (isWeb) return null
+
+ let aspectRatio = asset.width / asset.height
+
+ if (isNaN(aspectRatio)) {
+ aspectRatio = 16 / 9
+ }
+
+ aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
return (
-
- Compressing...
-
+
)
}
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
deleted file mode 100644
index 587885502b..0000000000
--- a/src/view/com/lists/ListCard.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {AppBskyGraphDefs, AtUri, RichText} from '@atproto/api'
-import {Trans} from '@lingui/macro'
-
-import {useSession} from '#/state/session'
-import {usePalette} from 'lib/hooks/usePalette'
-import {makeProfileLink} from 'lib/routes/links'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {s} from 'lib/styles'
-import {atoms as a} from '#/alf'
-import {RichText as RichTextCom} from '#/components/RichText'
-import {Link} from '../util/Link'
-import {Text} from '../util/text/Text'
-import {UserAvatar} from '../util/UserAvatar'
-
-export const ListCard = ({
- testID,
- list,
- noBg,
- noBorder,
- renderButton,
- style,
-}: {
- testID?: string
- list: AppBskyGraphDefs.ListView
- noBg?: boolean
- noBorder?: boolean
- renderButton?: () => JSX.Element
- style?: StyleProp
-}) => {
- const pal = usePalette('default')
- const {currentAccount} = useSession()
-
- const rkey = React.useMemo(() => {
- try {
- const urip = new AtUri(list.uri)
- return urip.rkey
- } catch {
- return ''
- }
- }, [list])
-
- const descriptionRichText = React.useMemo(() => {
- if (list.description) {
- return new RichText({
- text: list.description,
- facets: list.descriptionFacets,
- })
- }
- return undefined
- }, [list])
-
- return (
-
-
-
-
-
-
-
- {sanitizeDisplayName(list.name)}
-
-
- {list.purpose === 'app.bsky.graph.defs#curatelist' &&
- (list.creator.did === currentAccount?.did ? (
- User list by you
- ) : (
-
- User list by {sanitizeHandle(list.creator.handle, '@')}
-
- ))}
- {list.purpose === 'app.bsky.graph.defs#modlist' &&
- (list.creator.did === currentAccount?.did ? (
- Moderation list by you
- ) : (
-
- Moderation list by {sanitizeHandle(list.creator.handle, '@')}
-
- ))}
-
-
- {list.viewer?.muted ? (
-
-
- Muted
-
-
- ) : null}
-
- {list.viewer?.blocked ? (
-
-
- Blocked
-
-
- ) : null}
-
-
- {renderButton ? (
- {renderButton()}
- ) : undefined}
-
- {descriptionRichText ? (
-
-
-
- ) : undefined}
-
- )
-}
-
-const styles = StyleSheet.create({
- outer: {
- borderTopWidth: StyleSheet.hairlineWidth,
- paddingHorizontal: 6,
- },
- outerNoBorder: {
- borderTopWidth: 0,
- },
- layout: {
- flexDirection: 'row',
- alignItems: 'center',
- },
- layoutAvi: {
- width: 54,
- paddingLeft: 4,
- paddingTop: 8,
- paddingBottom: 10,
- },
- avi: {
- width: 40,
- height: 40,
- borderRadius: 20,
- resizeMode: 'cover',
- },
- layoutContent: {
- flex: 1,
- paddingRight: 10,
- paddingTop: 10,
- paddingBottom: 10,
- },
- layoutButton: {
- paddingRight: 10,
- },
- details: {
- paddingLeft: 54,
- paddingRight: 10,
- paddingBottom: 10,
- },
- pill: {
- borderRadius: 4,
- paddingHorizontal: 6,
- paddingVertical: 2,
- },
- btn: {
- paddingVertical: 7,
- borderRadius: 50,
- marginLeft: 6,
- paddingHorizontal: 14,
- },
-})
diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx
index 472d2688c7..b56fa6c75f 100644
--- a/src/view/com/lists/MyLists.tsx
+++ b/src/view/com/lists/MyLists.tsx
@@ -4,7 +4,6 @@ import {
FlatList as RNFlatList,
RefreshControl,
StyleProp,
- StyleSheet,
View,
ViewStyle,
} from 'react-native'
@@ -18,10 +17,13 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
+import {isWeb} from 'platform/detection'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
import {EmptyState} from 'view/com/util/EmptyState'
+import {atoms as a, useTheme} from '#/alf'
+import * as ListCard from '#/components/ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List} from '../util/List'
-import {ListCard} from './ListCard'
const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'}
@@ -41,8 +43,10 @@ export function MyLists({
testID?: string
}) {
const pal = usePalette('default')
+ const t = useTheme()
const {track} = useAnalytics()
const {_} = useLingui()
+ const moderationOpts = useModerationOpts()
const [isPTRing, setIsPTRing] = React.useState(false)
const {data, isFetching, isFetched, isError, error, refetch} =
useMyListsQuery(filter)
@@ -53,7 +57,7 @@ export function MyLists({
if (isError && isEmpty) {
items = items.concat([ERROR_ITEM])
}
- if (!isFetched && isFetching) {
+ if ((!isFetched && isFetching) || !moderationOpts) {
items = items.concat([LOADING])
} else if (isEmpty) {
items = items.concat([EMPTY])
@@ -61,7 +65,7 @@ export function MyLists({
items = items.concat(data)
}
return items
- }, [isError, isEmpty, isFetched, isFetching, data])
+ }, [isError, isEmpty, isFetched, isFetching, moderationOpts, data])
// events
// =
@@ -85,7 +89,6 @@ export function MyLists({
if (item === EMPTY) {
return (
)
} else if (item === LOADING) {
return (
-
+
)
@@ -109,15 +111,18 @@ export function MyLists({
return renderItem ? (
renderItem(item, index)
) : (
-
+
+
+
)
},
- [error, onRefresh, renderItem, _],
+ [renderItem, t.atoms.border_contrast_low, _, error, onRefresh],
)
if (inline) {
@@ -166,10 +171,3 @@ export function MyLists({
)
}
}
-
-const styles = StyleSheet.create({
- item: {
- paddingHorizontal: 18,
- paddingVertical: 4,
- },
-})
diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx
index 7c1146a016..8fdec7d5df 100644
--- a/src/view/com/modals/VerifyEmail.tsx
+++ b/src/view/com/modals/VerifyEmail.tsx
@@ -173,7 +173,7 @@ export function Component({
accessibilityLabel={_(msg`Confirmation code`)}
accessibilityHint=""
autoCapitalize="none"
- autoComplete="off"
+ autoComplete="one-time-code"
autoCorrect={false}
/>
) : undefined}
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index b591b4b7fd..b1cf3b4856 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -8,9 +8,6 @@ import {
} from 'react-native'
import {
AppBskyActorDefs,
- AppBskyEmbedExternal,
- AppBskyEmbedImages,
- AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphFollow,
@@ -25,7 +22,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
-import {parseTenorGif} from '#/lib/strings/embed-player'
import {logger} from '#/logger'
import {FeedNotification} from '#/state/queries/notifications/feed'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
@@ -52,11 +48,11 @@ import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/com
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
import {StarterPack} from '#/components/icons/StarterPack'
import {Link as NewLink} from '#/components/Link'
+import * as MediaPreview from '#/components/MediaPreview'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {FeedSourceCard} from '../feeds/FeedSourceCard'
import {Post} from '../post/Post'
-import {ImageHorzList} from '../util/images/ImageHorzList'
import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format'
import {Text} from '../util/text/Text'
@@ -84,7 +80,7 @@ let FeedItem = ({
}): React.ReactNode => {
const queryClient = useQueryClient()
const pal = usePalette('default')
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const t = useTheme()
const [isAuthorsExpanded, setAuthorsExpanded] = useState(false)
const itemHref = useMemo(() => {
@@ -225,11 +221,11 @@ let FeedItem = ({
}
const formattedCount =
- authors.length > 1 ? formatCount(authors.length - 1) : ''
+ authors.length > 1 ? formatCount(i18n, authors.length - 1) : ''
const firstAuthorName = sanitizeDisplayName(
authors[0].profile.displayName || authors[0].profile.handle,
)
- const niceTimestamp = niceDate(item.notification.indexedAt)
+ const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
const a11yLabelUsers =
authors.length > 1
? _(msg` and `) +
@@ -593,49 +589,14 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const pal = usePalette('default')
if (post && AppBskyFeedPost.isRecord(post?.record)) {
const text = post.record.text
- let images
- let isGif = false
-
- if (AppBskyEmbedImages.isView(post.embed)) {
- images = post.embed.images
- } else if (
- AppBskyEmbedRecordWithMedia.isView(post.embed) &&
- AppBskyEmbedImages.isView(post.embed.media)
- ) {
- images = post.embed.media.images
- } else if (
- AppBskyEmbedExternal.isView(post.embed) &&
- post.embed.external.thumb
- ) {
- let url: URL | undefined
- try {
- url = new URL(post.embed.external.uri)
- } catch {}
- if (url) {
- const {success} = parseTenorGif(url)
- if (success) {
- isGif = true
- images = [
- {
- thumb: post.embed.external.thumb,
- alt: post.embed.external.title,
- fullsize: post.embed.external.thumb,
- },
- ]
- }
- }
- }
return (
<>
{text?.length > 0 && {text}}
- {images && images.length > 0 && (
-
- )}
+
>
)
}
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index da230aade9..4c5bdf1a5c 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -1,23 +1,24 @@
import React, {useCallback, useMemo, useState} from 'react'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
-import {
- ListFooter,
- ListHeaderDesktop,
- ListMaybePlaceholder,
-} from '#/components/Lists'
-import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
-import {List} from '../util/List'
+import {isWeb} from 'platform/detection'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {List} from '#/view/com/util/List'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
-function renderItem({item}: {item: GetLikes.Like}) {
- return
+function renderItem({item, index}: {item: GetLikes.Like; index: number}) {
+ return (
+
+ )
}
function keyExtractor(item: GetLikes.Like) {
@@ -25,7 +26,6 @@ function keyExtractor(item: GetLikes.Like) {
}
export function PostLikedBy({uri}: {uri: string}) {
- const {_} = useLingui()
const initialNumToRender = useInitialNumToRender()
const [isPTRing, setIsPTRing] = useState(false)
@@ -78,6 +78,7 @@ export function PostLikedBy({uri}: {uri: string}) {
)
}
@@ -91,7 +92,6 @@ export function PostLikedBy({uri}: {uri: string}) {
onRefresh={onRefresh}
onEndReached={onEndReached}
onEndReachedThreshold={4}
- ListHeaderComponent={}
ListFooterComponent={
)
}
diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx
new file mode 100644
index 0000000000..48c8a69efb
--- /dev/null
+++ b/src/view/com/post-thread/PostQuotes.tsx
@@ -0,0 +1,137 @@
+import React, {useCallback, useState} from 'react'
+import {
+ AppBskyFeedDefs,
+ AppBskyFeedPost,
+ ModerationDecision,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {usePostQuotesQuery} from '#/state/queries/post-quotes'
+import {useResolveUriQuery} from '#/state/queries/resolve-uri'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {isWeb} from 'platform/detection'
+import {Post} from 'view/com/post/Post'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
+import {List} from '../util/List'
+
+function renderItem({
+ item,
+ index,
+}: {
+ item: {
+ post: AppBskyFeedDefs.PostView
+ moderation: ModerationDecision
+ record: AppBskyFeedPost.Record
+ }
+ index: number
+}) {
+ return
+}
+
+function keyExtractor(item: {
+ post: AppBskyFeedDefs.PostView
+ moderation: ModerationDecision
+ record: AppBskyFeedPost.Record
+}) {
+ return item.post.uri
+}
+
+export function PostQuotes({uri}: {uri: string}) {
+ const {_} = useLingui()
+ const initialNumToRender = useInitialNumToRender()
+ const [isPTRing, setIsPTRing] = useState(false)
+
+ const {
+ data: resolvedUri,
+ error: resolveError,
+ isLoading: isLoadingUri,
+ } = useResolveUriQuery(uri)
+ const {
+ data,
+ isLoading: isLoadingQuotes,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ error,
+ refetch,
+ } = usePostQuotesQuery(resolvedUri?.uri)
+
+ const moderationOpts = useModerationOpts()
+
+ const isError = Boolean(resolveError || error)
+
+ const quotes =
+ data?.pages
+ .flatMap(page =>
+ page.posts.map(post => {
+ if (!AppBskyFeedPost.isRecord(post.record) || !moderationOpts) {
+ return null
+ }
+ const moderation = moderatePost(post, moderationOpts)
+ return {post, record: post.record, moderation}
+ }),
+ )
+ .filter(item => item !== null) ?? []
+
+ const onRefresh = useCallback(async () => {
+ setIsPTRing(true)
+ try {
+ await refetch()
+ } catch (err) {
+ logger.error('Failed to refresh quotes', {message: err})
+ }
+ setIsPTRing(false)
+ }, [refetch, setIsPTRing])
+
+ const onEndReached = useCallback(async () => {
+ if (isFetchingNextPage || !hasNextPage || isError) return
+ try {
+ await fetchNextPage()
+ } catch (err) {
+ logger.error('Failed to load more quotes', {message: err})
+ }
+ }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
+
+ if (isLoadingUri || isLoadingQuotes || isError) {
+ return (
+
+ )
+ }
+
+ // loaded
+ // =
+ return (
+
+ }
+ // @ts-ignore our .web version only -prf
+ desktopFixedHeight
+ initialNumToRender={initialNumToRender}
+ windowSize={11}
+ sideBorders={false}
+ />
+ )
+}
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index 9038549a50..aeba9a34dd 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -1,20 +1,14 @@
import React, {useCallback, useMemo, useState} from 'react'
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
-import {
- ListFooter,
- ListHeaderDesktop,
- ListMaybePlaceholder,
-} from '#/components/Lists'
-import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
-import {List} from '../util/List'
+import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
+import {List} from '#/view/com/util/List'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
return
@@ -25,7 +19,6 @@ function keyExtractor(item: ActorDefs.ProfileViewBasic) {
}
export function PostRepostedBy({uri}: {uri: string}) {
- const {_} = useLingui()
const initialNumToRender = useInitialNumToRender()
const [isPTRing, setIsPTRing] = useState(false)
@@ -78,6 +71,7 @@ export function PostRepostedBy({uri}: {uri: string}) {
)
}
@@ -93,7 +87,6 @@ export function PostRepostedBy({uri}: {uri: string}) {
onRefresh={onRefresh}
onEndReached={onEndReached}
onEndReachedThreshold={4}
- ListHeaderComponent={}
ListFooterComponent={
)
}
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index c64be8d671..4c4b008097 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -3,7 +3,7 @@ import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {runOnJS} from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import {AppBskyFeedDefs} from '@atproto/api'
+import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -25,6 +25,7 @@ import {
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
import {useSetTitle} from 'lib/hooks/useSetTitle'
@@ -102,7 +103,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
isError: isThreadError,
error: threadError,
refetch,
- data: thread,
+ data: {thread, threadgate} = {},
} = usePostThreadQuery(uri)
const treeView = React.useMemo(
@@ -113,6 +114,12 @@ export function PostThread({uri}: {uri: string | undefined}) {
)
const rootPost = thread?.type === 'post' ? thread.post : undefined
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
+ const threadgateRecord = threadgate?.record as
+ | AppBskyFeedThreadgate.Record
+ | undefined
+ const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+ threadgateRecord,
+ })
const moderationOpts = useModerationOpts()
const isNoPwi = React.useMemo(() => {
@@ -175,11 +182,13 @@ export function PostThread({uri}: {uri: string | undefined}) {
threadModerationCache,
currentDid,
justPostedUris,
+ threadgateHiddenReplies,
),
- !!currentDid,
+ currentDid,
treeView,
threadModerationCache,
hiddenRepliesState !== HiddenRepliesState.Hide,
+ threadgateHiddenReplies,
)
}, [
thread,
@@ -189,6 +198,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
threadModerationCache,
hiddenRepliesState,
justPostedUris,
+ threadgateHiddenReplies,
])
const error = React.useMemo(() => {
@@ -418,6 +428,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
(item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
const hasUnrevealedParents =
index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
+
return (
,
): ThreadSkeletonParts | null {
if (!node) return null
return {
- parents: Array.from(flattenThreadParents(node, hasSession)),
+ parents: Array.from(flattenThreadParents(node, !!currentDid)),
highlightedPost: node,
replies: Array.from(
flattenThreadReplies(
node,
- hasSession,
+ currentDid,
treeView,
modCache,
showHiddenReplies,
+ threadgateRecordHiddenReplies,
),
),
}
@@ -594,14 +608,15 @@ enum HiddenReplyType {
function* flattenThreadReplies(
node: ThreadNode,
- hasSession: boolean,
+ currentDid: string | undefined,
treeView: boolean,
modCache: ThreadModerationCache,
showHiddenReplies: boolean,
+ threadgateRecordHiddenReplies: Set,
): Generator {
if (node.type === 'post') {
// dont show pwi-opted-out posts to logged out users
- if (!hasSession && hasPwiOptOut(node)) {
+ if (!currentDid && hasPwiOptOut(node)) {
return HiddenReplyType.None
}
@@ -616,6 +631,16 @@ function* flattenThreadReplies(
return HiddenReplyType.Hidden
}
}
+
+ if (!showHiddenReplies) {
+ const hiddenByThreadgate = threadgateRecordHiddenReplies.has(
+ node.post.uri,
+ )
+ const authorIsViewer = node.post.author.did === currentDid
+ if (hiddenByThreadgate && !authorIsViewer) {
+ return HiddenReplyType.Hidden
+ }
+ }
}
if (!node.ctx.isHighlightedPost) {
@@ -627,10 +652,11 @@ function* flattenThreadReplies(
for (const reply of node.replies) {
let hiddenReply = yield* flattenThreadReplies(
reply,
- hasSession,
+ currentDid,
treeView,
modCache,
showHiddenReplies,
+ threadgateRecordHiddenReplies,
)
if (hiddenReply > hiddenReplies) {
hiddenReplies = hiddenReply
diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx
index 6b6316eec2..914ee3b667 100644
--- a/src/view/com/post-thread/PostThreadFollowBtn.tsx
+++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx
@@ -6,7 +6,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
-import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {track} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
@@ -48,7 +47,6 @@ function PostThreadFollowBtnLoaded({
'PostThreadItem',
)
const requireAuth = useRequireAuth()
- const gate = useGate()
const isFollowing = !!profile.viewer?.following
const isFollowedBy = !!profile.viewer?.followedBy
@@ -140,7 +138,7 @@ function PostThreadFollowBtnLoaded({
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
numberOfLines={1}>
{!isFollowing ? (
- isFollowedBy && gate('show_follow_back_label_v2') ? (
+ isFollowedBy ? (
Follow Back
) : (
Follow
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 0ff3601434..8cd6e70be2 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
+ AppBskyFeedThreadgate,
AtUri,
ModerationDecision,
RichText as RichTextAPI,
@@ -16,6 +17,7 @@ import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {ThreadPost} from '#/state/queries/post-thread'
import {useComposerControls} from '#/state/shell/composer'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {MAX_POST_LINES} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@@ -29,6 +31,7 @@ import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf'
+import {AppModerationCause} from '#/components/Pills'
import {RichText} from '#/components/RichText'
import {ContentHider} from '../../../components/moderation/ContentHider'
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
@@ -40,7 +43,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostEmbeds} from '../util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
import {PostMeta} from '../util/PostMeta'
import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar'
@@ -61,6 +64,7 @@ export function PostThreadItem({
overrideBlur,
onPostReply,
hideTopBorder,
+ threadgateRecord,
}: {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
@@ -77,6 +81,7 @@ export function PostThreadItem({
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean
+ threadgateRecord?: AppBskyFeedThreadgate.Record
}) {
const postShadowed = usePostShadow(post)
const richText = useMemo(
@@ -111,6 +116,7 @@ export function PostThreadItem({
overrideBlur={overrideBlur}
onPostReply={onPostReply}
hideTopBorder={hideTopBorder}
+ threadgateRecord={threadgateRecord}
/>
)
}
@@ -154,6 +160,7 @@ let PostThreadItemLoaded = ({
overrideBlur,
onPostReply,
hideTopBorder,
+ threadgateRecord,
}: {
post: Shadow
record: AppBskyFeedPost.Record
@@ -171,9 +178,10 @@ let PostThreadItemLoaded = ({
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean
+ threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const pal = usePalette('default')
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const langPrefs = useLanguagePrefs()
const {openComposer} = useComposerControls()
const [limitLines, setLimitLines] = React.useState(
@@ -199,6 +207,27 @@ let PostThreadItemLoaded = ({
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
}, [post.uri, post.author])
const repostsTitle = _(msg`Reposts of this post`)
+ const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+ threadgateRecord,
+ })
+ const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
+ const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
+ const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did
+ return isControlledByViewer && isPostHiddenByThreadgate
+ ? [
+ {
+ type: 'reply-hidden',
+ source: {type: 'user', did: currentAccount?.did},
+ priority: 6,
+ },
+ ]
+ : []
+ }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri])
+ const quotesHref = React.useMemo(() => {
+ const urip = new AtUri(post.uri)
+ return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
+ }, [post.uri, post.author])
+ const quotesTitle = _(msg`Quotes of this post`)
const translatorUrl = getTranslatorLink(
record?.text || '',
@@ -315,6 +344,7 @@ let PostThreadItemLoaded = ({
size="lg"
includeMute
style={[a.pt_2xs, a.pb_sm]}
+ additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
-
+
)}
@@ -343,7 +377,9 @@ let PostThreadItemLoaded = ({
translatorUrl={translatorUrl}
needsTranslation={needsTranslation}
/>
- {post.repostCount !== 0 || post.likeCount !== 0 ? (
+ {post.repostCount !== 0 ||
+ post.likeCount !== 0 ||
+ post.quoteCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement.
{post.repostCount != null && post.repostCount !== 0 ? (
@@ -356,7 +392,7 @@ let PostThreadItemLoaded = ({
type="lg"
style={pal.textLight}>
- {formatCount(post.repostCount)}
+ {formatCount(i18n, post.repostCount)}
{' '}
) : null}
+ {post.quoteCount != null &&
+ post.quoteCount !== 0 &&
+ !post.viewer?.embeddingDisabled ? (
+
+
+
+ {formatCount(i18n, post.quoteCount)}
+ {' '}
+
+
+
+ ) : null}
{post.likeCount != null && post.likeCount !== 0 ? (
- {formatCount(post.likeCount)}
+ {formatCount(i18n, post.likeCount)}
{' '}
@@ -391,7 +449,9 @@ let PostThreadItemLoaded = ({
record={record}
richText={richText}
onPressReply={onPressReply}
+ onPostReply={onPostReply}
logContext="PostThreadItem"
+ threadgateRecord={threadgateRecord}
/>
@@ -512,6 +572,7 @@ let PostThreadItemLoaded = ({
{richText?.text ? (
@@ -534,7 +595,11 @@ let PostThreadItemLoaded = ({
) : undefined}
{post.embed && (
-
+
)}
@@ -647,8 +713,9 @@ function ExpandedPostDetails({
translatorUrl: string
}) {
const pal = usePalette('default')
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const openLink = useOpenLink()
+ const isRootPost = !('reply' in post.record)
const onTranslatePress = React.useCallback(() => {
openLink(translatorUrl)
@@ -664,8 +731,12 @@ function ExpandedPostDetails({
s.mt2,
s.mb10,
]}>
- {niceDate(post.indexedAt)}
-
+
+ {niceDate(i18n, post.indexedAt)}
+
+ {isRootPost && (
+
+ )}
{needsTranslation && (
<>
·
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 8121b8abcc..9033fb96f7 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -32,7 +32,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
import {PostAlerts} from '../../../components/moderation/PostAlerts'
import {Link, TextLink} from '../util/Link'
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostEmbeds} from '../util/post-embeds'
+import {PostEmbeds, PostEmbedViewContext} from '../util/post-embeds'
import {PostMeta} from '../util/PostMeta'
import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar'
@@ -238,7 +238,11 @@ function PostInner({
/>
) : undefined}
{post.embed ? (
-
+
) : null}
JSX.Element
extraData?: any
savedFeedConfig?: AppBskyActorDefs.SavedFeed
- outsideHeaderOffset?: number
+ initialNumToRender?: number
}): React.ReactNode => {
const theme = useTheme()
const {track} = useAnalytics()
@@ -192,9 +203,7 @@ let Feed = ({
const [isPTRing, setIsPTRing] = React.useState(false)
const checkForNewRef = React.useRef<(() => void) | null>(null)
const lastFetchRef = React.useRef(Date.now())
- const [feedType, feedUri] = feed.split('|')
- const feedIsDiscover = feedUri === DISCOVER_FEED_URI
- const feedIsFollowing = feedType === 'following'
+ const [feedType, feedUri, feedTab] = feed.split('|')
const gate = useGate()
const opts = React.useMemo(
@@ -338,14 +347,21 @@ let Feed = ({
}
if (hasSession) {
- const feedType = feedIsFollowing
- ? 'following'
- : feedIsDiscover
- ? 'discover'
- : undefined
+ let feedKind: 'following' | 'discover' | 'profile' | undefined
+ if (feedType === 'following') {
+ feedKind = 'following'
+ } else if (feedUri === DISCOVER_FEED_URI) {
+ feedKind = 'discover'
+ } else if (
+ feedType === 'author' &&
+ (feedTab === 'posts_and_author_threads' ||
+ feedTab === 'posts_with_replies')
+ ) {
+ feedKind = 'profile'
+ }
- if (feedType) {
- for (const interstitial of interstials[feedType]) {
+ if (feedKind) {
+ for (const interstitial of interstials[feedKind]) {
const shouldShow =
(interstitial.type === feedInterstitialType &&
gate('suggested_feeds_interstitial')) ||
@@ -376,9 +392,9 @@ let Feed = ({
isEmpty,
lastFetchedAt,
data,
+ feedType,
feedUri,
- feedIsDiscover,
- feedIsFollowing,
+ feedTab,
gate,
hasSession,
])
@@ -469,7 +485,7 @@ let Feed = ({
} else if (item.type === feedInterstitialType) {
return
} else if (item.type === followInterstitialType) {
- return
+ return
} else if (item.type === progressGuideInterstitialType) {
return
} else if (item.type === 'slice') {
@@ -545,8 +561,10 @@ let Feed = ({
desktopFixedHeight={
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
}
- initialNumToRender={initialNumToRender}
- windowSize={11}
+ initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
+ windowSize={9}
+ maxToRenderPerBatch={5}
+ updateCellsBatchingPeriod={40}
onItemSeen={feedFeedback.onItemSeen}
/>
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 0071e2401b..7537a46448 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -1,9 +1,10 @@
-import React, {memo, useId, useMemo, useState} from 'react'
+import React, {memo, useMemo, useState} from 'react'
import {StyleSheet, View} from 'react-native'
import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
+ AppBskyFeedThreadgate,
AtUri,
ModerationDecision,
RichText as RichTextAPI,
@@ -16,35 +17,35 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
-import {useGate} from '#/lib/statsig/statsig'
+import {isReasonFeedSource, ReasonFeedSource} from '#/lib/api/feed/types'
+import {MAX_POST_LINES} from '#/lib/constants'
+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 {countLines} from '#/lib/strings/helpers'
+import {s} from '#/lib/styles'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
+import {precacheProfile} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
-import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
-import {MAX_POST_LINES} from 'lib/constants'
-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 {countLines} from 'lib/strings/helpers'
-import {s} from 'lib/styles'
-import {precacheProfile} from 'state/queries/profile'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
+import {FeedNameText} from '#/view/com/util/FeedInfoText'
+import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
+import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
+import {PostMeta} from '#/view/com/util/PostMeta'
+import {Text} from '#/view/com/util/text/Text'
+import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a} from '#/alf'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import {ContentHider} from '#/components/moderation/ContentHider'
+import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
+import {PostAlerts} from '#/components/moderation/PostAlerts'
+import {AppModerationCause} from '#/components/Pills'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {RichText} from '#/components/RichText'
-import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
-import {PostAlerts} from '../../../components/moderation/PostAlerts'
-import {FeedNameText} from '../util/FeedInfoText'
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
-import {PostCtrls} from '../util/post-ctrls/PostCtrls'
-import {PostEmbeds} from '../util/post-embeds'
-import {VideoEmbed} from '../util/post-embeds/VideoEmbed'
-import {PostMeta} from '../util/PostMeta'
-import {Text} from '../util/text/Text'
-import {PreviewableUserAvatar} from '../util/UserAvatar'
import {AviFollowButton} from './AviFollowButton'
interface FeedItemProps {
@@ -63,6 +64,7 @@ interface FeedItemProps {
feedContext: string | undefined
hideTopBorder?: boolean
isParentBlocked?: boolean
+ isParentNotFound?: boolean
}
export function FeedItem({
@@ -78,7 +80,12 @@ export function FeedItem({
isThreadParent,
hideTopBorder,
isParentBlocked,
-}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
+ isParentNotFound,
+ rootPost,
+}: FeedItemProps & {
+ post: AppBskyFeedDefs.PostView
+ rootPost: AppBskyFeedDefs.PostView
+}): React.ReactNode {
const postShadowed = usePostShadow(post)
const richText = useMemo(
() =>
@@ -109,6 +116,8 @@ export function FeedItem({
isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder}
isParentBlocked={isParentBlocked}
+ isParentNotFound={isParentNotFound}
+ rootPost={rootPost}
/>
)
}
@@ -129,9 +138,12 @@ let FeedItemInner = ({
isThreadParent,
hideTopBorder,
isParentBlocked,
+ isParentNotFound,
+ rootPost,
}: FeedItemProps & {
richText: RichTextAPI
post: Shadow
+ rootPost: AppBskyFeedDefs.PostView
}): React.ReactNode => {
const queryClient = useQueryClient()
const {openComposer} = useComposerControls()
@@ -213,6 +225,16 @@ let FeedItemInner = ({
AppBskyFeedDefs.isReasonRepost(reason) &&
reason.by.did === currentAccount?.did
+ /**
+ * If `post[0]` in this slice is the actual root post (not an orphan thread),
+ * then we may have a threadgate record to reference
+ */
+ const threadgateRecord = AppBskyFeedThreadgate.isRecord(
+ rootPost.threadgate?.record,
+ )
+ ? rootPost.threadgate.record
+ : undefined
+
return (
- {showReplyTo && (parentAuthor || isParentBlocked) && (
-
- )}
+ {showReplyTo &&
+ (parentAuthor || isParentBlocked || isParentNotFound) && (
+
+ )}
-
@@ -372,23 +401,48 @@ let FeedItemInner = ({
FeedItemInner = memo(FeedItemInner)
let PostContent = ({
+ post,
moderation,
richText,
postEmbed,
postAuthor,
onOpenEmbed,
+ threadgateRecord,
}: {
moderation: ModerationDecision
richText: RichTextAPI
postEmbed: AppBskyFeedDefs.PostView['embed']
postAuthor: AppBskyFeedDefs.PostView['author']
onOpenEmbed: () => void
+ post: AppBskyFeedDefs.PostView
+ threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui()
+ const {currentAccount} = useSession()
const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES,
)
+ const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+ threadgateRecord,
+ })
+ const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
+ const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
+ const rootPostUri = AppBskyFeedPost.isRecord(post.record)
+ ? post.record?.reply?.root?.uri || post.uri
+ : undefined
+ const isControlledByViewer =
+ rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did
+ return isControlledByViewer && isPostHiddenByThreadgate
+ ? [
+ {
+ type: 'reply-hidden',
+ source: {type: 'user', did: currentAccount?.did},
+ priority: 6,
+ },
+ ]
+ : []
+ }, [post, currentAccount?.did, threadgateHiddenReplies])
const onPressShowMore = React.useCallback(() => {
setLimitLines(false)
@@ -400,7 +454,11 @@ let PostContent = ({
modui={moderation.ui('contentList')}
ignoreMute
childContainerStyle={styles.contentHiderChild}>
-
+
{richText.text ? (
) : null}
@@ -438,9 +497,11 @@ PostContent = memo(PostContent)
function ReplyToLabel({
profile,
blocked,
+ notFound,
}: {
profile: AppBskyActorDefs.ProfileViewBasic | undefined
blocked?: boolean
+ notFound?: boolean
}) {
const pal = usePalette('default')
const {currentAccount} = useSession()
@@ -448,6 +509,8 @@ function ReplyToLabel({
let label
if (blocked) {
label = Reply to a blocked post
+ } else if (notFound) {
+ label = Reply to a post
} else if (profile != null) {
const isMe = profile.did === currentAccount?.did
if (isMe) {
@@ -498,19 +561,6 @@ function ReplyToLabel({
)
}
-function VideoDebug() {
- const gate = useGate()
- const id = useId()
-
- if (!gate('video_debug')) return null
-
- return (
-
- )
-}
-
const styles = StyleSheet.create({
outer: {
paddingLeft: 10,
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index fcd1ec3b18..0920026f60 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -36,6 +36,8 @@ let FeedSlice = ({
isThreadChild={isThreadChildAt(slice.items, 0)}
hideTopBorder={hideTopBorder}
isParentBlocked={slice.items[0].isParentBlocked}
+ isParentNotFound={slice.items[0].isParentNotFound}
+ rootPost={slice.items[0].post}
/>
>
)
@@ -90,7 +96,9 @@ 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}
+ rootPost={slice.items[0].post}
/>
))}
>
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index 94ca33e6e1..8318f13de8 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -8,17 +8,26 @@ import {logger} from '#/logger'
import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
-import {
- ListFooter,
- ListHeaderDesktop,
- ListMaybePlaceholder,
-} from '#/components/Lists'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {List} from '../util/List'
import {ProfileCardWithFollowBtn} from './ProfileCard'
-function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
- return
+function renderItem({
+ item,
+ index,
+}: {
+ item: ActorDefs.ProfileViewBasic
+ index: number
+}) {
+ return (
+
+ )
}
function keyExtractor(item: ActorDefs.ProfileViewBasic) {
@@ -88,6 +97,7 @@ export function ProfileFollowers({name}: {name: string}) {
}
errorMessage={cleanError(resolveError || error)}
onRetry={isError ? refetch : undefined}
+ sideBorders={false}
/>
)
}
@@ -101,7 +111,6 @@ export function ProfileFollowers({name}: {name: string}) {
onRefresh={onRefresh}
onEndReached={onEndReached}
onEndReachedThreshold={4}
- ListHeaderComponent={}
ListFooterComponent={
)
}
diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx
index 9b447c955a..de4346afbc 100644
--- a/src/view/com/profile/ProfileFollows.tsx
+++ b/src/view/com/profile/ProfileFollows.tsx
@@ -8,17 +8,26 @@ import {logger} from '#/logger'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
-import {
- ListFooter,
- ListHeaderDesktop,
- ListMaybePlaceholder,
-} from '#/components/Lists'
+import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {List} from '../util/List'
import {ProfileCardWithFollowBtn} from './ProfileCard'
-function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
- return
+function renderItem({
+ item,
+ index,
+}: {
+ item: ActorDefs.ProfileViewBasic
+ index: number
+}) {
+ return (
+
+ )
}
function keyExtractor(item: ActorDefs.ProfileViewBasic) {
@@ -88,6 +97,7 @@ export function ProfileFollows({name}: {name: string}) {
}
errorMessage={cleanError(resolveError || error)}
onRetry={isError ? refetch : undefined}
+ sideBorders={false}
/>
)
}
@@ -101,7 +111,6 @@ export function ProfileFollows({name}: {name: string}) {
onRefresh={onRefresh}
onEndReached={onEndReached}
onEndReachedThreshold={4}
- ListHeaderComponent={}
ListFooterComponent={
)
}
diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
deleted file mode 100644
index 356b3f09cf..0000000000
--- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import React from 'react'
-import {ScrollView, View} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {logEvent} from '#/lib/statsig/statsig'
-import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
-import {isWeb} from 'platform/detection'
-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 = 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,
- requestDismiss,
-}: {
- actorDid: string
- requestDismiss: () => void
-}) {
- const t = useTheme()
- const {_} = useLingui()
- const {isLoading: isSuggestionsLoading, data} =
- useSuggestedFollowsByActorQuery({
- did: actorDid,
- })
- const moderationOpts = useModerationOpts()
- const isLoading = isSuggestionsLoading || !moderationOpts
-
- return (
-
-
-
-
- Similar accounts
-
-
-
-
-
-
-
-
-
- {isLoading ? (
- <>
-
-
-
-
-
- >
- ) : data ? (
- data.suggestions
- .filter(s => (s.associated?.labeler ? false : true))
- .map(profile => (
- {
- logEvent('profile:header:suggestedFollowsCard:press', {})
- }}
- style={[a.flex_1]}>
- {({hovered, pressed}) => (
-
-
-
-
-
-
-
-
-
-
- )}
-
- ))
- ) : (
-
- )}
-
-
-
-
- )
-}
diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx
index 82750959d6..71c5f1da13 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()
@@ -33,6 +33,7 @@ export function TestCtrls() {
},
'LoginForm',
)
+ setShowLoggedOut(false)
}
const onPressSignInBob = async () => {
await login(
@@ -43,6 +44,7 @@ export function TestCtrls() {
},
'LoginForm',
)
+ setShowLoggedOut(false)
}
return (
@@ -60,7 +62,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 25622d6fb4..455f0ee7fc 100644
--- a/src/view/com/util/AccountDropdownBtn.tsx
+++ b/src/view/com/util/AccountDropdownBtn.tsx
@@ -1,10 +1,20 @@
import React from 'react'
import {Pressable} from 'react-native'
+// import {
+// FontAwesomeIcon,
+// FontAwesomeIconStyle,
+// } from '@fortawesome/react-native-fontawesome'
+// import {msg} from '@lingui/macro'
+// import {useLingui} from '@lingui/react'
import {SessionAccount} 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'
-// @TODO Fabric
export function AccountDropdownBtn({
account: _account,
}: {
@@ -12,15 +22,13 @@ export function AccountDropdownBtn({
}) {
// 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',
@@ -31,17 +39,32 @@ export function AccountDropdownBtn({
// },
// ]
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 c62ac5ed1e..79dd2f4917 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -7,6 +7,7 @@ import {usePalette} from '#/lib/hooks/usePalette'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {useDedupe} from 'lib/hooks/useDedupe'
import {addStyle} from 'lib/styles'
+import {isIOS} from 'platform/detection'
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {FlatList_INTERNAL} from './Views'
@@ -49,7 +50,7 @@ function ListImpl(
) {
const isScrolledDown = useSharedValue(false)
const pal = usePalette('default')
- const dedupe = useDedupe()
+ const dedupe = useDedupe(400)
function handleScrolledDownChange(didScrollDown: boolean) {
onScrolledDownChange?.(didScrollDown)
@@ -68,6 +69,7 @@ function ListImpl(
onBeginDragFromContext?.(e, ctx)
},
onEndDrag(e, ctx) {
+ runOnJS(updateActiveViewAsync)()
onEndDragFromContext?.(e, ctx)
},
onScroll(e, ctx) {
@@ -81,11 +83,14 @@ function ListImpl(
}
}
- runOnJS(dedupe)(updateActiveViewAsync)
+ if (isIOS) {
+ runOnJS(dedupe)(updateActiveViewAsync)
+ }
},
// Note: adding onMomentumBegin here makes simulator scroll
// lag on Android. So either don't add it, or figure out why.
onMomentumEnd(e, ctx) {
+ runOnJS(updateActiveViewAsync)()
onMomentumEndFromContext?.(e, ctx)
},
})
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index b602da4323..c87ee209ec 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -4,11 +4,12 @@ import {
cancelAnimation,
interpolate,
useSharedValue,
+ withSpring,
} from 'react-native-reanimated'
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
-import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell'
+import {useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isNative, isWeb} from 'platform/detection'
@@ -21,12 +22,22 @@ function clamp(num: number, min: number, max: number) {
export function MainScrollProvider({children}: {children: React.ReactNode}) {
const {headerHeight} = useShellLayout()
- const mode = useMinimalShellMode()
- const setMode = useSetMinimalShellMode()
+ const {headerMode} = useMinimalShellMode()
const startDragOffset = useSharedValue(null)
const startMode = useSharedValue(null)
const didJustRestoreScroll = useSharedValue(false)
+ const setMode = React.useCallback(
+ (v: boolean) => {
+ 'worklet'
+ cancelAnimation(headerMode)
+ headerMode.value = withSpring(v ? 1 : 0, {
+ overshootClamping: true,
+ })
+ },
+ [headerMode],
+ )
+
useEffect(() => {
if (isWeb) {
return listenToForcedWindowScroll(() => {
@@ -55,11 +66,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
setMode(true)
} else {
// Snap to whichever state is the closest.
- setMode(Math.round(mode.value) === 1)
+ setMode(Math.round(headerMode.value) === 1)
}
}
},
- [startDragOffset, startMode, setMode, mode, headerHeight],
+ [startDragOffset, startMode, setMode, headerMode, headerHeight],
)
const onBeginDrag = useCallback(
@@ -67,10 +78,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
'worklet'
if (isNative) {
startDragOffset.value = e.contentOffset.y
- startMode.value = mode.value
+ startMode.value = headerMode.value
}
},
- [mode, startDragOffset, startMode],
+ [headerMode, startDragOffset, startMode],
)
const onEndDrag = useCallback(
@@ -102,7 +113,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
'worklet'
if (isNative) {
if (startDragOffset.value === null || startMode.value === null) {
- if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) {
+ if (
+ headerMode.value !== 0 &&
+ e.contentOffset.y < headerHeight.value
+ ) {
// If we're close enough to the top, always show the shell.
// Even if we're not dragging.
setMode(false)
@@ -119,11 +133,11 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
[-1, 1],
)
const newValue = clamp(startMode.value + dProgress, 0, 1)
- if (newValue !== mode.value) {
+ if (newValue !== headerMode.value) {
// Manually adjust the value. This won't be (and shouldn't be) animated.
// Cancel any any existing animation
- cancelAnimation(mode)
- mode.value = newValue
+ cancelAnimation(headerMode)
+ headerMode.value = newValue
}
} else {
if (didJustRestoreScroll.value) {
@@ -145,7 +159,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
},
[
headerHeight,
- mode,
+ headerMode,
setMode,
startDragOffset,
startMode,
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index b1567c2c69..3bd350bf32 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -1,6 +1,7 @@
import React, {memo, useCallback} from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
+import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {precacheProfile} from '#/state/queries/profile'
@@ -35,6 +36,8 @@ interface PostMetaOpts {
}
let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
+ const {i18n} = useLingui()
+
const pal = usePalette('default')
const displayName = opts.author.displayName || opts.author.handle
const handle = opts.author.handle
@@ -101,8 +104,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
type="md"
style={pal.textLight}
text={timeElapsed}
- accessibilityLabel={niceDate(opts.timestamp)}
- title={niceDate(opts.timestamp)}
+ accessibilityLabel={niceDate(i18n, opts.timestamp)}
+ title={niceDate(i18n, opts.timestamp)}
accessibilityHint=""
href={opts.postHref}
onBeforePress={onBeforePressPost}
diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx
index a495851826..70fed222f2 100644
--- a/src/view/com/util/TimeElapsed.tsx
+++ b/src/view/com/util/TimeElapsed.tsx
@@ -1,4 +1,6 @@
import React from 'react'
+import {I18n} from '@lingui/core'
+import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTickEveryMinute} from '#/state/shell'
@@ -10,19 +12,21 @@ export function TimeElapsed({
}: {
timestamp: string
children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
- timeToString?: (timeElapsed: string) => string
+ timeToString?: (i18n: I18n, timeElapsed: string) => string
}) {
+ const {i18n} = useLingui()
const ago = useGetTimeAgo()
- const format = timeToString ?? ago
const tick = useTickEveryMinute()
const [timeElapsed, setTimeAgo] = React.useState(() =>
- format(timestamp, tick),
+ timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick),
)
const [prevTick, setPrevTick] = React.useState(tick)
if (prevTick !== tick) {
setPrevTick(tick)
- setTimeAgo(format(timestamp, tick))
+ setTimeAgo(
+ timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick),
+ )
}
return children({timeElapsed})
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index f7c6bc2c91..51e76bdc39 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -59,7 +59,6 @@ function Toast({
a.flex_1,
t.atoms.bg,
a.shadow_lg,
- a.rounded_sm,
t.atoms.border_contrast_medium,
a.rounded_sm,
a.px_md,
diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx
index ed18c981a6..c4549dbc7f 100644
--- a/src/view/com/util/Views.web.tsx
+++ b/src/view/com/util/Views.web.tsx
@@ -47,7 +47,7 @@ export const CenteredView = React.forwardRef(function CenteredView(
if (!isMobile) {
style = addStyle(style, styles.container)
}
- if (sideBorders) {
+ if (sideBorders && !isMobile) {
style = addStyle(style, {
borderLeftWidth: StyleSheet.hairlineWidth,
borderRightWidth: StyleSheet.hairlineWidth,
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index e6fb0ad465..ee8e1f47a2 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -79,6 +79,7 @@ const styles = StyleSheet.create({
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
zIndex: 1,
+ cursor: 'pointer',
},
inner: {
justifyContent: 'center',
diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx
index 0104562aa5..bfbb2ff55e 100644
--- a/src/view/com/util/forms/DateInput.tsx
+++ b/src/view/com/util/forms/DateInput.tsx
@@ -1,19 +1,18 @@
-import React, {useState, useCallback} from 'react'
+import React, {useCallback, useState} from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
+import DatePicker from 'react-native-date-picker'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
-import {isIOS, isAndroid} from 'platform/detection'
-import {Button, ButtonType} from './Button'
-import {Text} from '../text/Text'
+import {useLingui} from '@lingui/react'
+
+import {usePalette} from 'lib/hooks/usePalette'
import {TypographyVariant} from 'lib/ThemeContext'
import {useTheme} from 'lib/ThemeContext'
-import {usePalette} from 'lib/hooks/usePalette'
-import {getLocales} from 'expo-localization'
-import DatePicker from 'react-native-date-picker'
-
-const LOCALE = getLocales()[0]
+import {isAndroid, isIOS} from 'platform/detection'
+import {Text} from '../text/Text'
+import {Button, ButtonType} from './Button'
interface Props {
testID?: string
@@ -30,16 +29,11 @@ interface Props {
}
export function DateInput(props: Props) {
+ const {i18n} = useLingui()
const [show, setShow] = useState(false)
const theme = useTheme()
const pal = usePalette('default')
- const formatter = React.useMemo(() => {
- return new Intl.DateTimeFormat(LOCALE.languageTag, {
- timeZone: props.handleAsUTC ? 'UTC' : undefined,
- })
- }, [props.handleAsUTC])
-
const onChangeInternal = useCallback(
(date: Date) => {
setShow(false)
@@ -74,7 +68,9 @@ export function DateInput(props: Props) {
- {formatter.format(props.value)}
+ {i18n.date(props.value, {
+ timeZone: props.handleAsUTC ? 'UTC' : undefined,
+ })}
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 6c82ec8cc2..03b6dd233c 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -1,5 +1,6 @@
import React, {memo} from 'react'
import {
+ Platform,
Pressable,
type PressableProps,
type StyleProp,
@@ -9,6 +10,7 @@ import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
+ AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
@@ -31,7 +33,11 @@ import {
usePostDeleteMutation,
useThreadMuteMutationQueue,
} from '#/state/queries/post'
+import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
+import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
+import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
import {useSession} from '#/state/session'
+import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {getCurrentRoute} from 'lib/routes/helpers'
import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
@@ -40,6 +46,10 @@ import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {EmbedDialog} from '#/components/dialogs/Embed'
+import {
+ PostInteractionSettingsDialog,
+ usePrefetchPostInteractionSettings,
+} from '#/components/dialogs/PostInteractionSettingsDialog'
import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
@@ -50,13 +60,16 @@ import {
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
} from '#/components/icons/Emoji'
+import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
+import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
@@ -73,6 +86,7 @@ let PostDropdownBtn = ({
hitSlop,
size,
timestamp,
+ threadgateRecord,
}: {
testID: string
post: Shadow
@@ -83,6 +97,7 @@ let PostDropdownBtn = ({
hitSlop?: PressableProps['hitSlop']
size?: 'lg' | 'md' | 'sm'
timestamp: string
+ threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const {hasSession, currentAccount} = useSession()
const theme = useTheme()
@@ -104,17 +119,44 @@ let PostDropdownBtn = ({
const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
+ const postInteractionSettingsDialogControl = useDialogControl()
+ const quotePostDetachConfirmControl = useDialogControl()
+ const hideReplyConfirmControl = useDialogControl()
+ const {mutateAsync: toggleReplyVisibility} =
+ useToggleReplyVisibilityMutation()
+
const postUri = post.uri
const postCid = post.cid
const postAuthor = post.author
+ const quoteEmbed = React.useMemo(() => {
+ if (!currentAccount || !post.embed) return
+ return getMaybeDetachedQuoteEmbed({
+ viewerDid: currentAccount.did,
+ post,
+ })
+ }, [post, currentAccount])
const rootUri = record.reply?.root?.uri || postUri
+ const isReply = Boolean(record.reply)
const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
post,
rootUri,
)
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did
+ const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
+ const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
+ threadgateRecord,
+ })
+ const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri)
+
+ const {mutateAsync: toggleQuoteDetachment, isPending} =
+ useToggleQuoteDetachmentMutation()
+
+ const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
+ postUri: post.uri,
+ rootPostUri: rootUri,
+ })
const href = React.useMemo(() => {
const urip = new AtUri(postUri)
@@ -242,7 +284,65 @@ let PostDropdownBtn = ({
[navigation, postUri],
)
+ const onToggleQuotePostAttachment = React.useCallback(async () => {
+ if (!quoteEmbed) return
+
+ const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
+ const isDetach = action === 'detach'
+
+ try {
+ await toggleQuoteDetachment({
+ post,
+ quoteUri: quoteEmbed.uri,
+ action: quoteEmbed.isDetached ? 'reattach' : 'detach',
+ })
+ Toast.show(
+ isDetach
+ ? _(msg`Quote post was successfully detached`)
+ : _(msg`Quote post was re-attached`),
+ )
+ } catch (e: any) {
+ Toast.show(_(msg`Updating quote attachment failed`))
+ logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
+ }
+ }, [_, quoteEmbed, post, toggleQuoteDetachment])
+
+ const canHidePostForMe = !isAuthor && !isPostHidden
const canEmbed = isWeb && gtMobile && !hideInPWI
+ const canHideReplyForEveryone =
+ !isAuthor && isRootPostAuthor && !isPostHidden && isReply
+ const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
+
+ const onToggleReplyVisibility = React.useCallback(async () => {
+ // TODO no threadgate?
+ if (!canHideReplyForEveryone) return
+
+ const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
+ const isHide = action === 'hide'
+
+ try {
+ await toggleReplyVisibility({
+ postUri: rootUri,
+ replyUri: postUri,
+ action,
+ })
+ Toast.show(
+ isHide
+ ? _(msg`Reply was successfully hidden`)
+ : _(msg`Reply visibility updated`),
+ )
+ } catch (e: any) {
+ Toast.show(_(msg`Updating reply visibility failed`))
+ logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
+ }
+ }, [
+ _,
+ isReplyHiddenByThreadgate,
+ rootUri,
+ postUri,
+ canHideReplyForEveryone,
+ toggleReplyVisibility,
+ ])
return (
@@ -383,20 +483,92 @@ let PostDropdownBtn = ({
{_(msg`Mute words & tags`)}
-
- {!isAuthor && !isPostHidden && (
-
- {_(msg`Hide post`)}
-
-
- )}
>
)}
+ {hasSession &&
+ (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
+ <>
+
+
+ {canHidePostForMe && (
+
+
+ {isReply
+ ? _(msg`Hide reply for me`)
+ : _(msg`Hide post for me`)}
+
+
+
+ )}
+ {canHideReplyForEveryone && (
+ hideReplyConfirmControl.open()
+ }>
+
+ {isReplyHiddenByThreadgate
+ ? _(msg`Show reply for everyone`)
+ : _(msg`Hide reply for everyone`)}
+
+
+
+ )}
+
+ {canDetachQuote && (
+ quotePostDetachConfirmControl.open()
+ }>
+
+ {quoteEmbed.isDetached
+ ? _(msg`Re-attach quote`)
+ : _(msg`Detach quote`)}
+
+
+
+ )}
+
+ >
+ )}
+
{hasSession && (
<>
@@ -412,13 +584,34 @@ let PostDropdownBtn = ({
)}
{isAuthor && (
-
- {_(msg`Delete post`)}
-
-
+ <>
+
+
+ {_(msg`Edit interaction settings`)}
+
+
+
+
+ {_(msg`Delete post`)}
+
+
+ >
)}
>
@@ -439,8 +632,10 @@ let PostDropdownBtn = ({
@@ -479,6 +674,33 @@ let PostDropdownBtn = ({
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
/>
+
+
+
+
+
+
)
}
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 61cb6f69f3..f57ab4e3c9 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -1,106 +1,224 @@
import React from 'react'
-import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native'
+import {DimensionValue, Pressable, View} from 'react-native'
import {Image} from 'expo-image'
-import {clamp} from 'lib/numbers'
-import {Dimensions} from 'lib/media/types'
-import * as imageSizes from 'lib/media/image-sizes'
+import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-const MIN_ASPECT_RATIO = 0.33 // 1/3
-const MAX_ASPECT_RATIO = 10 // 10/1
+import * as imageSizes from '#/lib/media/image-sizes'
+import {Dimensions} from '#/lib/media/types'
+import {isNative} from '#/platform/detection'
+import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop'
+import {Text} from '#/components/Typography'
-interface Props {
- alt?: string
- uri: string
- dimensionsHint?: Dimensions
- onPress?: () => void
- onLongPress?: () => void
- onPressIn?: () => void
- style?: StyleProp
- children?: React.ReactNode
+export function useImageAspectRatio({
+ src,
+ dimensions,
+}: {
+ src: string
+ dimensions: Dimensions | undefined
+}) {
+ const [raw, setAspectRatio] = React.useState(
+ dimensions ? calc(dimensions) : 1,
+ )
+ const {isCropped, constrained, max} = React.useMemo(() => {
+ const a34 = 0.75 // max of 3:4 ratio in feeds
+ const constrained = Math.max(raw, a34)
+ const max = Math.max(raw, 0.25) // max of 1:4 in thread
+ const isCropped = raw < constrained
+ return {
+ isCropped,
+ constrained,
+ max,
+ }
+ }, [raw])
+
+ React.useEffect(() => {
+ let aborted = false
+ if (dimensions) return
+ imageSizes.fetch(src).then(newDim => {
+ if (aborted) return
+ setAspectRatio(calc(newDim))
+ })
+ return () => {
+ aborted = true
+ }
+ }, [dimensions, setAspectRatio, src])
+
+ return {
+ dimensions,
+ raw,
+ constrained,
+ max,
+ isCropped,
+ }
+}
+
+export function ConstrainedImage({
+ aspectRatio,
+ fullBleed,
+ children,
+}: {
+ aspectRatio: number
+ fullBleed?: boolean
+ children: React.ReactNode
+}) {
+ const t = useTheme()
+ const {gtMobile} = useBreakpoints()
+ /**
+ * Computed as a % value to apply as `paddingTop`
+ */
+ const outerAspectRatio = React.useMemo(() => {
+ // capped to square or shorter
+ const ratio =
+ isNative || !gtMobile
+ ? Math.min(1 / aspectRatio, 1.5)
+ : Math.min(1 / aspectRatio, 1)
+ return `${ratio * 100}%`
+ }, [aspectRatio, gtMobile])
+
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
}
export function AutoSizedImage({
- alt,
- uri,
- dimensionsHint,
+ image,
+ crop = 'constrained',
+ hideBadge,
onPress,
onLongPress,
onPressIn,
- style,
- children = null,
-}: Props) {
+}: {
+ image: AppBskyEmbedImages.ViewImage
+ crop?: 'none' | 'square' | 'constrained'
+ hideBadge?: boolean
+ onPress?: () => void
+ onLongPress?: () => void
+ onPressIn?: () => void
+}) {
+ const t = useTheme()
const {_} = useLingui()
- const [dim, setDim] = React.useState(
- dimensionsHint || imageSizes.get(uri),
- )
- const [aspectRatio, setAspectRatio] = React.useState(
- dim ? calc(dim) : 1,
- )
- React.useEffect(() => {
- let aborted = false
- if (dim) {
- return
- }
- imageSizes.fetch(uri).then(newDim => {
- if (aborted) {
- return
- }
- setDim(newDim)
- setAspectRatio(calc(newDim))
- })
- }, [dim, setDim, setAspectRatio, uri])
+ const largeAlt = useLargeAltBadgeEnabled()
+ const {
+ constrained,
+ max,
+ isCropped: rawIsCropped,
+ } = useImageAspectRatio({
+ src: image.thumb,
+ dimensions: image.aspectRatio,
+ })
+ const cropDisabled = crop === 'none'
+ const isCropped = rawIsCropped && !cropDisabled
+ const hasAlt = !!image.alt
- if (onPress || onLongPress || onPressIn) {
+ const contents = (
+ <>
+
+
+ {(hasAlt || isCropped) && !hideBadge ? (
+
+ {isCropped && (
+
+ )}
+ {hasAlt && (
+
+ ALT
+
+ )}
+
+ ) : null}
+ >
+ )
+
+ if (cropDisabled) {
return (
- // disable a11y rule because in this case we want the tags on the image (#1640)
- // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
-
- {children}
+ // alt here is what screen readers actually use
+ accessibilityLabel={image.alt}
+ accessibilityHint={_(msg`Tap to view full image`)}
+ style={[
+ a.w_full,
+ a.rounded_sm,
+ a.overflow_hidden,
+ t.atoms.bg_contrast_25,
+ {aspectRatio: max},
+ ]}>
+ {contents}
)
+ } else {
+ return (
+
+
+ {contents}
+
+
+ )
}
-
- return (
-
-
- {children}
-
- )
}
function calc(dim: Dimensions) {
if (dim.width === 0 || dim.height === 0) {
return 1
}
- return clamp(dim.width / dim.height, MIN_ASPECT_RATIO, MAX_ASPECT_RATIO)
+ return dim.width / dim.height
}
-
-const styles = StyleSheet.create({
- container: {
- overflow: 'hidden',
- },
- image: {
- width: '100%',
- },
-})
diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx
index 9bbb2ac100..839674c8cd 100644
--- a/src/view/com/util/images/Gallery.tsx
+++ b/src/view/com/util/images/Gallery.tsx
@@ -1,13 +1,14 @@
import React, {ComponentProps, FC} from 'react'
-import {Pressable, StyleSheet, Text, View} from 'react-native'
+import {Pressable, View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {isWeb} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
-import {atoms as a} from '#/alf'
+import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void
@@ -17,7 +18,8 @@ interface GalleryItemProps {
onPress?: EventFunction
onLongPress?: EventFunction
onPressIn?: EventFunction
- imageStyle: ComponentProps['style']
+ imageStyle?: ComponentProps['style']
+ viewContext?: PostEmbedViewContext
}
export const GalleryItem: FC = ({
@@ -27,57 +29,69 @@ export const GalleryItem: FC = ({
onPress,
onPressIn,
onLongPress,
+ viewContext,
}) => {
+ const t = useTheme()
const {_} = useLingui()
const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index]
+ const hasAlt = !!image.alt
+ const hideBadges =
+ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
return (
onPress(index) : undefined}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
- style={a.flex_1}
+ style={[
+ a.flex_1,
+ a.rounded_xs,
+ a.overflow_hidden,
+ t.atoms.bg_contrast_25,
+ imageStyle,
+ ]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
- {image.alt === '' ? null : (
-
+ {hasAlt && !hideBadges ? (
+
+ style={[a.font_heavy, largeAltBadge ? a.text_xs : {fontSize: 8}]}>
ALT
- )}
+ ) : null}
)
}
-
-const styles = StyleSheet.create({
- altContainer: {
- backgroundColor: 'rgba(0, 0, 0, 0.75)',
- borderRadius: 6,
- paddingHorizontal: 6,
- paddingVertical: 3,
- position: 'absolute',
- // Related to margin/gap hack. This keeps the alt label in the same position
- // on all platforms
- right: isWeb ? 8 : 5,
- bottom: isWeb ? 8 : 5,
- },
- alt: {
- color: 'white',
- fontSize: 7,
- fontWeight: 'bold',
- },
-})
diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx
deleted file mode 100644
index bade2a4446..0000000000
--- a/src/view/com/util/images/ImageHorzList.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {Image} from 'expo-image'
-import {AppBskyEmbedImages} from '@atproto/api'
-import {Trans} from '@lingui/macro'
-
-import {atoms as a} from '#/alf'
-import {Text} from '#/components/Typography'
-
-interface Props {
- images: AppBskyEmbedImages.ViewImage[]
- style?: StyleProp
- gif?: boolean
-}
-
-export function ImageHorzList({images, style, gif}: Props) {
- return (
-
- {images.map(({thumb, alt}) => (
-
-
- {gif && (
-
-
- GIF
-
-
- )}
-
- ))}
-
- )
-}
-
-const styles = StyleSheet.create({
- altContainer: {
- backgroundColor: 'rgba(0, 0, 0, 0.75)',
- borderRadius: 6,
- paddingHorizontal: 6,
- paddingVertical: 3,
- position: 'absolute',
- right: 5,
- bottom: 5,
- zIndex: 2,
- },
- alt: {
- color: 'white',
- fontSize: 7,
- fontWeight: 'bold',
- },
-})
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index ba6c04f505..45da7f076d 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -1,8 +1,10 @@
import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
+import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyEmbedImages} from '@atproto/api'
+
+import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
+import {atoms as a, useBreakpoints} from '#/alf'
import {GalleryItem} from './Gallery'
-import {isWeb} from 'platform/detection'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
@@ -10,13 +12,25 @@ interface ImageLayoutGridProps {
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
style?: StyleProp
+ viewContext?: PostEmbedViewContext
}
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
+ const {gtMobile} = useBreakpoints()
+ const gap =
+ props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+ ? gtMobile
+ ? a.gap_xs
+ : a.gap_2xs
+ : gtMobile
+ ? a.gap_sm
+ : a.gap_xs
+ const count = props.images.length
+ const aspectRatio = count === 2 ? 2 : count === 3 ? 1.5 : 1
return (
-
-
+
+
)
@@ -27,36 +41,39 @@ interface ImageLayoutGridInnerProps {
onPress?: (index: number) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
+ viewContext?: PostEmbedViewContext
+ gap: {gap: number}
}
function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
+ const gap = props.gap
const count = props.images.length
switch (count) {
case 2:
return (
-
-
-
+
+
+
-
-
+
+
)
case 3:
return (
-
-
-
+
+
+
-
-
-
+
+
+
-
-
+
+
@@ -65,20 +82,20 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
case 4:
return (
<>
-
-
-
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
>
@@ -88,39 +105,3 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) {
return null
}
}
-
-// On web we use margin to calculate gap, as aspectRatio does not properly size
-// all images on web. On native though we cannot rely on margin, since the
-// negative margin interferes with the swipe controls on pagers.
-// https://github.com/facebook/yoga/issues/1418
-// https://github.com/bluesky-social/social-app/issues/2601
-const IMAGE_GAP = 5
-
-const styles = StyleSheet.create({
- container: isWeb
- ? {
- marginHorizontal: -IMAGE_GAP / 2,
- marginVertical: -IMAGE_GAP / 2,
- }
- : {
- gap: IMAGE_GAP,
- },
- flexRow: {
- flexDirection: 'row',
- gap: isWeb ? undefined : IMAGE_GAP,
- },
- smallItem: {flex: 1, aspectRatio: 1},
- image: isWeb
- ? {
- margin: IMAGE_GAP / 2,
- }
- : {},
- threeSingle: {
- flex: 2,
- aspectRatio: isWeb ? 1 : undefined,
- },
- threeDouble: {
- flex: 1,
- gap: isWeb ? undefined : IMAGE_GAP,
- },
-})
diff --git a/src/view/com/util/numeric/format.ts b/src/view/com/util/numeric/format.ts
index 71d8d73e04..cca9fc7e73 100644
--- a/src/view/com/util/numeric/format.ts
+++ b/src/view/com/util/numeric/format.ts
@@ -1,19 +1,12 @@
-export const formatCount = (num: number) =>
- Intl.NumberFormat('en-US', {
+import type {I18n} from '@lingui/core'
+
+export const formatCount = (i18n: I18n, num: number) => {
+ return i18n.number(num, {
notation: 'compact',
maximumFractionDigits: 1,
// `1,953` shouldn't be rounded up to 2k, it should be truncated.
// @ts-expect-error: `roundingMode` doesn't seem to be in the typings yet
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
roundingMode: 'trunc',
- }).format(num)
-
-export function formatCountShortOnly(num: number): string {
- if (num >= 1000000) {
- return (num / 1000000).toFixed(1) + 'M'
- }
- if (num >= 1000) {
- return (num / 1000).toFixed(1) + 'K'
- }
- return String(num)
+ })
}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 478b8f0f89..1cad5e0910 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -10,6 +10,7 @@ import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
+ AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
@@ -22,7 +23,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers'
-import {s} from '#/lib/styles'
import {Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {
@@ -35,14 +35,12 @@ import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
+import {CountWheel} from 'lib/custom-animations/CountWheel'
+import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
-import {
- Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
- Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
-} from '#/components/icons/Heart2'
import * as Prompt from '#/components/Prompt'
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {formatCount} from '../numeric/format'
@@ -58,7 +56,9 @@ let PostCtrls = ({
feedContext,
style,
onPressReply,
+ onPostReply,
logContext,
+ threadgateRecord,
}: {
big?: boolean
post: Shadow
@@ -67,10 +67,12 @@ let PostCtrls = ({
feedContext?: string | undefined
style?: StyleProp
onPressReply: () => void
+ onPostReply?: (postUri: string | undefined) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+ threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const t = useTheme()
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const {openComposer} = useComposerControls()
const {currentAccount} = useSession()
const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
@@ -84,6 +86,11 @@ let PostCtrls = ({
const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics()
const gate = useGate()
+ const isBlocked = Boolean(
+ post.author.viewer?.blocking ||
+ post.author.viewer?.blockedBy ||
+ post.author.viewer?.blockingByList,
+ )
const shouldShowLoggedOutWarning = React.useMemo(() => {
return (
@@ -99,8 +106,19 @@ let PostCtrls = ({
[t],
) as StyleProp
+ const [isToggleLikeIcon, setIsToggleLikeIcon] = React.useState(false)
+
const onPressToggleLike = React.useCallback(async () => {
+ if (isBlocked) {
+ Toast.show(
+ _(msg`Cannot interact with a blocked user`),
+ 'exclamation-circle',
+ )
+ return
+ }
+
try {
+ setIsToggleLikeIcon(true)
if (!post.viewer?.like) {
playHaptic()
sendInteraction({
@@ -119,6 +137,7 @@ let PostCtrls = ({
}
}
}, [
+ _,
playHaptic,
post.uri,
post.viewer?.like,
@@ -127,9 +146,18 @@ let PostCtrls = ({
sendInteraction,
captureAction,
feedContext,
+ isBlocked,
])
const onRepost = useCallback(async () => {
+ if (isBlocked) {
+ Toast.show(
+ _(msg`Cannot interact with a blocked user`),
+ 'exclamation-circle',
+ )
+ return
+ }
+
try {
if (!post.viewer?.repost) {
sendInteraction({
@@ -147,15 +175,25 @@ let PostCtrls = ({
}
}
}, [
+ _,
post.uri,
post.viewer?.repost,
queueRepost,
queueUnrepost,
sendInteraction,
feedContext,
+ isBlocked,
])
const onQuote = useCallback(() => {
+ if (isBlocked) {
+ Toast.show(
+ _(msg`Cannot interact with a blocked user`),
+ 'exclamation-circle',
+ )
+ return
+ }
+
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionQuote',
@@ -169,16 +207,22 @@ let PostCtrls = ({
author: post.author,
indexedAt: post.indexedAt,
},
+ quoteCount: post.quoteCount,
+ onPost: onPostReply,
})
}, [
- openComposer,
+ _,
+ sendInteraction,
post.uri,
post.cid,
post.author,
post.indexedAt,
- record.text,
- sendInteraction,
+ post.quoteCount,
feedContext,
+ openComposer,
+ record.text,
+ onPostReply,
+ isBlocked,
])
const onShare = useCallback(() => {
@@ -198,8 +242,9 @@ let PostCtrls = ({
a.gap_xs,
a.rounded_full,
a.flex_row,
- a.align_center,
a.justify_center,
+ a.align_center,
+ a.overflow_hidden,
{padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25,
],
@@ -238,7 +283,7 @@ let PostCtrls = ({
big ? a.text_md : {fontSize: 15},
a.user_select_none,
]}>
- {formatCount(post.replyCount)}
+ {formatCount(i18n, post.replyCount)}
) : undefined}
@@ -246,10 +291,11 @@ let PostCtrls = ({
@@ -270,29 +316,17 @@ let PostCtrls = ({
}
accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}>
- {post.viewer?.like ? (
-
- ) : (
-
- )}
- {typeof post.likeCount !== 'undefined' && post.likeCount > 0 ? (
-
- {formatCount(post.likeCount)}
-
- ) : undefined}
+
+
{big && (
@@ -338,6 +372,7 @@ let PostCtrls = ({
style={{padding: 5}}
hitSlop={POST_CTRL_HITSLOP}
timestamp={post.indexedAt}
+ threadgateRecord={threadgateRecord}
/>
{gate('debug_show_feedcontext') && feedContext && (
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index d49cda442c..8c4928dfcd 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -20,6 +20,7 @@ interface Props {
onRepost: () => void
onQuote: () => void
big?: boolean
+ embeddingDisabled: boolean
}
let RepostButton = ({
@@ -28,9 +29,10 @@ let RepostButton = ({
onRepost,
onQuote,
big,
+ embeddingDisabled,
}: Props): React.ReactNode => {
const t = useTheme()
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const requireAuth = useRequireAuth()
const dialogControl = Dialog.useDialogControl()
const playHaptic = useHaptics()
@@ -51,6 +53,9 @@ let RepostButton = ({
onPress={() => {
requireAuth(() => dialogControl.open())
}}
+ onLongPress={() => {
+ requireAuth(() => onQuote())
+ }}
style={[
a.flex_row,
a.align_center,
@@ -77,7 +82,7 @@ let RepostButton = ({
big ? a.text_md : {fontSize: 15},
isReposted && a.font_bold,
]}>
- {formatCount(repostCount)}
+ {formatCount(i18n, repostCount)}
) : undefined}
@@ -111,9 +116,14 @@ let RepostButton = ({
{
playHaptic()
dialogControl.close(() => {
@@ -123,9 +133,23 @@ let RepostButton = ({
size="large"
variant="ghost"
color="primary">
-
-
- {_(msg`Quote post`)}
+
+
+ {embeddingDisabled
+ ? _(msg`Quote posts disabled`)
+ : _(msg`Quote post`)}
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index 17ab736ced..111b41dd7c 100644
--- a/src/view/com/util/post-ctrls/RepostButton.web.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx
@@ -20,6 +20,7 @@ interface Props {
onRepost: () => void
onQuote: () => void
big?: boolean
+ embeddingDisabled: boolean
}
export const RepostButton = ({
@@ -28,6 +29,7 @@ export const RepostButton = ({
onRepost,
onQuote,
big,
+ embeddingDisabled,
}: Props) => {
const t = useTheme()
const {_} = useLingui()
@@ -76,10 +78,19 @@ export const RepostButton = ({
- {_(msg`Quote post`)}
+
+ {embeddingDisabled
+ ? _(msg`Quote posts disabled`)
+ : _(msg`Quote post`)}
+
@@ -117,6 +128,7 @@ const RepostInner = ({
repostCount?: number
big?: boolean
}) => {
+ const {i18n} = useLingui()
return (
@@ -129,7 +141,7 @@ const RepostInner = ({
isReposted && [a.font_bold],
a.user_select_none,
]}>
- {formatCount(repostCount)}
+ {formatCount(i18n, repostCount)}
) : undefined}
diff --git a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
new file mode 100644
index 0000000000..95fa0bb0ec
--- /dev/null
+++ b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
@@ -0,0 +1,65 @@
+import React from 'react'
+import {useVideoPlayer, VideoPlayer} from 'expo-video'
+
+import {isAndroid, isNative} from '#/platform/detection'
+
+const Context = React.createContext<{
+ activeSource: string
+ activeViewId: string | undefined
+ setActiveSource: (src: string | null, viewId: string | null) => void
+ player: VideoPlayer
+} | null>(null)
+
+export function Provider({children}: {children: React.ReactNode}) {
+ if (!isNative) {
+ throw new Error('ActiveVideoProvider may only be used on native.')
+ }
+
+ const [activeSource, setActiveSource] = React.useState('')
+ const [activeViewId, setActiveViewId] = React.useState()
+
+ const player = useVideoPlayer(activeSource, p => {
+ p.muted = true
+ p.loop = true
+ // We want to immediately call `play` so we get the loading state
+ p.play()
+ })
+
+ const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
+ // HACK
+ // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
+ // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
+ // apply it there.
+ if (src === activeSource && isAndroid) {
+ setActiveSource('')
+ setTimeout(() => {
+ setActiveSource(src ? src : '')
+ }, 100)
+ } else {
+ setActiveSource(src ? src : '')
+ }
+ setActiveViewId(viewId ? viewId : '')
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useActiveVideoNative() {
+ const context = React.useContext(Context)
+ if (!context) {
+ throw new Error(
+ 'useActiveVideoNative must be used within a ActiveVideoNativeProvider',
+ )
+ }
+ return context
+}
diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx
similarity index 66%
rename from src/view/com/util/post-embeds/ActiveVideoContext.tsx
rename to src/view/com/util/post-embeds/ActiveVideoWebContext.tsx
index d18dfc0908..bc43e997c7 100644
--- a/src/view/com/util/post-embeds/ActiveVideoContext.tsx
+++ b/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx
@@ -8,19 +8,21 @@ import React, {
} from 'react'
import {useWindowDimensions} from 'react-native'
-import {isNative} from '#/platform/detection'
-import {VideoPlayerProvider} from './VideoPlayerContext'
+import {isNative, isWeb} from '#/platform/detection'
-const ActiveVideoContext = React.createContext<{
+const Context = React.createContext<{
activeViewId: string | null
- setActiveView: (viewId: string, src: string) => void
+ setActiveView: (viewId: string) => void
sendViewPosition: (viewId: string, y: number) => void
} | null>(null)
-export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
+export function Provider({children}: {children: React.ReactNode}) {
+ if (!isWeb) {
+ throw new Error('ActiveVideoWebContext may onl be used on web.')
+ }
+
const [activeViewId, setActiveViewId] = useState(null)
const activeViewLocationRef = useRef(Infinity)
- const [source, setSource] = useState(null)
const {height: windowHeight} = useWindowDimensions()
// minimising re-renders by using refs
@@ -31,9 +33,8 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
}, [activeViewId])
const setActiveView = useCallback(
- (viewId: string, src: string) => {
+ (viewId: 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
@@ -88,32 +89,26 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
[activeViewId, setActiveView, sendViewPosition],
)
- return (
-
-
- {children}
-
-
- )
+ return {children}
}
-export function useActiveVideoView({source}: {source: string}) {
- const context = React.useContext(ActiveVideoContext)
+export function useActiveVideoWeb() {
+ const context = React.useContext(Context)
if (!context) {
- throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
+ throw new Error(
+ 'useActiveVideoWeb must be used within a ActiveVideoWebProvider',
+ )
}
+
+ const {activeViewId, setActiveView, sendViewPosition} = context
const id = useId()
return {
- active: context.activeViewId === id,
- setActive: useCallback(
- () => context.setActiveView(id, source),
- [context, id, source],
- ),
- currentActiveView: context.activeViewId,
- sendPosition: useCallback(
- (y: number) => context.sendViewPosition(id, y),
- [context, id],
- ),
+ active: activeViewId === id,
+ setActive: () => {
+ setActiveView(id)
+ },
+ currentActiveView: activeViewId,
+ sendPosition: (y: number) => sendViewPosition(id, y),
}
}
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
index 9fdede877d..111867fc61 100644
--- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -17,7 +17,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {WebView} from 'react-native-webview'
import {Image} from 'expo-image'
import {AppBskyEmbedExternal} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -29,6 +28,7 @@ import {useExternalEmbedsPrefs} from '#/state/preferences'
import {atoms as a} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {EventStopper} from '../EventStopper'
interface ShouldStartLoadRequest {
@@ -59,7 +59,7 @@ function PlaceholderOverlay({
onPress={onPress}
style={[styles.overlayContainer, styles.topRadius]}>
{!isPlayerActive ? (
-
+
) : (
)}
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
index 31c4419b50..5b6e1c9dce 100644
--- a/src/view/com/util/post-embeds/GifEmbed.tsx
+++ b/src/view/com/util/post-embeds/GifEmbed.tsx
@@ -8,7 +8,6 @@ import {
ViewStyle,
} from 'react-native'
import {AppBskyEmbedExternal} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -22,6 +21,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {GifView} from '../../../../../modules/expo-bluesky-gif-view'
import {GifViewStateChangeEvent} from '../../../../../modules/expo-bluesky-gif-view/src/GifView.types'
@@ -69,24 +69,7 @@ function PlaybackControls({
) : !isPlaying ? (
-
-
-
+
) : undefined}
)
@@ -155,7 +138,6 @@ export function GifEmbed({
accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={parsedAlt.alt}
/>
-
{!hideAlt && parsedAlt.isPreferred && }
@@ -183,7 +165,6 @@ function AltText({text}: {text: string}) {
ALT
-
Alt Text
diff --git a/src/view/com/util/post-embeds/ListEmbed.tsx b/src/view/com/util/post-embeds/ListEmbed.tsx
deleted file mode 100644
index fc5ad270fc..0000000000
--- a/src/view/com/util/post-embeds/ListEmbed.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react'
-import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ListCard} from 'view/com/lists/ListCard'
-import {AppBskyGraphDefs} from '@atproto/api'
-import {s} from 'lib/styles'
-
-export function ListEmbed({
- item,
- style,
-}: {
- item: AppBskyGraphDefs.ListView
- style?: StyleProp
-}) {
- const pal = usePalette('default')
-
- return (
-
-
-
- )
-}
-
-const styles = StyleSheet.create({
- container: {
- borderRadius: 8,
- },
- card: {
- borderTopWidth: 0,
- borderRadius: 8,
- },
-})
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 20c05b692b..87a6edebde 100644
--- a/src/view/com/util/post-embeds/QuoteEmbed.tsx
+++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx
@@ -11,9 +11,9 @@ import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
+ AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyFeedPost,
- moderatePost,
ModerationDecision,
RichText as RichTextAPI,
} from '@atproto/api'
@@ -24,8 +24,10 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_20} from '#/lib/constants'
+import {moderatePost_wrapped} from '#/lib/moderatePost_wrapped'
import {s} from '#/lib/styles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useSession} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {InfoCircleIcon} from 'lib/icons'
import {makeProfileLink} from 'lib/routes/links'
@@ -39,19 +41,23 @@ import {Link} from '../Link'
import {PostMeta} from '../PostMeta'
import {Text} from '../text/Text'
import {PostEmbeds} from '.'
+import {QuoteEmbedViewContext} from './types'
export function MaybeQuoteEmbed({
embed,
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
embed: AppBskyEmbedRecord.View
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const pal = usePalette('default')
+ const {currentAccount} = useSession()
if (
AppBskyEmbedRecord.isViewRecord(embed.record) &&
AppBskyFeedPost.isRecord(embed.record.value) &&
@@ -64,6 +70,7 @@ export function MaybeQuoteEmbed({
onOpen={onOpen}
style={style}
allowNestedQuotes={allowNestedQuotes}
+ viewContext={viewContext}
/>
)
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
@@ -84,6 +91,22 @@ export function MaybeQuoteEmbed({
)
+ } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) {
+ const isViewerOwner = currentAccount?.did
+ ? embed.record.uri.includes(currentAccount.did)
+ : false
+ return (
+
+
+
+ {isViewerOwner ? (
+ Removed by you
+ ) : (
+ Removed by author
+ )}
+
+
+ )
}
return null
}
@@ -94,17 +117,19 @@ function QuoteEmbedModerated({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const moderationOpts = useModerationOpts()
const moderation = React.useMemo(() => {
return moderationOpts
- ? moderatePost(viewRecordToPostView(viewRecord), moderationOpts)
+ ? moderatePost_wrapped(viewRecordToPostView(viewRecord), moderationOpts)
: undefined
}, [viewRecord, moderationOpts])
@@ -125,6 +150,7 @@ function QuoteEmbedModerated({
onOpen={onOpen}
style={style}
allowNestedQuotes={allowNestedQuotes}
+ viewContext={viewContext}
/>
)
}
@@ -141,6 +167,7 @@ export function QuoteEmbed({
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: QuoteEmbedViewContext
}) {
const queryClient = useQueryClient()
const pal = usePalette('default')
@@ -162,12 +189,17 @@ export function QuoteEmbed({
if (allowNestedQuotes) {
return e
} else {
- if (AppBskyEmbedImages.isView(e) || AppBskyEmbedExternal.isView(e)) {
+ if (
+ AppBskyEmbedImages.isView(e) ||
+ AppBskyEmbedExternal.isView(e) ||
+ AppBskyEmbedVideo.isView(e)
+ ) {
return e
} else if (
AppBskyEmbedRecordWithMedia.isView(e) &&
(AppBskyEmbedImages.isView(e.media) ||
- AppBskyEmbedExternal.isView(e.media))
+ AppBskyEmbedExternal.isView(e.media) ||
+ AppBskyEmbedVideo.isView(e.media))
) {
return e.media
}
diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx
index 4e2909f40b..3175266e41 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.tsx
@@ -1,23 +1,30 @@
-import React, {useCallback, useState} from 'react'
+import React, {useCallback, useEffect, useId, useState} from 'react'
import {View} from 'react-native'
+import {ImageBackground} from 'expo-image'
+import {PlayerError, VideoPlayerStatus} from 'expo-video'
+import {AppBskyEmbedVideo} from '@atproto/api'
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 {clamp} from '#/lib/numbers'
+import {useAutoplayDisabled} from 'state/preferences'
+import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
+import {atoms as a} from '#/alf'
+import {Button} from '#/components/Button'
+import {Loader} from '#/components/Loader'
+import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
import {ErrorBoundary} from '../ErrorBoundary'
-import {useActiveVideoView} from './ActiveVideoContext'
+import {useActiveVideoNative} from './ActiveVideoNativeContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
-export function VideoEmbed({source}: {source: string}) {
- const t = useTheme()
- const {active, setActive} = useActiveVideoView({source})
- const {_} = useLingui()
+interface Props {
+ embed: AppBskyEmbedVideo.View
+}
+export function VideoEmbed({embed}: Props) {
const [key, setKey] = useState(0)
+
const renderError = useCallback(
(error: unknown) => (
setKey(key + 1)} />
@@ -25,43 +32,180 @@ export function VideoEmbed({source}: {source: string}) {
[key],
)
+ let aspectRatio = 16 / 9
+ if (embed.aspectRatio) {
+ const {width, height} = embed.aspectRatio
+ aspectRatio = width / height
+ aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
+ }
+
return (
- {
- if (isActive) {
- setActive()
- }
- }}>
- {active ? (
-
- ) : (
-
-
-
- )}
-
+
)
}
+function InnerWrapper({embed}: Props) {
+ const {_} = useLingui()
+ const {activeSource, activeViewId, setActiveSource, player} =
+ useActiveVideoNative()
+ const viewId = useId()
+
+ const [playerStatus, setPlayerStatus] = useState<
+ VideoPlayerStatus | 'paused'
+ >('paused')
+ const [isMuted, setIsMuted] = useState(player.muted)
+ const [isFullscreen, setIsFullscreen] = React.useState(false)
+ const [timeRemaining, setTimeRemaining] = React.useState(0)
+ const disableAutoplay = useAutoplayDisabled()
+ const isActive = embed.playlist === activeSource && activeViewId === viewId
+ // There are some different loading states that we should pay attention to and show a spinner for
+ const isLoading =
+ isActive &&
+ (playerStatus === 'waitingToPlayAtSpecifiedRate' ||
+ playerStatus === 'loading')
+ // This happens whenever the visibility view decides that another video should start playing
+ const showOverlay = !isActive || isLoading || playerStatus === 'paused'
+
+ // send error up to error boundary
+ const [error, setError] = useState(null)
+ if (error) {
+ throw error
+ }
+
+ useEffect(() => {
+ if (isActive) {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
+ setIsMuted(isMuted)
+ })
+ const timeSub = player.addListener(
+ 'timeRemainingChange',
+ secondsRemaining => {
+ setTimeRemaining(secondsRemaining)
+ },
+ )
+ const statusSub = player.addListener(
+ 'statusChange',
+ (status, oldStatus, playerError) => {
+ setPlayerStatus(status)
+ if (status === 'error') {
+ setError(playerError ?? new Error('Unknown player error'))
+ }
+ if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') {
+ player.play()
+ }
+ },
+ )
+ return () => {
+ volumeSub.remove()
+ timeSub.remove()
+ statusSub.remove()
+ }
+ }
+ }, [player, isActive, disableAutoplay])
+
+ // The source might already be active (for example, if you are scrolling a list of quotes and its all the same
+ // video). In those cases, just start playing. Otherwise, setting the active source will result in the video
+ // start playback immediately
+ const startPlaying = (ignoreAutoplayPreference: boolean) => {
+ if (disableAutoplay && !ignoreAutoplayPreference) {
+ return
+ }
+
+ if (isActive) {
+ player.play()
+ } else {
+ setActiveSource(embed.playlist, viewId)
+ }
+ }
+
+ const onVisibilityStatusChange = (isVisible: boolean) => {
+ // When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change
+ // events
+ if (isFullscreen) {
+ return
+ }
+ if (isVisible) {
+ startPlaying(false)
+ } else {
+ // Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted
+ // until it gets replaced by another video
+ if (disableAutoplay) {
+ setActiveSource(null, null)
+ } else {
+ player.muted = true
+ if (player.playing) {
+ player.pause()
+ }
+ }
+ }
+ }
+
+ return (
+
+ {isActive ? (
+
+ ) : null}
+
+ startPlaying(true)}
+ label={_(msg`Play video`)}
+ color="secondary">
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
function VideoError({retry}: {error: unknown; retry: () => void}) {
return (
diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
index 5803b836df..a25f946416 100644
--- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx
@@ -1,21 +1,26 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {View} from 'react-native'
-import {Trans} from '@lingui/macro'
+import {AppBskyEmbedVideo} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {clamp} from '#/lib/numbers'
+import {useGate} from '#/lib/statsig/statsig'
import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
-} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
-import {atoms as a, useTheme} from '#/alf'
+ VideoNotFoundError,
+} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
+import {atoms as a} from '#/alf'
import {ErrorBoundary} from '../ErrorBoundary'
-import {useActiveVideoView} from './ActiveVideoContext'
+import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
-export function VideoEmbed({source}: {source: string}) {
- const t = useTheme()
+export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const ref = useRef(null)
+ const gate = useGate()
const {active, setActive, sendPosition, currentActiveView} =
- useActiveVideoView({source})
+ useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false)
useEffect(() => {
@@ -43,12 +48,25 @@ export function VideoEmbed({source}: {source: string}) {
[key],
)
+ if (!gate('video_view_on_posts')) {
+ return null
+ }
+
+ let aspectRatio = 16 / 9
+
+ if (embed.aspectRatio) {
+ const {width, height} = embed.aspectRatio
+ // min: 3/1, max: square
+ aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
+ }
+
return (
@@ -61,7 +79,7 @@ export function VideoEmbed({source}: {source: string}) {
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
void}) {
- const isHLS = error instanceof HLSUnsupportedError
+ const {_} = useLingui()
+
+ let showRetryButton = true
+ let text = null
+
+ if (error instanceof VideoNotFoundError) {
+ text = _(msg`Video not found.`)
+ } else if (error instanceof HLSUnsupportedError) {
+ showRetryButton = false
+ text = _(
+ msg`Your browser does not support the video format. Please try a different browser.`,
+ )
+ } else {
+ text = _(msg`An error occurred while loading the video. Please try again.`)
+ }
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 && }
+ {text}
+ {showRetryButton && }
)
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
new file mode 100644
index 0000000000..6636883f12
--- /dev/null
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
@@ -0,0 +1,48 @@
+import React from 'react'
+import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
+
+import {atoms as a, native, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+/**
+ * Absolutely positioned time indicator showing how many seconds are remaining
+ * Time is in seconds
+ */
+export function TimeIndicator({time}: {time: number}) {
+ const t = useTheme()
+
+ if (isNaN(time)) {
+ return null
+ }
+
+ const minutes = Math.floor(time / 60)
+ const seconds = String(time % 60).padStart(2, '0')
+
+ return (
+
+
+ {minutes}:{seconds}
+
+
+ )
+}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
index 0b48edf793..de9a2c74c2 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
@@ -1,119 +1,106 @@
-import React, {useCallback, useEffect, useRef, useState} from 'react'
+import React, {useCallback, useRef} from 'react'
import {Pressable, View} from 'react-native'
-import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
+import Animated, {FadeInDown} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video'
+import {AppBskyEmbedVideo} from '@atproto/api'
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 {useVideoPlayer} from '#/view/com/util/post-embeds/VideoPlayerContext'
-import {android, atoms as a, useTheme} from '#/alf'
+import {clamp} from '#/lib/numbers'
+import {isAndroid} from 'platform/detection'
+import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
+import {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'
+import {TimeIndicator} from './TimeIndicator'
-export function VideoEmbedInnerNative() {
- const player = useVideoPlayer()
+export function VideoEmbedInnerNative({
+ embed,
+ isFullscreen,
+ setIsFullscreen,
+ isMuted,
+ timeRemaining,
+}: {
+ embed: AppBskyEmbedVideo.View
+ isFullscreen: boolean
+ setIsFullscreen: (isFullscreen: boolean) => void
+ timeRemaining: number
+ isMuted: boolean
+}) {
+ const {_} = useLingui()
+ const {player} = useActiveVideoNative()
const ref = useRef(null)
- const isScreenFocused = useIsFocused()
- const isAppFocused = useAppState()
- const prevFocusedRef = useRef(isAppFocused)
-
- // resume video when coming back from background
- useEffect(() => {
- if (isAppFocused !== prevFocusedRef.current) {
- prevFocusedRef.current = isAppFocused
- if (isAppFocused === 'active') {
- player.play()
- }
- }
- }, [isAppFocused, player])
-
- // pause the video when the screen is not focused
- useEffect(() => {
- if (!isScreenFocused) {
- let wasPlaying = player.playing
- player.pause()
-
- return () => {
- if (wasPlaying) player.play()
- }
- }
- }, [isScreenFocused, player])
const enterFullscreen = useCallback(() => {
ref.current?.enterFullscreen()
}, [])
+ let aspectRatio = 16 / 9
+
+ if (embed.aspectRatio) {
+ const {width, height} = embed.aspectRatio
+ aspectRatio = width / height
+ aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
+ }
+
return (
-
+
{
+ contentFit="cover"
+ nativeControls={isFullscreen}
+ accessibilityIgnoresInvertColors
+ onFullscreenEnter={() => {
PlatformInfo.setAudioCategory(AudioCategory.Playback)
- PlatformInfo.setAudioActive(false)
- player.muted = false
- }}
- onExitFullscreen={() => {
- PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(true)
- player.muted = true
- if (!player.playing) player.play()
+ player.muted = false
+ setIsFullscreen(true)
+ if (isAndroid) {
+ player.play()
+ }
}}
+ onFullscreenExit={() => {
+ PlatformInfo.setAudioCategory(AudioCategory.Ambient)
+ PlatformInfo.setAudioActive(false)
+ player.muted = true
+ player.playbackRate = 1
+ setIsFullscreen(false)
+ }}
+ accessibilityLabel={
+ embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
+ }
+ accessibilityHint=""
+ />
+
-
)
}
-function Controls({
+function VideoControls({
player,
enterFullscreen,
+ timeRemaining,
+ isMuted,
}: {
player: VideoPlayer
enterFullscreen: () => void
+ timeRemaining: number
+ isMuted: boolean
}) {
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) {
@@ -147,37 +134,11 @@ function Controls({
// 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
+ const showTime = !isNaN(timeRemaining)
return (
- {showTime && (
-
-
- {minutes}:{seconds}
-
-
- )}
+ {showTime && }
- {duration > 0 && (
-
-
- {isMuted ? (
-
- ) : (
-
- )}
-
-
- )}
+
+
+ {isMuted ? (
+
+ ) : (
+
+ )}
+
+
)
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
index 59da5be42a..2760c7fafd 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.web.tsx
@@ -1,3 +1,3 @@
export function VideoEmbedInnerNative() {
- throw new Error('VideoEmbedInnerNative may not be used on native.')
+ throw new Error('VideoEmbedInnerNative may not be used on web.')
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
index c0021d9bb7..a30c0e1e9b 100644
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
+++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx
@@ -1,31 +1,33 @@
-import React, {useEffect, useRef, useState} from 'react'
+import React, {useEffect, useId, useRef, useState} from 'react'
import {View} from 'react-native'
+import {AppBskyEmbedVideo} from '@atproto/api'
import Hls from 'hls.js'
import {atoms as a} from '#/alf'
import {Controls} from './VideoWebControls'
export function VideoEmbedInnerWeb({
- source,
+ embed,
active,
setActive,
onScreen,
}: {
- source: string
- active?: boolean
- setActive?: () => void
- onScreen?: boolean
+ embed: AppBskyEmbedVideo.View
+ 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 figId = useId()
+
+ // send error up to error boundary
+ const [error, setError] = useState(null)
+ if (error) {
+ throw error
+ }
const hlsRef = useRef(undefined)
@@ -37,45 +39,69 @@ export function VideoEmbedInnerWeb({
hlsRef.current = hls
hls.attachMedia(ref.current)
- hls.loadSource(source)
+ hls.loadSource(embed.playlist)
// initial value, later on it's managed by Controls
hls.autoLevelCapping = 0
- hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
+ hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => {
if (data.subtitleTracks.length > 0) {
setHasSubtitleTrack(true)
}
})
+ hls.on(Hls.Events.ERROR, (_event, data) => {
+ if (data.fatal) {
+ if (
+ data.details === 'manifestLoadError' &&
+ data.response?.code === 404
+ ) {
+ setError(new VideoNotFoundError())
+ } else {
+ setError(data.error)
+ }
+ }
+ })
+
return () => {
hlsRef.current = undefined
hls.detachMedia()
hls.destroy()
}
- }, [source])
+ }, [embed.playlist])
return (
-
-
-
+
+
+
+
+ {embed.alt && (
+
+ {embed.alt}
+
+ )}
+
hlsRef: React.RefObject
active: boolean
@@ -11,6 +58,817 @@ export function Controls({}: {
onScreen: boolean
fullscreenRef: React.RefObject
hasSubtitleTrack: boolean
-}): React.ReactElement {
- throw new Error('Web-only component')
+}) {
+ const {
+ play,
+ pause,
+ playing,
+ muted,
+ toggleMute,
+ togglePlayPause,
+ currentTime,
+ duration,
+ buffering,
+ error,
+ canPlay,
+ } = useVideoUtils(videoRef)
+ const t = useTheme()
+ const {_} = useLingui()
+ const subtitlesEnabled = useSubtitlesEnabled()
+ const setSubtitlesEnabled = useSetSubtitlesEnabled()
+ const {
+ state: hovered,
+ onIn: onHover,
+ onOut: onEndHover,
+ } = 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) {
+ if (onScreen) {
+ if (!autoplayDisabled) 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()
+ if (autoplayDisabled) play()
+ } else {
+ togglePlayPause()
+ }
+ }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
+
+ 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 onSeek = useCallback(
+ (time: number) => {
+ if (!videoRef.current) return
+ if (videoRef.current.fastSeek) {
+ videoRef.current.fastSeek(time)
+ } else {
+ videoRef.current.currentTime = time
+ }
+ },
+ [videoRef],
+ )
+
+ const playStateBeforeSeekRef = useRef(false)
+
+ const onSeekStart = useCallback(() => {
+ drawFocus()
+ playStateBeforeSeekRef.current = playing
+ pause()
+ }, [playing, pause, drawFocus])
+
+ const onSeekEnd = useCallback(() => {
+ if (playStateBeforeSeekRef.current) {
+ play()
+ }
+ }, [play])
+
+ const seekLeft = useCallback(() => {
+ if (!videoRef.current) return
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const currentTime = videoRef.current.currentTime
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const duration = videoRef.current.duration || 0
+ onSeek(clamp(currentTime - 5, 0, duration))
+ }, [onSeek, videoRef])
+
+ const seekRight = useCallback(() => {
+ if (!videoRef.current) return
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const currentTime = videoRef.current.currentTime
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const duration = videoRef.current.duration || 0
+ onSeek(clamp(currentTime + 5, 0, duration))
+ }, [onSeek, videoRef])
+
+ const [showCursor, setShowCursor] = useState(true)
+ const cursorTimeoutRef = useRef>()
+ const onPointerMoveEmptySpace = useCallback(() => {
+ setShowCursor(true)
+ if (cursorTimeoutRef.current) {
+ clearTimeout(cursorTimeoutRef.current)
+ }
+ cursorTimeoutRef.current = setTimeout(() => {
+ setShowCursor(false)
+ onEndHover()
+ }, 2000)
+ }, [onEndHover])
+ const onPointerLeaveEmptySpace = useCallback(() => {
+ setShowCursor(false)
+ if (cursorTimeoutRef.current) {
+ clearTimeout(cursorTimeoutRef.current)
+ }
+ }, [])
+
+ const showControls =
+ ((focused || autoplayDisabled) && !playing) ||
+ (interactingViaKeypress ? hasFocus : hovered)
+
+ return (
+ {
+ evt.stopPropagation()
+ setInteractingViaKeypress(false)
+ }}
+ onPointerEnter={onHover}
+ onPointerMove={onHover}
+ onPointerLeave={onEndHover}
+ onFocus={onFocus}
+ onBlur={onBlur}
+ onKeyDown={onKeyDown}>
+
+ {!showControls && !focused && duration > 0 && (
+
+ )}
+
+
+
+
+
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+ {hasSubtitleTrack && (
+
+ )}
+
+ {!isIPhoneWeb && (
+
+ )}
+
+
+ {(buffering || error) && (
+
+ {buffering && }
+ {error && (
+
+ An error occurred
+
+ )}
+
+ )}
+
+ )
+}
+
+function ControlButton({
+ active,
+ activeLabel,
+ inactiveLabel,
+ activeIcon: ActiveIcon,
+ inactiveIcon: InactiveIcon,
+ onPress,
+}: {
+ active: boolean
+ activeLabel: string
+ inactiveLabel: string
+ activeIcon: React.ComponentType>
+ inactiveIcon: React.ComponentType>
+ onPress: () => void
+}) {
+ const t = useTheme()
+ return (
+
+ {active ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function Scrubber({
+ duration,
+ currentTime,
+ onSeek,
+ onSeekEnd,
+ onSeekStart,
+ seekLeft,
+ seekRight,
+ togglePlayPause,
+ drawFocus,
+}: {
+ duration: number
+ currentTime: number
+ onSeek: (time: number) => void
+ onSeekEnd: () => void
+ onSeekStart: () => void
+ seekLeft: () => void
+ seekRight: () => void
+ togglePlayPause: () => void
+ drawFocus: () => void
+}) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const [scrubberActive, setScrubberActive] = useState(false)
+ const {
+ state: hovered,
+ onIn: onStartHover,
+ onOut: onEndHover,
+ } = useInteractionState()
+ const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+ const [seekPosition, setSeekPosition] = useState(0)
+ const isSeekingRef = useRef(false)
+ const barRef = useRef(null)
+ const circleRef = useRef(null)
+
+ const seek = useCallback(
+ (evt: React.PointerEvent) => {
+ if (!barRef.current) return
+ const {left, width} = barRef.current.getBoundingClientRect()
+ const x = evt.clientX
+ const percent = clamp((x - left) / width, 0, 1) * duration
+ onSeek(percent)
+ setSeekPosition(percent)
+ },
+ [duration, onSeek],
+ )
+
+ const onPointerDown = useCallback(
+ (evt: React.PointerEvent) => {
+ const target = evt.target
+ if (target instanceof Element) {
+ evt.preventDefault()
+ target.setPointerCapture(evt.pointerId)
+ isSeekingRef.current = true
+ seek(evt)
+ setScrubberActive(true)
+ onSeekStart()
+ }
+ },
+ [seek, onSeekStart],
+ )
+
+ const onPointerMove = useCallback(
+ (evt: React.PointerEvent) => {
+ if (isSeekingRef.current) {
+ evt.preventDefault()
+ seek(evt)
+ }
+ },
+ [seek],
+ )
+
+ const onPointerUp = useCallback(
+ (evt: React.PointerEvent) => {
+ const target = evt.target
+ if (isSeekingRef.current && target instanceof Element) {
+ evt.preventDefault()
+ target.releasePointerCapture(evt.pointerId)
+ isSeekingRef.current = false
+ onSeekEnd()
+ setScrubberActive(false)
+ }
+ },
+ [onSeekEnd],
+ )
+
+ useEffect(() => {
+ // HACK: there's divergent browser behaviour about what to do when
+ // a pointerUp event is fired outside the element that captured the
+ // pointer. Firefox clicks on the element the mouse is over, so we have
+ // to make everything unclickable while seeking -sfn
+ if (isFirefox && scrubberActive) {
+ document.body.classList.add('force-no-clicks')
+
+ return () => {
+ document.body.classList.remove('force-no-clicks')
+ }
+ }
+ }, [scrubberActive, onSeekEnd])
+
+ useEffect(() => {
+ if (!circleRef.current) return
+ if (focused) {
+ const abortController = new AbortController()
+ const {signal} = abortController
+ circleRef.current.addEventListener(
+ 'keydown',
+ evt => {
+ // space: play/pause
+ // arrow left: seek backward
+ // arrow right: seek forward
+
+ if (evt.key === ' ') {
+ evt.preventDefault()
+ drawFocus()
+ togglePlayPause()
+ } else if (evt.key === 'ArrowLeft') {
+ evt.preventDefault()
+ drawFocus()
+ seekLeft()
+ } else if (evt.key === 'ArrowRight') {
+ evt.preventDefault()
+ drawFocus()
+ seekRight()
+ }
+ },
+ {signal},
+ )
+
+ return () => abortController.abort()
+ }
+ }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus])
+
+ const progress = scrubberActive ? seekPosition : currentTime
+ const progressPercent = (progress / duration) * 100
+
+ return (
+
+
+
+ {duration > 0 && (
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+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 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('stalled', handleStalled, {
+ signal: abortController.signal,
+ })
+ ref.current.addEventListener('ended', handleEnded, {
+ signal: abortController.signal,
+ })
+
+ return () => {
+ abortController.abort()
+ clearTimeout(bufferingTimeout)
+ }
+ }, [ref])
+
+ const play = useCallback(() => {
+ if (!ref.current) return
+
+ if (ref.current.ended) {
+ ref.current.currentTime = 0
+ }
+
+ if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
+ playWhenReadyRef.current = true
+ } else {
+ const promise = ref.current.play()
+ if (promise !== undefined) {
+ promise.catch(err => {
+ console.error('Error playing video:', err)
+ })
+ }
+ }
+ }, [ref])
+
+ const pause = useCallback(() => {
+ if (!ref.current) return
+
+ ref.current.pause()
+ playWhenReadyRef.current = false
+ }, [ref])
+
+ const togglePlayPause = useCallback(() => {
+ if (!ref.current) return
+
+ if (ref.current.paused) {
+ play()
+ } else {
+ pause()
+ }
+ }, [ref, play, pause])
+
+ const mute = useCallback(() => {
+ if (!ref.current) return
+
+ ref.current.muted = true
+ }, [ref])
+
+ const unmute = useCallback(() => {
+ if (!ref.current) return
+
+ ref.current.muted = false
+ }, [ref])
+
+ const toggleMute = useCallback(() => {
+ if (!ref.current) return
+
+ ref.current.muted = !ref.current.muted
+ }, [ref])
+
+ return {
+ play,
+ pause,
+ togglePlayPause,
+ duration,
+ currentTime,
+ playing,
+ muted,
+ mute,
+ unmute,
+ toggleMute,
+ buffering,
+ error,
+ canPlay,
+ }
+}
+
+function fullscreenSubscribe(onChange: () => void) {
+ document.addEventListener('fullscreenchange', onChange)
+ return () => document.removeEventListener('fullscreenchange', onChange)
+}
+
+function useFullscreen(ref: React.RefObject) {
+ const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
+ Boolean(document.fullscreenElement),
+ )
+
+ const toggleFullscreen = useCallback(() => {
+ if (isFullscreen) {
+ document.exitFullscreen()
+ } else {
+ if (!ref.current) return
+ ref.current.requestFullscreen()
+ }
+ }, [isFullscreen, ref])
+
+ return [isFullscreen, toggleFullscreen] as const
}
diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx
deleted file mode 100644
index 7caaf3abf7..0000000000
--- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.web.tsx
+++ /dev/null
@@ -1,587 +0,0 @@
-import React, {
- useCallback,
- useEffect,
- useRef,
- useState,
- useSyncExternalStore,
-} from 'react'
-import {Pressable, View} from 'react-native'
-import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import type Hls from 'hls.js'
-
-import {isIPhoneWeb} from 'platform/detection'
-import {
- useAutoplayDisabled,
- useSetSubtitlesEnabled,
- useSubtitlesEnabled,
-} from 'state/preferences'
-import {atoms as a, useTheme, web} from '#/alf'
-import {Button} from '#/components/Button'
-import {useInteractionState} from '#/components/hooks/useInteractionState'
-import {
- ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
- ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
-} from '#/components/icons/ArrowsDiagonal'
-import {
- CC_Filled_Corner0_Rounded as CCActiveIcon,
- CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
-} from '#/components/icons/CC'
-import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
-import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
-import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
-import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
-import {Loader} from '#/components/Loader'
-import {Text} from '#/components/Typography'
-
-export function Controls({
- videoRef,
- hlsRef,
- active,
- setActive,
- focused,
- setFocused,
- onScreen,
- fullscreenRef,
- hasSubtitleTrack,
-}: {
- videoRef: React.RefObject
- hlsRef: React.RefObject
- active: boolean
- setActive: () => void
- focused: boolean
- setFocused: (focused: boolean) => void
- onScreen: boolean
- fullscreenRef: React.RefObject
- hasSubtitleTrack: boolean
-}) {
- const {
- play,
- pause,
- playing,
- muted,
- toggleMute,
- togglePlayPause,
- currentTime,
- duration,
- buffering,
- error,
- canPlay,
- } = useVideoUtils(videoRef)
- const t = useTheme()
- const {_} = useLingui()
- const subtitlesEnabled = useSubtitlesEnabled()
- const setSubtitlesEnabled = useSetSubtitlesEnabled()
- const {
- state: hovered,
- onIn: onMouseEnter,
- onOut: onMouseLeave,
- } = useInteractionState()
- const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
- const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
- const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
-
- const onKeyDown = useCallback(() => {
- setInteractingViaKeypress(true)
- }, [])
-
- useEffect(() => {
- if (interactingViaKeypress) {
- document.addEventListener('click', () => setInteractingViaKeypress(false))
- return () => {
- document.removeEventListener('click', () =>
- setInteractingViaKeypress(false),
- )
- }
- }
- }, [interactingViaKeypress])
-
- // pause + unfocus when another video is active
- useEffect(() => {
- if (!active) {
- pause()
- setFocused(false)
- }
- }, [active, pause, setFocused])
-
- // autoplay/pause based on visibility
- const autoplayDisabled = useAutoplayDisabled()
- useEffect(() => {
- if (active && !autoplayDisabled) {
- if (onScreen) {
- play()
- } else {
- pause()
- }
- }
- }, [onScreen, pause, active, play, autoplayDisabled])
-
- // use minimal quality when not focused
- useEffect(() => {
- if (!hlsRef.current) return
- if (focused) {
- // auto decide quality based on network conditions
- hlsRef.current.autoLevelCapping = -1
- } else {
- hlsRef.current.autoLevelCapping = 0
- }
- }, [hlsRef, focused])
-
- useEffect(() => {
- if (!hlsRef.current) return
- if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
- hlsRef.current.subtitleTrack = 0
- } else {
- hlsRef.current.subtitleTrack = -1
- }
- }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
-
- // clicking on any button should focus the player, if it's not already focused
- const drawFocus = useCallback(() => {
- if (!active) {
- setActive()
- }
- setFocused(true)
- }, [active, setActive, setFocused])
-
- const onPressEmptySpace = useCallback(() => {
- if (!focused) {
- drawFocus()
- } else {
- togglePlayPause()
- }
- }, [togglePlayPause, drawFocus, focused])
-
- const onPressPlayPause = useCallback(() => {
- drawFocus()
- togglePlayPause()
- }, [drawFocus, togglePlayPause])
-
- const onPressSubtitles = useCallback(() => {
- drawFocus()
- setSubtitlesEnabled(!subtitlesEnabled)
- }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
-
- const onPressMute = useCallback(() => {
- drawFocus()
- toggleMute()
- }, [drawFocus, toggleMute])
-
- const onPressFullscreen = useCallback(() => {
- drawFocus()
- toggleFullscreen()
- }, [drawFocus, toggleFullscreen])
-
- const showControls =
- (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
-
- return (
- {
- evt.stopPropagation()
- setInteractingViaKeypress(false)
- }}
- onMouseEnter={onMouseEnter}
- onMouseLeave={onMouseLeave}
- onFocus={onFocus}
- onBlur={onBlur}
- onKeyDown={onKeyDown}>
-
-
-
- {playing ? (
-
- ) : (
-
- )}
-
-
-
- {formatTime(currentTime)} / {formatTime(duration)}
-
- {hasSubtitleTrack && (
-
- {subtitlesEnabled ? (
-
- ) : (
-
- )}
-
- )}
-
- {muted ? (
-
- ) : (
-
- )}
-
- {!isIPhoneWeb && (
-
- {isFullscreen ? (
-
- ) : (
-
- )}
-
- )}
-
- {(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
deleted file mode 100644
index 8f2d11f6bc..0000000000
--- a/src/view/com/util/post-embeds/VideoPlayerContext.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import React, {useContext} from 'react'
-import type {VideoPlayer} from 'expo-video'
-import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
-
-const VideoPlayerContext = React.createContext(null)
-
-export function VideoPlayerProvider({
- source,
- children,
-}: {
- source: string
- children: React.ReactNode
-}) {
- // eslint-disable-next-line @typescript-eslint/no-shadow
- const player = useExpoVideoPlayer(source, player => {
- player.loop = true
- player.muted = true
- player.play()
- })
-
- return (
-
- {children}
-
- )
-}
-
-export function useVideoPlayer() {
- const context = useContext(VideoPlayerContext)
- if (!context) {
- throw new Error('useVideoPlayer must be used within a VideoPlayerProvider')
- }
- return context
-}
diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx
deleted file mode 100644
index 329fb1206e..0000000000
--- a/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from 'react'
-
-export function VideoPlayerProvider({children}: {children: React.ReactNode}) {
- return children
-}
-
-export function useVideoPlayer() {
- throw new Error('useVideoPlayer must not be used on web')
-}
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index 0462212fbd..b4a6cf8251 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -3,7 +3,6 @@ import {
InteractionManager,
StyleProp,
StyleSheet,
- Text,
View,
ViewStyle,
} from 'react-native'
@@ -13,6 +12,7 @@ import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
+ AppBskyEmbedVideo,
AppBskyFeedDefs,
AppBskyGraphDefs,
moderateFeedGenerator,
@@ -21,22 +21,26 @@ import {
} from '@atproto/api'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
-import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
+import * as ListCard from '#/components/ListCard'
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
-import {ListEmbed} from './ListEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
+import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
+import {VideoEmbed} from './VideoEmbed'
+
+export * from './types'
type Embed =
| AppBskyEmbedRecord.View
| AppBskyEmbedImages.View
+ | AppBskyEmbedVideo.View
| AppBskyEmbedExternal.View
| AppBskyEmbedRecordWithMedia.View
| {$type: string; [k: string]: unknown}
@@ -47,15 +51,16 @@ export function PostEmbeds({
onOpen,
style,
allowNestedQuotes,
+ viewContext,
}: {
embed?: Embed
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp
allowNestedQuotes?: boolean
+ viewContext?: PostEmbedViewContext
}) {
const {openLightbox} = useLightboxControls()
- const largeAltBadge = useLargeAltBadgeEnabled()
// quote post with media
// =
@@ -66,8 +71,17 @@ export function PostEmbeds({
embed={embed.media}
moderation={moderation}
onOpen={onOpen}
+ viewContext={viewContext}
+ />
+
-
)
}
@@ -83,6 +97,7 @@ export function PostEmbeds({
return
}
+ // starter pack embed
if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
return
}
@@ -120,27 +135,26 @@ export function PostEmbeds({
}
if (images.length === 1) {
- const {alt, thumb, aspectRatio} = images[0]
+ const image = images[0]
return (
_openLightbox(0)}
onPressIn={() => onPressIn(0)}
- style={a.rounded_sm}>
- {alt === '' ? null : (
-
-
- ALT
-
-
- )}
-
+ hideBadge={
+ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+ }
+ />
)
@@ -153,6 +167,7 @@ export function PostEmbeds({
images={embed.images}
onPress={_openLightbox}
onPressIn={onPressIn}
+ viewContext={viewContext}
/>
@@ -175,6 +190,16 @@ export function PostEmbeds({
)
}
+ // video embed
+ // =
+ if (AppBskyEmbedVideo.isView(embed)) {
+ return (
+
+
+
+ )
+ }
+
return
}
@@ -203,10 +228,20 @@ function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) {
const moderation = React.useMemo(() => {
return moderationOpts ? moderateUserList(view, moderationOpts) : undefined
}, [view, moderationOpts])
+ const t = useTheme()
return (
-
+
+
+
)
}
diff --git a/src/view/com/util/post-embeds/types.ts b/src/view/com/util/post-embeds/types.ts
new file mode 100644
index 0000000000..08e9032768
--- /dev/null
+++ b/src/view/com/util/post-embeds/types.ts
@@ -0,0 +1,9 @@
+export enum PostEmbedViewContext {
+ ThreadHighlighted = 'ThreadHighlighted',
+ Feed = 'Feed',
+ FeedEmbedRecordWithMedia = 'FeedEmbedRecordWithMedia',
+}
+
+export enum QuoteEmbedViewContext {
+ FeedEmbedRecordWithMedia = PostEmbedViewContext.FeedEmbedRecordWithMedia,
+}
diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx
index 5cb5c6a39f..2992e5c7e9 100644
--- a/src/view/screens/AccessibilitySettings.tsx
+++ b/src/view/screens/AccessibilitySettings.tsx
@@ -108,7 +108,7 @@ export function AccessibilitySettingsScreen({}: Props) {
setAutoplayDisabled(!autoplayDisabled)}
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx
index 5bf9e8a160..6f1cd1bb8f 100644
--- a/src/view/screens/AppPasswords.tsx
+++ b/src/view/screens/AppPasswords.tsx
@@ -18,7 +18,6 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {useModalControls} from '#/state/modals'
-import {useLanguagePrefs} from '#/state/preferences'
import {
useAppPasswordDeleteMutation,
useAppPasswordsQuery,
@@ -218,9 +217,8 @@ function AppPassword({
privileged?: boolean
}) {
const pal = usePalette('default')
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const control = useDialogControl()
- const {contentLanguages} = useLanguagePrefs()
const deleteMutation = useAppPasswordDeleteMutation()
const onDelete = React.useCallback(async () => {
@@ -232,9 +230,6 @@ function AppPassword({
control.open()
}, [control])
- const primaryLocale =
- contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
-
return (
Created{' '}
- {Intl.DateTimeFormat(primaryLocale, {
+ {i18n.date(createdAt, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
- }).format(new Date(createdAt))}
+ })}
{privileged && (
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index 7d0d2fb03c..d83623adc1 100644
--- a/src/view/screens/DebugMod.tsx
+++ b/src/view/screens/DebugMod.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable no-restricted-imports */
import React from 'react'
import {View} from 'react-native'
import {
@@ -807,6 +808,7 @@ function MockPostFeedItem({
showReplyTo={false}
reason={undefined}
feedContext={''}
+ rootPost={post}
/>
)
}
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 6ee8b3ada6..c790a815b6 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -1,23 +1,18 @@
import React from 'react'
-import {ActivityIndicator, AppState, StyleSheet, View} from 'react-native'
+import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
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 {emitSoftReset} from '#/state/events'
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
-import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
+import {FeedParams} from '#/state/queries/post-feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSession} from '#/state/session'
-import {
- useMinimalShellMode,
- useSetDrawerSwipeDisabled,
- useSetMinimalShellMode,
-} from '#/state/shell'
+import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
@@ -28,7 +23,6 @@ import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
-import {TOURS, useTriggerTourIfQueued} from '#/tours'
import {HomeHeader} from '../com/home/HomeHeader'
type Props = NativeStackScreenProps
@@ -87,7 +81,6 @@ function HomeScreenReady({
const selectedIndex = Math.max(0, maybeFoundIndex)
const selectedFeed = allFeeds[selectedIndex]
const requestNotificationsPermission = useRequestNotificationsPermission()
- const triggerTourIfQueued = useTriggerTourIfQueued(TOURS.HOME)
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
useOTAUpdates()
@@ -108,30 +101,6 @@ 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()
@@ -139,16 +108,10 @@ function HomeScreenReady({
React.useCallback(() => {
setMinimalShellMode(false)
setDrawerSwipeDisabled(selectedIndex > 0)
- triggerTourIfQueued()
return () => {
setDrawerSwipeDisabled(false)
}
- }, [
- setDrawerSwipeDisabled,
- selectedIndex,
- setMinimalShellMode,
- triggerTourIfQueued,
- ]),
+ }, [setDrawerSwipeDisabled, selectedIndex, setMinimalShellMode]),
)
useFocusEffect(
@@ -160,30 +123,10 @@ function HomeScreenReady({
feedUrl: selectedFeed,
reason: 'focus',
})
- debugLogFollowingPrefs(selectedFeed)
}
}),
)
- const mode = useMinimalShellMode()
- const {isMobile} = useWebMediaQueries()
- useFocusEffect(
- React.useCallback(() => {
- const listener = AppState.addEventListener('change', nextAppState => {
- if (nextAppState === 'active') {
- if (isMobile && mode.value === 1) {
- // Reveal the bottom bar so you don't miss notifications or messages.
- // TODO: Experiment with only doing it when unread > 0.
- setMinimalShellMode(false)
- }
- }
- })
- return () => {
- listener.remove()
- }
- }, [setMinimalShellMode, mode, isMobile]),
- )
-
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
@@ -207,9 +150,8 @@ function HomeScreenReady({
feedUrl: feed,
reason,
})
- debugLogFollowingPrefs(feed)
},
- [allFeeds, debugLogFollowingPrefs],
+ [allFeeds],
)
const onPressSelected = React.useCallback(() => {
diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx
index 6f8ecc2e8b..3a01edff5a 100644
--- a/src/view/screens/ProfileFollowers.tsx
+++ b/src/view/screens/ProfileFollowers.tsx
@@ -1,12 +1,16 @@
import React from 'react'
-import {View} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
-import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {ViewHeader} from '../com/util/ViewHeader'
-import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
-import {useSetMinimalShellMode} from '#/state/shell'
-import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {useSetMinimalShellMode} from '#/state/shell'
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {isWeb} from 'platform/detection'
+import {CenteredView} from 'view/com/util/Views'
+import {atoms as a} from '#/alf'
+import {ListHeaderDesktop} from '#/components/Lists'
+import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
+import {ViewHeader} from '../com/util/ViewHeader'
type Props = NativeStackScreenProps
export const ProfileFollowersScreen = ({route}: Props) => {
@@ -21,9 +25,10 @@ export const ProfileFollowersScreen = ({route}: Props) => {
)
return (
-
-
+
+
+
-
+
)
}
diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx
index bdab201535..762a84a374 100644
--- a/src/view/screens/ProfileFollows.tsx
+++ b/src/view/screens/ProfileFollows.tsx
@@ -1,12 +1,16 @@
import React from 'react'
-import {View} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
-import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {ViewHeader} from '../com/util/ViewHeader'
-import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
-import {useSetMinimalShellMode} from '#/state/shell'
-import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {useSetMinimalShellMode} from '#/state/shell'
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {isWeb} from 'platform/detection'
+import {CenteredView} from 'view/com/util/Views'
+import {atoms as a} from '#/alf'
+import {ListHeaderDesktop} from '#/components/Lists'
+import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
+import {ViewHeader} from '../com/util/ViewHeader'
type Props = NativeStackScreenProps
export const ProfileFollowsScreen = ({route}: Props) => {
@@ -21,9 +25,10 @@ export const ProfileFollowsScreen = ({route}: Props) => {
)
return (
-
-
+
+
+
-
+
)
}
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 1b41d8d9af..f3d9bda8b6 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -32,6 +32,7 @@ import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {
useAddSavedFeedsMutation,
usePreferencesQuery,
+ UsePreferencesQueryResponse,
useRemoveFeedMutation,
useUpdateSavedFeedsMutation,
} from '#/state/queries/preferences'
@@ -64,9 +65,10 @@ import {LoadingScreen} from 'view/com/util/LoadingScreen'
import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast'
import {CenteredView} from 'view/com/util/Views'
+import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
-import {ScreenHider} from '#/components/moderation/ScreenHider'
+import * as Hider from '#/components/moderation/Hider'
import * as Prompt from '#/components/Prompt'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {RichText} from '#/components/RichText'
@@ -87,6 +89,7 @@ export function ProfileListScreen(props: Props) {
const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
)
+ const {data: preferences} = usePreferencesQuery()
const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
const moderationOpts = useModerationOpts()
@@ -109,12 +112,13 @@ export function ProfileListScreen(props: Props) {
)
}
- return resolvedUri && list && moderationOpts ? (
+ return resolvedUri && list && moderationOpts && preferences ? (
) : (
@@ -126,27 +130,32 @@ function ProfileListScreenLoaded({
uri,
list,
moderationOpts,
+ preferences,
}: Props & {
uri: string
list: AppBskyGraphDefs.ListView
moderationOpts: ModerationOpts
+ preferences: UsePreferencesQueryResponse
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const {openComposer} = useComposerControls()
const setMinimalShellMode = useSetMinimalShellMode()
+ const {currentAccount} = useSession()
const {rkey} = route.params
const feedSectionRef = React.useRef(null)
const aboutSectionRef = React.useRef(null)
const {openModal} = useModalControls()
- const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
+ const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST
const isScreenFocused = useIsFocused()
+ const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1
+ const isOwner = currentAccount?.did === list.creator.did
const moderation = React.useMemo(() => {
return moderateUserList(list, moderationOpts)
}, [list, moderationOpts])
- useSetTitle(list.name)
+ useSetTitle(isHidden ? _(msg`List Hidden`) : list.name)
useFocusEffect(
useCallback(() => {
@@ -178,34 +187,75 @@ function ProfileListScreenLoaded({
)
const renderHeader = useCallback(() => {
- return
- }, [rkey, list])
+ return
+ }, [rkey, list, preferences])
if (isCurateList) {
return (
-
+
+
+
+
+
+
+
+ {({headerHeight, scrollElRef, isFocused}) => (
+
+ )}
+ {({headerHeight, scrollElRef}) => (
+
+ )}
+
+ openComposer({})}
+ icon={
+
+ }
+ accessibilityRole="button"
+ accessibilityLabel={_(msg`New post`)}
+ accessibilityHint=""
+ />
+
+
+
+ )
+ }
+ return (
+
+
+
+
+
- {({headerHeight, scrollElRef, isFocused}) => (
-
- )}
+ renderHeader={renderHeader}>
{({headerHeight, scrollElRef}) => (
@@ -226,47 +276,20 @@ function ProfileListScreenLoaded({
accessibilityHint=""
/>
-
- )
- }
- return (
-
-
-
- {({headerHeight, scrollElRef}) => (
-
- )}
-
- openComposer({})}
- icon={
-
- }
- accessibilityRole="button"
- accessibilityLabel={_(msg`New post`)}
- accessibilityHint=""
- />
-
-
+
+
)
}
-function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
+function Header({
+ rkey,
+ list,
+ preferences,
+}: {
+ rkey: string
+ list: AppBskyGraphDefs.ListView
+ preferences: UsePreferencesQueryResponse
+}) {
// const pal = usePalette('default')
// const palInverted = usePalette('inverted')
const {_} = useLingui()
@@ -282,7 +305,6 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
const isBlocking = !!list.viewer?.blocked
const isMuting = !!list.viewer?.muted
// const isOwner = list.creator.did === currentAccount?.did
- const {data: preferences} = usePreferencesQuery()
const {track} = useAnalytics()
const playHaptic = useHaptics()
@@ -643,7 +665,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
cid: list.cid,
}}
/>
- {isCurateList || isPinned ? (
+ {isCurateList ? (
-
- {isCurrentAccount ? (
- {
- if (isNative) {
- logout('Settings')
- resetToTab('HomeTab')
- } else {
- navigate('Home').then(() => {
- logout('Settings')
- })
- }
- }}
- accessibilityRole="button"
- accessibilityLabel={_(msg`Sign out`)}
- accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}
- activeOpacity={0.8}>
-
- Sign out
-
-
- ) : (
-
- )}
+
)
@@ -173,6 +147,7 @@ export function SettingsScreen({}: Props) {
const {accounts, currentAccount} = useSession()
const {mutate: clearPreferences} = useClearPreferencesMutation()
const {setShowLoggedOut} = useLoggedOutViewControls()
+ const {logoutEveryAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const exportCarControl = useDialogControl()
const birthdayControl = useDialogControl()
@@ -237,6 +212,10 @@ export function SettingsScreen({}: Props) {
openModal({name: 'delete-account'})
}, [openModal])
+ const onPressLogoutEveryAccount = React.useCallback(() => {
+ logoutEveryAccount('Settings')
+ }, [logoutEveryAccount])
+
const onPressResetPreferences = React.useCallback(async () => {
clearPreferences()
}, [clearPreferences])
@@ -394,6 +373,15 @@ export function SettingsScreen({}: Props) {
) : null}
+ {accounts.length > 1 && (
+
+
+ Other accounts
+
+
+
+ )}
+
{accounts
.filter(a => a.did !== currentAccount?.did)
.map(account => (
@@ -422,6 +410,29 @@ export function SettingsScreen({}: Props) {
Add account
+
+
+
+
+
+
+ {accounts.length > 1 ? (
+ Sign out of all accounts
+ ) : (
+ Sign out
+ )}
+
+
diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx
index a732e0cdea..7d3780801a 100644
--- a/src/view/shell/Composer.ios.tsx
+++ b/src/view/shell/Composer.ios.tsx
@@ -33,6 +33,7 @@ export const Composer = observer(function ComposerImpl({}: {
replyTo={state?.replyTo}
onPost={state?.onPost}
quote={state?.quote}
+ quoteCount={state?.quoteCount}
mention={state?.mention}
text={state?.text}
imageUris={state?.imageUris}
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index b978d6b857..1c97df9c39 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -55,6 +55,7 @@ export const Composer = observer(function ComposerImpl({
replyTo={state.replyTo}
onPost={state.onPost}
quote={state.quote}
+ quoteCount={state.quoteCount}
mention={state.mention}
text={state.text}
imageUris={state.imageUris}
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 64353db237..42696139e0 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -58,9 +58,10 @@ export function Composer({}: {winHeight: number}) {
diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx
index 0e852edd1a..facead2c1e 100644
--- a/src/view/shell/Drawer.tsx
+++ b/src/view/shell/Drawer.tsx
@@ -30,7 +30,7 @@ import {colors, s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {isWeb} from 'platform/detection'
import {NavSignupCard} from '#/view/shell/NavSignupCard'
-import {formatCountShortOnly} from 'view/com/util/numeric/format'
+import {formatCount} 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'
@@ -68,7 +68,7 @@ let DrawerProfileCard = ({
account: SessionAccount
onPressProfile: () => void
}): React.ReactNode => {
- const {_} = useLingui()
+ const {_, i18n} = useLingui()
const pal = usePalette('default')
const {data: profile} = useProfileQuery({did: account.did})
@@ -108,7 +108,7 @@ let DrawerProfileCard = ({
- {formatCountShortOnly(profile?.followersCount ?? 0)}
+ {formatCount(i18n, profile?.followersCount ?? 0)}
{' '}
- {formatCountShortOnly(profile?.followsCount ?? 0)}
+ {formatCount(i18n, profile?.followsCount ?? 0)}
{' '}
- {isAtSearch ? (
-
- ) : (
-
- )}
-
+ isAtSearch ? (
+
+ ) : (
+
+ )
}
onPress={onPressSearch}
accessibilityRole="search"
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index c89d2a63cf..21c253ee00 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -41,7 +41,6 @@ import {
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
} from '#/components/icons/UserCircle'
-import {HomeTourExploreWrapper} from '#/tours/HomeTour'
import {styles} from './BottomBarStyles'
export function BottomBarWeb() {
@@ -95,12 +94,10 @@ export function BottomBarWeb() {
{({isActive}) => {
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
return (
-
-
-
+
)
}}
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index 49fb7fc99a..ca8073f573 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -63,7 +63,6 @@ import {
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
} from '#/components/icons/UserCircle'
-import {HomeTourExploreWrapper} from '#/tours/HomeTour'
import {router} from '../../../routes'
const NAV_ICON_WIDTH = 28
@@ -341,19 +340,14 @@ export function DesktopLeftNav() {
iconFilled={}
label={_(msg`Home`)}
/>
-
- }
- iconFilled={
-
- }
- label={_(msg`Search`)}
- />
-
+ }
+ iconFilled={
+
+ }
+ label={_(msg`Search`)}
+ />
{
+ if (!isAndroid) return
+ const onFocusOrBlur = () => {
+ setTimeout(() => {
+ dedupe(updateActiveViewAsync)
+ }, 500)
+ }
+ navigation.addListener('state', onFocusOrBlur)
+ return () => {
+ navigation.removeListener('state', onFocusOrBlur)
+ }
+ }, [dedupe, navigation])
+
return (
<>
*,
+ .force-no-clicks * {
+ pointer-events: none !important;
+ }
diff --git a/yarn.lock b/yarn.lock
index 3d30261a10..4f05ccc7d4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,39 +34,78 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
-"@atproto/api@0.12.29":
- version "0.12.29"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.29.tgz#95a19202c2f0eec4c955909685be11009ba9b9a1"
- integrity sha512-PyzPLjGWR0qNOMrmj3Nt3N5NuuANSgOk/33Bu3j+rFjjPrHvk9CI6iQPU6zuDaDCoyOTRJRafw8X/aMQw+ilgw==
+"@atproto-labs/fetch-node@0.1.0":
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.1.0.tgz#692666d57ec24a7ba0813077a303baccf26108e0"
+ integrity sha512-DUHgaGw8LBqiGg51pUDuWK/alMcmNbpcK7ALzlF2Gw//TNLTsgrj0qY9aEtK+np9rEC+x/o3bN4SGnuQEpgqIg==
+ dependencies:
+ "@atproto-labs/fetch" "0.1.0"
+ "@atproto-labs/pipe" "0.1.0"
+ ipaddr.js "^2.1.0"
+ psl "^1.9.0"
+ undici "^6.14.1"
+
+"@atproto-labs/fetch@0.1.0":
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.1.0.tgz#50a46943fd2f321dd748de28c73ba7cbfa493132"
+ integrity sha512-uirja+uA/C4HNk7vayM+AJqsccxQn2wVziUHxbsjJGt/K6Q8ZOKDaEX2+GrcXvpUVcqUKh+94JFjuzH+CAEUlg==
+ dependencies:
+ "@atproto-labs/pipe" "0.1.0"
+ optionalDependencies:
+ zod "^3.23.8"
+
+"@atproto-labs/pipe@0.1.0":
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/pipe/-/pipe-0.1.0.tgz#c8d86923b6d8e900d39efe6fdcdf0d897c434086"
+ integrity sha512-ghOqHFyJlQVFPESzlVHjKroP0tPzbmG5Jms0dNI9yLDEfL8xp4OFPWLX4f6T8mRq69wWs4nIDM3sSsFbFqLa1w==
+
+"@atproto-labs/simple-store-memory@0.1.1":
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store-memory/-/simple-store-memory-0.1.1.tgz#54526a1f8ec978822be9fad75106ad8b78500dd3"
+ integrity sha512-PCRqhnZ8NBNBvLku53O56T0lsVOtclfIrQU/rwLCc4+p45/SBPrRYNBi6YFq5rxZbK6Njos9MCmILV/KLQxrWA==
+ dependencies:
+ "@atproto-labs/simple-store" "0.1.1"
+ lru-cache "^10.2.0"
+
+"@atproto-labs/simple-store@0.1.1":
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
+ integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
+
+"@atproto/api@0.13.5":
+ version "0.13.5"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.5.tgz#04305cdb0a467ba366305c5e95cebb7ce0d39735"
+ integrity sha512-yT/YimcKYkrI0d282Zxo7O30OSYR+KDW89f81C6oYZfDRBcShC1aniVV8kluP5LrEAg8O27yrOSnBgx2v7XPew==
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.1"
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/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/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==
+"@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" "^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 +115,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 +144,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 +198,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 +230,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 +264,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 +344,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 +390,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 +433,23 @@
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"
+
+"@atproto/xrpc@^0.6.1":
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
+ integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
+ dependencies:
+ "@atproto/lexicon" "^0.4.1"
+ zod "^3.23.8"
"@aws-crypto/crc32@3.0.0":
version "3.0.0"
@@ -3308,6 +3410,11 @@
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
+"@dominicstop/ts-event-emitter@^1.1.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@dominicstop/ts-event-emitter/-/ts-event-emitter-1.1.0.tgz#1f3d3fa878a1ccab686931280757954719cf88e4"
+ integrity sha512-CcxmJIvUb1vsFheuGGVSQf4KdPZC44XolpUT34+vlal+LyQoBUOn31pjFET5M9ctOxEpt8xa0M3/2M7uUiAoJw==
+
"@egjs/hammerjs@^2.0.17":
version "2.0.17"
resolved "https://registry.yarnpkg.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz#5dc02af75a6a06e4c2db0202cae38c9263895124"
@@ -4190,6 +4297,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"
@@ -9671,10 +9803,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"
@@ -10025,15 +10157,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
- version "1.0.30001596"
- resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz"
- integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==
-
-caniuse-lite@^1.0.30001587:
- version "1.0.30001620"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz#78bb6f35b8fe315b96b8590597094145d0b146b4"
- integrity sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520, caniuse-lite@^1.0.30001587:
+ version "1.0.30001655"
+ resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
+ integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
caniuse-lite@^1.0.30001646:
version "1.0.30001651"
@@ -10413,11 +10540,6 @@ commander@11.0.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67"
integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==
-commander@2, commander@^2.20.0:
- version "2.20.3"
- resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
- integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
-
commander@2.20.0:
version "2.20.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
@@ -10428,6 +10550,11 @@ commander@^10.0.0, commander@^10.0.1:
resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==
+commander@^2.20.0:
+ version "2.20.3"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+ integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
commander@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
@@ -10545,6 +10672,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"
@@ -10919,16 +11051,6 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
-d3-array@^1.2.0:
- version "1.2.4"
- resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
- integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
-
-d3-polygon@^1.0.3:
- version "1.0.6"
- resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
- integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
-
dag-map@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/dag-map/-/dag-map-1.0.2.tgz#e8379f041000ed561fc515475c1ed2c85eece8d7"
@@ -11410,11 +11532,6 @@ duplexer@^0.1.2:
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==
-earcut@^2.1.1:
- version "2.2.4"
- resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.4.tgz#6d02fd4d68160c114825d06890a92ecaae60343a"
- integrity sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==
-
eastasianwidth@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb"
@@ -12476,7 +12593,7 @@ expo-system-ui@~3.0.4:
"@react-native/normalize-colors" "~0.74.83"
debug "^4.3.2"
-expo-task-manager@~11.8.2:
+expo-task-manager@~11.8.1:
version "11.8.2"
resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-11.8.2.tgz#1090a445565ca65ed99991166ddda38575b3dc8c"
integrity sha512-Uhy3ol5gYeZOyeRFddYjLI1B2DGRH1gjp/YC8Hpn5p5MVENviySoKNF+wd98rRvOAokzrzElyDBHSTfX+C3tpg==
@@ -12488,10 +12605,10 @@ expo-updates-interface@~0.16.2:
resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-0.16.2.tgz#ad1ac2ca8ee5a8cc84052ea3c18a11da64da569b"
integrity sha512-929XBU70q5ELxkKADj1xL0UIm3HvhYhNAOZv5DSk7rrKvLo7QDdPyl+JVnwZm9LrkNbH4wuE2rLoKu1KMgZ+9A==
-expo-updates@~0.25.21:
- version "0.25.21"
- resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-0.25.21.tgz#4b117ee23261e386382c24154600d782b6b0765b"
- integrity sha512-eNElrzLFHky2au0H8MqdAJdqh40412PHCdANdXtB/P/XIS1xVte74T9rUf053iPswnLLuQfCUpZYSfRKTcsDlg==
+expo-updates@~0.25.14:
+ version "0.25.24"
+ resolved "https://registry.yarnpkg.com/expo-updates/-/expo-updates-0.25.24.tgz#db460af1f6e2125f33fc505440ad6251b07bf1ef"
+ integrity sha512-juqdOUvaMfu6zeUg3fTk6ciLw4QK+0HXNR0+X41BVOFilNmlTFQZ6LyRGJAZJP7HQs2bHR5d/btAXkejtIqVXw==
dependencies:
"@expo/code-signing-certificates" "0.0.5"
"@expo/config" "~9.0.0-beta.0"
@@ -12510,9 +12627,9 @@ expo-updates@~0.25.21:
resolve-from "^5.0.0"
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==
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.2.5.tgz#3d2bd7667a90ca4efa5c456cae675922e28bf445"
+ integrity sha512-882zQr2rgP9nA/xDIIEEaho5+PwhHTubg9MTUk2c4LU5+6Y/JzvqJjTVx9Hui3c2R3KIaucJwsE7surahgcszA==
expo-web-browser@~13.0.3:
version "13.0.3"
@@ -12940,18 +13057,6 @@ flow-parser@0.*:
resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.215.0.tgz#9b153fa27ab238bcc0bb1ff73b63bdb15d3f277d"
integrity sha512-8bjwzy8vi+fNDy8YoTBNtQUSZa53i7UWJJTunJojOtjab9cMNhOCwohionuMgDQUU0y21QTTtPOX6OQEOQT72A==
-flubber@~0.4.2:
- version "0.4.2"
- resolved "https://registry.yarnpkg.com/flubber/-/flubber-0.4.2.tgz#14452d4a838cc3b9f2fb6175da94e35acd55fbaa"
- integrity sha512-79RkJe3rA4nvRCVc2uXjj7U/BAUq84TS3KHn6c0Hr9K64vhj83ZNLUziNx4pJoBumSPhOl5VjH+Z0uhi+eE8Uw==
- dependencies:
- d3-array "^1.2.0"
- d3-polygon "^1.0.3"
- earcut "^2.1.1"
- svg-path-properties "^0.2.1"
- svgpath "^2.2.1"
- topojson-client "^3.0.0"
-
follow-redirects@^1.0.0, follow-redirects@^1.14.9, follow-redirects@^1.15.0:
version "1.15.2"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
@@ -13573,13 +13678,6 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
dependencies:
react-is "^16.7.0"
-hoist-non-react-statics@~3.0.1:
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.0.1.tgz#fba3e7df0210eb9447757ca1a7cb607162f0a364"
- integrity sha512-1kXwPsOi0OGQIZNVMPvgWJ9tSnGMiMfJdihqEzrPEXlHOBh9AAHXX/QYmAJTXztnz/K+PQ8ryCb4eGaN6HlGbQ==
- dependencies:
- react-is "^16.3.2"
-
hoopy@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/hoopy/-/hoopy-0.1.4.tgz#609207d661100033a9a9402ad3dea677381c1b1d"
@@ -13986,6 +14084,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"
@@ -13996,6 +14099,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"
@@ -15558,6 +15666,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"
@@ -15810,6 +15923,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"
@@ -16058,11 +16178,6 @@ lodash.chunk@^4.2.0:
resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc"
integrity sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==
-lodash.clamp@~4.0.3:
- version "4.0.3"
- resolved "https://registry.yarnpkg.com/lodash.clamp/-/lodash.clamp-4.0.3.tgz#5c24bedeeeef0753560dc2b4cb4671f90a6ddfaa"
- integrity sha512-HvzRFWjtcguTW7yd8NJBshuNaCa8aqNFtnswdT7f/cMd/1YKy5Zzoq4W/Oxvnx9l7aeY258uSdDfM793+eLsVg==
-
lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
@@ -16347,11 +16462,6 @@ memfs@^3.1.2, memfs@^3.4.3:
dependencies:
fs-monkey "^1.0.4"
-memoize-one@5.1.1:
- version "5.1.1"
- resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
- integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
-
memoize-one@^5.0.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e"
@@ -16779,11 +16889,6 @@ minizlib@^2.1.1:
minipass "^3.0.0"
yallist "^4.0.0"
-mitt@~1.1.3:
- version "1.1.3"
- resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.1.3.tgz#528c506238a05dce11cd914a741ea2cc332da9b8"
- integrity sha512-mUDCnVNsAi+eD6qA0HkRkwYczbLHJ49z17BGe2PYRhZL4wpZUFZGJHU7/5tmvohoma+Hdn0Vh/oJTiPEmgSruA==
-
mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
@@ -16983,6 +17088,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"
@@ -17236,6 +17348,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"
@@ -17779,18 +17896,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"
@@ -17827,22 +17944,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"
@@ -18604,6 +18721,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"
@@ -19057,7 +19179,7 @@ react-freeze@^1.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
-react-is@^16.13.0, react-is@^16.13.1, react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.4:
+react-is@^16.13.0, react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.4:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -19131,15 +19253,22 @@ react-native-get-random-values@~1.11.0:
dependencies:
fast-base64-decode "^1.0.0"
-react-native-image-crop-picker@0.41.2:
- version "0.41.2"
- resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.41.2.tgz#824fa8fee8391fbb3e0b5ae2973221a2dff0cafb"
- integrity sha512-GcDu/adXU/1y/MrxsbOfqcGRGWC2pTttt5VGy/jyRJ6GXfoC29fTQf8SG5kGtc5schSR6K+mKYO4uW6eJPljlQ==
+react-native-image-crop-picker@0.40.3:
+ version "0.40.3"
+ resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.40.3.tgz#a6b135cd1218a33ad126c1a148ec5a1bd01737ff"
+ integrity sha512-45PKcTnsnLS+E36YwoXutllQdRSOuOsMN0IRcAcwsFXOuAQIOtugXlAuGGL28JHKb/ATaSSPvqCSrdG65Jv3GA==
-react-native-keyboard-controller@^1.12.7:
- version "1.12.7"
- resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.12.7.tgz#347a6a645d1118e9a9e8e7b9952dd44e87830079"
- integrity sha512-eccg0JtZk5n/up3I7pEg606Bo9eLsgVGtTPnmdv507AJg5UeAAMEhx93P7YXDZmwJ+38oFn3DbL7a0eIUE3Lxw==
+react-native-ios-context-menu@^1.15.3:
+ version "1.15.3"
+ resolved "https://registry.yarnpkg.com/react-native-ios-context-menu/-/react-native-ios-context-menu-1.15.3.tgz#c02e6a7af2df8c08d0b3e1c8f3395484b3c9c760"
+ integrity sha512-UNkVl7ocvSpNaEpvBvE1aHOfDy/DFdZ5I+ElfnTXFsRxrVZmxLtST0b1q2wSWGWDmd2Ig2AYd7GRbYtcY222Ag==
+ dependencies:
+ "@dominicstop/ts-event-emitter" "^1.1.0"
+
+react-native-keyboard-controller@^1.12.1:
+ version "1.13.4"
+ resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.13.4.tgz#0f85ac2a1b594fd65283d98cb044f331ab598ec5"
+ integrity sha512-80unzD4S+ybgYOluhdeV4zV62ejg6Jt0l5iw7PuA1y3aM1a1+5tS2WoHLNk8605oDaGcVLGNMNF0Qv4GWe97Bg==
react-native-pager-view@6.2.3:
version "6.2.3"
@@ -19865,16 +19994,6 @@ rn-fetch-blob@^0.12.0:
base-64 "0.1.0"
glob "7.0.6"
-rn-tourguide@bluesky-social/rn-tourguide:
- version "3.3.0"
- resolved "https://codeload.github.com/bluesky-social/rn-tourguide/tar.gz/a14bb85536b317b94d82801900df4cf57f81aef7"
- dependencies:
- flubber "~0.4.2"
- hoist-non-react-statics "~3.0.1"
- lodash.clamp "~4.0.3"
- memoize-one "5.1.1"
- mitt "~1.1.3"
-
roarr@^7.0.4:
version "7.15.1"
resolved "https://registry.yarnpkg.com/roarr/-/roarr-7.15.1.tgz#e4d93105c37b5ea7dd1200d96a3500f757ddc39f"
@@ -20444,6 +20563,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"
@@ -20634,6 +20760,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"
@@ -21001,11 +21137,6 @@ svg-parser@^2.0.2:
resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5"
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
-svg-path-properties@^0.2.1:
- version "0.2.2"
- resolved "https://registry.yarnpkg.com/svg-path-properties/-/svg-path-properties-0.2.2.tgz#b073d81be7292eae0e233ab8a83f58dc27113296"
- integrity sha512-GmrB+b6woz6CCdQe6w1GHs/1lt25l7SR5hmhF8jRdarpv/OgjLyuQygLu1makJapixeb1aQhP/Oa1iKi93o/aQ==
-
svgo@^1.2.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
@@ -21038,11 +21169,6 @@ svgo@^2.7.0:
picocolors "^1.0.0"
stable "^0.1.8"
-svgpath@^2.2.1:
- version "2.6.0"
- resolved "https://registry.yarnpkg.com/svgpath/-/svgpath-2.6.0.tgz#5b160ef3d742b7dfd2d721bf90588d3450d7a90d"
- integrity sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==
-
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@@ -21267,6 +21393,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"
@@ -21354,13 +21487,6 @@ token-types@^4.1.1:
"@tokenizer/token" "^0.3.0"
ieee754 "^1.2.1"
-topojson-client@^3.0.0:
- version "3.1.0"
- resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.1.0.tgz#22e8b1ed08a2b922feeb4af6f53b6ef09a467b99"
- integrity sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==
- dependencies:
- commander "2"
-
totalist@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
@@ -21466,6 +21592,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"
@@ -21603,6 +21734,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"
@@ -21647,6 +21783,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"
@@ -22826,7 +22967,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==