Merge remote-tracking branch 'upstream/main' into Improve-notification-localization

This commit is contained in:
Minseo Lee
2024-08-26 14:10:17 +09:00
196 changed files with 32145 additions and 19518 deletions
+13
View File
@@ -71,6 +71,19 @@ module.exports = {
'simple-import-sort/exports': 'warn', 'simple-import-sort/exports': 'warn',
// TODO: Reenable when we figure out why it gets stuck on CI. // TODO: Reenable when we figure out why it gets stuck on CI.
// 'react-compiler/react-compiler': 'error', // 'react-compiler/react-compiler': 'error',
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@atproto/api',
importNames: ['moderatePost'],
message:
'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.',
},
],
},
],
}, },
ignorePatterns: [ ignorePatterns: [
'**/__mocks__/*.ts', '**/__mocks__/*.ts',
+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',
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 476 B

+2 -1
View File
@@ -10,7 +10,8 @@
], ],
"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",
+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==
+1
View File
@@ -255,6 +255,7 @@ func serve(cctx *cli.Context) error {
e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost) e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost)
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric)
// starter packs // starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack) e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
+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)
+27 -26
View File
@@ -79,32 +79,33 @@ export async function createServer(
plc: {port: port2}, plc: {port: port2},
}) })
// DISABLED - looks like dev-env added this and now it conflicts
// add the test mod authority // add the test mod authority
const agent = new BskyAgent({service: pdsUrl}) // const agent = new BskyAgent({service: pdsUrl})
const res = await agent.api.com.atproto.server.createAccount({ // const res = await agent.api.com.atproto.server.createAccount({
email: 'mod-authority@test.com', // email: 'mod-authority@test.com',
handle: 'mod-authority.test', // handle: 'mod-authority.test',
password: 'hunter2', // password: 'hunter2',
}) // })
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`) // agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
await agent.api.app.bsky.actor.profile.create( // await agent.api.app.bsky.actor.profile.create(
{repo: res.data.did}, // {repo: res.data.did},
{ // {
displayName: 'Dev-env Moderation', // displayName: 'Dev-env Moderation',
description: `The pretend version of mod.bsky.app`, // description: `The pretend version of mod.bsky.app`,
}, // },
) // )
await agent.api.app.bsky.labeler.service.create( // await agent.api.app.bsky.labeler.service.create(
{repo: res.data.did, rkey: 'self'}, // {repo: res.data.did, rkey: 'self'},
{ // {
policies: { // policies: {
labelValues: ['!hide', '!warn'], // labelValues: ['!hide', '!warn'],
labelValueDefinitions: [], // labelValueDefinitions: [],
}, // },
createdAt: new Date().toISOString(), // createdAt: new Date().toISOString(),
}, // },
) // )
const pic = fs.readFileSync( const pic = fs.readFileSync(
path.join(__dirname, '..', 'assets', 'default-avatar.png'), path.join(__dirname, '..', 'assets', 'default-avatar.png'),
@@ -156,7 +157,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 +333,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',
}
+4 -4
View File
@@ -32,7 +32,7 @@
"lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsc --project ./tsconfig.check.json", "typecheck": "tsc --project ./tsconfig.check.json",
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts", "e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
"e2e:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", "e2e:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
"e2e:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android", "e2e:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
"e2e:run": "maestro test __e2e__", "e2e:run": "maestro test __e2e__",
@@ -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.3",
"@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.
+21 -15
View File
@@ -50,6 +50,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls' import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@@ -61,6 +62,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()
@@ -121,21 +123,23 @@ function InnerApp() {
<ModerationOptsProvider> <ModerationOptsProvider>
<LoggedOutViewProvider> <LoggedOutViewProvider>
<SelectedFeedProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <HiddenRepliesProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<TourProvider> <MutedThreadsProvider>
<ProgressGuideProvider> <TourProvider>
<GestureHandlerRootView <ProgressGuideProvider>
style={s.h100pct}> <GestureHandlerRootView
<TestCtrls /> style={s.h100pct}>
<Shell /> <TestCtrls />
</GestureHandlerRootView> <Shell />
</ProgressGuideProvider> </GestureHandlerRootView>
</TourProvider> </ProgressGuideProvider>
</MutedThreadsProvider> </TourProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider> </SelectedFeedProvider>
</LoggedOutViewProvider> </LoggedOutViewProvider>
</ModerationOptsProvider> </ModerationOptsProvider>
@@ -157,6 +161,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))
}, []) }, [])
+16 -13
View File
@@ -39,6 +39,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web' import {ToastContainer} from '#/view/com/util/Toast.web'
@@ -105,19 +106,21 @@ function InnerApp() {
<ModerationOptsProvider> <ModerationOptsProvider>
<LoggedOutViewProvider> <LoggedOutViewProvider>
<SelectedFeedProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <HiddenRepliesProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<SafeAreaProvider> <MutedThreadsProvider>
<TourProvider> <SafeAreaProvider>
<ProgressGuideProvider> <TourProvider>
<Shell /> <ProgressGuideProvider>
</ProgressGuideProvider> <Shell />
</TourProvider> </ProgressGuideProvider>
</SafeAreaProvider> </TourProvider>
</MutedThreadsProvider> </SafeAreaProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider> </SelectedFeedProvider>
</LoggedOutViewProvider> </LoggedOutViewProvider>
</ModerationOptsProvider> </ModerationOptsProvider>
+66 -58
View File
@@ -15,10 +15,12 @@ import {
StackActions, StackActions,
} from '@react-navigation/native' } from '@react-navigation/native'
import {timeout} from 'lib/async/timeout' import {init as initAnalytics} from '#/lib/analytics/analytics'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {timeout} from '#/lib/async/timeout'
import {usePalette} from 'lib/hooks/usePalette' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {buildStateObject} from 'lib/routes/helpers' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {buildStateObject} from '#/lib/routes/helpers'
import { import {
AllNavigatorParams, AllNavigatorParams,
BottomTabNavigatorParams, BottomTabNavigatorParams,
@@ -28,20 +30,62 @@ import {
MyProfileTabNavigatorParams, MyProfileTabNavigatorParams,
NotificationsTabNavigatorParams, NotificationsTabNavigatorParams,
SearchTabNavigatorParams, SearchTabNavigatorParams,
} from 'lib/routes/types' } from '#/lib/routes/types'
import {RouteParams, State} from 'lib/routes/types' import {RouteParams, State} from '#/lib/routes/types'
import {bskyTitle} from 'lib/strings/headings' import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
import {isAndroid, isNative, isWeb} from 'platform/detection' import {bskyTitle} from '#/lib/strings/headings'
import {isAndroid, isNative, isWeb} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from '#/state/shell/reminders'
import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings'
import {AppPasswords} from '#/view/screens/AppPasswords'
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
import {DebugModScreen} from '#/view/screens/DebugMod'
import {FeedsScreen} from '#/view/screens/Feeds'
import {HomeScreen} from '#/view/screens/Home'
import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings'
import {ListsScreen} from '#/view/screens/Lists'
import {LogScreen} from '#/view/screens/Log'
import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
import {NotFoundScreen} from '#/view/screens/NotFound'
import {NotificationsScreen} from '#/view/screens/Notifications'
import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings'
import {PostThreadScreen} from '#/view/screens/PostThread'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {AppPasswords} from 'view/screens/AppPasswords' import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {PreferencesThreads} from '#/view/screens/PreferencesThreads'
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed' import {ProfileScreen} from '#/view/screens/Profile'
import {PreferencesThreads} from 'view/screens/PreferencesThreads' import {ProfileFeedScreen} from '#/view/screens/ProfileFeed'
import {SavedFeeds} from 'view/screens/SavedFeeds' import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
import {ProfileFollowersScreen} from '#/view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows'
import {ProfileListScreen} from '#/view/screens/ProfileList'
import {SavedFeeds} from '#/view/screens/SavedFeeds'
import {SearchScreen} from '#/view/screens/Search'
import {SettingsScreen} from '#/view/screens/Settings'
import {Storybook} from '#/view/screens/Storybook'
import {SupportScreen} from '#/view/screens/Support'
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag' import HashtagScreen from '#/screens/Hashtag'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesScreen} from '#/screens/Messages/List'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation' import {ModerationScreen} from '#/screens/Moderation'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
@@ -50,51 +94,8 @@ import {
StarterPackScreenShort, StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen' } from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard' import {Wizard} from '#/screens/StarterPack/Wizard'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army' import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {init as initAnalytics} from './lib/analytics/analytics'
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
import {router} from './routes'
import {MessagesConversationScreen} from './screens/Messages/Conversation'
import {MessagesScreen} from './screens/Messages/List'
import {MessagesSettingsScreen} from './screens/Messages/Settings'
import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session'
import {
shouldRequestEmailConfirmation,
snoozeEmailConfirmationPrompt,
} from './state/shell/reminders'
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
import {DebugModScreen} from './view/screens/DebugMod'
import {FeedsScreen} from './view/screens/Feeds'
import {HomeScreen} from './view/screens/Home'
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
import {ListsScreen} from './view/screens/Lists'
import {LogScreen} from './view/screens/Log'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {NotificationsScreen} from './view/screens/Notifications'
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {PostThreadScreen} from './view/screens/PostThread'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
import {ProfileScreen} from './view/screens/Profile'
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
import {ProfileListScreen} from './view/screens/ProfileList'
import {SearchScreen} from './view/screens/Search'
import {SettingsScreen} from './view/screens/Settings'
import {Storybook} from './view/screens/Storybook'
import {SupportScreen} from './view/screens/Support'
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>() const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -212,6 +213,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
title: title(msg`Post by @${route.params.name}`), title: title(msg`Post by @${route.params.name}`),
})} })}
/> />
<Stack.Screen
name="PostQuotes"
getComponent={() => PostQuotesScreen}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen <Stack.Screen
name="ProfileFeed" name="ProfileFeed"
getComponent={() => ProfileFeedScreen} getComponent={() => ProfileFeedScreen}
+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>
+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'
+10 -1
View File
@@ -13,6 +13,15 @@ import {
} from '#/components/moderation/ModerationDetailsDialog' } from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export type AppModerationCause =
| ModerationCause
| {
type: 'reply-hidden'
source: {type: 'user'; did: string}
priority: 6
downgraded?: boolean
}
export type CommonProps = { export type CommonProps = {
size?: 'sm' | 'lg' size?: 'sm' | 'lg'
} }
@@ -40,7 +49,7 @@ export function Row({
} }
export type LabelProps = { export type LabelProps = {
cause: ModerationCause cause: AppModerationCause
disableDetailsDialog?: boolean disableDetailsDialog?: boolean
noBg?: boolean noBg?: boolean
} & CommonProps } & CommonProps
+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 (
@@ -9,14 +9,15 @@ import {
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset' import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted' import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
import {isNative, isWeb} from 'platform/detection' import {isNative, isWeb} from 'platform/detection'
import {useListMembersQuery} from 'state/queries/list-members' import {useAllListMembersQuery} from 'state/queries/list-members'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {List, ListRef} from 'view/com/util/List' import {List, ListRef} from 'view/com/util/List'
import {SectionRef} from '#/screens/Profile/Sections/types' import {SectionRef} from '#/screens/Profile/Sections/types'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ListMaybePlaceholder} from '#/components/Lists' import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Default as ProfileCard} from '#/components/ProfileCard' import {Default as ProfileCard} from '#/components/ProfileCard'
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) { function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
@@ -39,17 +40,20 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
ref, ref,
) { ) {
const t = useTheme() const t = useTheme()
const [initialHeaderHeight] = React.useState(headerHeight) const bottomBarOffset = useBottomBarOffset(200)
const bottomBarOffset = useBottomBarOffset(20) const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data, refetch, isError} = useListMembersQuery(listUri, 50) const {data, refetch, isError} = useAllListMembersQuery(listUri)
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
// The server returns these sorted by descending creation date, so we want to invert // The server returns these sorted by descending creation date, so we want to invert
const profiles = data?.pages
.flatMap(p => p.items.map(i => i.subject)) const profiles = data
.filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler) ?.filter(
p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler,
)
.map(p => p.subject)
.reverse() .reverse()
const isOwn = new AtUri(listUri).host === currentAccount?.did const isOwn = new AtUri(listUri).host === currentAccount?.did
@@ -99,7 +103,11 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
if (!data) { if (!data) {
return ( return (
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}> <View
style={[
a.h_full_vh,
{marginTop: headerHeight, marginBottom: bottomBarOffset},
]}>
<ListMaybePlaceholder <ListMaybePlaceholder
isLoading={true} isLoading={true}
isError={isError} isError={isError}
@@ -118,10 +126,13 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
ref={scrollElRef} ref={scrollElRef}
headerOffset={headerHeight} headerOffset={headerHeight}
ListFooterComponent={ ListFooterComponent={
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} /> <ListFooter
style={{paddingBottom: bottomBarOffset, borderTopWidth: 0}}
/>
} }
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
desktopFixedHeight desktopFixedHeight
initialNumToRender={initialNumToRender}
refreshing={isPTRing} refreshing={isPTRing}
onRefresh={async () => { onRefresh={async () => {
setIsPTRing(true) setIsPTRing(true)
+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)
@@ -7,6 +7,7 @@ import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State' import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
@@ -42,6 +43,7 @@ export function WizardEditListDialog({
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender()
const listRef = useRef<BottomSheetFlatListMethods>(null) const listRef = useRef<BottomSheetFlatListMethods>(null)
@@ -148,6 +150,7 @@ export function WizardEditListDialog({
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
removeClippedSubviews={true} removeClippedSubviews={true}
initialNumToRender={initialNumToRender}
/> />
</Dialog.Outer> </Dialog.Outer>
) )
@@ -12,7 +12,7 @@ import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {DISCOVER_FEED_URI} from 'lib/constants' import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {useSession} from 'state/session' import {useSession} from 'state/session'
@@ -130,7 +130,8 @@ export function WizardProfileCard({
const isMe = profile.did === currentAccount?.did const isMe = profile.did === currentAccount?.did
const included = isMe || state.profiles.some(p => p.did === profile.did) const included = isMe || state.profiles.some(p => p.did === profile.did)
const disabled = isMe || (!included && state.profiles.length >= 49) const disabled =
isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName) ? sanitizeDisplayName(profile.displayName)
+129 -168
View File
@@ -1,39 +1,34 @@
import React from 'react' import React from 'react'
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetPostThread, AppBskyFeedPost,
AppBskyGraphDefs, AppBskyGraphDefs,
AtUri, AtUri,
BskyAgent,
} from '@atproto/api' } 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 {useQueryClient} from '@tanstack/react-query'
import {createThreadgate} from '#/lib/api'
import {until} from '#/lib/async/until'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links' import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import { import {
ThreadgateSetting, ThreadgateAllowUISetting,
threadgateViewToSettings, threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate' } from '#/state/queries/threadgate'
import {useAgent} from '#/state/session'
import * as Toast from 'view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {
PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {TextLink} from '../view/com/util/Link'
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps { interface WhoCanReplyProps {
@@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const t = useTheme() const t = useTheme()
const infoDialogControl = useDialogControl() const infoDialogControl = useDialogControl()
const editDialogControl = useDialogControl() const editDialogControl = useDialogControl()
const agent = useAgent()
const queryClient = useQueryClient()
const settings = React.useMemo( /*
() => threadgateViewToSettings(post.threadgate), * `WhoCanReply` is only used for root posts atm, in case this changes
[post], * unexpectedly, we should check to make sure it's for sure the root URI.
) */
const isRootPost = !('reply' in post.record) const rootUri =
AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
? post.record.reply.root.uri
: post.uri
const settings = React.useMemo(() => {
return threadgateViewToAllowUISetting(post.threadgate)
}, [post.threadgate])
if (!isRootPost) { const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
return null postUri: post.uri,
} rootPostUri: rootUri,
if (!settings.length && !isThreadAuthor) { })
return null
}
const isEverybody = settings.length === 0 const anyoneCanReply =
const isNobody = !!settings.find(gate => gate.type === 'nobody') settings.length === 1 && settings[0].type === 'everybody'
const description = isEverybody const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
const description = anyoneCanReply
? _(msg`Everybody can reply`) ? _(msg`Everybody can reply`)
: isNobody : noOneCanReply
? _(msg`Replies disabled`) ? _(msg`Replies disabled`)
: _(msg`Some people can reply`) : _(msg`Some people can reply`)
const onPressEdit = () => { const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) { if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss() Keyboard.dismiss()
} }
@@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
} }
} }
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
return
}
try {
if (newSettings.length) {
await createThreadgate(agent, post.uri, newSettings)
} else {
await agent.api.com.atproto.repo.deleteRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: new AtUri(post.uri).rkey,
})
}
await whenAppViewReady(agent, post.uri, res => {
const thread = res.data.thread
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
const fetchedSettings = threadgateViewToSettings(
thread.post.threadgate,
)
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
}
return false
})
Toast.show(_(msg`Thread settings updated`))
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
} catch (err) {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
logger.error('Failed to edit threadgate', {message: err})
}
}
return ( return (
<> <>
<Button <Button
label={ label={
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
} }
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open} onPress={onPressOpen}
{...(isThreadAuthor
? Platform.select({
web: {
onHoverIn: prefetchPostInteractionSettings,
},
native: {
onPressIn: prefetchPostInteractionSettings,
},
})
: {})}
hitSlop={HITSLOP_10}> hitSlop={HITSLOP_10}>
{({hovered}) => ( {({hovered}) => (
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}> <View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
@@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
]}> ]}>
{description} {description}
</Text> </Text>
{isThreadAuthor && ( {isThreadAuthor && (
<PencilLine width={12} fill={t.palette.primary_500} /> <PencilLine width={12} fill={t.palette.primary_500} />
)} )}
</View> </View>
)} )}
</Button> </Button>
<WhoCanReplyDialog
control={infoDialogControl} {isThreadAuthor ? (
post={post} <PostInteractionSettingsDialog
settings={settings} postUri={post.uri}
/> rootPostUri={rootUri}
{isThreadAuthor && (
<ThreadgateEditorDialog
control={editDialogControl} control={editDialogControl}
threadgate={settings} initialThreadgateView={post.threadgate}
onConfirm={onEditConfirm} />
) : (
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
/> />
)} )}
</> </>
@@ -174,7 +148,7 @@ function Icon({
}: { }: {
color: string color: string
width?: number width?: number
settings: ThreadgateSetting[] settings: ThreadgateAllowUISetting[]
}) { }) {
const isEverybody = settings.length === 0 const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody') const isNobody = !!settings.find(gate => gate.type === 'nobody')
@@ -186,79 +160,84 @@ function WhoCanReplyDialog({
control, control,
post, post,
settings, settings,
embeddingDisabled,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[] settings: ThreadgateAllowUISetting[]
}) { embeddingDisabled: boolean
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<WhoCanReplyDialogInner post={post} settings={settings} />
</Dialog.Outer>
)
}
function WhoCanReplyDialogInner({
post,
settings,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) { }) {
const {_} = useLingui() const {_} = useLingui()
return ( return (
<Dialog.ScrollableInner <Dialog.Outer control={control}>
label={_(msg`Who can reply dialog`)} <Dialog.Handle />
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> <Dialog.ScrollableInner
<View style={[a.gap_sm]}> label={_(msg`Dialog: adjust who can interact with this post`)}
<Text style={[a.font_bold, a.text_xl]}> style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<Trans>Who can reply?</Trans> <View style={[a.gap_sm]}>
</Text> <Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
<Rules post={post} settings={settings} /> <Trans>Who can interact with this post?</Trans>
</View> </Text>
</Dialog.ScrollableInner> <Rules
post={post}
settings={settings}
embeddingDisabled={embeddingDisabled}
/>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
) )
} }
function Rules({ function Rules({
post, post,
settings, settings,
embeddingDisabled,
}: { }: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[] settings: ThreadgateAllowUISetting[]
embeddingDisabled: boolean
}) { }) {
const t = useTheme() const t = useTheme()
return ( return (
<Text <>
style={[ <Text
a.text_md, style={[
a.leading_tight, a.text_sm,
a.flex_wrap, a.leading_snug,
t.atoms.text_contrast_medium, a.flex_wrap,
]}> t.atoms.text_contrast_medium,
{!settings.length ? ( ]}>
<Trans>Everybody can reply</Trans> {settings[0].type === 'everybody' ? (
) : settings[0].type === 'nobody' ? ( <Trans>Everybody can reply to this post.</Trans>
<Trans>Replies to this thread are disabled</Trans> ) : settings[0].type === 'nobody' ? (
) : ( <Trans>Replies to this post are disabled.</Trans>
<Trans> ) : (
Only{' '} <Trans>
{settings.map((rule, i) => ( Only{' '}
<> {settings.map((rule, i) => (
<Rule <React.Fragment key={`rule-${i}`}>
key={`rule-${i}`} <Rule rule={rule} post={post} lists={post.threadgate!.lists} />
rule={rule} <Separator i={i} length={settings.length} />
post={post} </React.Fragment>
lists={post.threadgate!.lists} ))}{' '}
/> can reply.
<Separator key={`sep-${i}`} i={i} length={settings.length} /> </Trans>
</> )}{' '}
))}{' '} </Text>
can reply {embeddingDisabled && (
</Trans> <Text
style={[
a.text_sm,
a.leading_snug,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
<Trans>No one but the author can quote this post.</Trans>
</Text>
)} )}
</Text> </>
) )
} }
@@ -267,11 +246,10 @@ function Rule({
post, post,
lists, lists,
}: { }: {
rule: ThreadgateSetting rule: ThreadgateAllowUISetting
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) { }) {
const t = useTheme()
if (rule.type === 'mention') { if (rule.type === 'mention') {
return <Trans>mentioned users</Trans> return <Trans>mentioned users</Trans>
} }
@@ -279,12 +257,12 @@ function Rule({
return ( return (
<Trans> <Trans>
users followed by{' '} users followed by{' '}
<TextLink <InlineLinkText
type="sm" label={`@${post.author.handle}`}
href={makeProfileLink(post.author)} to={makeProfileLink(post.author)}
text={`@${post.author.handle}`} style={[a.text_sm, a.leading_snug]}>
style={{color: t.palette.primary_500}} @{post.author.handle}
/> </InlineLinkText>
</Trans> </Trans>
) )
} }
@@ -294,12 +272,12 @@ function Rule({
const listUrip = new AtUri(list.uri) const listUrip = new AtUri(list.uri)
return ( return (
<Trans> <Trans>
<TextLink <InlineLinkText
type="sm" label={list.name}
href={makeListLink(listUrip.hostname, listUrip.rkey)} to={makeListLink(listUrip.hostname, listUrip.rkey)}
text={list.name} style={[a.text_sm, a.leading_snug]}>
style={{color: t.palette.primary_500}} {list.name}
/>{' '} </InlineLinkText>{' '}
members members
</Trans> </Trans>
) )
@@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
} }
return <>, </> return <>, </>
} }
async function whenAppViewReady(
agent: BskyAgent,
uri: string,
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() =>
agent.app.bsky.feed.getPostThread({
uri,
depth: 0,
}),
)
}
+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!`)}
@@ -0,0 +1,538 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import isEqual from 'lodash.isequal'
import {logger} from '#/logger'
import {STALE} from '#/state/queries'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {
createPostgateQueryKey,
getPostgateRecord,
usePostgateQuery,
useWritePostgateMutation,
} from '#/state/queries/postgate'
import {
createPostgateRecord,
embeddingRules,
} from '#/state/queries/postgate/util'
import {
createThreadgateViewQueryKey,
getThreadgateView,
ThreadgateAllowUISetting,
threadgateViewToAllowUISetting,
useSetThreadgateAllowMutation,
useThreadgateViewQuery,
} from '#/state/queries/threadgate'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export type PostInteractionSettingsFormProps = {
onSave: () => void
isSaving?: boolean
postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
threadgateAllowUISettings: ThreadgateAllowUISetting[]
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
replySettingsDisabled?: boolean
}
export function PostInteractionSettingsControlledDialog({
control,
...rest
}: PostInteractionSettingsFormProps & {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<PostInteractionSettingsForm {...rest} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
export type PostInteractionSettingsDialogProps = {
control: Dialog.DialogControlProps
/**
* URI of the post to edit the interaction settings for. Could be a root post
* or could be a reply.
*/
postUri: string
/**
* The URI of the root post in the thread. Used to determine if the viewer
* owns the threadgate record and can therefore edit it.
*/
rootPostUri: string
/**
* Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
* happen to have one before opening the settings dialog.
*/
initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
}
export function PostInteractionSettingsDialog(
props: PostInteractionSettingsDialogProps,
) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<PostInteractionSettingsDialogControlledInner {...props} />
</Dialog.Outer>
)
}
export function PostInteractionSettingsDialogControlledInner(
props: PostInteractionSettingsDialogProps,
) {
const {_} = useLingui()
const {currentAccount} = useSession()
const [isSaving, setIsSaving] = React.useState(false)
const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
useThreadgateViewQuery({postUri: props.rootPostUri})
const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
postUri: props.postUri,
})
const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
const [editedPostgate, setEditedPostgate] =
React.useState<AppBskyFeedPostgate.Record>()
const [editedAllowUISettings, setEditedAllowUISettings] =
React.useState<ThreadgateAllowUISetting[]>()
const isLoading = isLoadingThreadgate || isLoadingPostgate
const threadgateView = threadgateViewLoaded || props.initialThreadgateView
const isThreadgateOwnedByViewer = React.useMemo(() => {
return currentAccount?.did === new AtUri(props.rootPostUri).host
}, [props.rootPostUri, currentAccount?.did])
const postgateValue = React.useMemo(() => {
return (
editedPostgate || postgate || createPostgateRecord({post: props.postUri})
)
}, [postgate, editedPostgate, props.postUri])
const allowUIValue = React.useMemo(() => {
return (
editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
)
}, [threadgateView, editedAllowUISettings])
const onSave = React.useCallback(async () => {
if (!editedPostgate && !editedAllowUISettings) {
props.control.close()
return
}
setIsSaving(true)
try {
const requests = []
if (editedPostgate) {
requests.push(
writePostgateRecord({
postUri: props.postUri,
postgate: editedPostgate,
}),
)
}
if (editedAllowUISettings && isThreadgateOwnedByViewer) {
requests.push(
setThreadgateAllow({
postUri: props.rootPostUri,
allow: editedAllowUISettings,
}),
)
}
await Promise.all(requests)
props.control.close()
} catch (e: any) {
logger.error(`Failed to save post interaction settings`, {
context: 'PostInteractionSettingsDialogControlledInner',
safeMessage: e.message,
})
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
} finally {
setIsSaving(false)
}
}, [
_,
props.postUri,
props.rootPostUri,
props.control,
editedPostgate,
editedAllowUISettings,
setIsSaving,
writePostgateRecord,
setThreadgateAllow,
isThreadgateOwnedByViewer,
])
return (
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
{isLoading ? (
<Loader size="xl" />
) : (
<PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer}
isSaving={isSaving}
onSave={onSave}
postgate={postgateValue}
onChangePostgate={setEditedPostgate}
threadgateAllowUISettings={allowUIValue}
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
/>
)}
</Dialog.ScrollableInner>
)
}
export function PostInteractionSettingsForm({
onSave,
isSaving,
postgate,
onChangePostgate,
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
replySettingsDisabled,
}: PostInteractionSettingsFormProps) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState(
!(
postgate.embeddingRules &&
postgate.embeddingRules.find(
v => v.$type === embeddingRules.disableRule.$type,
)
),
)
const onPressAudience = (setting: ThreadgateAllowUISetting) => {
// remove boolean values
let newSelected: ThreadgateAllowUISetting[] =
threadgateAllowUISettings.filter(
v => v.type !== 'nobody' && v.type !== 'everybody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
onChangeThreadgateAllowUISettings(newSelected)
}
const onChangeQuotesEnabled = React.useCallback(
(enabled: boolean) => {
setQuotesEnabled(enabled)
onChangePostgate(
createPostgateRecord({
...postgate,
embeddingRules: enabled ? [] : [embeddingRules.disableRule],
}),
)
},
[setQuotesEnabled, postgate, onChangePostgate],
)
const noOneCanReply = !!threadgateAllowUISettings.find(
v => v.type === 'nobody',
)
return (
<View>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Post interaction settings</Trans>
</Text>
<View style={[a.gap_lg]}>
<Text style={[a.text_md]}>
<Trans>Customize who can interact with this post.</Trans>
</Text>
<Divider />
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Quote settings</Trans>
</Text>
<Toggle.Item
name="quoteposts"
type="checkbox"
label={
quotesEnabled
? _(msg`Click to disable quote posts of this post.`)
: _(msg`Click to enable quote posts of this post.`)
}
value={quotesEnabled}
onChange={onChangeQuotesEnabled}
style={[, a.justify_between, a.pt_xs]}>
<Text style={[t.atoms.text_contrast_medium]}>
{quotesEnabled ? (
<Trans>Quote posts enabled</Trans>
) : (
<Trans>Quote posts disabled</Trans>
)}
</Text>
<Toggle.Switch />
</Toggle.Item>
</View>
<Divider />
{replySettingsDisabled && (
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
a.flex_row,
a.align_center,
a.gap_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo fill={t.atoms.text_contrast_low.color} />
<Text
style={[
a.flex_1,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Reply settings are chosen by the author of the thread
</Trans>
</Text>
</View>
)}
<View
style={[
a.gap_sm,
{
opacity: replySettingsDisabled ? 0.3 : 1,
},
]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Reply settings</Trans>
</Text>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Allow replies from:</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={
!!threadgateAllowUISettings.find(v => v.type === 'everybody')
}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={noOneCanReply}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
</View>
{!noOneCanReply && (
<>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'mention',
)
}
onPress={() => onPressAudience({type: 'mention'})}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'following',
)
}
onPress={() => onPressAudience({type: 'following'})}
disabled={replySettingsDisabled}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'list' && v.list === list.uri,
)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
disabled={replySettingsDisabled}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</>
)}
</View>
</View>
</View>
<Button
label={_(msg`Save`)}
onPress={onSave}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{_(msg`Save`)}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />}
</Button>
</View>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
disabled,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
disabled?: boolean
}) {
const t = useTheme()
return (
<Button
disabled={disabled}
onPress={onPress}
label={label}
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}
export function usePrefetchPostInteractionSettings({
postUri,
rootPostUri,
}: {
postUri: string
rootPostUri: string
}) {
const queryClient = useQueryClient()
const agent = useAgent()
return React.useCallback(async () => {
try {
await Promise.all([
queryClient.prefetchQuery({
queryKey: createPostgateQueryKey(postUri),
queryFn: () => getPostgateRecord({agent, postUri}),
staleTime: STALE.SECONDS.THIRTY,
}),
queryClient.prefetchQuery({
queryKey: createThreadgateViewQueryKey(rootPostUri),
queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
staleTime: STALE.SECONDS.THIRTY,
}),
])
} catch (e: any) {
logger.error(`Failed to prefetch post interaction settings`, {
safeMessage: e.message,
})
}
}, [queryClient, agent, postUri, rootPostUri])
}
-217
View File
@@ -1,217 +0,0 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import isEqual from 'lodash.isequal'
import {useMyListsQuery} from '#/state/queries/my-lists'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Text} from '#/components/Typography'
interface ThreadgateEditorDialogProps {
control: Dialog.DialogControlProps
threadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}
export function ThreadgateEditorDialog({
control,
threadgate,
onChange,
onConfirm,
}: ThreadgateEditorDialogProps) {
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogContent
seedThreadgate={threadgate}
onChange={onChange}
onConfirm={onConfirm}
/>
</Dialog.Outer>
)
}
function DialogContent({
seedThreadgate,
onChange,
onConfirm,
}: {
seedThreadgate: ThreadgateSetting[]
onChange?: (v: ThreadgateSetting[]) => void
onConfirm?: (v: ThreadgateSetting[]) => void
}) {
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [draft, setDraft] = React.useState(seedThreadgate)
const [prevSeedThreadgate, setPrevSeedThreadgate] =
React.useState(seedThreadgate)
if (seedThreadgate !== prevSeedThreadgate) {
// New data flowed from above (e.g. due to update coming through).
setPrevSeedThreadgate(seedThreadgate)
setDraft(seedThreadgate) // Reset draft.
}
function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
setDraft(nextThreadgate)
onChange?.(nextThreadgate)
}
const onPressEverybody = () => {
updateThreadgate([])
}
const onPressNobody = () => {
updateThreadgate([{type: 'nobody'}])
}
const onPressAudience = (setting: ThreadgateSetting) => {
// remove nobody
let newSelected: ThreadgateSetting[] = draft.filter(
v => v.type !== 'nobody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
newSelected.push(setting)
} else {
newSelected.splice(i, 1)
}
updateThreadgate(newSelected)
}
const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
return (
<Dialog.ScrollableInner
label={_(msg`Choose who can reply`)}
style={[{maxWidth: 500}, a.w_full]}>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Choose who can reply</Trans>
</Text>
<Text style={a.mt_xs}>
<Trans>Either choose "Everybody" or "Nobody"</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={draft.length === 0}
onPress={onPressEverybody}
style={{flex: 1}}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={!!draft.find(v => v.type === 'nobody')}
onPress={onPressNobody}
style={{flex: 1}}
/>
</View>
<Text style={a.mt_md}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={!!draft.find(v => v.type === 'mention')}
onPress={() => onPressAudience({type: 'mention'})}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={!!draft.find(v => v.type === 'following')}
onPress={() => onPressAudience({type: 'following'})}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!draft.find(v => v.type === 'list' && v.list === list.uri)
}
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
null}
</View>
</View>
<Button
label={doneLabel}
onPress={() => {
control.close()
onConfirm?.(draft)
}}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{doneLabel}</ButtonText>
</Button>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Button
onPress={onPress}
label={label}
accessibilityHint="Select this option"
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}
+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({
+1 -1
View File
@@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from 'react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
export function useThrottledValue<T>(value: T, time?: number) { export function useThrottledValue<T>(value: T, time: number) {
const pendingValueRef = useRef(value) const pendingValueRef = useRef(value)
const [throttledValue, setThrottledValue] = useState(value) const [throttledValue, setThrottledValue] = useState(value)
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
})
+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})
@@ -8,17 +8,19 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {listUriToHref} from '#/lib/strings/url-helpers' import {listUriToHref} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {AppModerationCause} from '#/components/Pills'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog' export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
export interface ModerationDetailsDialogProps { export interface ModerationDetailsDialogProps {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
modcause: ModerationCause modcause?: ModerationCause | AppModerationCause
} }
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const desc = useModerationCauseDescription(modcause) const desc = useModerationCauseDescription(modcause)
const {currentAccount} = useSession()
let name let name
let description let description
@@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({
} else if (modcause.type === 'hidden') { } else if (modcause.type === 'hidden') {
name = _(msg`Post Hidden by You`) name = _(msg`Post Hidden by You`)
description = _(msg`You have hidden this post.`) description = _(msg`You have hidden this post.`)
} else if (modcause.type === 'reply-hidden') {
const isYou = currentAccount?.did === modcause.source.did
name = isYou
? _(msg`Reply Hidden by You`)
: _(msg`Reply Hidden by Thread Author`)
description = isYou
? _(msg`You hid this reply.`)
: _(msg`The author of this thread has hidden this reply.`)
} else if (modcause.type === 'label') { } else if (modcause.type === 'label') {
name = desc.name name = desc.name
description = desc.description description = desc.description
@@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}> <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
{name} {name}
</Text> </Text>
<Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}> <Text style={[t.atoms.text, a.text_md, a.leading_snug]}>
{description} {description}
</Text> </Text>
{modcause.type === 'label' && ( {modcause?.type === 'label' && (
<> <View style={[a.pt_lg]}>
<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]}>
{modcause.source.type === 'user' ? ( {modcause.source.type === 'user' ? (
@@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({
</Trans> </Trans>
)} )}
</Text> </Text>
</> </View>
)} )}
{isNative && <View style={{height: 40}} />} {isNative && <View style={{height: 40}} />}
+12 -2
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {StyleProp, ViewStyle} from 'react-native' import {StyleProp, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api' import {ModerationCause, ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation' import {getModerationCauseKey} from '#/lib/moderation'
import * as Pills from '#/components/Pills' import * as Pills from '#/components/Pills'
@@ -9,13 +9,15 @@ export function PostAlerts({
modui, modui,
size = 'sm', size = 'sm',
style, style,
additionalCauses,
}: { }: {
modui: ModerationUI modui: ModerationUI
size?: Pills.CommonProps['size'] size?: Pills.CommonProps['size']
includeMute?: boolean includeMute?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
}) { }) {
if (!modui.alert && !modui.inform) { if (!modui.alert && !modui.inform && !additionalCauses?.length) {
return null return null
} }
@@ -37,6 +39,14 @@ export function PostAlerts({
noBg={size === 'sm'} noBg={size === 'sm'}
/> />
))} ))}
{additionalCauses?.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
noBg={size === 'sm'}
/>
))}
</Pills.Row> </Pills.Row>
) )
} }
-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,
} }
} }
+61 -85
View File
@@ -3,22 +3,30 @@ import {
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyFeedThreadgate, AppBskyFeedPostgate,
BskyAgent, BskyAgent,
ComAtprotoLabelDefs, ComAtprotoLabelDefs,
ComAtprotoRepoUploadBlob,
RichText, RichText,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {logger} from '#/logger' import {logger} from '#/logger'
import {ThreadgateSetting} from '#/state/queries/threadgate' import {writePostgateRecord} from '#/state/queries/postgate'
import {
createThreadgateRecord,
ThreadgateAllowUISetting,
threadgateAllowUISettingToAllowRecordValue,
writeThreadgateRecord,
} 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 +36,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
@@ -61,7 +50,8 @@ interface PostOpts {
extLink?: ExternalEmbedDraft extLink?: ExternalEmbedDraft
images?: ImageModel[] images?: ImageModel[]
labels?: string[] labels?: string[]
threadgate?: ThreadgateSetting[] threadgate: ThreadgateAllowUISetting[]
postgate: AppBskyFeedPostgate.Record
onStateChange?: (state: string) => void onStateChange?: (state: string) => void
langs?: string[] langs?: string[]
} }
@@ -249,7 +239,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
labels, labels,
}) })
} catch (e: any) { } catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`) logger.error(`Failed to create post`, {
safeMessage: e.message,
})
if (isNetworkError(e)) { if (isNetworkError(e)) {
throw new Error( throw new Error(
'Post failed to upload. Please check your Internet connection and try again.', 'Post failed to upload. Please check your Internet connection and try again.',
@@ -259,68 +251,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
} }
} }
try { if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
// TODO: this needs to be batch-created with the post! try {
if (opts.threadgate?.length) { // TODO: this needs to be batch-created with the post!
await createThreadgate(agent, res.uri, opts.threadgate) await writeThreadgateRecord({
agent,
postUri: res.uri,
threadgate: createThreadgateRecord({
post: res.uri,
allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
}),
})
} catch (e: any) {
logger.error(`Failed to create threadgate`, {
context: 'composer',
safeMessage: e.message,
})
throw new Error(
'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
)
}
}
if (
opts.postgate.embeddingRules?.length ||
opts.postgate.detachedEmbeddingUris?.length
) {
try {
// TODO: this needs to be batch-created with the post!
await writePostgateRecord({
agent,
postUri: res.uri,
postgate: {
...opts.postgate,
post: res.uri,
},
})
} catch (e: any) {
logger.error(`Failed to create postgate`, {
context: 'composer',
safeMessage: e.message,
})
throw new Error(
'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
)
} }
} catch (e: any) {
console.error(`Failed to create threadgate: ${e.toString()}`)
throw new Error(
'Post reply-controls failed to be set. Your post was created but anyone can reply to it.',
)
} }
return res return res
} }
export async function createThreadgate(
agent: BskyAgent,
postUri: string,
threadgate: ThreadgateSetting[],
) {
let allow: (
| AppBskyFeedThreadgate.MentionRule
| AppBskyFeedThreadgate.FollowingRule
| AppBskyFeedThreadgate.ListRule
)[] = []
if (!threadgate.find(v => v.type === 'nobody')) {
for (const rule of threadgate) {
if (rule.type === 'mention') {
allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'})
} else if (rule.type === 'following') {
allow.push({$type: 'app.bsky.feed.threadgate#followingRule'})
} else if (rule.type === 'list') {
allow.push({
$type: 'app.bsky.feed.threadgate#listRule',
list: rule.list,
})
}
}
}
const postUrip = new AtUri(postUri)
await agent.api.com.atproto.repo.putRecord({
repo: agent.session!.did,
collection: 'app.bsky.feed.threadgate',
rkey: postUrip.rkey,
record: {
$type: 'app.bsky.feed.threadgate',
post: postUri,
allow,
createdAt: new Date().toISOString(),
},
})
}
// 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}`)
}
+2 -1
View File
@@ -12,6 +12,7 @@ export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG
export const EMBED_SERVICE = 'https://embed.bsky.app' export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download' export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
export const STARTER_PACK_MAX_SIZE = 150
// HACK // HACK
// Yes, this is exactly what it looks like. It's a hard-coded constant // Yes, this is exactly what it looks like. It's a hard-coded constant
@@ -20,7 +21,7 @@ export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
// code and update this number with each release until we can get the // code and update this number with each release until we can get the
// server route done. // server route done.
// -prf // -prf
export const JOINED_THIS_WEEK = 21797 // as of Jul5 2024 export const JOINED_THIS_WEEK = 50676 // as of Aug 17, 2024
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new` const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
export function FEEDBACK_FORM_URL({ export function FEEDBACK_FORM_URL({
+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())
}
}
}
}
+19 -10
View File
@@ -6,16 +6,20 @@ import {useShellLayout} from '#/state/shell/shell-layout'
// 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.
export function useMinimalShellHeaderTransform() { export function useMinimalShellHeaderTransform() {
const mode = useMinimalShellMode() const {headerMode} = useMinimalShellMode()
const {headerHeight} = useShellLayout() const {headerHeight} = useShellLayout()
const headerTransform = useAnimatedStyle(() => { const headerTransform = useAnimatedStyle(() => {
return { return {
pointerEvents: mode.value === 0 ? 'auto' : 'none', pointerEvents: headerMode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2), opacity: Math.pow(1 - headerMode.value, 2),
transform: [ transform: [
{ {
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]), translateY: interpolate(
headerMode.value,
[0, 1],
[0, -headerHeight.value],
),
}, },
], ],
} }
@@ -25,31 +29,36 @@ export function useMinimalShellHeaderTransform() {
} }
export function useMinimalShellFooterTransform() { export function useMinimalShellFooterTransform() {
const mode = useMinimalShellMode() const {footerMode} = useMinimalShellMode()
const {footerHeight} = useShellLayout() const {footerHeight} = useShellLayout()
const footerTransform = useAnimatedStyle(() => { const footerTransform = useAnimatedStyle(() => {
return { return {
pointerEvents: mode.value === 0 ? 'auto' : 'none', pointerEvents: footerMode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2), opacity: Math.pow(1 - footerMode.value, 2),
transform: [ transform: [
{ {
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]), translateY: interpolate(
footerMode.value,
[0, 1],
[0, footerHeight.value],
),
}, },
], ],
} }
}) })
return footerTransform return footerTransform
} }
export function useMinimalShellFabTransform() { export function useMinimalShellFabTransform() {
const mode = useMinimalShellMode() const {footerMode} = useMinimalShellMode()
const fabTransform = useAnimatedStyle(() => { const fabTransform = useAnimatedStyle(() => {
return { return {
transform: [ transform: [
{ {
translateY: interpolate(mode.value, [0, 1], [-44, 0]), translateY: interpolate(footerMode.value, [0, 1], [-44, 0]),
}, },
], ],
} }
+8
View File
@@ -107,6 +107,11 @@ export async function extractBskyMeta(
return meta return meta
} }
export class EmbeddingDisabledError extends Error {
constructor() {
super('Embedding is disabled for this record')
}
}
export async function getPostAsQuote( export async function getPostAsQuote(
getPost: ReturnType<typeof useGetPost>, getPost: ReturnType<typeof useGetPost>,
url: string, url: string,
@@ -115,6 +120,9 @@ export async function getPostAsQuote(
const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
const post = await getPost({uri: uri}) const post = await getPost({uri: uri})
if (post.viewer?.embeddingDisabled) {
throw new EmbeddingDisabledError()
}
return { return {
uri: post.uri, uri: post.uri,
cid: post.cid, cid: post.cid,
+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 -1
View File
@@ -1,4 +1,5 @@
import {moderatePost, BSKY_LABELER_DID} from '@atproto/api' /* eslint-disable-next-line no-restricted-imports */
import {BSKY_LABELER_DID, moderatePost} from '@atproto/api'
type ModeratePost = typeof moderatePost type ModeratePost = typeof moderatePost
type Options = Parameters<ModeratePost>[1] type Options = Parameters<ModeratePost>[1]
+8 -5
View File
@@ -1,17 +1,20 @@
import { import {
ModerationCause,
ModerationUI,
InterpretedLabelValueDefinition,
LABELS,
AppBskyLabelerDefs, AppBskyLabelerDefs,
BskyAgent, BskyAgent,
InterpretedLabelValueDefinition,
LABELS,
ModerationCause,
ModerationOpts, ModerationOpts,
ModerationUI,
} from '@atproto/api' } from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {AppModerationCause} from '#/components/Pills'
export function getModerationCauseKey(cause: ModerationCause): string { export function getModerationCauseKey(
cause: ModerationCause | AppModerationCause,
): string {
const source = const source =
cause.source.type === 'labeler' cause.source.type === 'labeler'
? cause.source.did ? cause.source.did
@@ -8,11 +8,13 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useLabelDefinitions} from '#/state/preferences' import {useLabelDefinitions} from '#/state/preferences'
import {useSession} from '#/state/session'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Props as SVGIconProps} from '#/components/icons/common' import {Props as SVGIconProps} from '#/components/icons/common'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {AppModerationCause} from '#/components/Pills'
import {useGlobalLabelStrings} from './useGlobalLabelStrings' import {useGlobalLabelStrings} from './useGlobalLabelStrings'
import {getDefinition, getLabelStrings} from './useLabelInfo' import {getDefinition, getLabelStrings} from './useLabelInfo'
@@ -27,8 +29,9 @@ export interface ModerationCauseDescription {
} }
export function useModerationCauseDescription( export function useModerationCauseDescription(
cause: ModerationCause | undefined, cause: ModerationCause | AppModerationCause | undefined,
): ModerationCauseDescription { ): ModerationCauseDescription {
const {currentAccount} = useSession()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const {labelDefs, labelers} = useLabelDefinitions() const {labelDefs, labelers} = useLabelDefinitions()
const globalLabelStrings = useGlobalLabelStrings() const globalLabelStrings = useGlobalLabelStrings()
@@ -111,6 +114,18 @@ export function useModerationCauseDescription(
description: _(msg`You have hidden this post`), description: _(msg`You have hidden this post`),
} }
} }
if (cause.type === 'reply-hidden') {
const isMe = currentAccount?.did === cause.source.did
return {
icon: EyeSlash,
name: isMe
? _(msg`Reply Hidden by You`)
: _(msg`Reply Hidden by Thread Author`),
description: isMe
? _(msg`You hid this reply.`)
: _(msg`The author of this thread has hidden this reply.`),
}
}
if (cause.type === 'label') { if (cause.type === 'label') {
const def = cause.labelDef || getDefinition(labelDefs, cause.label) const def = cause.labelDef || getDefinition(labelDefs, cause.label)
const strings = getLabelStrings(i18n.locale, globalLabelStrings, def) const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
@@ -150,5 +165,13 @@ export function useModerationCauseDescription(
name: '', name: '',
description: ``, description: ``,
} }
}, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale]) }, [
labelDefs,
labelers,
globalLabelStrings,
cause,
_,
i18n.locale,
currentAccount?.did,
])
} }
+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
@@ -20,6 +20,7 @@ export type CommonNavigatorParams = {
PostThread: {name: string; rkey: string} PostThread: {name: string; rkey: string}
PostLikedBy: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string}
PostRepostedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string}
PostQuotes: {name: string; rkey: string}
ProfileFeed: {name: string; rkey: string} ProfileFeed: {name: string; rkey: string}
ProfileFeedLikedBy: {name: string; rkey: string} ProfileFeedLikedBy: {name: string; rkey: string}
ProfileLabelerLikedBy: {name: string} ProfileLabelerLikedBy: {name: string}
+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 -1
View File
@@ -1,9 +1,9 @@
export type Gate = export type Gate =
// Keep this alphabetic please. // Keep this alphabetic please.
| 'debug_show_feedcontext' | 'debug_show_feedcontext'
| 'fixed_bottom_bar'
| 'new_user_guided_tour' | 'new_user_guided_tour'
| 'onboarding_minimum_interests' | 'onboarding_minimum_interests'
| 'session_withproxy_fix'
| 'show_follow_back_label_v2' | 'show_follow_back_label_v2'
| 'suggested_feeds_interstitial' | 'suggested_feeds_interstitial'
| 'video_debug' | 'video_debug'
+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,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -21,6 +21,7 @@ export const router = new Router({
PostThread: '/profile/:name/post/:rkey', PostThread: '/profile/:name/post/:rkey',
PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostLikedBy: '/profile/:name/post/:rkey/liked-by',
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
PostQuotes: '/profile/:name/post/:rkey/quotes',
ProfileFeed: '/profile/:name/feed/:rkey', ProfileFeed: '/profile/:name/feed/:rkey',
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by', ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
+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>
)
}
+11 -8
View File
@@ -23,6 +23,7 @@ import {useProgressGuideControls} from '#/state/shell/progress-guide'
import {uploadBlob} from 'lib/api' import {uploadBlob} from 'lib/api'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications' import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs' import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
import {getAllListMembers} from 'state/queries/list-members'
import { import {
useActiveStarterPack, useActiveStarterPack,
useSetActiveStarterPack, useSetActiveStarterPack,
@@ -73,18 +74,20 @@ export function StepFinished() {
starterPack: activeStarterPack.uri, starterPack: activeStarterPack.uri,
}) })
starterPack = spRes.data.starterPack starterPack = spRes.data.starterPack
if (starterPack.list) {
const listRes = await agent.app.bsky.graph.getList({
list: starterPack.list.uri,
limit: 50,
})
listItems = listRes.data.items
}
} catch (e) { } catch (e) {
logger.error('Failed to fetch starter pack', {safeMessage: e}) logger.error('Failed to fetch starter pack', {safeMessage: e})
// don't tell the user, just get them through onboarding. // don't tell the user, just get them through onboarding.
} }
try {
if (starterPack?.list) {
listItems = await getAllListMembers(agent, starterPack.list.uri)
}
} catch (e) {
logger.error('Failed to fetch starter pack list items', {
safeMessage: e,
})
// don't tell the user, just get them through onboarding.
}
} }
try { try {
@@ -143,7 +143,8 @@ export function StepInterests() {
track('OnboardingV2:StepInterests:Start') track('OnboardingV2:StepInterests:Start')
}, [track]) }, [track])
const isMinimumInterestsEnabled = gate('onboarding_minimum_interests') const isMinimumInterestsEnabled =
gate('onboarding_minimum_interests') && data?.interests.length !== 0
const meetsMinimumRequirement = isMinimumInterestsEnabled const meetsMinimumRequirement = isMinimumInterestsEnabled
? interests.length >= MIN_INTERESTS ? interests.length >= MIN_INTERESTS
: true : true
+8 -4
View File
@@ -4,6 +4,7 @@ import {
BskyAgent, BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {TID} from '@atproto/common-web' import {TID} from '@atproto/common-web'
import chunk from 'lodash.chunk'
import {until} from '#/lib/async/until' import {until} from '#/lib/async/until'
@@ -29,10 +30,13 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
value: r, value: r,
})) }))
await agent.com.atproto.repo.applyWrites({ const chunks = chunk(followWrites, 50)
repo: session.did, for (const chunk of chunks) {
writes: followWrites, await agent.com.atproto.repo.applyWrites({
}) repo: session.did,
writes: chunk,
})
}
await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length) await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length)
const followUris = new Map() const followUris = new Map()
@@ -4,11 +4,12 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy'
import {makeRecordUri} from 'lib/strings/url-helpers' import {ViewHeader} from '#/view/com/util/ViewHeader'
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' import {atoms as a} from '#/alf'
import {ViewHeader} from '../com/util/ViewHeader'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
export const PostLikedByScreen = ({route}: Props) => { export const PostLikedByScreen = ({route}: Props) => {
@@ -24,7 +25,7 @@ export const PostLikedByScreen = ({route}: Props) => {
) )
return ( return (
<View style={{flex: 1}}> <View style={a.flex_1}>
<ViewHeader title={_(msg`Liked By`)} /> <ViewHeader title={_(msg`Liked By`)} />
<PostLikedByComponent uri={uri} /> <PostLikedByComponent uri={uri} />
</View> </View>

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