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

+ {content.record.name} +

+

+ Starter pack by{' '} + {content.creator.displayName || `@${content.creator.handle}`} +

+
+
+ {content.record.description && ( +

{content.record.description}

+ )} + {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && ( +

+ {content.joinedAllTimeCount} users have joined! +

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