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 ( +
+ {content.alt} +
+ +
+
+ ) +} + +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 ( - + + ) : null} + {isOwner ? ( + + ) : list.viewer?.muted || list.viewer?.blocked ? ( + + ) : null} + + + + + ) +} diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx index dc63a869a6..674edc41eb 100644 --- a/src/screens/Messages/Conversation/MessageInput.tsx +++ b/src/screens/Messages/Conversation/MessageInput.tsx @@ -23,6 +23,7 @@ import { useSaveMessageDraft, } from '#/state/messages/message-drafts' import {isIOS} from 'platform/detection' +import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {useSharedInputStyles} from '#/components/forms/TextField' @@ -41,6 +42,7 @@ export function MessageInput({ hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode + openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { const {_} = useLingui() const t = useTheme() diff --git a/src/screens/Messages/Conversation/MessageInput.web.tsx b/src/screens/Messages/Conversation/MessageInput.web.tsx index a4a8a78522..0b7e479209 100644 --- a/src/screens/Messages/Conversation/MessageInput.web.tsx +++ b/src/screens/Messages/Conversation/MessageInput.web.tsx @@ -12,9 +12,16 @@ import { } from '#/state/messages/message-drafts' import {isSafari, isTouchDevice} from 'lib/browser' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' +import { + Emoji, + EmojiPickerPosition, +} from '#/view/com/composer/text-input/web/EmojiPicker.web' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' import {useSharedInputStyles} from '#/components/forms/TextField' +import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' import {useExtractEmbedFromFacets} from './MessageInputEmbed' @@ -23,11 +30,13 @@ export function MessageInput({ hasEmbed, setEmbed, children, + openEmojiPicker, }: { onSendMessage: (message: string) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode + openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { const {isTabletOrDesktop} = useWebMediaQueries() const {_} = useLingui() @@ -40,6 +49,7 @@ export function MessageInput({ const [isFocused, setIsFocused] = React.useState(false) const [isHovered, setIsHovered] = React.useState(false) const [textAreaHeight, setTextAreaHeight] = React.useState(38) + const textAreaRef = React.useRef(null) const onSubmit = React.useCallback(() => { if (!hasEmbed && message.trim() === '') { @@ -94,6 +104,23 @@ export function MessageInput({ [], ) + const onEmojiInserted = React.useCallback( + (emoji: Emoji) => { + const position = textAreaRef.current?.selectionStart ?? 0 + setMessage( + message => + message.slice(0, position) + emoji.native + message.slice(position), + ) + }, + [setMessage], + ) + React.useEffect(() => { + textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) + return () => { + textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) + } + }, [onEmojiInserted]) + useSaveMessageDraft(message) useExtractEmbedFromFacets(message, setEmbed) @@ -106,7 +133,7 @@ export function MessageInput({ t.atoms.bg_contrast_25, { paddingRight: a.p_sm.padding - 2, - paddingLeft: a.p_md.padding - 2, + paddingLeft: a.p_sm.padding - 2, borderWidth: 1, borderRadius: 23, borderColor: 'transparent', @@ -118,7 +145,44 @@ export function MessageInput({ // @ts-expect-error web only onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> + )} - {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 && ( - <> - - - - )} + {hasSession && } - ) -} 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 ? ( - - ) : ( - - )} - - {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}> - + + + {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 ( + + + + + + + + ) +} + +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. + + + )} + + )} + + + + + + + + ) +} + +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}}} + /> + + + + + + ) +} + +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 ( + + + + + + + ) +} 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 ( - ) } 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 ( - @@ -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 = ({ ) } 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 = ({ 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} + + + + + ) +} + 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 ( - -

-