Merge remote-tracking branch 'origin/main' into fast-follow

* origin/main: (50 commits)
  Add `list hidden` screen (#4958)
  Expose more methods, support disabled items (#4954)
  Expose more props from button (#4953)
  Fix orphaned feed slices, handle blocks (#4944)
  Tweak `expo-modules-core` hack patch (#4955)
  [Experiment] Always show bottom bar (#4946)
  Revert "[Video] Download videos" (#4945)
  Move global "Sign out" out of the current account row (#4941)
  Hack patch for testing OTA update crash behavior (#4942)
  [Video] Download videos (#4886)
  swap control files (#4936)
  [Embed] Starter pack embed embed (#4935)
  [Video] set audio category to ambient every time a new player is made (#4934)
  Add `/live/` to supported YouTube embed URLs (#4932)
  [Video] Try/catch video play/pause (#4930)
  Don't kick to login screen on network error (#4911)
  Remove .withProxy() calls (#4929)
  [Video] Audio duck off main thread (#4926)
  subclass agent to add setPersistSessionHandler (#4928)
  [Video] Fix crash when switching tabs (#4925)
  ...
This commit is contained in:
Eric Bailey
2024-08-21 09:37:43 -05:00
132 changed files with 3287 additions and 2238 deletions
+8
View File
@@ -340,12 +340,14 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://youtube.com/watch?v=videoId', 'https://youtube.com/watch?v=videoId',
'https://youtube.com/watch?v=videoId&feature=share', 'https://youtube.com/watch?v=videoId&feature=share',
'https://youtube.com/shorts/videoId', 'https://youtube.com/shorts/videoId',
'https://youtube.com/live/videoId',
'https://m.youtube.com/watch?v=videoId', 'https://m.youtube.com/watch?v=videoId',
'https://music.youtube.com/watch?v=videoId', 'https://music.youtube.com/watch?v=videoId',
'https://youtube.com/shorts/', 'https://youtube.com/shorts/',
'https://youtube.com/', 'https://youtube.com/',
'https://youtube.com/random', 'https://youtube.com/random',
'https://youtube.com/live/',
'https://twitch.tv/channelName', 'https://twitch.tv/channelName',
'https://www.twitch.tv/channelName', 'https://www.twitch.tv/channelName',
@@ -475,10 +477,16 @@ describe('parseEmbedPlayerFromUrl', () => {
source: 'youtube', source: 'youtube',
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0', 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,
undefined, undefined,
undefined,
{ {
type: 'twitch_video', type: 'twitch_video',
+3 -2
View File
@@ -10,11 +10,12 @@
], ],
"rules": { "rules": {
"simple-import-sort/imports": "warn", "simple-import-sort/imports": "warn",
"simple-import-sort/exports": "warn" "simple-import-sort/exports": "warn",
'no-else-return': 'off'
}, },
"parserOptions": { "parserOptions": {
"sourceType": "module", "sourceType": "module",
"ecmaVersion": "latest", "ecmaVersion": "latest",
"project": "./bskyembed/tsconfig.json" "project": "./bskyembed/tsconfig.json"
} }
} }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><defs><linearGradient x1="0" y1="0" x2="100%" y2="0" gradientTransform="rotate(45)" id="sky_V5w1FF_xb91wVQ_1euhBX"><stop offset="0" stop-color="#0A7AFF"></stop><stop offset="1" stop-color="#59B9FF"></stop></linearGradient></defs><path fill="url(#sky_V5w1FF_xb91wVQ_1euhBX)" fill-rule="evenodd" clip-rule="evenodd" d="M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z"></path><path fill="url(#sky_V5w1FF_xb91wVQ_1euhBX)" fill-rule="evenodd" clip-rule="evenodd" d="M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z"></path></svg>

After

Width:  |  Height:  |  Size: 982 B

+1 -1
View File
@@ -9,7 +9,7 @@
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src" "lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.12.2", "@atproto/api": "0.13.1",
"@preact/preset-vite": "^2.8.2", "@preact/preset-vite": "^2.8.2",
"@vitejs/plugin-legacy": "^5.3.2", "@vitejs/plugin-legacy": "^5.3.2",
"preact": "^10.4.8", "preact": "^10.4.8",
+76 -16
View File
@@ -6,12 +6,15 @@ import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyGraphDefs, AppBskyGraphDefs,
AppBskyGraphStarterpack,
AppBskyLabelerDefs, AppBskyLabelerDefs,
AtUri,
} from '@atproto/api' } from '@atproto/api'
import {ComponentChildren, h} from 'preact' import {ComponentChildren, h} from 'preact'
import {useMemo} from 'preact/hooks' import {useMemo} from 'preact/hooks'
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg' import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
import starterPackIcon from '../../assets/starterPack.svg'
import {CONTENT_LABELS, labelsToInfo} from '../labels' import {CONTENT_LABELS, labelsToInfo} from '../labels'
import {getRkey} from '../utils' import {getRkey} from '../utils'
import {Link} from './link' import {Link} from './link'
@@ -105,7 +108,7 @@ export function Embed({
// Case 3.2: List // Case 3.2: List
if (AppBskyGraphDefs.isListView(record)) { if (AppBskyGraphDefs.isListView(record)) {
return ( return (
<GenericWithImage <GenericWithImageEmbed
image={record.avatar} image={record.avatar}
title={record.name} title={record.name}
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`} href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
@@ -122,7 +125,7 @@ export function Embed({
// Case 3.3: Feed // Case 3.3: Feed
if (AppBskyFeedDefs.isGeneratorView(record)) { if (AppBskyFeedDefs.isGeneratorView(record)) {
return ( return (
<GenericWithImage <GenericWithImageEmbed
image={record.avatar} image={record.avatar}
title={record.displayName} title={record.displayName}
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`} href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
@@ -134,28 +137,27 @@ export function Embed({
// Case 3.4: Labeler // Case 3.4: Labeler
if (AppBskyLabelerDefs.isLabelerView(record)) { if (AppBskyLabelerDefs.isLabelerView(record)) {
return ( // Embed type does not exist in the app, so show nothing
<GenericWithImage return null
image={record.creator.avatar}
title={record.creator.displayName || record.creator.handle}
href={`/profile/${record.creator.did}`}
subtitle="Labeler"
description={`Liked by ${record.likeCount ?? 0} users`}
/>
)
} }
// Case 3.5: Post not found // Case 3.5: Starter pack
if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
return <StarterPackEmbed content={record} />
}
// Case 3.6: Post not found
if (AppBskyEmbedRecord.isViewNotFound(record)) { if (AppBskyEmbedRecord.isViewNotFound(record)) {
return <Info>Quoted post not found, it may have been deleted.</Info> return <Info>Quoted post not found, it may have been deleted.</Info>
} }
// Case 3.6: Post blocked // Case 3.7: Post blocked
if (AppBskyEmbedRecord.isViewBlocked(record)) { if (AppBskyEmbedRecord.isViewBlocked(record)) {
return <Info>The quoted post is blocked.</Info> return <Info>The quoted post is blocked.</Info>
} }
throw new Error('Unknown embed type') // Unknown embed type
return null
} }
// Case 4: Record with media // 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) { } catch (err) {
return ( return (
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info> <Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
@@ -314,7 +317,7 @@ function ExternalEmbed({
) )
} }
function GenericWithImage({ function GenericWithImageEmbed({
title, title,
subtitle, subtitle,
href, href,
@@ -350,3 +353,60 @@ function GenericWithImage({
</Link> </Link>
) )
} }
function StarterPackEmbed({
content,
}: {
content: AppBskyGraphDefs.StarterPackViewBasic
}) {
if (!AppBskyGraphStarterpack.isRecord(content.record)) {
return null
}
const starterPackHref = getStarterPackHref(content)
const imageUri = getStarterPackImage(content)
return (
<Link
href={starterPackHref}
className="w-full rounded-lg overflow-hidden border flex flex-col items-stretch">
<img src={imageUri} className="aspect-[1.91/1] object-cover" />
<div className="py-3 px-4">
<div className="flex space-x-2 items-center">
<img src={starterPackIcon} className="w-10 h-10" />
<div>
<p className="font-semibold leading-[21px]">
{content.record.name}
</p>
<p className="text-sm text-textLight line-clamp-2 leading-[18px]">
Starter pack by{' '}
{content.creator.displayName || `@${content.creator.handle}`}
</p>
</div>
</div>
{content.record.description && (
<p className="text-sm mt-1">{content.record.description}</p>
)}
{!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && (
<p className="text-sm font-semibold text-textLight mt-1">
{content.joinedAllTimeCount} users have joined!
</p>
)}
</div>
</Link>
)
}
// 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}`
}
+4 -1
View File
@@ -132,7 +132,10 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
key={counter} key={counter}
href={segment.link.uri} href={segment.link.uri}
className="text-blue-400 hover:underline" 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} {segment.text}
</Link>, </Link>,
) )
+2 -2
View File
@@ -1,6 +1,6 @@
import '../index.css' import '../index.css'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
import {h, render} from 'preact' import {h, render} from 'preact'
import logo from '../../assets/logo.svg' import logo from '../../assets/logo.svg'
@@ -12,7 +12,7 @@ import {getRkey} from '../utils'
const root = document.getElementById('app') const root = document.getElementById('app')
if (!root) throw new Error('No root element') if (!root) throw new Error('No root element')
const agent = new BskyAgent({ const agent = new AtpAgent({
service: 'https://public.api.bsky.app', service: 'https://public.api.bsky.app',
}) })
+46 -20
View File
@@ -20,15 +20,16 @@
"@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24" "@jridgewell/trace-mapping" "^0.3.24"
"@atproto/api@^0.12.2": "@atproto/api@0.13.1":
version "0.12.2" version "0.13.1"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.2.tgz#5df6d4f60dea0395c84fdebd9e81a7e853edf130" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
integrity sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA== integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0" "@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0" "@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.5.0" "@atproto/xrpc" "^0.6.0"
await-lock "^2.2.2"
multiformats "^9.9.0" multiformats "^9.9.0"
tlds "^1.234.0" tlds "^1.234.0"
@@ -42,29 +43,29 @@
uint8arrays "3.0.0" uint8arrays "3.0.0"
zod "^3.21.4" zod "^3.21.4"
"@atproto/lexicon@^0.4.0": "@atproto/lexicon@^0.4.1":
version "0.4.0" version "0.4.1"
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576" resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0"
integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ== integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/syntax" "^0.3.0" "@atproto/syntax" "^0.3.0"
iso-datestring-validator "^2.2.2" iso-datestring-validator "^2.2.2"
multiformats "^9.9.0" multiformats "^9.9.0"
zod "^3.21.4" zod "^3.23.8"
"@atproto/syntax@^0.3.0": "@atproto/syntax@^0.3.0":
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3" resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA== integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
"@atproto/xrpc@^0.5.0": "@atproto/xrpc@^0.6.0":
version "0.5.0" version "0.6.0"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032" resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog== integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
dependencies: dependencies:
"@atproto/lexicon" "^0.4.0" "@atproto/lexicon" "^0.4.1"
zod "^3.21.4" zod "^3.23.8"
"@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2":
version "7.24.2" version "7.24.2"
@@ -1710,6 +1711,11 @@ available-typed-arrays@^1.0.7:
dependencies: dependencies:
possible-typed-array-names "^1.0.0" 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: babel-plugin-polyfill-corejs2@^0.4.10:
version "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" 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" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-1.0.0-pre2.tgz#46a83a79f1b287807e9aaafc6a5dd8bcde626f9c"
integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A== integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0: "string-width-cjs@npm:string-width@^4.2.0":
name string-width-cjs 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" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -3795,7 +3809,14 @@ string.prototype.trimstart@^1.0.8:
define-properties "^1.2.1" define-properties "^1.2.1"
es-object-atoms "^1.0.0" 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" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -4192,3 +4213,8 @@ zod@^3.21.4:
version "3.22.4" version "3.22.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg== 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==
+3 -6
View File
@@ -1,14 +1,11 @@
import 'react-native-gesture-handler' // must be first import 'react-native-gesture-handler' // must be first
import {LogBox} from 'react-native'
import '#/platform/polyfills' import '#/platform/polyfills'
import {IS_TEST} from '#/env'
import {LogBox} from 'react-native'
import {registerRootComponent} from 'expo' import {registerRootComponent} from 'expo'
import {doPolyfill} from '#/lib/api/api-polyfill'
import App from '#/App' import App from '#/App'
import {IS_TEST} from '#/env'
doPolyfill()
if (IS_TEST) { if (IS_TEST) {
LogBox.ignoreAllLogs() // suppress all logs in tests LogBox.ignoreAllLogs() // suppress all logs in tests
+2 -3
View File
@@ -1,9 +1,8 @@
import '#/platform/markBundleStartTime' import '#/platform/markBundleStartTime'
import '#/platform/polyfills' import '#/platform/polyfills'
import {registerRootComponent} from 'expo' import {registerRootComponent} from 'expo'
import {doPolyfill} from '#/lib/api/api-polyfill'
import App from '#/App' import App from '#/App'
doPolyfill()
registerRootComponent(App) registerRootComponent(App)
+2 -2
View File
@@ -156,7 +156,7 @@ class Mocker {
} }
async createUser(name: string) { 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( const inviteRes = await agent.api.com.atproto.server.createInviteCode(
{useCount: 1}, {useCount: 1},
@@ -332,7 +332,7 @@ class Mocker {
} }
async createInvite(forAccount: string) { 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( await agent.api.com.atproto.server.createInviteCode(
{useCount: 1, forAccount}, {useCount: 1, forAccount},
{ {
+2 -1
View File
@@ -1,6 +1,7 @@
import * as PlatformInfo from './src/PlatformInfo' import * as PlatformInfo from './src/PlatformInfo'
import {AudioCategory} from './src/PlatformInfo/types'
import * as Referrer from './src/Referrer' import * as Referrer from './src/Referrer'
import * as SharedPrefs from './src/SharedPrefs' import * as SharedPrefs from './src/SharedPrefs'
import VisibilityView from './src/VisibilityView' import VisibilityView from './src/VisibilityView'
export {PlatformInfo, Referrer, SharedPrefs, VisibilityView} export {AudioCategory, PlatformInfo, Referrer, SharedPrefs, VisibilityView}
@@ -1,11 +1,46 @@
import ExpoModulesCore import ExpoModulesCore
public class ExpoPlatformInfoModule: Module { public class ExpoPlatformInfoModule: Module {
private var prevAudioActive: Bool?
private var prevAudioCategory: AVAudioSession.Category?
public func definition() -> ModuleDefinition { public func definition() -> ModuleDefinition {
Name("ExpoPlatformInfo") Name("ExpoPlatformInfo")
Function("getIsReducedMotionEnabled") { Function("getIsReducedMotionEnabled") {
return UIAccessibility.isReduceMotionEnabled 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]
)
}
}
}
} }
} }
@@ -1,7 +1,20 @@
import {Platform} from 'react-native'
import {requireNativeModule} from 'expo-modules-core' import {requireNativeModule} from 'expo-modules-core'
import {AudioCategory} from './types'
const NativeModule = requireNativeModule('ExpoPlatformInfo') const NativeModule = requireNativeModule('ExpoPlatformInfo')
export function getIsReducedMotionEnabled(): boolean { export function getIsReducedMotionEnabled(): boolean {
return NativeModule.getIsReducedMotionEnabled() 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)
}
@@ -1,5 +1,25 @@
import {NotImplementedError} from '../NotImplemented' import {NotImplementedError} from '../NotImplemented'
import {AudioCategory} from './types'
export function getIsReducedMotionEnabled(): boolean { export function getIsReducedMotionEnabled(): boolean {
throw new NotImplementedError() 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})
}
@@ -1,6 +1,17 @@
import {NotImplementedError} from '../NotImplemented'
import {AudioCategory} from './types'
export function getIsReducedMotionEnabled(): boolean { export function getIsReducedMotionEnabled(): boolean {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return false return false
} }
return window.matchMedia('(prefers-reduced-motion: reduce)').matches 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})
}
@@ -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',
}
+3 -3
View File
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.12.29", "@atproto/api": "0.13.0",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -139,7 +139,7 @@
"expo-system-ui": "~3.0.4", "expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1", "expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14", "expo-updates": "~0.25.14",
"expo-video": "^1.1.10", "expo-video": "^1.2.4",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"history": "^5.3.0", "history": "^5.3.0",
@@ -208,7 +208,7 @@
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@atproto/dev-env": "^0.3.5", "@atproto/dev-env": "^0.3.39",
"@babel/core": "^7.23.2", "@babel/core": "^7.23.2",
"@babel/preset-env": "^7.20.0", "@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
-28
View File
@@ -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];
+14 -1
View File
@@ -13,7 +13,7 @@ index bb74e80..0aa0202 100644
Map<String, Object> constants = new HashMap<>(3); Map<String, Object> constants = new HashMap<>(3);
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>()); 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 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 --- a/node_modules/expo-modules-core/build/uuid/uuid.js
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js +++ b/node_modules/expo-modules-core/build/uuid/uuid.js
@@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
@@ -24,3 +24,16 @@ index 109d3fe..c7fce9e 100644
const nativeUuidv4 = globalThis?.expo?.uuidv4; const nativeUuidv4 = globalThis?.expo?.uuidv4;
const nativeUuidv5 = globalThis?.expo?.uuidv5; const nativeUuidv5 = globalThis?.expo?.uuidv5;
function uuidv4() { 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()
}
}
-20
View File
@@ -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<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
+ val fullscreenButton =
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
fullscreenButton?.visibility = if (visible) {
android.view.View.VISIBLE
} else {
+135
View File
@@ -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<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
+ val fullscreenButton =
+ findViewById<android.widget.ImageButton>(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<Unit>()
val onPictureInPictureStop by EventDispatcher<Unit>()
+ 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;
}
+6
View File
@@ -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.
+3
View File
@@ -61,6 +61,7 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {Provider as TourProvider} from '#/tours' import {Provider as TourProvider} from '#/tours'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
@@ -157,6 +158,8 @@ function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)
React.useEffect(() => { React.useEffect(() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(false)
initPersistedState().then(() => setReady(true)) initPersistedState().then(() => setReady(true))
}, []) }, [])
+14 -14
View File
@@ -186,19 +186,19 @@ export function createThemes({
white: color.gray_0, white: color.gray_0,
black: color.trueBlack, black: color.trueBlack,
contrast_25: color.gray_1000, contrast_25: color.gray_975,
contrast_50: color.gray_975, contrast_50: color.gray_950,
contrast_100: color.gray_950, contrast_100: color.gray_900,
contrast_200: color.gray_900, contrast_200: color.gray_800,
contrast_300: color.gray_800, contrast_300: color.gray_700,
contrast_400: color.gray_700, contrast_400: color.gray_600,
contrast_500: color.gray_600, contrast_500: color.gray_500,
contrast_600: color.gray_500, contrast_600: color.gray_400,
contrast_700: color.gray_400, contrast_700: color.gray_300,
contrast_800: color.gray_300, contrast_800: color.gray_200,
contrast_900: color.gray_200, contrast_900: color.gray_100,
contrast_950: color.gray_100, contrast_950: color.gray_50,
contrast_975: color.gray_50, contrast_975: color.gray_25,
primary_25: color.primary_975, primary_25: color.primary_975,
primary_50: color.primary_950, primary_50: color.primary_950,
@@ -400,7 +400,7 @@ export function createThemes({
color: darkPalette.contrast_400, color: darkPalette.contrast_400,
}, },
text_contrast_medium: { text_contrast_medium: {
color: darkPalette.contrast_700, color: darkPalette.contrast_600,
}, },
text_contrast_high: { text_contrast_high: {
color: darkPalette.contrast_900, color: darkPalette.contrast_900,
+58 -46
View File
@@ -1,6 +1,8 @@
import React from 'react' import React from 'react'
import { import {
AccessibilityProps, AccessibilityProps,
GestureResponderEvent,
MouseEvent,
Pressable, Pressable,
PressableProps, PressableProps,
StyleProp, StyleProp,
@@ -65,7 +67,15 @@ type NonTextElements =
export type ButtonProps = Pick< export type ButtonProps = Pick<
PressableProps, PressableProps,
'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop' | 'disabled'
| 'onPress'
| 'testID'
| 'onLongPress'
| 'hitSlop'
| 'onHoverIn'
| 'onHoverOut'
| 'onPressIn'
| 'onPressOut'
> & > &
AccessibilityProps & AccessibilityProps &
VariantProps & { VariantProps & {
@@ -115,30 +125,50 @@ export const Button = React.forwardRef<View, ButtonProps>(
focused: false, focused: false,
}) })
const onPressIn = React.useCallback(() => { const onPressInOuter = rest.onPressIn
setState(s => ({ const onPressIn = React.useCallback(
...s, (e: GestureResponderEvent) => {
pressed: true, setState(s => ({
})) ...s,
}, [setState]) pressed: true,
const onPressOut = React.useCallback(() => { }))
setState(s => ({ onPressInOuter?.(e)
...s, },
pressed: false, [setState, onPressInOuter],
})) )
}, [setState]) const onPressOutOuter = rest.onPressOut
const onHoverIn = React.useCallback(() => { const onPressOut = React.useCallback(
setState(s => ({ (e: GestureResponderEvent) => {
...s, setState(s => ({
hovered: true, ...s,
})) pressed: false,
}, [setState]) }))
const onHoverOut = React.useCallback(() => { onPressOutOuter?.(e)
setState(s => ({ },
...s, [setState, onPressOutOuter],
hovered: false, )
})) const onHoverInOuter = rest.onHoverIn
}, [setState]) 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(() => { const onFocus = React.useCallback(() => {
setState(s => ({ setState(s => ({
...s, ...s,
@@ -202,28 +232,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
} else if (color === 'secondary') { } else if (color === 'secondary') {
if (variant === 'solid') { if (variant === 'solid') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push(t.atoms.bg_contrast_25)
backgroundColor: select(t.name, { hoverStyles.push(t.atoms.bg_contrast_50)
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,
}),
})
} else { } else {
baseStyles.push({ baseStyles.push(t.atoms.bg_contrast_100)
backgroundColor: select(t.name, {
light: t.palette.contrast_100,
dim: t.palette.contrast_25,
dark: t.palette.contrast_25,
}),
})
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
baseStyles.push(a.border, t.atoms.bg, { baseStyles.push(a.border, t.atoms.bg, {
+4 -28
View File
@@ -2,21 +2,18 @@ import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {CenteredView} from 'view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {router} from '#/routes'
export function Error({ export function Error({
title, title,
message, message,
onRetry, onRetry,
onGoBack: onGoBackProp, onGoBack,
hideBackButton, hideBackButton,
sideBorders = true, sideBorders = true,
}: { }: {
@@ -27,31 +24,10 @@ export function Error({
hideBackButton?: boolean hideBackButton?: boolean
sideBorders?: boolean sideBorders?: boolean
}) { }) {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const goBack = useGoBack(onGoBack)
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])
return ( return (
<CenteredView <CenteredView
@@ -96,7 +72,7 @@ export function Error({
variant="solid" variant="solid"
color={onRetry ? 'secondary' : 'primary'} color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)} label={_(msg`Return to previous page`)}
onPress={onGoBack} onPress={goBack}
size="large" size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
<ButtonText> <ButtonText>
+12 -2
View File
@@ -133,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const userActionSnapshot = userActionHistory.useActionHistorySnapshot() const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
const dids = React.useMemo(() => { const dids = React.useMemo(() => {
const {likes, follows, seen} = userActionSnapshot const {likes, follows, followSuggestions, seen} = userActionSnapshot
const likeDids = likes const likeDids = likes
.map(l => new AtUri(l)) .map(l => new AtUri(l))
.map(uri => uri.host) .map(uri => uri.host)
.filter(did => !follows.includes(did)) .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 const seenDids = seen
.sort(sortSeenPosts) .sort(sortSeenPosts)
.map(l => new AtUri(l.uri)) .map(l => new AtUri(l.uri))
.map(uri => uri.host) .map(uri => uri.host)
return [...new Set([...likeDids, ...seenDids])].filter( return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
did => did !== currentAccount?.did, did => did !== currentAccount?.did,
) )
}, [userActionSnapshot, currentAccount]) }, [userActionSnapshot, currentAccount])
+42 -6
View File
@@ -1,13 +1,20 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' 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 {Trans} from '@lingui/macro'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {useModerationOpts} from 'state/preferences/moderation-opts'
import {precacheList} from 'state/queries/feed' import {precacheList} from 'state/queries/feed'
import {useTheme} from '#/alf' import {useSession} from 'state/session'
import {atoms as a} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import { import {
Avatar, Avatar,
Description, Description,
@@ -16,6 +23,7 @@ import {
SaveButton, SaveButton,
} from '#/components/FeedCard' } from '#/components/FeedCard'
import {Link as InternalLink, LinkProps} from '#/components/Link' import {Link as InternalLink, LinkProps} from '#/components/Link'
import * as Hider from '#/components/moderation/Hider'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
/* /*
@@ -43,6 +51,11 @@ type Props = {
export function Default(props: Props) { export function Default(props: Props) {
const {view, showPinButton} = props const {view, showPinButton} = props
const moderationOpts = useModerationOpts()
const moderation = moderationOpts
? moderateUserList(view, moderationOpts)
: undefined
return ( return (
<Link {...props}> <Link {...props}>
<Outer> <Outer>
@@ -52,6 +65,7 @@ export function Default(props: Props) {
title={view.name} title={view.name}
creator={view.creator} creator={view.creator}
purpose={view.purpose} purpose={view.purpose}
modUi={moderation?.ui('contentView')}
/> />
{showPinButton && view.purpose === CURATELIST && ( {showPinButton && view.purpose === CURATELIST && (
<SaveButton view={view} pin /> <SaveButton view={view} pin />
@@ -89,18 +103,40 @@ export function TitleAndByline({
title, title,
creator, creator,
purpose = CURATELIST, purpose = CURATELIST,
modUi,
}: { }: {
title: string title: string
creator?: AppBskyActorDefs.ProfileViewBasic creator?: AppBskyActorDefs.ProfileViewBasic
purpose?: AppBskyGraphDefs.ListView['purpose'] purpose?: AppBskyGraphDefs.ListView['purpose']
modUi?: ModerationUI
}) { }) {
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession()
return ( return (
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}> <Hider.Outer
{title} modui={modUi}
</Text> isContentVisibleInitialState={
creator && currentAccount?.did === creator.did
}
allowOverride={creator && currentAccount?.did === creator.did}>
<Hider.Mask>
<Text
style={[a.text_md, a.font_bold, a.leading_snug, a.italic]}
numberOfLines={1}>
<Trans>Hidden list</Trans>
</Text>
</Hider.Mask>
<Hider.Content>
<Text
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{title}
</Text>
</Hider.Content>
</Hider.Outer>
{creator && ( {creator && (
<Text <Text
style={[a.leading_snug, t.atoms.text_contrast_medium]} style={[a.leading_snug, t.atoms.text_contrast_medium]}
+5 -1
View File
@@ -1,8 +1,12 @@
import React from 'react' import React from 'react'
import type {ContextType} from '#/components/Menu/types' import type {ContextType, ItemContextType} from '#/components/Menu/types'
export const Context = React.createContext<ContextType>({ export const Context = React.createContext<ContextType>({
// @ts-ignore // @ts-ignore
control: null, control: null,
}) })
export const ItemContext = React.createContext<ItemContextType>({
disabled: false,
})
+27 -7
View File
@@ -9,7 +9,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Context} from '#/components/Menu/context' import {Context, ItemContext} from '#/components/Menu/context'
import { import {
ContextType, ContextType,
GroupProps, GroupProps,
@@ -125,8 +125,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
}} }}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
onPressIn={onPressIn} onPressIn={e => {
onPressOut={onPressOut} onPressIn()
rest.onPressIn?.(e)
}}
onPressOut={e => {
onPressOut()
rest.onPressOut?.(e)
}}
style={[ style={[
a.flex_row, a.flex_row,
a.align_center, a.align_center,
@@ -138,15 +144,18 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
{minHeight: 44, paddingVertical: 10}, {minHeight: 44, paddingVertical: 10},
style, style,
(focused || pressed) && [t.atoms.bg_contrast_50], (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
]}> ]}>
{children} <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
{children}
</ItemContext.Provider>
</Pressable> </Pressable>
) )
} }
export function ItemText({children, style}: ItemTextProps) { export function ItemText({children, style}: ItemTextProps) {
const t = useTheme() const t = useTheme()
const {disabled} = React.useContext(ItemContext)
return ( return (
<Text <Text
numberOfLines={1} numberOfLines={1}
@@ -155,9 +164,10 @@ export function ItemText({children, style}: ItemTextProps) {
a.flex_1, a.flex_1,
a.text_md, a.text_md,
a.font_bold, a.font_bold,
t.atoms.text_contrast_medium, t.atoms.text_contrast_high,
{paddingTop: 3}, {paddingTop: 3},
style, style,
disabled && t.atoms.text_contrast_low,
]}> ]}>
{children} {children}
</Text> </Text>
@@ -166,7 +176,17 @@ export function ItemText({children, style}: ItemTextProps) {
export function ItemIcon({icon: Comp}: ItemIconProps) { export function ItemIcon({icon: Comp}: ItemIconProps) {
const t = useTheme() const t = useTheme()
return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} /> const {disabled} = React.useContext(ItemContext)
return (
<Comp
size="lg"
fill={
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
)
} }
export function Group({children, style}: GroupProps) { export function Group({children, style}: GroupProps) {
+32 -14
View File
@@ -9,7 +9,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {atoms as a, flatten, useTheme, web} from '#/alf' import {atoms as a, flatten, useTheme, web} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Context} from '#/components/Menu/context' import {Context, ItemContext} from '#/components/Menu/context'
import { import {
ContextType, ContextType,
GroupProps, GroupProps,
@@ -239,18 +239,21 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
a.rounded_xs, a.rounded_xs,
{minHeight: 32, paddingHorizontal: 10}, {minHeight: 32, paddingHorizontal: 10},
web({outline: 0}), web({outline: 0}),
(hovered || focused) && [ (hovered || focused) &&
web({outline: '0 !important'}), !rest.disabled && [
t.name === 'light' web({outline: '0 !important'}),
? t.atoms.bg_contrast_25 t.name === 'light'
: t.atoms.bg_contrast_50, ? t.atoms.bg_contrast_25
], : t.atoms.bg_contrast_50,
],
])} ])}
{...web({ {...web({
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
})}> })}>
{children} <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
{children}
</ItemContext.Provider>
</Pressable> </Pressable>
</DropdownMenu.Item> </DropdownMenu.Item>
) )
@@ -258,8 +261,16 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
export function ItemText({children, style}: ItemTextProps) { export function ItemText({children, style}: ItemTextProps) {
const t = useTheme() const t = useTheme()
const {disabled} = React.useContext(ItemContext)
return ( return (
<Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}> <Text
style={[
a.flex_1,
a.font_bold,
t.atoms.text_contrast_high,
style,
disabled && t.atoms.text_contrast_low,
]}>
{children} {children}
</Text> </Text>
) )
@@ -267,10 +278,9 @@ export function ItemText({children, style}: ItemTextProps) {
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) { export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
const t = useTheme() const t = useTheme()
const {disabled} = React.useContext(ItemContext)
return ( return (
<Comp <View
size="md"
fill={t.atoms.text_contrast_medium.color}
style={[ style={[
position === 'left' && { position === 'left' && {
marginLeft: -2, marginLeft: -2,
@@ -279,8 +289,16 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
marginRight: -2, marginRight: -2,
marginLeft: 12, marginLeft: 12,
}, },
]} ]}>
/> <Comp
size="md"
fill={
disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
</View>
) )
} }
+7 -3
View File
@@ -1,18 +1,22 @@
import React from 'react' import React from 'react'
import { import {
AccessibilityProps,
GestureResponderEvent, GestureResponderEvent,
PressableProps, PressableProps,
AccessibilityProps,
} from 'react-native' } from 'react-native'
import {Props as SVGIconProps} from '#/components/icons/common'
import * as Dialog from '#/components/Dialog'
import {TextStyleProp, ViewStyleProp} from '#/alf' import {TextStyleProp, ViewStyleProp} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Props as SVGIconProps} from '#/components/icons/common'
export type ContextType = { export type ContextType = {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
} }
export type ItemContextType = {
disabled: boolean
}
export type RadixPassThroughTriggerProps = { export type RadixPassThroughTriggerProps = {
id: string id: string
type: 'button' type: 'button'
+11 -24
View File
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation' import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions' import {ReportOption} from '#/lib/moderation/useReportOptions'
import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -37,7 +36,6 @@ export function SubmitView({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()
const gate = useGate()
const [details, setDetails] = React.useState<string>('') const [details, setDetails] = React.useState<string>('')
const [submitting, setSubmitting] = React.useState<boolean>(false) const [submitting, setSubmitting] = React.useState<boolean>(false)
const [selectedServices, setSelectedServices] = React.useState<string[]>([ const [selectedServices, setSelectedServices] = React.useState<string[]>([
@@ -63,27 +61,17 @@ export function SubmitView({
} }
const results = await Promise.all( const results = await Promise.all(
selectedServices.map(did => { selectedServices.map(did => {
if (gate('session_withproxy_fix')) { return agent
return agent .createModerationReport(report, {
.createModerationReport(report, { encoding: 'application/json',
encoding: 'application/json', headers: {
headers: { 'atproto-proxy': `${did}#atproto_labeler`,
'atproto-proxy': `${did}#atproto_labeler`, },
}, })
}) .then(
.then( _ => true,
_ => true, _ => false,
_ => false, )
)
} else {
return agent
.withProxy('atproto_labeler', did)
.createModerationReport(report)
.then(
_ => true,
_ => false,
)
}
}), }),
) )
@@ -108,7 +96,6 @@ export function SubmitView({
onSubmitComplete, onSubmitComplete,
setError, setError,
agent, agent,
gate,
]) ])
return ( return (
+14 -7
View File
@@ -1,8 +1,7 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api' import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -17,7 +16,11 @@ import {StarterPack} from '#/components/icons/StarterPack'
import {BaseLink} from '#/components/Link' import {BaseLink} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) { export function Default({
starterPack,
}: {
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
}) {
if (!starterPack) return null if (!starterPack) return null
return ( return (
<Link starterPack={starterPack}> <Link starterPack={starterPack}>
@@ -29,7 +32,7 @@ export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
export function Notification({ export function Notification({
starterPack, starterPack,
}: { }: {
starterPack?: StarterPackViewBasic starterPack?: AppBskyGraphDefs.StarterPackViewBasic
}) { }) {
if (!starterPack) return null if (!starterPack) return null
return ( return (
@@ -44,7 +47,7 @@ export function Card({
noIcon, noIcon,
noDescription, noDescription,
}: { }: {
starterPack: StarterPackViewBasic starterPack: AppBskyGraphDefs.StarterPackViewBasic
noIcon?: boolean noIcon?: boolean
noDescription?: boolean noDescription?: boolean
}) { }) {
@@ -94,7 +97,7 @@ export function Link({
starterPack, starterPack,
children, children,
}: { }: {
starterPack: StarterPackViewBasic starterPack: AppBskyGraphDefs.StarterPackViewBasic
onPress?: () => void onPress?: () => void
children: React.ReactNode 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 t = useTheme()
const imageUri = getStarterPackOgCard(starterPack) const imageUri = getStarterPackOgCard(starterPack)
+3 -1
View File
@@ -249,7 +249,9 @@ function DialogError({details}: {details?: string}) {
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
return ( return (
<Dialog.ScrollableInner style={a.gap_md} label={_(msg`An error occured`)}> <Dialog.ScrollableInner
style={a.gap_md}
label={_(msg`An error has occurred`)}>
<Dialog.Close /> <Dialog.Close />
<ErrorScreen <ErrorScreen
title={_(msg`Oh no!`)} title={_(msg`Oh no!`)}
+1
View File
@@ -193,6 +193,7 @@ export function createInput(Component: typeof TextInput) {
lineHeight: a.text_md.fontSize * 1.1875, lineHeight: a.text_md.fontSize * 1.1875,
textAlignVertical: rest.multiline ? 'top' : undefined, textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 80 : undefined, minHeight: rest.multiline ? 80 : undefined,
minWidth: 0,
}, },
// fix for autofill styles covering border // fix for autofill styles covering border
web({ web({
+2 -20
View File
@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {StyleProp, View, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api' import {ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -40,7 +40,7 @@ export function ContentHider({
if (!blur || (ignoreMute && isJustAMute(modui))) { if (!blur || (ignoreMute && isJustAMute(modui))) {
return ( return (
<View testID={testID} style={[styles.outer, style]}> <View testID={testID} style={style}>
{children} {children}
</View> </View>
) )
@@ -163,21 +163,3 @@ export function ContentHider({
</View> </View>
) )
} }
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',
},
})
+89
View File
@@ -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<Context>({} 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 (
<Context.Provider value={ctx}>
{children}
<ModerationDetailsDialog control={control} modcause={blur} />
</Context.Provider>
)
}
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
}
+14 -29
View File
@@ -7,7 +7,6 @@ import {useMutation} from '@tanstack/react-query'
import {useLabelInfo} from '#/lib/moderation/useLabelInfo' import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
@@ -204,7 +203,6 @@ function AppealForm({
const [details, setDetails] = React.useState('') const [details, setDetails] = React.useState('')
const isAccountReport = 'did' in subject const isAccountReport = 'did' in subject
const agent = useAgent() const agent = useAgent()
const gate = useGate()
const sourceName = labeler const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@') ? sanitizeHandle(labeler.creator.handle, '@')
: label.src : label.src
@@ -214,35 +212,22 @@ function AppealForm({
const $type = !isAccountReport const $type = !isAccountReport
? 'com.atproto.repo.strongRef' ? 'com.atproto.repo.strongRef'
: 'com.atproto.admin.defs#repoRef' : 'com.atproto.admin.defs#repoRef'
if (gate('session_withproxy_fix')) { await agent.createModerationReport(
await agent.createModerationReport( {
{ reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
reasonType: ComAtprotoModerationDefs.REASONAPPEAL, subject: {
subject: { $type,
$type, ...subject,
...subject,
},
reason: details,
}, },
{ reason: details,
encoding: 'application/json', },
headers: { {
'atproto-proxy': `${label.src}#atproto_labeler`, 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 => { onError: err => {
logger.error('Failed to submit label appeal', {message: err}) logger.error('Failed to submit label appeal', {message: err})
@@ -18,7 +18,7 @@ export {useDialogControl as useModerationDetailsDialogControl} from '#/component
export interface ModerationDetailsDialogProps { export interface ModerationDetailsDialogProps {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
modcause: ModerationCause modcause?: ModerationCause
} }
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@@ -123,7 +123,7 @@ function ModerationDetailsDialogInner({
{description} {description}
</Text> </Text>
{modcause.type === 'label' && ( {modcause?.type === 'label' && (
<> <>
<Divider /> <Divider />
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
-85
View File
@@ -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<string, string>
body: any
}
async function fetchHandler(
reqUri: string,
reqMethod: string,
reqHeaders: Record<string, string>,
reqBody: any,
): Promise<FetchHandlerResponse> {
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<string, string> = {}
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,
}
}
-3
View File
@@ -1,3 +0,0 @@
export function doPolyfill() {
// no polyfill is needed on web
}
+26 -5
View File
@@ -23,6 +23,7 @@ type FeedSliceItem = {
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
isParentBlocked: boolean isParentBlocked: boolean
isParentNotFound: boolean
} }
type AuthorContext = { type AuthorContext = {
@@ -68,6 +69,7 @@ export class FeedViewPostsSlice {
} }
const parent = reply?.parent const parent = reply?.parent
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent) const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent)
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
if (AppBskyFeedDefs.isPostView(parent)) { if (AppBskyFeedDefs.isPostView(parent)) {
parentAuthor = parent.author parentAuthor = parent.author
@@ -77,6 +79,7 @@ export class FeedViewPostsSlice {
record: post.record, record: post.record,
parentAuthor, parentAuthor,
isParentBlocked, isParentBlocked,
isParentNotFound,
}) })
if (!reply || reason) { if (!reply || reason) {
return return
@@ -89,23 +92,40 @@ export class FeedViewPostsSlice {
this.isOrphan = true this.isOrphan = true
return 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 grandparentAuthor = reply.grandparentAuthor
const isGrandparentBlocked = Boolean( const isGrandparentBlocked = Boolean(
grandparentAuthor?.viewer?.blockedBy || grandparent && AppBskyFeedDefs.isBlockedPost(grandparent),
grandparentAuthor?.viewer?.blocking || )
grandparentAuthor?.viewer?.blockingByList, const isGrandparentNotFound = Boolean(
grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent),
) )
this.items.unshift({ this.items.unshift({
post: parent, post: parent,
record: parent.record, record: parent.record,
parentAuthor: grandparentAuthor, parentAuthor: grandparentAuthor,
isParentBlocked: isGrandparentBlocked, isParentBlocked: isGrandparentBlocked,
isParentNotFound: isGrandparentNotFound,
}) })
if (isGrandparentBlocked) { if (isGrandparentBlocked) {
this.isOrphan = true 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 ( if (
!AppBskyFeedDefs.isPostView(root) || !AppBskyFeedDefs.isPostView(root) ||
!AppBskyFeedPost.isRecord(root.record) || !AppBskyFeedPost.isRecord(root.record) ||
@@ -121,6 +141,7 @@ export class FeedViewPostsSlice {
post: root, post: root,
record: root.record, record: root.record,
isParentBlocked: false, isParentBlocked: false,
isParentNotFound: false,
parentAuthor: undefined, parentAuthor: undefined,
}) })
if (parent.record.reply?.parent.uri !== root.uri) { if (parent.record.reply?.parent.uri !== root.uri) {
+11 -14
View File
@@ -1,7 +1,6 @@
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed, AppBskyFeedGetFeed as GetCustomFeed,
AtpAgent,
BskyAgent, BskyAgent,
} from '@atproto/api' } from '@atproto/api'
@@ -51,7 +50,7 @@ export class CustomFeedAPI implements FeedAPI {
const agent = this.agent const agent = this.agent
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed) const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
const res = agent.session const res = agent.did
? await this.agent.app.bsky.feed.getFeed( ? await this.agent.app.bsky.feed.getFeed(
{ {
...this.params, ...this.params,
@@ -106,34 +105,32 @@ async function loggedOutFetch({
let contentLangs = getContentLanguages().join(',') let contentLangs = getContentLanguages().join(',')
// manually construct fetch call so we can add the `lang` cache-busting param // 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}${ `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
cursor ? `&cursor=${cursor}` : '' cursor ? `&cursor=${cursor}` : ''
}&limit=${limit}&lang=${contentLangs}`, }&limit=${limit}&lang=${contentLangs}`,
'GET', {method: 'GET', headers: {'Accept-Language': contentLangs}},
{'Accept-Language': contentLangs},
undefined,
) )
if (res.body?.feed?.length) { let data = res.ok ? await res.json() : null
if (data?.feed?.length) {
return { return {
success: true, success: true,
data: res.body, data,
} }
} }
// no data, try again with language headers removed // 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}${ `https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
cursor ? `&cursor=${cursor}` : '' cursor ? `&cursor=${cursor}` : ''
}&limit=${limit}`, }&limit=${limit}`,
'GET', {method: 'GET', headers: {'Accept-Language': ''}},
{'Accept-Language': ''},
undefined,
) )
if (res.body?.feed?.length) { data = res.ok ? await res.json() : null
if (data?.feed?.length) {
return { return {
success: true, success: true,
data: res.body, data,
} }
} }
+5 -34
View File
@@ -6,7 +6,6 @@ import {
AppBskyFeedThreadgate, AppBskyFeedThreadgate,
BskyAgent, BskyAgent,
ComAtprotoLabelDefs, ComAtprotoLabelDefs,
ComAtprotoRepoUploadBlob,
RichText, RichText,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
@@ -15,10 +14,13 @@ import {logger} from '#/logger'
import {ThreadgateSetting} from '#/state/queries/threadgate' import {ThreadgateSetting} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors' import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' 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 {ImageModel} from 'state/models/media/image'
import {LinkMeta} from '../link-meta/link-meta' import {LinkMeta} from '../link-meta/link-meta'
import {safeDeleteAsync} from '../media/manip' import {safeDeleteAsync} from '../media/manip'
import {uploadBlob} from './upload-blob'
export {uploadBlob}
export interface ExternalEmbedDraft { export interface ExternalEmbedDraft {
uri: string uri: string
@@ -28,25 +30,6 @@ export interface ExternalEmbedDraft {
localThumb?: ImageModel localThumb?: ImageModel
} }
export async function uploadBlob(
agent: BskyAgent,
blob: string,
encoding: string,
): Promise<ComAtprotoRepoUploadBlob.Response> {
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 { interface PostOpts {
rawText: string rawText: string
replyTo?: string replyTo?: string
@@ -301,7 +284,7 @@ export async function createThreadgate(
const postUrip = new AtUri(postUri) const postUrip = new AtUri(postUri)
await agent.api.com.atproto.repo.putRecord({ await agent.api.com.atproto.repo.putRecord({
repo: agent.session!.did, repo: agent.accountDid,
collection: 'app.bsky.feed.threadgate', collection: 'app.bsky.feed.threadgate',
rkey: postUrip.rkey, rkey: postUrip.rkey,
record: { 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
}
+82
View File
@@ -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<ComAtprotoRepoUploadBlob.Response> {
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<Blob> {
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<T>(
uri: string,
fn: (path: string) => Promise<T>,
): Promise<T> {
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)
}
}
+26
View File
@@ -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<ComAtprotoRepoUploadBlob.Response> {
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}`)
}
+23
View File
@@ -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<NavigationProp>()
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())
}
}
}
}
+18
View File
@@ -2,6 +2,7 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
import {useMinimalShellMode} from '#/state/shell/minimal-mode' import {useMinimalShellMode} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout' 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. // Keep these separated so that we only pay for useAnimatedStyle that gets used.
@@ -27,8 +28,13 @@ export function useMinimalShellHeaderTransform() {
export function useMinimalShellFooterTransform() { export function useMinimalShellFooterTransform() {
const mode = useMinimalShellMode() const mode = useMinimalShellMode()
const {footerHeight} = useShellLayout() const {footerHeight} = useShellLayout()
const gate = useGate()
const isFixedBottomBar = gate('fixed_bottom_bar')
const footerTransform = useAnimatedStyle(() => { const footerTransform = useAnimatedStyle(() => {
if (isFixedBottomBar) {
return {}
}
return { return {
pointerEvents: mode.value === 0 ? 'auto' : 'none', pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2), opacity: Math.pow(1 - mode.value, 2),
@@ -39,13 +45,25 @@ export function useMinimalShellFooterTransform() {
], ],
} }
}) })
return footerTransform return footerTransform
} }
export function useMinimalShellFabTransform() { export function useMinimalShellFabTransform() {
const mode = useMinimalShellMode() const mode = useMinimalShellMode()
const gate = useGate()
const isFixedBottomBar = gate('fixed_bottom_bar')
const fabTransform = useAnimatedStyle(() => { const fabTransform = useAnimatedStyle(() => {
if (isFixedBottomBar) {
return {
transform: [
{
translateY: -44,
},
],
}
}
return { return {
transform: [ transform: [
{ {
+1 -7
View File
@@ -218,13 +218,7 @@ export async function safeDeleteAsync(path: string) {
// Normalize is necessary for Android, otherwise it doesn't delete. // Normalize is necessary for Android, otherwise it doesn't delete.
const normalizedPath = normalizePath(path) const normalizedPath = normalizePath(path)
try { try {
await Promise.allSettled([ await deleteAsync(normalizedPath, {idempotent: true})
deleteAsync(normalizedPath, {idempotent: true}),
// HACK: Try this one too. Might exist due to api-polyfill hack.
deleteAsync(normalizedPath.replace(/\.jpe?g$/, '.bin'), {
idempotent: true,
}),
])
} catch (e) { } catch (e) {
console.error('Failed to delete file', e) console.error('Failed to delete file', e)
} }
+2 -12
View File
@@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api'
import {logger} from '#/logger' import {logger} from '#/logger'
import {SessionAccount, useAgent, useSession} from '#/state/session' 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 {devicePlatform, isAndroid, isNative} from 'platform/detection'
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler' import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
@@ -86,7 +86,6 @@ export function useNotificationsRegistration() {
} }
export function useRequestNotificationsPermission() { export function useRequestNotificationsPermission() {
const gate = useGate()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
@@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() {
) { ) {
return return
} }
if ( if (context === 'AfterOnboarding') {
context === 'StartOnboarding' &&
gate('request_notifications_permission_after_onboarding_v2')
) {
return
}
if (
context === 'AfterOnboarding' &&
!gate('request_notifications_permission_after_onboarding_v2')
) {
return return
} }
if (context === 'Home' && !currentAccount) { if (context === 'Home' && !currentAccount) {
+66 -1
View File
@@ -2,18 +2,83 @@ import React, {useRef, useState} from 'react'
import {AppState, AppStateStatus} from 'react-native' import {AppState, AppStateStatus} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
import {focusManager, QueryClient} from '@tanstack/react-query' import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query'
import { import {
PersistQueryClientProvider, PersistQueryClientProvider,
PersistQueryClientProviderProps, PersistQueryClientProviderProps,
} from '@tanstack/react-query-persist-client' } from '@tanstack/react-query-persist-client'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
// any query keys in this array will be persisted to AsyncStorage // any query keys in this array will be persisted to AsyncStorage
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info' export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot] const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
async function checkIsOnline(): Promise<boolean> {
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<void> | 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 => { focusManager.setEventListener(onFocus => {
if (isNative) { if (isNative) {
const subscription = AppState.addEventListener( const subscription = AppState.addEventListener(
+1
View File
@@ -14,6 +14,7 @@ export type LogEvents = {
} }
'account:loggedOut': { 'account:loggedOut': {
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated' logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
scope: 'current' | 'every'
} }
'notifications:openApp': {} 'notifications:openApp': {}
'notifications:request': { 'notifications:request': {
+1 -9
View File
@@ -2,18 +2,10 @@ export type Gate =
// Keep this alphabetic please. // Keep this alphabetic please.
| 'auto_show_prof_sugg_follows' | 'auto_show_prof_sugg_follows'
| 'debug_show_feedcontext' | 'debug_show_feedcontext'
| 'explore_page_profile_card_social_proof' | 'fixed_bottom_bar'
| 'native_pwi_disabled'
| 'new_user_guided_tour' | 'new_user_guided_tour'
| 'new_user_progress_guide'
| 'onboarding_minimum_interests' | 'onboarding_minimum_interests'
| 'request_notifications_permission_after_onboarding_v2'
| 'session_withproxy_fix'
| 'show_avi_follow_button'
| 'show_follow_back_label_v2' | 'show_follow_back_label_v2'
| 'suggested_feeds_interstitial' | 'suggested_feeds_interstitial'
| 'suggested_follows_interstitial'
| 'ungroup_follow_backs'
| 'video_debug' | 'video_debug'
| 'videos' | 'videos'
| 'small_avi_thumb'
+10 -5
View File
@@ -103,16 +103,21 @@ export function parseEmbedPlayerFromUrl(
urlp.hostname === 'm.youtube.com' || urlp.hostname === 'm.youtube.com' ||
urlp.hostname === 'music.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 = 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) const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
if (videoId) { if (videoId) {
return { return {
type: page === 'shorts' ? 'youtube_short' : 'youtube_video', type: isShorts ? 'youtube_short' : 'youtube_video',
source: page === 'shorts' ? 'youtubeShorts' : 'youtube', source: isShorts ? 'youtubeShorts' : 'youtube',
hideDetails: page === 'shorts' ? true : undefined, hideDetails: isShorts ? true : undefined,
playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`, playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
} }
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native' import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
import {Theme, TypographyVariant} from './ThemeContext'
import {isWeb} from 'platform/detection' 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 // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
export const colors = { export const colors = {
+3 -3
View File
@@ -295,9 +295,9 @@ export const darkTheme: Theme = {
...defaultTheme.palette, ...defaultTheme.palette,
default: { default: {
background: darkPalette.black, background: darkPalette.black,
backgroundLight: darkPalette.contrast_50, backgroundLight: darkPalette.contrast_25,
text: darkPalette.white, text: darkPalette.white,
textLight: darkPalette.contrast_700, textLight: darkPalette.contrast_600,
textInverted: darkPalette.black, textInverted: darkPalette.black,
link: darkPalette.primary_500, link: darkPalette.primary_500,
border: darkPalette.contrast_100, border: darkPalette.contrast_100,
@@ -344,7 +344,7 @@ export const dimTheme: Theme = {
default: { default: {
...darkTheme.palette.default, ...darkTheme.palette.default,
background: dimPalette.black, background: dimPalette.black,
backgroundLight: dimPalette.contrast_50, backgroundLight: dimPalette.contrast_25,
text: dimPalette.white, text: dimPalette.white,
textLight: dimPalette.contrast_700, textLight: dimPalette.contrast_700,
textInverted: dimPalette.black, textInverted: dimPalette.black,
+3 -3
View File
@@ -38,7 +38,7 @@ export function Deactivated() {
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
const hasOtherAccounts = accounts.length > 1 const hasOtherAccounts = accounts.length > 1
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {logout} = useSessionApi() const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent() const agent = useAgent()
const [pending, setPending] = React.useState(false) const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | undefined>() const [error, setError] = React.useState<string | undefined>()
@@ -72,8 +72,8 @@ export function Deactivated() {
// So we change the URL ourselves. The navigator will pick it up on remount. // So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/') history.pushState(null, '', '/')
} }
logout('Deactivated') logoutCurrentAccount('Deactivated')
}, [logout]) }, [logoutCurrentAccount])
const handleActivate = React.useCallback(async () => { const handleActivate = React.useCallback(async () => {
try { try {
+216
View File
@@ -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 (
<CenteredView
style={[
a.flex_1,
a.align_center,
a.gap_5xl,
!gtMobile && a.justify_between,
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}
sideBorders={true}>
<View style={[a.w_full, a.align_center, a.gap_lg]}>
<EyeSlash
style={{color: t.atoms.text_contrast_medium.color}}
height={42}
width={42}
/>
<View style={[a.gap_sm, a.align_center]}>
<Text style={[a.font_bold, a.text_3xl]}>
<Trans>List has been hidden</Trans>
</Text>
<Text
style={[
a.text_md,
a.text_center,
a.px_md,
t.atoms.text_contrast_high,
{lineHeight: 1.4},
]}>
<Trans>
This list - created by{' '}
<Text style={[a.text_md, !isOwner && a.font_bold]}>
{isOwner
? _(msg`you`)
: sanitizeHandle(list.creator.handle, '@')}
</Text>{' '}
- contains possible violations of Bluesky's community guidelines
in its name or description.
</Trans>
</Text>
</View>
</View>
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
<View style={[a.gap_md]}>
{savedFeedConfig ? (
<Button
variant="solid"
color="secondary"
size="medium"
label={_(msg`Remove from saved feeds`)}
onPress={onRemoveList}
disabled={isProcessing}>
<ButtonText>
<Trans>Removed from saved feeds</Trans>
</ButtonText>
{isProcessing ? (
<ButtonIcon icon={Loader} position="right" />
) : null}
</Button>
) : null}
{isOwner ? (
<Button
variant="solid"
color="secondary"
size="medium"
label={_(msg`Show list anyway`)}
onPress={() => setIsContentVisible(true)}
disabled={isProcessing}>
<ButtonText>
<Trans>Show anyway</Trans>
</ButtonText>
</Button>
) : list.viewer?.muted || list.viewer?.blocked ? (
<Button
variant="solid"
color="secondary"
size="medium"
label={_(msg`Unsubscribe from list`)}
onPress={() => {
if (isModList) {
onUnsubscribe()
} else {
onRemoveList()
}
}}
disabled={isProcessing}>
<ButtonText>
<Trans>Unsubscribe from list</Trans>
</ButtonText>
{isProcessing ? (
<ButtonIcon icon={Loader} position="right" />
) : null}
</Button>
) : null}
</View>
<Button
variant="solid"
color="primary"
label={_(msg`Return to previous page`)}
onPress={goBack}
size="medium"
disabled={isProcessing}>
<ButtonText>
<Trans>Go Back</Trans>
</ButtonText>
</Button>
</View>
</CenteredView>
)
}
@@ -162,7 +162,7 @@ let ProfileHeaderStandard = ({
hideBackButton={hideBackButton} hideBackButton={hideBackButton}
isPlaceholderProfile={isPlaceholderProfile}> isPlaceholderProfile={isPlaceholderProfile}>
<View <View
style={[a.px_lg, a.pt_md, a.pb_sm]} style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
pointerEvents={isIOS ? 'auto' : 'box-none'}> pointerEvents={isIOS ? 'auto' : 'box-none'}>
<View <View
style={[ style={[
@@ -35,7 +35,7 @@ function DeactivateAccountDialogInner({
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()
const {logout} = useSessionApi() const {logoutCurrentAccount} = useSessionApi()
const [pending, setPending] = React.useState(false) const [pending, setPending] = React.useState(false)
const [error, setError] = React.useState<string | undefined>() const [error, setError] = React.useState<string | undefined>()
@@ -44,7 +44,7 @@ function DeactivateAccountDialogInner({
setPending(true) setPending(true)
await agent.com.atproto.server.deactivateAccount({}) await agent.com.atproto.server.deactivateAccount({})
control.close(() => { control.close(() => {
logout('Deactivated') logoutCurrentAccount('Deactivated')
}) })
} catch (e: any) { } catch (e: any) {
switch (e.message) { switch (e.message) {
@@ -66,7 +66,7 @@ function DeactivateAccountDialogInner({
} finally { } finally {
setPending(false) setPending(false)
} }
}, [agent, control, logout, _, setPending]) }, [agent, control, logoutCurrentAccount, _, setPending])
return ( return (
<> <>
+4 -4
View File
@@ -23,7 +23,7 @@ export function SignupQueued() {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
const {logout} = useSessionApi() const {logoutCurrentAccount} = useSessionApi()
const agent = useAgent() const agent = useAgent()
const [isProcessing, setProcessing] = React.useState(false) const [isProcessing, setProcessing] = React.useState(false)
@@ -40,7 +40,7 @@ export function SignupQueued() {
const res = await agent.com.atproto.temp.checkSignupQueue() const res = await agent.com.atproto.temp.checkSignupQueue()
if (res.data.activated) { if (res.data.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding // 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)) { if (!isSignupQueued(agent.session?.accessJwt)) {
onboardingDispatch({type: 'start'}) onboardingDispatch({type: 'start'})
} }
@@ -153,7 +153,7 @@ export function SignupQueued() {
variant="ghost" variant="ghost"
size="large" size="large"
label={_(msg`Log out`)} label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}> onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}> <ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans> <Trans>Log out</Trans>
</ButtonText> </ButtonText>
@@ -182,7 +182,7 @@ export function SignupQueued() {
variant="ghost" variant="ghost"
size="large" size="large"
label={_(msg`Log out`)} label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}> onPress={() => logoutCurrentAccount('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}> <ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans> <Trans>Log out</Trans>
</ButtonText> </ButtonText>
+16
View File
@@ -22,6 +22,22 @@ export function listenSessionDropped(fn: () => void): UnlistenFn {
return () => emitter.off('session-dropped', fn) 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() { export function emitPostCreated() {
emitter.emit('post-created') emitter.emit('post-created')
} }
+15 -31
View File
@@ -1,11 +1,10 @@
import React from 'react' import React from 'react'
import {AppState, AppStateStatus} from 'react-native' import {AppState, AppStateStatus} from 'react-native'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import throttle from 'lodash.throttle' import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants' import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed' import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
import {getFeedPostSlice} from '#/view/com/posts/Feed' import {getFeedPostSlice} from '#/view/com/posts/Feed'
@@ -25,7 +24,6 @@ const stateContext = React.createContext<StateContext>({
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) { export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const agent = useAgent() const agent = useAgent()
const gate = useGate()
const enabled = isDiscoverFeed(feed) && hasSession const enabled = isDiscoverFeed(feed) && hasSession
const queue = React.useRef<Set<string>>(new Set()) const queue = React.useRef<Set<string>>(new Set())
const history = React.useRef< const history = React.useRef<
@@ -49,34 +47,20 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
queue.current.clear() queue.current.clear()
// Send to the feed // Send to the feed
if (gate('session_withproxy_fix')) { agent.app.bsky.feed
agent.app.bsky.feed .sendInteractions(
.sendInteractions( {interactions},
{interactions}, {
{ encoding: 'application/json',
encoding: 'application/json', headers: {
headers: { // TODO when we start sending to other feeds, we need to grab their DID -prf
// TODO when we start sending to other feeds, we need to grab their DID -prf 'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
},
}, },
) },
.catch((e: any) => { )
logger.warn('Failed to send feed interactions', {error: e}) .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})
})
}
// Send to Statsig // Send to Statsig
if (aggregatedStats.current === null) { if (aggregatedStats.current === null) {
@@ -84,7 +68,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
} }
sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions) sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
throttledFlushAggregatedStats() throttledFlushAggregatedStats()
}, [agent, gate, throttledFlushAggregatedStats]) }, [agent, throttledFlushAggregatedStats])
const sendToFeed = React.useMemo( const sendToFeed = React.useMemo(
() => () =>
+2 -1
View File
@@ -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 // order the feeds/lists in the order they were pinned
const result: SavedFeedSourceInfo[] = [] const result: SavedFeedSourceInfo[] = []
+1 -1
View File
@@ -17,7 +17,7 @@ import {useAgent, useSession} from '../session'
import {invalidate as invalidateMyLists} from './my-lists' import {invalidate as invalidateMyLists} from './my-lists'
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-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 const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
export function useListQuery(uri?: string) { export function useListQuery(uri?: string) {
-3
View File
@@ -26,7 +26,6 @@ import {
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useGate} from '#/lib/statsig/statsig'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts' import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..' import {STALE} from '..'
@@ -59,7 +58,6 @@ export function useNotificationFeedQuery(opts?: {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const unreads = useUnreadNotificationsApi() const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false const enabled = opts?.enabled !== false
const gate = useGate()
// false: force showing all notifications // false: force showing all notifications
// undefined: let the server decide // undefined: let the server decide
@@ -88,7 +86,6 @@ export function useNotificationFeedQuery(opts?: {
queryClient, queryClient,
moderationOpts, moderationOpts,
fetchAdditionalData: true, fetchAdditionalData: true,
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
priority, priority,
}) })
page = fetchedPage page = fetchedPage
+1 -4
View File
@@ -8,7 +8,6 @@ import {useQueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3' import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast' import BroadcastChannel from '#/lib/broadcast'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {resetBadgeCount} from 'lib/notifications/notifications' import {resetBadgeCount} from 'lib/notifications/notifications'
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const gate = useGate()
const [numUnread, setNumUnread] = React.useState('') 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 // only fetch subjects when the page is going to be used
// in the notifications query, otherwise skip it // in the notifications query, otherwise skip it
fetchAdditionalData: !!invalidate, fetchAdditionalData: !!invalidate,
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
}) })
const unreadCount = countUnread(page) const unreadCount = countUnread(page)
const unreadCountStr = 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 checkUnreadRef.current = api.checkUnread
return ( return (
+2 -7
View File
@@ -30,7 +30,6 @@ export async function fetchPage({
queryClient, queryClient,
moderationOpts, moderationOpts,
fetchAdditionalData, fetchAdditionalData,
shouldUngroupFollowBacks,
}: { }: {
agent: BskyAgent agent: BskyAgent
cursor: string | undefined cursor: string | undefined
@@ -38,7 +37,6 @@ export async function fetchPage({
queryClient: QueryClient queryClient: QueryClient
moderationOpts: ModerationOpts | undefined moderationOpts: ModerationOpts | undefined
fetchAdditionalData: boolean fetchAdditionalData: boolean
shouldUngroupFollowBacks?: () => boolean
priority?: boolean priority?: boolean
}): Promise<{ }): Promise<{
page: FeedPage page: FeedPage
@@ -58,7 +56,7 @@ export async function fetchPage({
) )
// group notifications which are essentially similar (follows, likes on a post) // 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 // we fetch subjects of notifications (usually posts) now instead of lazily
// in the UI to avoid relayouts // in the UI to avoid relayouts
@@ -117,7 +115,6 @@ export function shouldFilterNotif(
export function groupNotifications( export function groupNotifications(
notifs: AppBskyNotificationListNotifications.Notification[], notifs: AppBskyNotificationListNotifications.Notification[],
options?: {shouldUngroupFollowBacks?: () => boolean},
): FeedNotification[] { ): FeedNotification[] {
const groupedNotifs: FeedNotification[] = [] const groupedNotifs: FeedNotification[] = []
for (const notif of notifs) { for (const notif of notifs) {
@@ -137,9 +134,7 @@ export function groupNotifications(
const prevIsFollowBack = const prevIsFollowBack =
groupedNotif.notification.reason === 'follow' && groupedNotif.notification.reason === 'follow' &&
groupedNotif.notification.author.viewer?.following groupedNotif.notification.author.viewer?.following
const shouldUngroup = const shouldUngroup = nextIsFollowBack || prevIsFollowBack
(nextIsFollowBack || prevIsFollowBack) &&
options?.shouldUngroupFollowBacks?.()
if (!shouldUngroup) { if (!shouldUngroup) {
groupedNotif.additional = groupedNotif.additional || [] groupedNotif.additional = groupedNotif.additional || []
groupedNotif.additional.push(notif) groupedNotif.additional.push(notif)
+2
View File
@@ -80,6 +80,7 @@ export interface FeedPostSliceItem {
moderation: ModerationDecision moderation: ModerationDecision
parentAuthor?: AppBskyActorDefs.ProfileViewBasic parentAuthor?: AppBskyActorDefs.ProfileViewBasic
isParentBlocked?: boolean isParentBlocked?: boolean
isParentNotFound?: boolean
} }
export interface FeedPostSlice { export interface FeedPostSlice {
@@ -326,6 +327,7 @@ export function usePostFeedQuery(
moderation: moderations[i], moderation: moderations[i],
parentAuthor: item.parentAuthor, parentAuthor: item.parentAuthor,
isParentBlocked: item.isParentBlocked, isParentBlocked: item.isParentBlocked,
isParentNotFound: item.isParentNotFound,
} }
return feedPostSliceItem return feedPostSliceItem
}), }),
+18 -1
View File
@@ -137,6 +137,7 @@ export function sortThread(
opts: UsePreferencesQueryResponse['threadViewPrefs'], opts: UsePreferencesQueryResponse['threadViewPrefs'],
modCache: ThreadModerationCache, modCache: ThreadModerationCache,
currentDid: string | undefined, currentDid: string | undefined,
justPostedUris: Set<string>,
): ThreadNode { ): ThreadNode {
if (node.type !== 'post') { if (node.type !== 'post') {
return node return node
@@ -150,6 +151,20 @@ export function sortThread(
return -1 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 aIsByOp = a.post.author.did === node.post?.author.did
const bIsByOp = b.post.author.did === node.post?.author.did const bIsByOp = b.post.author.did === node.post?.author.did
if (aIsByOp && bIsByOp) { if (aIsByOp && bIsByOp) {
@@ -206,7 +221,9 @@ export function sortThread(
} }
return b.post.indexedAt.localeCompare(a.post.indexedAt) 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 return node
} }
+2 -2
View File
@@ -37,14 +37,14 @@ export function usePreferencesQuery() {
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
queryKey: preferencesQueryKey, queryKey: preferencesQueryKey,
queryFn: async () => { queryFn: async () => {
if (agent.session?.did === undefined) { if (!agent.did) {
return DEFAULT_LOGGED_OUT_PREFERENCES return DEFAULT_LOGGED_OUT_PREFERENCES
} else { } else {
const res = await agent.getPreferences() const res = await agent.getPreferences()
// save to local storage to ensure there are labels on initial requests // save to local storage to ensure there are labels on initial requests
saveLabelers( saveLabelers(
agent.session.did, agent.did,
res.moderationPrefs.labelers.map(l => l.did), res.moderationPrefs.labelers.map(l => l.did),
) )
+4 -12
View File
@@ -40,18 +40,10 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
pages: data.pages.map(page => { pages: data.pages.map(page => {
return { return {
...page, ...page,
lists: page.lists lists: page.lists.filter(list => {
/* const decision = moderateUserList(list, moderationOpts!)
* Starter packs use a reference list, which we do not want to return !decision.ui('contentList').filter
* 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
}),
} }
}), }),
} }
+15
View File
@@ -222,6 +222,7 @@ export function useProfileFollowMutationQueue(
logContext: LogEvents['profile:follow']['logContext'] & logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'], LogEvents['profile:unfollow']['logContext'],
) { ) {
const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const did = profile.did const did = profile.did
const initialFollowingUri = profile.viewer?.following const initialFollowingUri = profile.viewer?.following
@@ -253,6 +254,20 @@ export function useProfileFollowMutationQueue(
updateProfileShadow(queryClient, did, { updateProfileShadow(queryClient, did, {
followingUri: finalFollowingUri, 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)
})
}
}, },
}) })
+140 -45
View File
@@ -27,7 +27,7 @@ describe('session', () => {
`) `)
const agent = new BskyAgent({service: 'https://alice.com'}) const agent = new BskyAgent({service: 'https://alice.com'})
agent.session = { agent.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -76,7 +76,7 @@ describe('session', () => {
state = run(state, [ state = run(state, [
{ {
type: 'logged-out', type: 'logged-out-every-account',
}, },
]) ])
// Should keep the account but clear out the tokens. // Should keep the account but clear out the tokens.
@@ -118,7 +118,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -166,7 +166,7 @@ describe('session', () => {
`) `)
const agent2 = new BskyAgent({service: 'https://bob.com'}) const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = { agent2.sessionManager.session = {
active: true, active: true,
did: 'bob-did', did: 'bob-did',
handle: 'bob.test', handle: 'bob.test',
@@ -230,7 +230,7 @@ describe('session', () => {
`) `)
const agent3 = new BskyAgent({service: 'https://alice.com'}) const agent3 = new BskyAgent({service: 'https://alice.com'})
agent3.session = { agent3.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice-updated.test', handle: 'alice-updated.test',
@@ -294,7 +294,7 @@ describe('session', () => {
`) `)
const agent4 = new BskyAgent({service: 'https://jay.com'}) const agent4 = new BskyAgent({service: 'https://jay.com'})
agent4.session = { agent4.sessionManager.session = {
active: true, active: true,
did: 'jay-did', did: 'jay-did',
handle: 'jay.test', handle: 'jay.test',
@@ -372,7 +372,7 @@ describe('session', () => {
state = run(state, [ state = run(state, [
{ {
// Log everyone out. // Log everyone out.
type: 'logged-out', type: 'logged-out-every-account',
}, },
]) ])
expect(state.accounts.length).toBe(3) expect(state.accounts.length).toBe(3)
@@ -445,7 +445,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -466,7 +466,7 @@ describe('session', () => {
state = run(state, [ state = run(state, [
{ {
type: 'logged-out', type: 'logged-out-every-account',
}, },
]) ])
expect(state.accounts.length).toBe(1) expect(state.accounts.length).toBe(1)
@@ -502,7 +502,7 @@ describe('session', () => {
`) `)
const agent2 = new BskyAgent({service: 'https://alice.com'}) const agent2 = new BskyAgent({service: 'https://alice.com'})
agent2.session = { agent2.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -553,7 +553,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -598,7 +598,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -606,7 +606,7 @@ describe('session', () => {
refreshJwt: 'alice-refresh-jwt-1', refreshJwt: 'alice-refresh-jwt-1',
} }
const agent2 = new BskyAgent({service: 'https://bob.com'}) const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = { agent2.sessionManager.session = {
active: true, active: true,
did: 'bob-did', did: 'bob-did',
handle: 'bob.test', handle: 'bob.test',
@@ -674,11 +674,108 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe(undefined) 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', () => { it('updates stored account with refreshed tokens', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -695,7 +792,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1) expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('alice-did') expect(state.currentAgentState.did).toBe('alice-did')
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice-updated.test', handle: 'alice-updated.test',
@@ -748,7 +845,7 @@ describe('session', () => {
} }
`) `)
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice-updated.test', handle: 'alice-updated.test',
@@ -801,7 +898,7 @@ describe('session', () => {
} }
`) `)
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice-updated.test', handle: 'alice-updated.test',
@@ -859,7 +956,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -876,7 +973,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1) expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('alice-did') expect(state.currentAgentState.did).toBe('alice-did')
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice-updated.test', handle: 'alice-updated.test',
@@ -907,7 +1004,7 @@ describe('session', () => {
]) ])
expect(lastState === state).toBe(true) expect(lastState === state).toBe(true)
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice-updated.test', handle: 'alice-updated.test',
@@ -931,7 +1028,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -940,7 +1037,7 @@ describe('session', () => {
} }
const agent2 = new BskyAgent({service: 'https://bob.com'}) const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = { agent2.sessionManager.session = {
active: true, active: true,
did: 'bob-did', did: 'bob-did',
handle: 'bob.test', handle: 'bob.test',
@@ -965,7 +1062,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(2) expect(state.accounts.length).toBe(2)
expect(state.currentAgentState.did).toBe('bob-did') expect(state.currentAgentState.did).toBe('bob-did')
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice-updated.test', handle: 'alice-updated.test',
@@ -1032,7 +1129,7 @@ describe('session', () => {
} }
`) `)
agent2.session = { agent2.sessionManager.session = {
active: true, active: true,
did: 'bob-did', did: 'bob-did',
handle: 'bob-updated.test', handle: 'bob-updated.test',
@@ -1099,7 +1196,7 @@ describe('session', () => {
// Ignore other events for inactive agent. // Ignore other events for inactive agent.
const lastState = state const lastState = state
agent1.session = undefined agent1.sessionManager.session = undefined
state = run(state, [ state = run(state, [
{ {
type: 'received-agent-event', type: 'received-agent-event',
@@ -1126,7 +1223,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -1135,7 +1232,7 @@ describe('session', () => {
} }
const agent2 = new BskyAgent({service: 'https://bob.com'}) const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = { agent2.sessionManager.session = {
active: true, active: true,
did: 'bob-did', did: 'bob-did',
handle: 'bob.test', handle: 'bob.test',
@@ -1162,7 +1259,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1) expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('bob-did') expect(state.currentAgentState.did).toBe('bob-did')
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -1184,11 +1281,11 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('bob-did') expect(state.currentAgentState.did).toBe('bob-did')
}) })
it('does soft logout on network error', () => { it('ignores network errors', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -1206,7 +1303,7 @@ describe('session', () => {
expect(state.accounts.length).toBe(1) expect(state.accounts.length).toBe(1)
expect(state.currentAgentState.did).toBe('alice-did') expect(state.currentAgentState.did).toBe('alice-did')
agent1.session = undefined agent1.sessionManager.session = undefined
state = run(state, [ state = run(state, [
{ {
type: 'received-agent-event', type: 'received-agent-event',
@@ -1217,11 +1314,9 @@ describe('session', () => {
}, },
]) ])
expect(state.accounts.length).toBe(1) 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].accessJwt).toBe('alice-access-jwt-1')
expect(state.accounts[0].refreshJwt).toBe('alice-refresh-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(` expect(printState(state)).toMatchInlineSnapshot(`
{ {
"accounts": [ "accounts": [
@@ -1242,9 +1337,9 @@ describe('session', () => {
], ],
"currentAgentState": { "currentAgentState": {
"agent": { "agent": {
"service": "https://public.api.bsky.app/", "service": "https://alice.com/",
}, },
"did": undefined, "did": "alice-did",
}, },
"needsPersist": true, "needsPersist": true,
} }
@@ -1255,7 +1350,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -1273,7 +1368,7 @@ describe('session', () => {
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.currentAgentState.did).toBe('alice-did') expect(state.currentAgentState.did).toBe('alice-did')
agent1.session = undefined agent1.sessionManager.session = undefined
state = run(state, [ state = run(state, [
{ {
type: 'received-agent-event', type: 'received-agent-event',
@@ -1320,7 +1415,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -1338,7 +1433,7 @@ describe('session', () => {
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1') expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
expect(state.currentAgentState.did).toBe('alice-did') expect(state.currentAgentState.did).toBe('alice-did')
agent1.session = undefined agent1.sessionManager.session = undefined
state = run(state, [ state = run(state, [
{ {
type: 'received-agent-event', type: 'received-agent-event',
@@ -1385,7 +1480,7 @@ describe('session', () => {
let state = getInitialState([]) let state = getInitialState([])
const agent1 = new BskyAgent({service: 'https://alice.com'}) const agent1 = new BskyAgent({service: 'https://alice.com'})
agent1.session = { agent1.sessionManager.session = {
active: true, active: true,
did: 'alice-did', did: 'alice-did',
handle: 'alice.test', handle: 'alice.test',
@@ -1393,7 +1488,7 @@ describe('session', () => {
refreshJwt: 'alice-refresh-jwt-1', refreshJwt: 'alice-refresh-jwt-1',
} }
const agent2 = new BskyAgent({service: 'https://bob.com'}) const agent2 = new BskyAgent({service: 'https://bob.com'})
agent2.session = { agent2.sessionManager.session = {
active: true, active: true,
did: 'bob-did', did: 'bob-did',
handle: 'bob.test', handle: 'bob.test',
@@ -1416,7 +1511,7 @@ describe('session', () => {
expect(state.currentAgentState.did).toBe('bob-did') expect(state.currentAgentState.did).toBe('bob-did')
const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'}) const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'})
anotherTabAgent1.session = { anotherTabAgent1.sessionManager.session = {
active: true, active: true,
did: 'jay-did', did: 'jay-did',
handle: 'jay.test', handle: 'jay.test',
@@ -1424,7 +1519,7 @@ describe('session', () => {
refreshJwt: 'jay-refresh-jwt-1', refreshJwt: 'jay-refresh-jwt-1',
} }
const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'}) const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'})
anotherTabAgent2.session = { anotherTabAgent2.sessionManager.session = {
active: true, active: true,
did: 'bob-did', did: 'bob-did',
handle: 'bob.test', handle: 'bob.test',
@@ -1492,7 +1587,7 @@ describe('session', () => {
`) `)
const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'}) const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'})
anotherTabAgent3.session = { anotherTabAgent3.sessionManager.session = {
active: true, active: true,
did: 'clarence-did', did: 'clarence-did',
handle: 'clarence.test', handle: 'clarence.test',
+84 -35
View File
@@ -12,6 +12,7 @@ import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger' import {logger} from '#/logger'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders' import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
import {addSessionErrorLog} from './logging' import {addSessionErrorLog} from './logging'
import { import {
configureModerationForAccount, configureModerationForAccount,
@@ -22,7 +23,7 @@ import {isSessionExpired, isSignupQueued} from './util'
export function createPublicAgent() { export function createPublicAgent() {
configureModerationForGuest() // Side effect but only relevant for tests 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( export async function createAgentAndResume(
@@ -33,9 +34,9 @@ export async function createAgentAndResume(
event: AtpSessionEvent, event: AtpSessionEvent,
) => void, ) => void,
) { ) {
const agent = new BskyAgent({service: storedAccount.service}) const agent = new BskyAppAgent({service: storedAccount.service})
if (storedAccount.pdsUrl) { 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 gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
const moderation = configureModerationForAccount(agent, storedAccount) const moderation = configureModerationForAccount(agent, storedAccount)
@@ -43,9 +44,8 @@ export async function createAgentAndResume(
if (isSessionExpired(storedAccount)) { if (isSessionExpired(storedAccount)) {
await networkRetry(1, () => agent.resumeSession(prevSession)) await networkRetry(1, () => agent.resumeSession(prevSession))
} else { } else {
agent.session = prevSession agent.sessionManager.session = prevSession
if (!storedAccount.signupQueued) { if (!storedAccount.signupQueued) {
// Intentionally not awaited to unblock the UI:
networkRetry(3, () => agent.resumeSession(prevSession)).catch( networkRetry(3, () => agent.resumeSession(prevSession)).catch(
(e: any) => { (e: any) => {
logger.error(`networkRetry failed to resume session`, { 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( export async function createAgentAndLogin(
@@ -81,13 +81,13 @@ export async function createAgentAndLogin(
event: AtpSessionEvent, event: AtpSessionEvent,
) => void, ) => void,
) { ) {
const agent = new BskyAgent({service}) const agent = new BskyAppAgent({service})
await agent.login({identifier, password, authFactorToken}) await agent.login({identifier, password, authFactorToken})
const account = agentToSessionAccountOrThrow(agent) const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates') const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const moderation = configureModerationForAccount(agent, account) const moderation = configureModerationForAccount(agent, account)
return prepareAgent(agent, moderation, gates, onSessionChange) return agent.prepare(gates, moderation, onSessionChange)
} }
export async function createAgentAndCreateAccount( export async function createAgentAndCreateAccount(
@@ -116,7 +116,7 @@ export async function createAgentAndCreateAccount(
event: AtpSessionEvent, event: AtpSessionEvent,
) => void, ) => void,
) { ) {
const agent = new BskyAgent({service}) const agent = new BskyAppAgent({service})
await agent.createAccount({ await agent.createAccount({
email, email,
password, password,
@@ -174,32 +174,7 @@ export async function createAgentAndCreateAccount(
logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`}) logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`})
} }
return prepareAgent(agent, gates, moderation, onSessionChange) return agent.prepare(gates, moderation, onSessionChange)
}
async function prepareAgent(
agent: BskyAgent,
// Not awaited in the calling code so we can delay blocking on them.
gates: Promise<void>,
moderation: Promise<void>,
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}
} }
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
@@ -251,3 +226,77 @@ export function sessionAccountToSession(
status: account.status, 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<void>,
moderation: Promise<void>,
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}
+36 -10
View File
@@ -11,6 +11,7 @@ import {IS_DEV} from '#/env'
import {emitSessionDropped} from '../events' import {emitSessionDropped} from '../events'
import { import {
agentToSessionAccount, agentToSessionAccount,
BskyAppAgent,
createAgentAndCreateAccount, createAgentAndCreateAccount,
createAgentAndLogin, createAgentAndLogin,
createAgentAndResume, createAgentAndResume,
@@ -34,7 +35,8 @@ const AgentContext = React.createContext<BskyAgent | null>(null)
const ApiContext = React.createContext<SessionApiContext>({ const ApiContext = React.createContext<SessionApiContext>({
createAccount: async () => {}, createAccount: async () => {},
login: async () => {}, login: async () => {},
logout: async () => {}, logoutCurrentAccount: async () => {},
logoutEveryAccount: async () => {},
resumeSession: async () => {}, resumeSession: async () => {},
removeAccount: () => {}, removeAccount: () => {},
}) })
@@ -114,14 +116,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[onAgentSessionChange, cancelPendingTask], [onAgentSessionChange, cancelPendingTask],
) )
const logout = React.useCallback<SessionApiContext['logout']>( const logoutCurrentAccount = React.useCallback<
SessionApiContext['logoutEveryAccount']
>(
logContext => { logContext => {
addSessionDebugLog({type: 'method:start', method: 'logout'}) addSessionDebugLog({type: 'method:start', method: 'logout'})
cancelPendingTask() cancelPendingTask()
dispatch({ 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'}) addSessionDebugLog({type: 'method:end', method: 'logout'})
}, },
[cancelPendingTask], [cancelPendingTask],
@@ -202,7 +221,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} else { } else {
const agent = state.currentAgentState.agent as BskyAgent const agent = state.currentAgentState.agent as BskyAgent
const prevSession = agent.session const prevSession = agent.session
agent.session = sessionAccountToSession(syncedAccount) agent.sessionManager.session = sessionAccountToSession(syncedAccount)
addSessionDebugLog({ addSessionDebugLog({
type: 'agent:patch', type: 'agent:patch',
agent, agent,
@@ -229,17 +248,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({ () => ({
createAccount, createAccount,
login, login,
logout, logoutCurrentAccount,
logoutEveryAccount,
resumeSession, resumeSession,
removeAccount, removeAccount,
}), }),
[createAccount, login, logout, resumeSession, removeAccount], [
createAccount,
login,
logoutCurrentAccount,
logoutEveryAccount,
resumeSession,
removeAccount,
],
) )
// @ts-ignore // @ts-ignore
if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent 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) const currentAgentRef = React.useRef(agent)
React.useEffect(() => { React.useEffect(() => {
if (currentAgentRef.current !== agent) { if (currentAgentRef.current !== agent) {
@@ -249,8 +276,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent}) addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
// We never reuse agents so let's fully neutralize the previous one. // We never reuse agents so let's fully neutralize the previous one.
// This ensures it won't try to consume any refresh tokens. // This ensures it won't try to consume any refresh tokens.
prevAgent.session = undefined prevAgent.dispose()
prevAgent.setPersistSessionHandler(undefined)
} }
}, [agent]) }, [agent])
+1 -1
View File
@@ -56,7 +56,7 @@ type Log =
type: 'agent:patch' type: 'agent:patch'
agent: object agent: object
prevSession: AtpSessionData | undefined prevSession: AtpSessionData | undefined
nextSession: AtpSessionData nextSession: AtpSessionData | undefined
} }
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer { export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
+23 -8
View File
@@ -42,7 +42,10 @@ export type Action =
accountDid: string accountDid: string
} }
| { | {
type: 'logged-out' type: 'logged-out-current-account'
}
| {
type: 'logged-out-every-account'
} }
| { | {
type: 'synced-accounts' type: 'synced-accounts'
@@ -79,12 +82,8 @@ let reducer = (state: State, action: Action): State => {
return state return state
} }
if (sessionEvent === 'network-error') { if (sessionEvent === 'network-error') {
// Don't change stored accounts but kick to the choose account screen. // Assume it's transient.
return { return state
accounts: state.accounts,
currentAgentState: createPublicAgentState(),
needsPersist: true,
}
} }
const existingAccount = state.accounts.find(a => a.did === accountDid) const existingAccount = state.accounts.find(a => a.did === accountDid)
if ( if (
@@ -142,7 +141,23 @@ let reducer = (state: State, action: Action): State => {
needsPersist: true, 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 { return {
accounts: state.accounts.map(a => ({ accounts: state.accounts.map(a => ({
...a, ...a,
+6 -6
View File
@@ -29,12 +29,12 @@ export type SessionApiContext = {
}, },
logContext: LogEvents['account:loggedIn']['logContext'], logContext: LogEvents['account:loggedIn']['logContext'],
) => Promise<void> ) => Promise<void>
/** logoutCurrentAccount: (
* A full logout. Clears the `currentAccount` from session, AND removes logContext: LogEvents['account:loggedOut']['logContext'],
* access tokens from all accounts, so that returning as any user will ) => void
* require a full login. logoutEveryAccount: (
*/ logContext: LogEvents['account:loggedOut']['logContext'],
logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void ) => void
resumeSession: (account: SessionAccount) => Promise<void> resumeSession: (account: SessionAccount) => Promise<void>
removeAccount: (account: SessionAccount) => void removeAccount: (account: SessionAccount) => void
} }
+3 -2
View File
@@ -1,10 +1,11 @@
import React from 'react' import React from 'react'
import { import {
AppBskyActorDefs,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyRichtextFacet, AppBskyRichtextFacet,
ModerationDecision, ModerationDecision,
AppBskyActorDefs,
} from '@atproto/api' } from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export interface ComposerOptsPostRef { export interface ComposerOptsPostRef {
@@ -31,7 +32,7 @@ export interface ComposerOptsQuote {
} }
export interface ComposerOpts { export interface ComposerOpts {
replyTo?: ComposerOptsPostRef replyTo?: ComposerOptsPostRef
onPost?: () => void onPost?: (postUri: string | undefined) => void
quote?: ComposerOptsQuote quote?: ComposerOptsQuote
mention?: string // handle of user to mention mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void openPicker?: (pos: DOMRect | undefined) => void
+1 -6
View File
@@ -2,7 +2,6 @@ import React from 'react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import { import {
ProgressGuideToast, ProgressGuideToast,
ProgressGuideToastRef, ProgressGuideToastRef,
@@ -61,7 +60,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const {mutateAsync, variables, isPending} = const {mutateAsync, variables, isPending} =
useSetActiveProgressGuideMutation() useSetActiveProgressGuideMutation()
const gate = useGate()
const activeProgressGuide = ( const activeProgressGuide = (
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
@@ -89,9 +87,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const controls = React.useMemo(() => { const controls = React.useMemo(() => {
return { return {
startProgressGuide(guide: ProgressGuideName) { startProgressGuide(guide: ProgressGuideName) {
if (!gate('new_user_progress_guide')) {
return
}
if (guide === 'like-10-and-follow-7') { if (guide === 'like-10-and-follow-7') {
const guideObj = { const guideObj = {
guide: 'like-10-and-follow-7', guide: 'like-10-and-follow-7',
@@ -148,7 +143,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
mutateAsync(guide?.isComplete ? undefined : guide) mutateAsync(guide?.isComplete ? undefined : guide)
}, },
} }
}, [activeProgressGuide, mutateAsync, gate, setLocalGuideState]) }, [activeProgressGuide, mutateAsync, setLocalGuideState])
return ( return (
<ProgressGuideContext.Provider value={localGuideState}> <ProgressGuideContext.Provider value={localGuideState}>
+13
View File
@@ -2,6 +2,7 @@ import React from 'react'
const LIKE_WINDOW = 100 const LIKE_WINDOW = 100
const FOLLOW_WINDOW = 100 const FOLLOW_WINDOW = 100
const FOLLOW_SUGGESTION_WINDOW = 100
const SEEN_WINDOW = 100 const SEEN_WINDOW = 100
export type SeenPost = { export type SeenPost = {
@@ -22,6 +23,10 @@ export type UserActionHistory = {
* The last 100 DIDs the user has followed * The last 100 DIDs the user has followed
*/ */
follows: string[] 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 * The last 100 post URIs the user has seen from the Discover feed only
*/ */
@@ -31,6 +36,7 @@ export type UserActionHistory = {
const userActionHistory: UserActionHistory = { const userActionHistory: UserActionHistory = {
likes: [], likes: [],
follows: [], follows: [],
followSuggestions: [],
seen: [], seen: [],
} }
@@ -58,6 +64,13 @@ export function follow(dids: string[]) {
.concat(dids) .concat(dids)
.slice(-FOLLOW_WINDOW) .slice(-FOLLOW_WINDOW)
} }
export function followSuggestion(dids: string[]) {
userActionHistory.followSuggestions = userActionHistory.followSuggestions
.concat(dids)
.slice(-FOLLOW_SUGGESTION_WINDOW)
}
export function unfollow(dids: string[]) { export function unfollow(dids: string[]) {
userActionHistory.follows = userActionHistory.follows.filter( userActionHistory.follows = userActionHistory.follows.filter(
uri => !dids.includes(uri), uri => !dids.includes(uri),
+2 -48
View File
@@ -1,25 +1,20 @@
import React from 'react' import React from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
import {isIOS, isNative} from '#/platform/detection' import {isIOS} from '#/platform/detection'
import {useSession} from '#/state/session'
import { import {
useLoggedOutView, useLoggedOutView,
useLoggedOutViewControls, useLoggedOutViewControls,
} from '#/state/shell/logged-out' } from '#/state/shell/logged-out'
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' 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 {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {Text} from '#/view/com/util/text/Text'
import {Login} from '#/screens/Login' import {Login} from '#/screens/Login'
import {Signup} from '#/screens/Signup' import {Signup} from '#/screens/Signup'
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen' import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
@@ -34,7 +29,6 @@ enum ScreenState {
export {ScreenState as LoggedOutScreenState} export {ScreenState as LoggedOutScreenState}
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const {hasSession} = useSession()
const {_} = useLingui() const {_} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
@@ -52,10 +46,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
} }
}) })
const {clearRequestedAccount} = useLoggedOutViewControls() const {clearRequestedAccount} = useLoggedOutViewControls()
const navigation = useNavigation<NavigationProp>()
const gate = useGate()
const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount
React.useEffect(() => { React.useEffect(() => {
screen('Login') screen('Login')
setMinimalShellMode(true) setMinimalShellMode(true)
@@ -68,10 +59,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
clearRequestedAccount() clearRequestedAccount()
}, [clearRequestedAccount, onDismiss]) }, [clearRequestedAccount, onDismiss])
const onPressSearch = React.useCallback(() => {
navigation.navigate(`SearchTab`)
}, [navigation])
return ( return (
<View testID="noSessionView" style={[s.hContentRegion, pal.view]}> <View testID="noSessionView" style={[s.hContentRegion, pal.view]}>
<ErrorBoundary> <ErrorBoundary>
@@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
}} }}
/> />
</Pressable> </Pressable>
) : isNative &&
!hasSession &&
isFirstScreen &&
!gate('native_pwi_disabled') ? (
<Pressable
accessibilityHint={_(msg`Search for users`)}
accessibilityLabel={_(msg`Search for users`)}
accessibilityRole="button"
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 4,
position: 'absolute',
top: 20,
right: 20,
paddingHorizontal: 16,
paddingVertical: 8,
zIndex: 100,
backgroundColor: pal.btn.backgroundColor,
borderRadius: 100,
}}
onPress={onPressSearch}>
<Text type="lg-bold" style={[pal.text]}>
<Trans>Search</Trans>{' '}
</Text>
<FontAwesomeIcon
icon="search"
size={16}
style={{
color: String(pal.text.color),
}}
/>
</Pressable>
) : null} ) : null}
{screenState === ScreenState.S_StarterPack ? ( {screenState === ScreenState.S_StarterPack ? (
+1 -1
View File
@@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({
emitPostCreated() emitPostCreated()
} }
setLangPrefs.savePostLanguageToHistory() setLangPrefs.savePostLanguageToHistory()
onPost?.() onPost?.(postUri)
onClose() onClose()
Toast.show( Toast.show(
replyTo replyTo
+1 -1
View File
@@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'} type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
/> />
<View style={styles.replyToPost}> <View style={styles.replyToPost}>
<Text type="xl-medium" style={t.atoms.text}> <Text type="xl-medium" style={t.atoms.text} numberOfLines={1}>
{sanitizeDisplayName( {sanitizeDisplayName(
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
)} )}
+44 -56
View File
@@ -85,71 +85,59 @@ export const TextInput = forwardRef(function TextInputImpl(
const pastSuggestedUris = useRef(new Set<string>()) const pastSuggestedUris = useRef(new Set<string>())
const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>()) const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
const onChangeText = useCallback( const onChangeText = useCallback(
(newText: string) => { async (newText: string) => {
/* const mayBePaste = newText.length > prevLength.current + 1
* 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
const newRt = new RichText({text: newText}) const newRt = new RichText({text: newText})
newRt.detectFacetsWithoutResolution() newRt.detectFacetsWithoutResolution()
setRichText(newRt) setRichText(newRt)
const prefix = getMentionAt( const prefix = getMentionAt(
newText, newText,
textInputSelection.current?.start || 0, textInputSelection.current?.start || 0,
) )
if (prefix) { if (prefix) {
setAutocompletePrefix(prefix.value) setAutocompletePrefix(prefix.value)
} else if (autocompletePrefix) { } else if (autocompletePrefix) {
setAutocompletePrefix('') setAutocompletePrefix('')
} }
const nextDetectedUris = new Map<string, LinkFacetMatch>() const nextDetectedUris = new Map<string, LinkFacetMatch>()
if (newRt.facets) { if (newRt.facets) {
for (const facet of newRt.facets) { for (const facet of newRt.facets) {
for (const feature of facet.features) { for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) { if (AppBskyRichtextFacet.isLink(feature)) {
if (isUriImage(feature.uri)) { if (isUriImage(feature.uri)) {
const res = await downloadAndResize({ const res = await downloadAndResize({
uri: feature.uri, uri: feature.uri,
width: POST_IMG_MAX.width, width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height, height: POST_IMG_MAX.height,
mode: 'contain', mode: 'contain',
maxSize: POST_IMG_MAX.size, maxSize: POST_IMG_MAX.size,
timeout: 15e3, timeout: 15e3,
}) })
if (res !== undefined) { if (res !== undefined) {
onPhotoPasted(res.path) onPhotoPasted(res.path)
}
} else {
nextDetectedUris.set(feature.uri, {facet, rt: newRt})
} }
} else {
nextDetectedUris.set(feature.uri, {facet, rt: newRt})
} }
} }
} }
} }
const suggestedUri = suggestLinkCardUri( }
mayBePaste, const suggestedUri = suggestLinkCardUri(
nextDetectedUris, mayBePaste,
prevDetectedUris.current, nextDetectedUris,
pastSuggestedUris.current, prevDetectedUris.current,
) pastSuggestedUris.current,
prevDetectedUris.current = nextDetectedUris )
if (suggestedUri) { prevDetectedUris.current = nextDetectedUris
onNewLink(suggestedUri) if (suggestedUri) {
} onNewLink(suggestedUri)
prevLength.current = newText.length }
}, 1) prevLength.current = newText.length
}, },
[setRichText, autocompletePrefix, onPhotoPasted, onNewLink], [setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
) )
@@ -16,8 +16,8 @@ export function VideoPreview({
}) { }) {
const player = useVideoPlayer(video.uri, player => { const player = useVideoPlayer(video.uri, player => {
player.loop = true player.loop = true
player.muted = true
player.play() player.play()
player.volume = 0
}) })
return ( return (
@@ -3,6 +3,7 @@ import {View} from 'react-native'
// @ts-expect-error no type definition // @ts-expect-error no type definition
import ProgressPie from 'react-native-progress/Pie' import ProgressPie from 'react-native-progress/Pie'
import {ImagePickerAsset} from 'expo-image-picker' import {ImagePickerAsset} from 'expo-image-picker'
import {Trans} from '@lingui/macro'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -46,7 +47,9 @@ export function VideoTranscodeProgress({
color={t.atoms.text.color} color={t.atoms.text.color}
progress={progress} progress={progress}
/> />
<Text>Compressing...</Text> <Text>
<Trans>Compressing...</Trans>
</Text>
</View> </View>
</View> </View>
) )
-183
View File
@@ -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<ViewStyle>
}) => {
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 (
<Link
testID={testID}
style={[
styles.outer,
pal.border,
noBorder && styles.outerNoBorder,
!noBg && pal.view,
style,
]}
href={makeProfileLink(list.creator, 'lists', rkey)}
title={list.name}
asAnchor
anchorNoUnderline>
<View style={styles.layout}>
<View style={styles.layoutAvi}>
<UserAvatar type="list" size={40} avatar={list.avatar} />
</View>
<View style={styles.layoutContent}>
<Text
type="lg"
style={[s.bold, pal.text]}
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(list.name)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>User list by you</Trans>
) : (
<Trans>
User list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
{list.purpose === 'app.bsky.graph.defs#modlist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>Moderation list by you</Trans>
) : (
<Trans>
Moderation list by {sanitizeHandle(list.creator.handle, '@')}
</Trans>
))}
</Text>
<View style={s.flexRow}>
{list.viewer?.muted ? (
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
<Trans>Muted</Trans>
</Text>
</View>
) : null}
{list.viewer?.blocked ? (
<View style={[s.mt5, pal.btn, styles.pill]}>
<Text type="xs" style={pal.text}>
<Trans>Blocked</Trans>
</Text>
</View>
) : null}
</View>
</View>
{renderButton ? (
<View style={styles.layoutButton}>{renderButton()}</View>
) : undefined}
</View>
{descriptionRichText ? (
<View style={styles.details}>
<RichTextCom
style={[a.flex_1]}
numberOfLines={20}
value={descriptionRichText}
/>
</View>
) : undefined}
</Link>
)
}
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,
},
})
+19 -21
View File
@@ -4,7 +4,6 @@ import {
FlatList as RNFlatList, FlatList as RNFlatList,
RefreshControl, RefreshControl,
StyleProp, StyleProp,
StyleSheet,
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
@@ -18,10 +17,13 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
import {useAnalytics} from 'lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles' 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 {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 {ErrorMessage} from '../util/error/ErrorMessage'
import {List} from '../util/List' import {List} from '../util/List'
import {ListCard} from './ListCard'
const LOADING = {_reactKey: '__loading__'} const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'} const EMPTY = {_reactKey: '__empty__'}
@@ -41,8 +43,10 @@ export function MyLists({
testID?: string testID?: string
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const t = useTheme()
const {track} = useAnalytics() const {track} = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const moderationOpts = useModerationOpts()
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
const {data, isFetching, isFetched, isError, error, refetch} = const {data, isFetching, isFetched, isError, error, refetch} =
useMyListsQuery(filter) useMyListsQuery(filter)
@@ -53,7 +57,7 @@ export function MyLists({
if (isError && isEmpty) { if (isError && isEmpty) {
items = items.concat([ERROR_ITEM]) items = items.concat([ERROR_ITEM])
} }
if (!isFetched && isFetching) { if ((!isFetched && isFetching) || !moderationOpts) {
items = items.concat([LOADING]) items = items.concat([LOADING])
} else if (isEmpty) { } else if (isEmpty) {
items = items.concat([EMPTY]) items = items.concat([EMPTY])
@@ -61,7 +65,7 @@ export function MyLists({
items = items.concat(data) items = items.concat(data)
} }
return items return items
}, [isError, isEmpty, isFetched, isFetching, data]) }, [isError, isEmpty, isFetched, isFetching, moderationOpts, data])
// events // events
// = // =
@@ -85,7 +89,6 @@ export function MyLists({
if (item === EMPTY) { if (item === EMPTY) {
return ( return (
<EmptyState <EmptyState
key={item._reactKey}
icon="list-ul" icon="list-ul"
message={_(msg`You have no lists.`)} message={_(msg`You have no lists.`)}
testID="listsEmpty" testID="listsEmpty"
@@ -94,14 +97,13 @@ export function MyLists({
} else if (item === ERROR_ITEM) { } else if (item === ERROR_ITEM) {
return ( return (
<ErrorMessage <ErrorMessage
key={item._reactKey}
message={cleanError(error)} message={cleanError(error)}
onPressTryAgain={onRefresh} onPressTryAgain={onRefresh}
/> />
) )
} else if (item === LOADING) { } else if (item === LOADING) {
return ( return (
<View key={item._reactKey} style={{padding: 20}}> <View style={{padding: 20}}>
<ActivityIndicator /> <ActivityIndicator />
</View> </View>
) )
@@ -109,15 +111,18 @@ export function MyLists({
return renderItem ? ( return renderItem ? (
renderItem(item, index) renderItem(item, index)
) : ( ) : (
<ListCard <View
key={item.uri} style={[
list={item} (index !== 0 || isWeb) && a.border_t,
testID={`list-${item.name}`} t.atoms.border_contrast_low,
style={styles.item} a.px_lg,
/> a.py_lg,
]}>
<ListCard.Default view={item} />
</View>
) )
}, },
[error, onRefresh, renderItem, _], [renderItem, t.atoms.border_contrast_low, _, error, onRefresh],
) )
if (inline) { if (inline) {
@@ -166,10 +171,3 @@ export function MyLists({
) )
} }
} }
const styles = StyleSheet.create({
item: {
paddingHorizontal: 18,
paddingVertical: 4,
},
})
+3 -7
View File
@@ -25,7 +25,6 @@ import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useGate} from '#/lib/statsig/statsig'
import {parseTenorGif} from '#/lib/strings/embed-player' import {parseTenorGif} from '#/lib/strings/embed-player'
import {logger} from '#/logger' import {logger} from '#/logger'
import {FeedNotification} from '#/state/queries/notifications/feed' import {FeedNotification} from '#/state/queries/notifications/feed'
@@ -87,7 +86,6 @@ let FeedItem = ({
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const gate = useGate()
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
const itemHref = useMemo(() => { const itemHref = useMemo(() => {
if (item.type === 'post-like' || item.type === 'repost') { 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`) action = _(msg`followed you back`)
} else { } else {
action = _(msg`followed you`) action = _(msg`followed you`)
@@ -255,6 +253,7 @@ let FeedItem = ({
borderColor: pal.colors.unreadNotifBorder, borderColor: pal.colors.unreadNotifBorder,
}, },
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth}, {borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
a.overflow_hidden,
]} ]}
href={itemHref} href={itemHref}
noFeedback noFeedback
@@ -547,7 +546,7 @@ function ExpandedAuthorsList({
}, [heightInterp, visible]) }, [heightInterp, visible])
return ( return (
<Animated.View style={[heightStyle, styles.overflowHidden]}> <Animated.View style={[a.overflow_hidden, heightStyle]}>
{visible && {visible &&
authors.map(author => ( authors.map(author => (
<NewLink <NewLink
@@ -643,9 +642,6 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
overflowHidden: {
overflow: 'hidden',
},
pointer: isWeb pointer: isWeb
? { ? {
// @ts-ignore web only // @ts-ignore web only
@@ -193,13 +193,11 @@ const styles = StyleSheet.create({
tabBarContainer: { tabBarContainer: {
// @ts-ignore web-only // @ts-ignore web-only
position: 'sticky', position: 'sticky',
overflow: 'hidden',
top: 0, top: 0,
zIndex: 1, zIndex: 1,
}, },
tabBarContainerDesktop: { tabBarContainerDesktop: {
marginHorizontal: 'auto', marginHorizontal: 'auto',
paddingHorizontal: 2,
width: 600, width: 600,
borderLeftWidth: 1, borderLeftWidth: 1,
borderRightWidth: 1, borderRightWidth: 1,
+1 -1
View File
@@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({
left: 0, left: 0,
right: 0, right: 0,
top: '100%', top: '100%',
borderBottomWidth: 1, borderBottomWidth: StyleSheet.hairlineWidth,
}, },
}) })
+86 -23
View File
@@ -1,11 +1,14 @@
import React, {useEffect, useRef} from 'react' import React, {useRef} from 'react'
import {useWindowDimensions, View} from 'react-native' import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {runOnJS} from 'react-native-reanimated' import {runOnJS} from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyFeedDefs} from '@atproto/api' import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {clamp} from '#/lib/numbers'
import {ScrollProvider} from '#/lib/ScrollContext' import {ScrollProvider} from '#/lib/ScrollContext'
import {isAndroid, isNative, isWeb} from '#/platform/detection' import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -21,7 +24,9 @@ import {
} from '#/state/queries/post-thread' } from '#/state/queries/post-thread'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
@@ -30,9 +35,9 @@ import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {ComposePrompt} from '../composer/Prompt'
import {List, ListMethods} from '../util/List' import {List, ListMethods} from '../util/List'
import {ViewHeader} from '../util/ViewHeader' import {ViewHeader} from '../util/ViewHeader'
import {PostThreadComposePrompt} from './PostThreadComposePrompt'
import {PostThreadItem} from './PostThreadItem' import {PostThreadItem} from './PostThreadItem'
import {PostThreadLoadMore} from './PostThreadLoadMore' import {PostThreadLoadMore} from './PostThreadLoadMore'
import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
@@ -80,15 +85,7 @@ const keyExtractor = (item: RowItem) => {
return item._reactKey return item._reactKey
} }
export function PostThread({ export function PostThread({uri}: {uri: string | undefined}) {
uri,
onCanReply,
onPressReply,
}: {
uri: string | undefined
onCanReply: (canReply: boolean) => void
onPressReply: () => unknown
}) {
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
@@ -163,12 +160,22 @@ export function PostThread({
return cache return cache
}, [thread, moderationOpts]) }, [thread, moderationOpts])
const [justPostedUris, setJustPostedUris] = React.useState(
() => new Set<string>(),
)
const skeleton = React.useMemo(() => { const skeleton = React.useMemo(() => {
const threadViewPrefs = preferences?.threadViewPrefs const threadViewPrefs = preferences?.threadViewPrefs
if (!threadViewPrefs || !thread) return null if (!threadViewPrefs || !thread) return null
return createThreadSkeleton( return createThreadSkeleton(
sortThread(thread, threadViewPrefs, threadModerationCache, currentDid), sortThread(
thread,
threadViewPrefs,
threadModerationCache,
currentDid,
justPostedUris,
),
!!currentDid, !!currentDid,
treeView, treeView,
threadModerationCache, threadModerationCache,
@@ -181,6 +188,7 @@ export function PostThread({
treeView, treeView,
threadModerationCache, threadModerationCache,
hiddenRepliesState, hiddenRepliesState,
justPostedUris,
]) ])
const error = React.useMemo(() => { const error = React.useMemo(() => {
@@ -210,14 +218,6 @@ export function PostThread({
return null return null
}, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
useEffect(() => {
if (error) {
onCanReply(false)
} else if (rootPost) {
onCanReply(!rootPost.viewer?.replyDisabled)
}
}, [rootPost, onCanReply, error])
// construct content // construct content
const posts = React.useMemo(() => { const posts = React.useMemo(() => {
if (!skeleton) return [] if (!skeleton) return []
@@ -313,6 +313,38 @@ export function PostThread({
setMaxReplies(prev => prev + 50) setMaxReplies(prev => prev + 50)
}, [isFetching, maxReplies, posts.length]) }, [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 = const hasParents =
skeleton?.highlightedPost?.type === 'post' && skeleton?.highlightedPost?.type === 'post' &&
(skeleton.highlightedPost.ctx.isParentLoading || (skeleton.highlightedPost.ctx.isParentLoading ||
@@ -324,7 +356,9 @@ export function PostThread({
if (item === REPLY_PROMPT && hasSession) { if (item === REPLY_PROMPT && hasSession) {
return ( return (
<View> <View>
{!isMobile && <ComposePrompt onPressCompose={onPressReply} />} {!isMobile && (
<PostThreadComposePrompt onPressCompose={onPressReply} />
)}
</View> </View>
) )
} else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
@@ -406,7 +440,7 @@ export function PostThread({
HiddenRepliesState.ShowAndOverridePostHider && HiddenRepliesState.ShowAndOverridePostHider &&
item.ctx.depth > 0 item.ctx.depth > 0
} }
onPostReply={refetch} onPostReply={onPostReply}
hideTopBorder={index === 0 && !item.ctx.isParentLoading} hideTopBorder={index === 0 && !item.ctx.isParentLoading}
/> />
</View> </View>
@@ -473,10 +507,30 @@ export function PostThread({
sideBorders={false} sideBorders={false}
/> />
</ScrollProvider> </ScrollProvider>
{isMobile && canReply && hasSession && (
<MobileComposePrompt onPressReply={onPressReply} />
)}
</CenteredView> </CenteredView>
) )
} }
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
const safeAreaInsets = useSafeAreaInsets()
const fabMinimalShellTransform = useMinimalShellFabTransform()
return (
<Animated.View
style={[
styles.prompt,
fabMinimalShellTransform,
{
bottom: clamp(safeAreaInsets.bottom, 15, 30),
},
]}>
<PostThreadComposePrompt onPressCompose={onPressReply} />
</Animated.View>
)
}
function isThreadPost(v: unknown): v is ThreadPost { function isThreadPost(v: unknown): v is ThreadPost {
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
} }
@@ -622,3 +676,12 @@ function hasBranchingReplies(node?: ThreadNode) {
} }
return true return true
} }
const styles = StyleSheet.create({
prompt: {
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
left: 0,
right: 0,
},
})
@@ -10,7 +10,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { export function PostThreadComposePrompt({
onPressCompose,
}: {
onPressCompose: () => void
}) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: profile} = useProfileQuery({did: currentAccount?.did})
const pal = usePalette('default') const pal = usePalette('default')
+4 -3
View File
@@ -75,7 +75,7 @@ export function PostThreadItem({
showParentReplyLine?: boolean showParentReplyLine?: boolean
hasPrecedingItem: boolean hasPrecedingItem: boolean
overrideBlur: boolean overrideBlur: boolean
onPostReply: () => void onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean hideTopBorder?: boolean
}) { }) {
const postShadowed = usePostShadow(post) const postShadowed = usePostShadow(post)
@@ -169,7 +169,7 @@ let PostThreadItemLoaded = ({
showParentReplyLine?: boolean showParentReplyLine?: boolean
hasPrecedingItem: boolean hasPrecedingItem: boolean
overrideBlur: boolean overrideBlur: boolean
onPostReply: () => void onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean hideTopBorder?: boolean
}): React.ReactNode => { }): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
@@ -660,7 +660,7 @@ function ExpandedPostDetails({
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.flex_wrap, a.flex_wrap,
a.gap_sm, a.gap_xs,
s.mt2, s.mt2,
s.mb10, s.mb10,
]}> ]}>
@@ -742,6 +742,7 @@ const styles = StyleSheet.create({
flexWrap: 'wrap', flexWrap: 'wrap',
paddingBottom: 4, paddingBottom: 4,
paddingRight: 10, paddingRight: 10,
overflow: 'hidden',
}, },
postTextLargeContainer: { postTextLargeContainer: {
paddingHorizontal: 0, paddingHorizontal: 0,
+7 -6
View File
@@ -12,17 +12,17 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' 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 {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 {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {precacheProfile} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' 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 {AviFollowButton} from '#/view/com/posts/AviFollowButton'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {ProfileHoverCard} from '#/components/ProfileHoverCard' import {ProfileHoverCard} from '#/components/ProfileHoverCard'
@@ -280,6 +280,7 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
overflow: 'hidden',
}, },
replyLine: { replyLine: {
position: 'absolute', position: 'absolute',
+1 -3
View File
@@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native'
import {createHitslop} from '#/lib/constants' import {createHitslop} from '#/lib/constants'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -37,7 +36,6 @@ export function AviFollowButton({
profile: profile, profile: profile,
logContext: 'AvatarButton', logContext: 'AvatarButton',
}) })
const gate = useGate()
const {currentAccount, hasSession} = useSession() const {currentAccount, hasSession} = useSession()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
@@ -80,7 +78,7 @@ export function AviFollowButton({
}, },
] ]
return hasSession && gate('show_avi_follow_button') ? ( return hasSession ? (
<View style={a.relative}> <View style={a.relative}>
{children} {children}

Some files were not shown because too many files have changed in this diff Show More