Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 485036d65c | |||
| b2410c655f | |||
| ecbfcfd6b9 | |||
| 5724903ba3 | |||
| 0ac13b17ac | |||
| dd19a1a323 | |||
| 3f84d3f024 | |||
| ba80cc0ae7 | |||
| bf924a9a43 | |||
| 6f0764de1d | |||
| 0a75969475 | |||
| e51eb391df | |||
| 9ebc2785e4 | |||
| faf66f3178 | |||
| 69053b1b0a | |||
| e33b88ed7d | |||
| ea5ab99399 | |||
| 551c4a4f32 | |||
| d52d29621e | |||
| d92731b1eb | |||
| b136c44287 | |||
| 16d556c3c9 | |||
| 94d2180aaa | |||
| 5ae0d40a14 | |||
| 9aa2b2d14e | |||
| b69c40da33 | |||
| 9b534b968d | |||
| def9dda29c | |||
| fc5cc189b5 | |||
| b5ea87c981 | |||
| d646d590cc | |||
| 1d5e341ada | |||
| 632f71acc9 | |||
| 80320f9d7d | |||
| 3f98747925 | |||
| fa12bf5d87 | |||
| 1f657b3ac5 | |||
| 425dd5f27f | |||
| 5ec8761b29 | |||
| 990bf306c5 | |||
| b8dbb71781 | |||
| 2ae3ffcf78 | |||
| 27bb383268 | |||
| df5bf28e61 | |||
| 92989282ae | |||
| 9f1c411360 | |||
| 85d70fe3ef | |||
| d5c78b9183 | |||
| 61f0be705d | |||
| 6616a6467e | |||
| 56ab5e177f | |||
| ddb0b80017 | |||
| 5463e9718f | |||
| 723896a45f | |||
| e54298ec2c | |||
| f235be9819 | |||
| 3976d6738b | |||
| 2939ee7df7 | |||
| 40ab67fc4b | |||
| a5af24b53b | |||
| b6e515c664 | |||
| f3b57dd456 | |||
| 11061b628e | |||
| b9975697e2 | |||
| b6fa0d2d04 | |||
| 21e214c235 | |||
| 26d3777ecc | |||
| 630ebf523d | |||
| 57be2ea15b | |||
| 7e11b862e9 | |||
| 1fce7a793d |
@@ -71,6 +71,19 @@ module.exports = {
|
||||
'simple-import-sort/exports': 'warn',
|
||||
// TODO: Reenable when we figure out why it gets stuck on CI.
|
||||
// 'react-compiler/react-compiler': 'error',
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@atproto/api',
|
||||
importNames: ['moderatePost'],
|
||||
message:
|
||||
'Please use `moderatePost_wrapped` from `#/lib/moderatePost_wrapped` instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
ignorePatterns: [
|
||||
'**/__mocks__/*.ts',
|
||||
|
||||
@@ -340,12 +340,14 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
'https://youtube.com/watch?v=videoId',
|
||||
'https://youtube.com/watch?v=videoId&feature=share',
|
||||
'https://youtube.com/shorts/videoId',
|
||||
'https://youtube.com/live/videoId',
|
||||
'https://m.youtube.com/watch?v=videoId',
|
||||
'https://music.youtube.com/watch?v=videoId',
|
||||
|
||||
'https://youtube.com/shorts/',
|
||||
'https://youtube.com/',
|
||||
'https://youtube.com/random',
|
||||
'https://youtube.com/live/',
|
||||
|
||||
'https://twitch.tv/channelName',
|
||||
'https://www.twitch.tv/channelName',
|
||||
@@ -475,10 +477,16 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
source: 'youtube',
|
||||
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
|
||||
},
|
||||
{
|
||||
type: 'youtube_video',
|
||||
source: 'youtube',
|
||||
playerUri: 'https://bsky.app/iframe/youtube.html?videoId=videoId&start=0',
|
||||
},
|
||||
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
{
|
||||
type: 'twitch_video',
|
||||
|
||||
@@ -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 |
+3
-2
@@ -10,11 +10,12 @@
|
||||
],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": "warn",
|
||||
"simple-import-sort/exports": "warn"
|
||||
"simple-import-sort/exports": "warn",
|
||||
'no-else-return': 'off'
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": "latest",
|
||||
"project": "./bskyembed/tsconfig.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -9,7 +9,7 @@
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.2",
|
||||
"@atproto/api": "0.13.1",
|
||||
"@preact/preset-vite": "^2.8.2",
|
||||
"@vitejs/plugin-legacy": "^5.3.2",
|
||||
"preact": "^10.4.8",
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyGraphStarterpack,
|
||||
AppBskyLabelerDefs,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {ComponentChildren, h} from 'preact'
|
||||
import {useMemo} from 'preact/hooks'
|
||||
|
||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||
import starterPackIcon from '../../assets/starterPack.svg'
|
||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||
import {getRkey} from '../utils'
|
||||
import {Link} from './link'
|
||||
@@ -105,7 +108,7 @@ export function Embed({
|
||||
// Case 3.2: List
|
||||
if (AppBskyGraphDefs.isListView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
<GenericWithImageEmbed
|
||||
image={record.avatar}
|
||||
title={record.name}
|
||||
href={`/profile/${record.creator.did}/lists/${getRkey(record)}`}
|
||||
@@ -122,7 +125,7 @@ export function Embed({
|
||||
// Case 3.3: Feed
|
||||
if (AppBskyFeedDefs.isGeneratorView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
<GenericWithImageEmbed
|
||||
image={record.avatar}
|
||||
title={record.displayName}
|
||||
href={`/profile/${record.creator.did}/feed/${getRkey(record)}`}
|
||||
@@ -134,28 +137,27 @@ export function Embed({
|
||||
|
||||
// Case 3.4: Labeler
|
||||
if (AppBskyLabelerDefs.isLabelerView(record)) {
|
||||
return (
|
||||
<GenericWithImage
|
||||
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`}
|
||||
/>
|
||||
)
|
||||
// Embed type does not exist in the app, so show nothing
|
||||
return null
|
||||
}
|
||||
|
||||
// Case 3.5: Post not found
|
||||
// Case 3.5: Starter pack
|
||||
if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
|
||||
return <StarterPackEmbed content={record} />
|
||||
}
|
||||
|
||||
// Case 3.6: Post not found
|
||||
if (AppBskyEmbedRecord.isViewNotFound(record)) {
|
||||
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)) {
|
||||
return <Info>The quoted post is blocked.</Info>
|
||||
}
|
||||
|
||||
throw new Error('Unknown embed type')
|
||||
// Unknown embed type
|
||||
return null
|
||||
}
|
||||
|
||||
// Case 4: Record with media
|
||||
@@ -182,7 +184,8 @@ export function Embed({
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('Unsupported embed type')
|
||||
// Unknown embed type
|
||||
return null
|
||||
} catch (err) {
|
||||
return (
|
||||
<Info>{err instanceof Error ? err.message : 'An error occurred'}</Info>
|
||||
@@ -314,7 +317,7 @@ function ExternalEmbed({
|
||||
)
|
||||
}
|
||||
|
||||
function GenericWithImage({
|
||||
function GenericWithImageEmbed({
|
||||
title,
|
||||
subtitle,
|
||||
href,
|
||||
@@ -350,3 +353,60 @@ function GenericWithImage({
|
||||
</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}`
|
||||
}
|
||||
|
||||
@@ -132,7 +132,10 @@ function PostContent({record}: {record: AppBskyFeedPost.Record | null}) {
|
||||
key={counter}
|
||||
href={segment.link.uri}
|
||||
className="text-blue-400 hover:underline"
|
||||
disableTracking={!segment.link.uri.startsWith('https://bsky.app')}>
|
||||
disableTracking={
|
||||
!segment.link.uri.startsWith('https://bsky.app') &&
|
||||
!segment.link.uri.startsWith('https://go.bsky.app')
|
||||
}>
|
||||
{segment.text}
|
||||
</Link>,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import '../index.css'
|
||||
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AtpAgent} from '@atproto/api'
|
||||
import {h, render} from 'preact'
|
||||
|
||||
import logo from '../../assets/logo.svg'
|
||||
@@ -12,7 +12,7 @@ import {getRkey} from '../utils'
|
||||
const root = document.getElementById('app')
|
||||
if (!root) throw new Error('No root element')
|
||||
|
||||
const agent = new BskyAgent({
|
||||
const agent = new AtpAgent({
|
||||
service: 'https://public.api.bsky.app',
|
||||
})
|
||||
|
||||
|
||||
+46
-20
@@ -20,15 +20,16 @@
|
||||
"@jridgewell/gen-mapping" "^0.3.5"
|
||||
"@jridgewell/trace-mapping" "^0.3.24"
|
||||
|
||||
"@atproto/api@^0.12.2":
|
||||
version "0.12.2"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.2.tgz#5df6d4f60dea0395c84fdebd9e81a7e853edf130"
|
||||
integrity sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA==
|
||||
"@atproto/api@0.13.1":
|
||||
version "0.13.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
|
||||
integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/lexicon" "^0.4.0"
|
||||
"@atproto/lexicon" "^0.4.1"
|
||||
"@atproto/syntax" "^0.3.0"
|
||||
"@atproto/xrpc" "^0.5.0"
|
||||
"@atproto/xrpc" "^0.6.0"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
|
||||
@@ -42,29 +43,29 @@
|
||||
uint8arrays "3.0.0"
|
||||
zod "^3.21.4"
|
||||
|
||||
"@atproto/lexicon@^0.4.0":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.0.tgz#63e8829945d80c25524882caa8ed27b1151cc576"
|
||||
integrity sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==
|
||||
"@atproto/lexicon@^0.4.1":
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.1.tgz#19155210570a2fafbcc7d4f655d9b813948e72a0"
|
||||
integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/syntax" "^0.3.0"
|
||||
iso-datestring-validator "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
zod "^3.21.4"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/syntax@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
|
||||
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
|
||||
|
||||
"@atproto/xrpc@^0.5.0":
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.5.0.tgz#dacbfd8f7b13f0ab5bd56f8fdd4b460e132a6032"
|
||||
integrity sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==
|
||||
"@atproto/xrpc@^0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
|
||||
integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
|
||||
dependencies:
|
||||
"@atproto/lexicon" "^0.4.0"
|
||||
zod "^3.21.4"
|
||||
"@atproto/lexicon" "^0.4.1"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2":
|
||||
version "7.24.2"
|
||||
@@ -1710,6 +1711,11 @@ available-typed-arrays@^1.0.7:
|
||||
dependencies:
|
||||
possible-typed-array-names "^1.0.0"
|
||||
|
||||
await-lock@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
|
||||
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
|
||||
|
||||
babel-plugin-polyfill-corejs2@^0.4.10:
|
||||
version "0.4.10"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.10.tgz#276f41710b03a64f6467433cab72cbc2653c38b1"
|
||||
@@ -3730,8 +3736,16 @@ stack-trace@^1.0.0-pre2:
|
||||
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-1.0.0-pre2.tgz#46a83a79f1b287807e9aaafc6a5dd8bcde626f9c"
|
||||
integrity sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
||||
name string-width-cjs
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -3795,7 +3809,14 @@ string.prototype.trimstart@^1.0.8:
|
||||
define-properties "^1.2.1"
|
||||
es-object-atoms "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -4192,3 +4213,8 @@ zod@^3.21.4:
|
||||
version "3.22.4"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
|
||||
integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==
|
||||
|
||||
zod@^3.23.8:
|
||||
version "3.23.8"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
|
||||
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
|
||||
|
||||
@@ -255,6 +255,7 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost)
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric)
|
||||
|
||||
// starter packs
|
||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||
|
||||
@@ -253,6 +253,11 @@
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.force-no-clicks > *,
|
||||
.force-no-clicks * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
{% include "scripts.html" %}
|
||||
|
||||
+25
-24
@@ -79,32 +79,33 @@ export async function createServer(
|
||||
plc: {port: port2},
|
||||
})
|
||||
|
||||
// DISABLED - looks like dev-env added this and now it conflicts
|
||||
// add the test mod authority
|
||||
const agent = new BskyAgent({service: pdsUrl})
|
||||
const res = await agent.api.com.atproto.server.createAccount({
|
||||
email: 'mod-authority@test.com',
|
||||
handle: 'mod-authority.test',
|
||||
password: 'hunter2',
|
||||
})
|
||||
agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||
await agent.api.app.bsky.actor.profile.create(
|
||||
{repo: res.data.did},
|
||||
{
|
||||
displayName: 'Dev-env Moderation',
|
||||
description: `The pretend version of mod.bsky.app`,
|
||||
},
|
||||
)
|
||||
// const agent = new BskyAgent({service: pdsUrl})
|
||||
// const res = await agent.api.com.atproto.server.createAccount({
|
||||
// email: 'mod-authority@test.com',
|
||||
// handle: 'mod-authority.test',
|
||||
// password: 'hunter2',
|
||||
// })
|
||||
// agent.api.setHeader('Authorization', `Bearer ${res.data.accessJwt}`)
|
||||
// await agent.api.app.bsky.actor.profile.create(
|
||||
// {repo: res.data.did},
|
||||
// {
|
||||
// displayName: 'Dev-env Moderation',
|
||||
// description: `The pretend version of mod.bsky.app`,
|
||||
// },
|
||||
// )
|
||||
|
||||
await agent.api.app.bsky.labeler.service.create(
|
||||
{repo: res.data.did, rkey: 'self'},
|
||||
{
|
||||
policies: {
|
||||
labelValues: ['!hide', '!warn'],
|
||||
labelValueDefinitions: [],
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
)
|
||||
// await agent.api.app.bsky.labeler.service.create(
|
||||
// {repo: res.data.did, rkey: 'self'},
|
||||
// {
|
||||
// policies: {
|
||||
// labelValues: ['!hide', '!warn'],
|
||||
// labelValueDefinitions: [],
|
||||
// },
|
||||
// createdAt: new Date().toISOString(),
|
||||
// },
|
||||
// )
|
||||
|
||||
const pic = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'assets', 'default-avatar.png'),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import ExpoModulesCore
|
||||
|
||||
public class ExpoPlatformInfoModule: Module {
|
||||
private var prevAudioActive: Bool?
|
||||
private var prevAudioCategory: AVAudioSession.Category?
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("ExpoPlatformInfo")
|
||||
|
||||
@@ -10,19 +13,33 @@ public class ExpoPlatformInfoModule: Module {
|
||||
|
||||
Function("setAudioCategory") { (audioCategoryString: String) in
|
||||
let audioCategory = AVAudioSession.Category(rawValue: audioCategoryString)
|
||||
try? AVAudioSession.sharedInstance().setCategory(audioCategory)
|
||||
if audioCategory == self.prevAudioCategory {
|
||||
return
|
||||
}
|
||||
self.prevAudioCategory = audioCategory
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
try? AVAudioSession.sharedInstance().setCategory(audioCategory)
|
||||
}
|
||||
}
|
||||
|
||||
Function("setAudioActive") { (active: Bool) in
|
||||
if active == self.prevAudioActive {
|
||||
return
|
||||
}
|
||||
self.prevAudioActive = active
|
||||
if active {
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
}
|
||||
} else {
|
||||
try? AVAudioSession
|
||||
.sharedInstance()
|
||||
.setActive(
|
||||
false,
|
||||
options: [.notifyOthersOnDeactivation]
|
||||
)
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
try? AVAudioSession
|
||||
.sharedInstance()
|
||||
.setActive(
|
||||
false,
|
||||
options: [.notifyOthersOnDeactivation]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.90.0",
|
||||
"version": "1.91.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -32,7 +32,7 @@
|
||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||
"typecheck": "tsc --project ./tsconfig.check.json",
|
||||
"e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
|
||||
"e2e:mock-server": "NODE_ENV=development ./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
|
||||
"e2e:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
|
||||
"e2e:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
|
||||
"e2e:run": "maestro test __e2e__",
|
||||
@@ -52,7 +52,7 @@
|
||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.13.0",
|
||||
"@atproto/api": "0.13.5",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
@@ -199,7 +199,6 @@
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"rn-tourguide": "bluesky-social/rn-tourguide",
|
||||
"sentry-expo": "~7.0.1",
|
||||
"statsig-react-native-expo": "^4.6.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
|
||||
@@ -13,7 +13,7 @@ index bb74e80..0aa0202 100644
|
||||
Map<String, Object> constants = new HashMap<>(3);
|
||||
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
||||
diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
|
||||
index 109d3fe..c7fce9e 100644
|
||||
index 109d3fe..c421931 100644
|
||||
--- a/node_modules/expo-modules-core/build/uuid/uuid.js
|
||||
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js
|
||||
@@ -1,5 +1,7 @@
|
||||
@@ -24,3 +24,16 @@ index 109d3fe..c7fce9e 100644
|
||||
const nativeUuidv4 = globalThis?.expo?.uuidv4;
|
||||
const nativeUuidv5 = globalThis?.expo?.uuidv5;
|
||||
function uuidv4() {
|
||||
diff --git a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
|
||||
index ee2268a..4851b67 100644
|
||||
--- a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
|
||||
+++ b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
|
||||
@@ -173,7 +173,7 @@ public final class SharedObjectRegistry {
|
||||
}
|
||||
|
||||
internal func clear() {
|
||||
- Self.lockQueue.async {
|
||||
+ Self.lockQueue.sync {
|
||||
self.pairs.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
+258
-11
@@ -1,3 +1,27 @@
|
||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
||||
index 473f964..f37aff9 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
||||
@@ -41,6 +41,11 @@ sealed class PlayerEvent {
|
||||
override val name = "playToEnd"
|
||||
}
|
||||
|
||||
+ data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() {
|
||||
+ override val name = "timeRemainingChange"
|
||||
+ override val arguments = arrayOf(timeRemaining)
|
||||
+ }
|
||||
+
|
||||
fun emit(player: VideoPlayer, listeners: List<VideoPlayerListener>) {
|
||||
when (this) {
|
||||
is StatusChanged -> listeners.forEach { it.onStatusChanged(player, status, oldStatus, error) }
|
||||
@@ -49,6 +54,7 @@ sealed class PlayerEvent {
|
||||
is SourceChanged -> listeners.forEach { it.onSourceChanged(player, source, oldSource) }
|
||||
is PlaybackRateChanged -> listeners.forEach { it.onPlaybackRateChanged(player, rate, oldRate) }
|
||||
is PlayedToEnd -> listeners.forEach { it.onPlayedToEnd(player) }
|
||||
+ is PlayerTimeRemainingChanged -> listeners.forEach { it.onPlayerTimeRemainingChanged(player, timeRemaining) }
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -8,10 +32,10 @@ index 9905e13..47342ff 100644
|
||||
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)
|
||||
@@ -20,6 +44,42 @@ index 9905e13..47342ff 100644
|
||||
fullscreenButton?.visibility = if (visible) {
|
||||
android.view.View.VISIBLE
|
||||
} else {
|
||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
|
||||
new file mode 100644
|
||||
index 0000000..0249e23
|
||||
--- /dev/null
|
||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
|
||||
@@ -0,0 +1,29 @@
|
||||
+import android.os.Handler
|
||||
+import android.os.Looper
|
||||
+import androidx.annotation.OptIn
|
||||
+import androidx.media3.common.util.UnstableApi
|
||||
+import expo.modules.video.PlayerEvent
|
||||
+import expo.modules.video.VideoPlayer
|
||||
+import kotlin.math.floor
|
||||
+
|
||||
+@OptIn(UnstableApi::class)
|
||||
+class ProgressTracker(private val videoPlayer: VideoPlayer) : Runnable {
|
||||
+ private val handler: Handler = Handler(Looper.getMainLooper())
|
||||
+ private val player = videoPlayer.player
|
||||
+
|
||||
+ init {
|
||||
+ handler.post(this)
|
||||
+ }
|
||||
+
|
||||
+ override fun run() {
|
||||
+ val currentPosition = player.currentPosition
|
||||
+ val duration = player.duration
|
||||
+ val timeRemaining = floor(((duration - currentPosition) / 1000).toDouble())
|
||||
+ videoPlayer.sendEvent(PlayerEvent.PlayerTimeRemainingChanged(timeRemaining))
|
||||
+ handler.postDelayed(this, 1000 /* ms */)
|
||||
+ }
|
||||
+
|
||||
+ fun remove() {
|
||||
+ handler.removeCallbacks(this)
|
||||
+ }
|
||||
+}
|
||||
\ No newline at end of file
|
||||
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
|
||||
@@ -33,8 +93,76 @@ index ec3da2a..5a1397a 100644
|
||||
+ "onEnterFullscreen",
|
||||
+ "onExitFullscreen"
|
||||
)
|
||||
|
||||
|
||||
Prop("player") { view: VideoView, player: VideoPlayer ->
|
||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
||||
index 58f00af..5ad8237 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
||||
@@ -1,5 +1,6 @@
|
||||
package expo.modules.video
|
||||
|
||||
+import ProgressTracker
|
||||
import android.content.Context
|
||||
import android.view.SurfaceView
|
||||
import androidx.media3.common.MediaItem
|
||||
@@ -35,11 +36,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||
.Builder(context, renderersFactory)
|
||||
.setLooper(context.mainLooper)
|
||||
.build()
|
||||
+ var progressTracker: ProgressTracker? = null
|
||||
|
||||
val serviceConnection = PlaybackServiceConnection(WeakReference(player))
|
||||
|
||||
var playing by IgnoreSameSet(false) { new, old ->
|
||||
sendEvent(PlayerEvent.IsPlayingChanged(new, old))
|
||||
+ addOrRemoveProgressTracker()
|
||||
}
|
||||
|
||||
var uncommittedSource: VideoSource? = source
|
||||
@@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
+ this.progressTracker?.remove()
|
||||
+ this.progressTracker = null
|
||||
+
|
||||
appContext?.reactContext?.unbindService(serviceConnection)
|
||||
serviceConnection.playbackServiceBinder?.service?.unregisterPlayer(player)
|
||||
VideoManager.unregisterVideoPlayer(this@VideoPlayer)
|
||||
@@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||
listeners.removeAll { it.get() == videoPlayerListener }
|
||||
}
|
||||
|
||||
- private fun sendEvent(event: PlayerEvent) {
|
||||
+ fun sendEvent(event: PlayerEvent) {
|
||||
// Emits to the native listeners
|
||||
event.emit(this, listeners.mapNotNull { it.get() })
|
||||
// Emits to the JS side
|
||||
@@ -240,4 +246,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
||||
sendEvent(eventName, *args)
|
||||
}
|
||||
}
|
||||
+
|
||||
+ private fun addOrRemoveProgressTracker() {
|
||||
+ this.progressTracker?.remove()
|
||||
+ if (this.playing) {
|
||||
+ this.progressTracker = ProgressTracker(this)
|
||||
+ } else {
|
||||
+ this.progressTracker = null
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
||||
index f654254..dcfe3f0 100644
|
||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
||||
@@ -15,4 +15,5 @@ interface VideoPlayerListener {
|
||||
fun onSourceChanged(player: VideoPlayer, source: VideoSource?, oldSource: VideoSource?) {}
|
||||
fun onPlaybackRateChanged(player: VideoPlayer, rate: Float, oldRate: Float?) {}
|
||||
fun onPlayedToEnd(player: VideoPlayer) {}
|
||||
+ fun onPlayerTimeRemainingChanged(player: VideoPlayer, timeRemaining: Double) {}
|
||||
}
|
||||
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
|
||||
@@ -45,7 +173,7 @@ index a951d80..3932535 100644
|
||||
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
|
||||
@@ -55,7 +183,7 @@ index a951d80..3932535 100644
|
||||
+ 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)
|
||||
@@ -63,9 +191,22 @@ index a951d80..3932535 100644
|
||||
+ this.onExitFullscreen(mapOf())
|
||||
isInFullscreen = false
|
||||
}
|
||||
|
||||
|
||||
diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||
index a09fcfe..65fe29a 100644
|
||||
--- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||
+++ b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||
@@ -128,6 +128,8 @@ export type VideoPlayerEvents = {
|
||||
* Handler for an event emitted when the current media source of the player changes.
|
||||
*/
|
||||
sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
|
||||
+
|
||||
+ timeRemainingChange(timeRemaining: number): void;
|
||||
};
|
||||
/**
|
||||
* Describes the current status of the player.
|
||||
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
|
||||
index cb9ca6d..ed8bb7e 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 {
|
||||
@@ -77,6 +218,7 @@ index cb9ca6d..60e9f4e 100644
|
||||
+ onExitFullscreen?: () => void;
|
||||
}
|
||||
//# sourceMappingURL=VideoView.types.d.ts.map
|
||||
\ No newline at end of file
|
||||
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
|
||||
@@ -90,19 +232,111 @@ index c537a12..e4a918f 100644
|
||||
+ "onEnterFullscreen",
|
||||
+ "onExitFullscreen"
|
||||
)
|
||||
|
||||
|
||||
Prop("player") { (view, player: VideoPlayer?) in
|
||||
diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift
|
||||
index 3315b88..f482390 100644
|
||||
--- a/node_modules/expo-video/ios/VideoPlayer.swift
|
||||
+++ b/node_modules/expo-video/ios/VideoPlayer.swift
|
||||
@@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObse
|
||||
safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource)
|
||||
}
|
||||
|
||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {
|
||||
+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining)
|
||||
+ }
|
||||
+
|
||||
func safeEmit<each A: AnyArgument>(event: String, arguments: repeat each A) {
|
||||
if self.appContext != nil {
|
||||
self.emit(event: event, arguments: repeat each arguments)
|
||||
diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||
index d289e26..d0fdd30 100644
|
||||
--- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
|
||||
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?)
|
||||
func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool)
|
||||
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status)
|
||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double)
|
||||
}
|
||||
|
||||
// Default implementations for the delegate
|
||||
@@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate {
|
||||
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {}
|
||||
func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) {}
|
||||
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {}
|
||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {}
|
||||
}
|
||||
|
||||
// Wrapper used to store WeakReferences to the observer delegate
|
||||
@@ -91,6 +93,7 @@ class VideoPlayerObserver {
|
||||
private var playerVolumeObserver: NSKeyValueObservation?
|
||||
private var playerCurrentItemObserver: NSKeyValueObservation?
|
||||
private var playerIsMutedObserver: NSKeyValueObservation?
|
||||
+ private var playerPeriodicTimeObserver: Any?
|
||||
|
||||
// Current player item observers
|
||||
private var playbackBufferEmptyObserver: NSKeyValueObservation?
|
||||
@@ -152,6 +155,9 @@ class VideoPlayerObserver {
|
||||
playerVolumeObserver?.invalidate()
|
||||
playerIsMutedObserver?.invalidate()
|
||||
playerCurrentItemObserver?.invalidate()
|
||||
+ if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver {
|
||||
+ player?.removeTimeObserver(playerPeriodicTimeObserver)
|
||||
+ }
|
||||
}
|
||||
|
||||
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
|
||||
@@ -270,6 +276,7 @@ class VideoPlayerObserver {
|
||||
|
||||
if isPlaying != (player.timeControlStatus == .playing) {
|
||||
isPlaying = player.timeControlStatus == .playing
|
||||
+ addOrRemovePeriodicTimeObserver()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,4 +317,30 @@ class VideoPlayerObserver {
|
||||
}
|
||||
}
|
||||
}
|
||||
+
|
||||
+ private func onPlayerTimeRemainingChanged(_ player: AVPlayer, _ timeRemaining: Double) {
|
||||
+ delegates.forEach { delegate in
|
||||
+ delegate.value?.onPlayerTimeRemainingChanged(player: player, timeRemaining: timeRemaining)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ private func addOrRemovePeriodicTimeObserver() {
|
||||
+ guard let player = self.player else {
|
||||
+ return
|
||||
+ }
|
||||
+
|
||||
+ if isPlaying {
|
||||
+ // Add the time update listener
|
||||
+ playerPeriodicTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1.0, preferredTimescale: Int32(NSEC_PER_SEC)), queue: nil) { event in
|
||||
+ guard let duration = player.currentItem?.duration else {
|
||||
+ return
|
||||
+ }
|
||||
+
|
||||
+ let timeRemaining = (duration.seconds - event.seconds).rounded()
|
||||
+ self.onPlayerTimeRemainingChanged(player, timeRemaining)
|
||||
+ }
|
||||
+ } else if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver {
|
||||
+ player.removeTimeObserver(playerPeriodicTimeObserver)
|
||||
+ }
|
||||
+ }
|
||||
}
|
||||
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 {
|
||||
@@ -112,7 +346,7 @@ index f4579e4..10c5908 100644
|
||||
+ onEnterFullscreen()
|
||||
isFullscreen = true
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
||||
if wasPlaying {
|
||||
self.player?.pointer.play()
|
||||
@@ -121,6 +355,19 @@ index f4579e4..10c5908 100644
|
||||
self.isFullscreen = false
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||
index aaf4b63..f438196 100644
|
||||
--- a/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||
+++ b/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||
@@ -151,6 +151,8 @@ export type VideoPlayerEvents = {
|
||||
* Handler for an event emitted when the current media source of the player changes.
|
||||
*/
|
||||
sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
|
||||
+
|
||||
+ timeRemainingChange(timeRemaining: number): void;
|
||||
};
|
||||
|
||||
/**
|
||||
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
|
||||
|
||||
+10
-10
@@ -50,8 +50,9 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
|
||||
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {Shell} from '#/view/shell'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
@@ -59,7 +60,6 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
@@ -122,10 +122,10 @@ function InnerApp() {
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<TourProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
@@ -133,10 +133,10 @@ function InnerApp() {
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
|
||||
+12
-12
@@ -39,7 +39,8 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
|
||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||
import {Shell} from '#/view/shell/index'
|
||||
@@ -47,7 +48,6 @@ import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
|
||||
function InnerApp() {
|
||||
@@ -105,19 +105,19 @@ function InnerApp() {
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<TourProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
|
||||
+66
-58
@@ -15,10 +15,12 @@ import {
|
||||
StackActions,
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {buildStateObject} from 'lib/routes/helpers'
|
||||
import {init as initAnalytics} from '#/lib/analytics/analytics'
|
||||
import {timeout} from '#/lib/async/timeout'
|
||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
|
||||
import {buildStateObject} from '#/lib/routes/helpers'
|
||||
import {
|
||||
AllNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
@@ -28,20 +30,62 @@ import {
|
||||
MyProfileTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
} from 'lib/routes/types'
|
||||
import {RouteParams, State} from 'lib/routes/types'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {isAndroid, isNative, isWeb} from 'platform/detection'
|
||||
} from '#/lib/routes/types'
|
||||
import {RouteParams, State} from '#/lib/routes/types'
|
||||
import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig'
|
||||
import {bskyTitle} from '#/lib/strings/headings'
|
||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {
|
||||
shouldRequestEmailConfirmation,
|
||||
snoozeEmailConfirmationPrompt,
|
||||
} from '#/state/shell/reminders'
|
||||
import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings'
|
||||
import {AppPasswords} from '#/view/screens/AppPasswords'
|
||||
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
|
||||
import {DebugModScreen} from '#/view/screens/DebugMod'
|
||||
import {FeedsScreen} from '#/view/screens/Feeds'
|
||||
import {HomeScreen} from '#/view/screens/Home'
|
||||
import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings'
|
||||
import {ListsScreen} from '#/view/screens/Lists'
|
||||
import {LogScreen} from '#/view/screens/Log'
|
||||
import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts'
|
||||
import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
|
||||
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
|
||||
import {NotFoundScreen} from '#/view/screens/NotFound'
|
||||
import {NotificationsScreen} from '#/view/screens/Notifications'
|
||||
import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings'
|
||||
import {PostThreadScreen} from '#/view/screens/PostThread'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {AppPasswords} from 'view/screens/AppPasswords'
|
||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
|
||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed'
|
||||
import {PreferencesThreads} from '#/view/screens/PreferencesThreads'
|
||||
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
|
||||
import {ProfileScreen} from '#/view/screens/Profile'
|
||||
import {ProfileFeedScreen} from '#/view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
|
||||
import {ProfileFollowersScreen} from '#/view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows'
|
||||
import {ProfileListScreen} from '#/view/screens/ProfileList'
|
||||
import {SavedFeeds} from '#/view/screens/SavedFeeds'
|
||||
import {SearchScreen} from '#/view/screens/Search'
|
||||
import {SettingsScreen} from '#/view/screens/Settings'
|
||||
import {Storybook} from '#/view/screens/Storybook'
|
||||
import {SupportScreen} from '#/view/screens/Support'
|
||||
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
|
||||
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
|
||||
import {MessagesScreen} from '#/screens/Messages/List'
|
||||
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
|
||||
import {PostQuotesScreen} from '#/screens/Post/PostQuotes'
|
||||
import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
|
||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||
@@ -50,51 +94,8 @@ import {
|
||||
StarterPackScreenShort,
|
||||
} from '#/screens/StarterPack/StarterPackScreen'
|
||||
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
import {router} from './routes'
|
||||
import {MessagesConversationScreen} from './screens/Messages/Conversation'
|
||||
import {MessagesScreen} from './screens/Messages/List'
|
||||
import {MessagesSettingsScreen} from './screens/Messages/Settings'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
import {
|
||||
shouldRequestEmailConfirmation,
|
||||
snoozeEmailConfirmationPrompt,
|
||||
} from './state/shell/reminders'
|
||||
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||
import {DebugModScreen} from './view/screens/DebugMod'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {LogScreen} from './view/screens/Log'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||
import {ProfileScreen} from './view/screens/Profile'
|
||||
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
|
||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
import {Storybook} from './view/screens/Storybook'
|
||||
import {SupportScreen} from './view/screens/Support'
|
||||
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
|
||||
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -212,6 +213,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
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
|
||||
name="ProfileFeed"
|
||||
getComponent={() => ProfileFeedScreen}
|
||||
|
||||
+55
-25
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AccessibilityProps,
|
||||
GestureResponderEvent,
|
||||
MouseEvent,
|
||||
Pressable,
|
||||
PressableProps,
|
||||
StyleProp,
|
||||
@@ -65,7 +67,15 @@ type NonTextElements =
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
|
||||
| 'disabled'
|
||||
| 'onPress'
|
||||
| 'testID'
|
||||
| 'onLongPress'
|
||||
| 'hitSlop'
|
||||
| 'onHoverIn'
|
||||
| 'onHoverOut'
|
||||
| 'onPressIn'
|
||||
| 'onPressOut'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
@@ -115,30 +125,50 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
focused: false,
|
||||
})
|
||||
|
||||
const onPressIn = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onPressOut = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: false,
|
||||
}))
|
||||
}, [setState])
|
||||
const onHoverIn = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onHoverOut = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: false,
|
||||
}))
|
||||
}, [setState])
|
||||
const onPressInOuter = rest.onPressIn
|
||||
const onPressIn = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: true,
|
||||
}))
|
||||
onPressInOuter?.(e)
|
||||
},
|
||||
[setState, onPressInOuter],
|
||||
)
|
||||
const onPressOutOuter = rest.onPressOut
|
||||
const onPressOut = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: false,
|
||||
}))
|
||||
onPressOutOuter?.(e)
|
||||
},
|
||||
[setState, onPressOutOuter],
|
||||
)
|
||||
const onHoverInOuter = rest.onHoverIn
|
||||
const onHoverIn = React.useCallback(
|
||||
(e: MouseEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: true,
|
||||
}))
|
||||
onHoverInOuter?.(e)
|
||||
},
|
||||
[setState, onHoverInOuter],
|
||||
)
|
||||
const onHoverOutOuter = rest.onHoverOut
|
||||
const onHoverOut = React.useCallback(
|
||||
(e: MouseEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: false,
|
||||
}))
|
||||
onHoverOutOuter?.(e)
|
||||
},
|
||||
[setState, onHoverOutOuter],
|
||||
)
|
||||
const onFocus = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
|
||||
@@ -2,21 +2,18 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useGoBack} from 'lib/hooks/useGoBack'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export function Error({
|
||||
title,
|
||||
message,
|
||||
onRetry,
|
||||
onGoBack: onGoBackProp,
|
||||
onGoBack,
|
||||
hideBackButton,
|
||||
sideBorders = true,
|
||||
}: {
|
||||
@@ -27,31 +24,10 @@ export function Error({
|
||||
hideBackButton?: boolean
|
||||
sideBorders?: boolean
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const onGoBack = React.useCallback(() => {
|
||||
if (onGoBackProp) {
|
||||
onGoBackProp()
|
||||
return
|
||||
}
|
||||
if (canGoBack) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
|
||||
// Checking the state for routes ensures that web doesn't encounter errors while going back
|
||||
if (navigation.getState()?.routes) {
|
||||
navigation.dispatch(StackActions.push(...router.matchPath('/')))
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
}
|
||||
}
|
||||
}, [navigation, canGoBack, onGoBackProp])
|
||||
const goBack = useGoBack(onGoBack)
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
@@ -96,7 +72,7 @@ export function Error({
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={_(msg`Return to previous page`)}
|
||||
onPress={onGoBack}
|
||||
onPress={goBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
<ButtonText>
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
moderateUserList,
|
||||
ModerationUI,
|
||||
} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useModerationOpts} from 'state/preferences/moderation-opts'
|
||||
import {precacheList} from 'state/queries/feed'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {useSession} from 'state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
Avatar,
|
||||
Description,
|
||||
@@ -16,6 +23,7 @@ import {
|
||||
SaveButton,
|
||||
} from '#/components/FeedCard'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
/*
|
||||
@@ -43,6 +51,11 @@ type Props = {
|
||||
|
||||
export function Default(props: Props) {
|
||||
const {view, showPinButton} = props
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = moderationOpts
|
||||
? moderateUserList(view, moderationOpts)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Link {...props}>
|
||||
<Outer>
|
||||
@@ -52,6 +65,7 @@ export function Default(props: Props) {
|
||||
title={view.name}
|
||||
creator={view.creator}
|
||||
purpose={view.purpose}
|
||||
modUi={moderation?.ui('contentView')}
|
||||
/>
|
||||
{showPinButton && view.purpose === CURATELIST && (
|
||||
<SaveButton view={view} pin />
|
||||
@@ -89,18 +103,40 @@ export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
purpose = CURATELIST,
|
||||
modUi,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
modUi?: ModerationUI
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Hider.Outer
|
||||
modui={modUi}
|
||||
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 && (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
import type {ContextType} from '#/components/Menu/types'
|
||||
import type {ContextType, ItemContextType} from '#/components/Menu/types'
|
||||
|
||||
export const Context = React.createContext<ContextType>({
|
||||
// @ts-ignore
|
||||
control: null,
|
||||
})
|
||||
|
||||
export const ItemContext = React.createContext<ItemContextType>({
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {Context, ItemContext} from '#/components/Menu/context'
|
||||
import {
|
||||
ContextType,
|
||||
GroupProps,
|
||||
@@ -125,8 +125,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onPressIn={e => {
|
||||
onPressIn()
|
||||
rest.onPressIn?.(e)
|
||||
}}
|
||||
onPressOut={e => {
|
||||
onPressOut()
|
||||
rest.onPressOut?.(e)
|
||||
}}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
@@ -138,15 +144,18 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
t.atoms.border_contrast_low,
|
||||
{minHeight: 44, paddingVertical: 10},
|
||||
style,
|
||||
(focused || pressed) && [t.atoms.bg_contrast_50],
|
||||
(focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
|
||||
]}>
|
||||
{children}
|
||||
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
|
||||
{children}
|
||||
</ItemContext.Provider>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemText({children, style}: ItemTextProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = React.useContext(ItemContext)
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
@@ -155,9 +164,10 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
t.atoms.text_contrast_high,
|
||||
{paddingTop: 3},
|
||||
style,
|
||||
disabled && t.atoms.text_contrast_low,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
@@ -166,7 +176,17 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
|
||||
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
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) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import {atoms as a, flatten, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {Context, ItemContext} from '#/components/Menu/context'
|
||||
import {
|
||||
ContextType,
|
||||
GroupProps,
|
||||
@@ -239,18 +239,21 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
||||
a.rounded_xs,
|
||||
{minHeight: 32, paddingHorizontal: 10},
|
||||
web({outline: 0}),
|
||||
(hovered || focused) && [
|
||||
web({outline: '0 !important'}),
|
||||
t.name === 'light'
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg_contrast_50,
|
||||
],
|
||||
(hovered || focused) &&
|
||||
!rest.disabled && [
|
||||
web({outline: '0 !important'}),
|
||||
t.name === 'light'
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg_contrast_50,
|
||||
],
|
||||
])}
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}>
|
||||
{children}
|
||||
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
|
||||
{children}
|
||||
</ItemContext.Provider>
|
||||
</Pressable>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
@@ -258,8 +261,16 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
||||
|
||||
export function ItemText({children, style}: ItemTextProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = React.useContext(ItemContext)
|
||||
return (
|
||||
<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}
|
||||
</Text>
|
||||
)
|
||||
@@ -267,10 +278,9 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
|
||||
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = React.useContext(ItemContext)
|
||||
return (
|
||||
<Comp
|
||||
size="md"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
<View
|
||||
style={[
|
||||
position === 'left' && {
|
||||
marginLeft: -2,
|
||||
@@ -279,8 +289,16 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
marginRight: -2,
|
||||
marginLeft: 12,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
]}>
|
||||
<Comp
|
||||
size="md"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AccessibilityProps,
|
||||
GestureResponderEvent,
|
||||
PressableProps,
|
||||
AccessibilityProps,
|
||||
} from 'react-native'
|
||||
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {TextStyleProp, ViewStyleProp} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
|
||||
export type ContextType = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
}
|
||||
|
||||
export type ItemContextType = {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type RadixPassThroughTriggerProps = {
|
||||
id: string
|
||||
type: 'button'
|
||||
|
||||
@@ -13,6 +13,15 @@ import {
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type AppModerationCause =
|
||||
| ModerationCause
|
||||
| {
|
||||
type: 'reply-hidden'
|
||||
source: {type: 'user'; did: string}
|
||||
priority: 6
|
||||
downgraded?: boolean
|
||||
}
|
||||
|
||||
export type CommonProps = {
|
||||
size?: 'sm' | 'lg'
|
||||
}
|
||||
@@ -40,7 +49,7 @@ export function Row({
|
||||
}
|
||||
|
||||
export type LabelProps = {
|
||||
cause: ModerationCause
|
||||
cause: AppModerationCause
|
||||
disableDetailsDialog?: boolean
|
||||
noBg?: boolean
|
||||
} & CommonProps
|
||||
|
||||
@@ -377,7 +377,7 @@ function Inner({
|
||||
hide: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {_, i18n} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const moderation = React.useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
@@ -393,8 +393,8 @@ function Inner({
|
||||
profile.viewer?.blocking ||
|
||||
profile.viewer?.blockedBy ||
|
||||
profile.viewer?.blockingByList
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const following = formatCount(i18n, profile.followsCount || 0)
|
||||
const followers = formatCount(i18n, profile.followersCount || 0)
|
||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||
one: 'follower',
|
||||
other: 'followers',
|
||||
|
||||
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -37,7 +36,6 @@ export function SubmitView({
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const gate = useGate()
|
||||
const [details, setDetails] = React.useState<string>('')
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||
@@ -63,27 +61,17 @@ export function SubmitView({
|
||||
}
|
||||
const results = await Promise.all(
|
||||
selectedServices.map(did => {
|
||||
if (gate('session_withproxy_fix')) {
|
||||
return agent
|
||||
.createModerationReport(report, {
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${did}#atproto_labeler`,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
_ => true,
|
||||
_ => false,
|
||||
)
|
||||
} else {
|
||||
return agent
|
||||
.withProxy('atproto_labeler', did)
|
||||
.createModerationReport(report)
|
||||
.then(
|
||||
_ => true,
|
||||
_ => false,
|
||||
)
|
||||
}
|
||||
return agent
|
||||
.createModerationReport(report, {
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${did}#atproto_labeler`,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
_ => true,
|
||||
_ => false,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -108,7 +96,6 @@ export function SubmitView({
|
||||
onSubmitComplete,
|
||||
setError,
|
||||
agent,
|
||||
gate,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,14 +9,15 @@ import {
|
||||
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {useListMembersQuery} from 'state/queries/list-members'
|
||||
import {useAllListMembersQuery} from 'state/queries/list-members'
|
||||
import {useSession} from 'state/session'
|
||||
import {List, ListRef} from 'view/com/util/List'
|
||||
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Default as ProfileCard} from '#/components/ProfileCard'
|
||||
|
||||
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
|
||||
@@ -39,17 +40,20 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
ref,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const [initialHeaderHeight] = React.useState(headerHeight)
|
||||
const bottomBarOffset = useBottomBarOffset(20)
|
||||
const bottomBarOffset = useBottomBarOffset(200)
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const {currentAccount} = useSession()
|
||||
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
|
||||
const {data, refetch, isError} = useAllListMembersQuery(listUri)
|
||||
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
|
||||
// The server returns these sorted by descending creation date, so we want to invert
|
||||
const profiles = data?.pages
|
||||
.flatMap(p => p.items.map(i => i.subject))
|
||||
.filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
|
||||
|
||||
const profiles = data
|
||||
?.filter(
|
||||
p => !isBlockedOrBlocking(p.subject) && !p.subject.associated?.labeler,
|
||||
)
|
||||
.map(p => p.subject)
|
||||
.reverse()
|
||||
const isOwn = new AtUri(listUri).host === currentAccount?.did
|
||||
|
||||
@@ -99,7 +103,11 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
|
||||
<View
|
||||
style={[
|
||||
a.h_full_vh,
|
||||
{marginTop: headerHeight, marginBottom: bottomBarOffset},
|
||||
]}>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={true}
|
||||
isError={isError}
|
||||
@@ -118,10 +126,13 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
ref={scrollElRef}
|
||||
headerOffset={headerHeight}
|
||||
ListFooterComponent={
|
||||
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
|
||||
<ListFooter
|
||||
style={{paddingBottom: bottomBarOffset, borderTopWidth: 0}}
|
||||
/>
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={async () => {
|
||||
setIsPTRing(true)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
@@ -17,7 +16,11 @@ import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {BaseLink} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
|
||||
export function Default({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
<Link starterPack={starterPack}>
|
||||
@@ -29,7 +32,7 @@ export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
|
||||
export function Notification({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: StarterPackViewBasic
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
@@ -44,7 +47,7 @@ export function Card({
|
||||
noIcon,
|
||||
noDescription,
|
||||
}: {
|
||||
starterPack: StarterPackViewBasic
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
noIcon?: boolean
|
||||
noDescription?: boolean
|
||||
}) {
|
||||
@@ -94,7 +97,7 @@ export function Link({
|
||||
starterPack,
|
||||
children,
|
||||
}: {
|
||||
starterPack: StarterPackViewBasic
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
onPress?: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
@@ -129,7 +132,11 @@ export function Link({
|
||||
)
|
||||
}
|
||||
|
||||
export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
|
||||
export function Embed({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const imageUri = getStarterPackOgCard(starterPack)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useSession} from 'state/session'
|
||||
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||
@@ -42,6 +43,7 @@ export function WizardEditListDialog({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
|
||||
@@ -148,6 +150,7 @@ export function WizardEditListDialog({
|
||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||
keyboardDismissMode="on-drag"
|
||||
removeClippedSubviews={true}
|
||||
initialNumToRender={initialNumToRender}
|
||||
/>
|
||||
</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 {useLingui} from '@lingui/react'
|
||||
|
||||
import {DISCOVER_FEED_URI} from 'lib/constants'
|
||||
import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from 'lib/constants'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useSession} from 'state/session'
|
||||
@@ -130,7 +130,8 @@ export function WizardProfileCard({
|
||||
|
||||
const isMe = profile.did === currentAccount?.did
|
||||
const included = isMe || state.profiles.some(p => p.did === profile.did)
|
||||
const disabled = isMe || (!included && state.profiles.length >= 49)
|
||||
const disabled =
|
||||
isMe || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
|
||||
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
|
||||
const displayName = profile.displayName
|
||||
? sanitizeDisplayName(profile.displayName)
|
||||
|
||||
+129
-168
@@ -1,39 +1,34 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetPostThread,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {createThreadgate} from '#/lib/api'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
|
||||
import {
|
||||
ThreadgateSetting,
|
||||
threadgateViewToSettings,
|
||||
ThreadgateAllowUISetting,
|
||||
threadgateViewToAllowUISetting,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {
|
||||
PostInteractionSettingsDialog,
|
||||
usePrefetchPostInteractionSettings,
|
||||
} from '#/components/dialogs/PostInteractionSettingsDialog'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TextLink} from '../view/com/util/Link'
|
||||
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
|
||||
|
||||
interface WhoCanReplyProps {
|
||||
@@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
const t = useTheme()
|
||||
const infoDialogControl = useDialogControl()
|
||||
const editDialogControl = useDialogControl()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const settings = React.useMemo(
|
||||
() => threadgateViewToSettings(post.threadgate),
|
||||
[post],
|
||||
)
|
||||
const isRootPost = !('reply' in post.record)
|
||||
/*
|
||||
* `WhoCanReply` is only used for root posts atm, in case this changes
|
||||
* unexpectedly, we should check to make sure it's for sure the root URI.
|
||||
*/
|
||||
const rootUri =
|
||||
AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
|
||||
? post.record.reply.root.uri
|
||||
: post.uri
|
||||
const settings = React.useMemo(() => {
|
||||
return threadgateViewToAllowUISetting(post.threadgate)
|
||||
}, [post.threadgate])
|
||||
|
||||
if (!isRootPost) {
|
||||
return null
|
||||
}
|
||||
if (!settings.length && !isThreadAuthor) {
|
||||
return null
|
||||
}
|
||||
const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
|
||||
postUri: post.uri,
|
||||
rootPostUri: rootUri,
|
||||
})
|
||||
|
||||
const isEverybody = settings.length === 0
|
||||
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
||||
const description = isEverybody
|
||||
const anyoneCanReply =
|
||||
settings.length === 1 && settings[0].type === 'everybody'
|
||||
const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
|
||||
const description = anyoneCanReply
|
||||
? _(msg`Everybody can reply`)
|
||||
: isNobody
|
||||
: noOneCanReply
|
||||
? _(msg`Replies disabled`)
|
||||
: _(msg`Some people can reply`)
|
||||
|
||||
const onPressEdit = () => {
|
||||
const onPressOpen = () => {
|
||||
if (isNative && Keyboard.isVisible()) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
@@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
|
||||
if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (newSettings.length) {
|
||||
await createThreadgate(agent, post.uri, newSettings)
|
||||
} else {
|
||||
await agent.api.com.atproto.repo.deleteRecord({
|
||||
repo: agent.session!.did,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: new AtUri(post.uri).rkey,
|
||||
})
|
||||
}
|
||||
await whenAppViewReady(agent, post.uri, res => {
|
||||
const thread = res.data.thread
|
||||
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
|
||||
const fetchedSettings = threadgateViewToSettings(
|
||||
thread.post.threadgate,
|
||||
)
|
||||
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
|
||||
}
|
||||
return false
|
||||
})
|
||||
Toast.show(_(msg`Thread settings updated`))
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [POST_THREAD_RQKEY_ROOT],
|
||||
})
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`There was an issue. Please check your internet connection and try again.`,
|
||||
),
|
||||
'xmark',
|
||||
)
|
||||
logger.error('Failed to edit threadgate', {message: err})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={
|
||||
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}>
|
||||
{({hovered}) => (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
|
||||
@@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
]}>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{isThreadAuthor && (
|
||||
<PencilLine width={12} fill={t.palette.primary_500} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
<WhoCanReplyDialog
|
||||
control={infoDialogControl}
|
||||
post={post}
|
||||
settings={settings}
|
||||
/>
|
||||
{isThreadAuthor && (
|
||||
<ThreadgateEditorDialog
|
||||
|
||||
{isThreadAuthor ? (
|
||||
<PostInteractionSettingsDialog
|
||||
postUri={post.uri}
|
||||
rootPostUri={rootUri}
|
||||
control={editDialogControl}
|
||||
threadgate={settings}
|
||||
onConfirm={onEditConfirm}
|
||||
initialThreadgateView={post.threadgate}
|
||||
/>
|
||||
) : (
|
||||
<WhoCanReplyDialog
|
||||
control={infoDialogControl}
|
||||
post={post}
|
||||
settings={settings}
|
||||
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -174,7 +148,7 @@ function Icon({
|
||||
}: {
|
||||
color: string
|
||||
width?: number
|
||||
settings: ThreadgateSetting[]
|
||||
settings: ThreadgateAllowUISetting[]
|
||||
}) {
|
||||
const isEverybody = settings.length === 0
|
||||
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
||||
@@ -186,79 +160,84 @@ function WhoCanReplyDialog({
|
||||
control,
|
||||
post,
|
||||
settings,
|
||||
embeddingDisabled,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
post: AppBskyFeedDefs.PostView
|
||||
settings: ThreadgateSetting[]
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<WhoCanReplyDialogInner post={post} settings={settings} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function WhoCanReplyDialogInner({
|
||||
post,
|
||||
settings,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
settings: ThreadgateSetting[]
|
||||
settings: ThreadgateAllowUISetting[]
|
||||
embeddingDisabled: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Who can reply dialog`)}
|
||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_bold, a.text_xl]}>
|
||||
<Trans>Who can reply?</Trans>
|
||||
</Text>
|
||||
<Rules post={post} settings={settings} />
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Dialog: adjust who can interact with this post`)}
|
||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
|
||||
<Trans>Who can interact with this post?</Trans>
|
||||
</Text>
|
||||
<Rules
|
||||
post={post}
|
||||
settings={settings}
|
||||
embeddingDisabled={embeddingDisabled}
|
||||
/>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Rules({
|
||||
post,
|
||||
settings,
|
||||
embeddingDisabled,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
settings: ThreadgateSetting[]
|
||||
settings: ThreadgateAllowUISetting[]
|
||||
embeddingDisabled: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_tight,
|
||||
a.flex_wrap,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{!settings.length ? (
|
||||
<Trans>Everybody can reply</Trans>
|
||||
) : settings[0].type === 'nobody' ? (
|
||||
<Trans>Replies to this thread are disabled</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Only{' '}
|
||||
{settings.map((rule, i) => (
|
||||
<>
|
||||
<Rule
|
||||
key={`rule-${i}`}
|
||||
rule={rule}
|
||||
post={post}
|
||||
lists={post.threadgate!.lists}
|
||||
/>
|
||||
<Separator key={`sep-${i}`} i={i} length={settings.length} />
|
||||
</>
|
||||
))}{' '}
|
||||
can reply
|
||||
</Trans>
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.flex_wrap,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{settings[0].type === 'everybody' ? (
|
||||
<Trans>Everybody can reply to this post.</Trans>
|
||||
) : settings[0].type === 'nobody' ? (
|
||||
<Trans>Replies to this post are disabled.</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Only{' '}
|
||||
{settings.map((rule, i) => (
|
||||
<React.Fragment key={`rule-${i}`}>
|
||||
<Rule rule={rule} post={post} lists={post.threadgate!.lists} />
|
||||
<Separator i={i} length={settings.length} />
|
||||
</React.Fragment>
|
||||
))}{' '}
|
||||
can reply.
|
||||
</Trans>
|
||||
)}{' '}
|
||||
</Text>
|
||||
{embeddingDisabled && (
|
||||
<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,
|
||||
lists,
|
||||
}: {
|
||||
rule: ThreadgateSetting
|
||||
rule: ThreadgateAllowUISetting
|
||||
post: AppBskyFeedDefs.PostView
|
||||
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
|
||||
}) {
|
||||
const t = useTheme()
|
||||
if (rule.type === 'mention') {
|
||||
return <Trans>mentioned users</Trans>
|
||||
}
|
||||
@@ -279,12 +257,12 @@ function Rule({
|
||||
return (
|
||||
<Trans>
|
||||
users followed by{' '}
|
||||
<TextLink
|
||||
type="sm"
|
||||
href={makeProfileLink(post.author)}
|
||||
text={`@${post.author.handle}`}
|
||||
style={{color: t.palette.primary_500}}
|
||||
/>
|
||||
<InlineLinkText
|
||||
label={`@${post.author.handle}`}
|
||||
to={makeProfileLink(post.author)}
|
||||
style={[a.text_sm, a.leading_snug]}>
|
||||
@{post.author.handle}
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
@@ -294,12 +272,12 @@ function Rule({
|
||||
const listUrip = new AtUri(list.uri)
|
||||
return (
|
||||
<Trans>
|
||||
<TextLink
|
||||
type="sm"
|
||||
href={makeListLink(listUrip.hostname, listUrip.rkey)}
|
||||
text={list.name}
|
||||
style={{color: t.palette.primary_500}}
|
||||
/>{' '}
|
||||
<InlineLinkText
|
||||
label={list.name}
|
||||
to={makeListLink(listUrip.hostname, listUrip.rkey)}
|
||||
style={[a.text_sm, a.leading_snug]}>
|
||||
{list.name}
|
||||
</InlineLinkText>{' '}
|
||||
members
|
||||
</Trans>
|
||||
)
|
||||
@@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
|
||||
}
|
||||
return <>, </>
|
||||
}
|
||||
|
||||
async function whenAppViewReady(
|
||||
agent: BskyAgent,
|
||||
uri: string,
|
||||
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
|
||||
) {
|
||||
await until(
|
||||
5, // 5 tries
|
||||
1e3, // 1s delay between tries
|
||||
fn,
|
||||
() =>
|
||||
agent.app.bsky.feed.getPostThread({
|
||||
uri,
|
||||
depth: 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ function EmbedDialogInner({
|
||||
timestamp,
|
||||
}: Omit<EmbedDialogProps, 'control'>) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {_, i18n} = useLingui()
|
||||
const ref = useRef<TextInput>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -86,9 +86,9 @@ function EmbedDialogInner({
|
||||
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
|
||||
postAuthor.handle,
|
||||
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||
niceDate(timestamp),
|
||||
niceDate(i18n, timestamp),
|
||||
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||
}, [postUri, postCid, record, timestamp, postAuthor])
|
||||
}, [i18n, postUri, postCid, record, timestamp, postAuthor])
|
||||
|
||||
return (
|
||||
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChatBskyConvoDefs,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -153,14 +154,14 @@ let MessageItemMetadata = ({
|
||||
)
|
||||
|
||||
const relativeTimestamp = useCallback(
|
||||
(timestamp: string) => {
|
||||
(i18n: I18n, timestamp: string) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
|
||||
const time = new Intl.DateTimeFormat(undefined, {
|
||||
const time = i18n.date(date, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
}).format(date)
|
||||
})
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
@@ -182,13 +183,13 @@ let MessageItemMetadata = ({
|
||||
return _(msg`Yesterday, ${time}`)
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
return i18n.date(date, {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
})
|
||||
},
|
||||
[_],
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {localizeDate} from './utils'
|
||||
|
||||
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
|
||||
// iOS: open a dialog with an inline date picker
|
||||
@@ -25,6 +25,7 @@ export function DateFieldButton({
|
||||
isInvalid?: boolean
|
||||
accessibilityHint?: string
|
||||
}) {
|
||||
const {i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {
|
||||
@@ -91,7 +92,7 @@ export function DateFieldButton({
|
||||
t.atoms.text,
|
||||
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||
]}>
|
||||
{localizeDate(value)}
|
||||
{i18n.date(value, {timeZone: 'UTC'})}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import {getLocales} from 'expo-localization'
|
||||
|
||||
const LOCALE = getLocales()[0]
|
||||
|
||||
// we need the date in the form yyyy-MM-dd to pass to the input
|
||||
export function toSimpleDateString(date: Date | string): string {
|
||||
const _date = typeof date === 'string' ? new Date(date) : date
|
||||
return _date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
export function localizeDate(date: Date | string): string {
|
||||
const _date = typeof date === 'string' ? new Date(date) : date
|
||||
return new Intl.DateTimeFormat(LOCALE.languageTag, {
|
||||
timeZone: 'UTC',
|
||||
}).format(_date)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ export function useInteractionState() {
|
||||
|
||||
const onIn = React.useCallback(() => {
|
||||
setState(true)
|
||||
}, [setState])
|
||||
}, [])
|
||||
const onOut = React.useCallback(() => {
|
||||
setState(false)
|
||||
}, [setState])
|
||||
}, [])
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from 'react'
|
||||
|
||||
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 [throttledValue, setThrottledValue] = useState(value)
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
@@ -204,7 +203,6 @@ function AppealForm({
|
||||
const [details, setDetails] = React.useState('')
|
||||
const isAccountReport = 'did' in subject
|
||||
const agent = useAgent()
|
||||
const gate = useGate()
|
||||
const sourceName = labeler
|
||||
? sanitizeHandle(labeler.creator.handle, '@')
|
||||
: label.src
|
||||
@@ -214,35 +212,22 @@ function AppealForm({
|
||||
const $type = !isAccountReport
|
||||
? 'com.atproto.repo.strongRef'
|
||||
: 'com.atproto.admin.defs#repoRef'
|
||||
if (gate('session_withproxy_fix')) {
|
||||
await agent.createModerationReport(
|
||||
{
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type,
|
||||
...subject,
|
||||
},
|
||||
reason: details,
|
||||
await agent.createModerationReport(
|
||||
{
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type,
|
||||
...subject,
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${label.src}#atproto_labeler`,
|
||||
},
|
||||
reason: details,
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${label.src}#atproto_labeler`,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
await agent
|
||||
.withProxy('atproto_labeler', label.src)
|
||||
.createModerationReport({
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type,
|
||||
...subject,
|
||||
},
|
||||
reason: details,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onError: err => {
|
||||
logger.error('Failed to submit label appeal', {message: err})
|
||||
|
||||
@@ -8,17 +8,19 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {listUriToHref} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
|
||||
|
||||
export interface ModerationDetailsDialogProps {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
modcause: ModerationCause
|
||||
modcause?: ModerationCause | AppModerationCause
|
||||
}
|
||||
|
||||
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
|
||||
@@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const desc = useModerationCauseDescription(modcause)
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
let name
|
||||
let description
|
||||
@@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({
|
||||
} else if (modcause.type === 'hidden') {
|
||||
name = _(msg`Post Hidden by You`)
|
||||
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') {
|
||||
name = desc.name
|
||||
description = desc.description
|
||||
@@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({
|
||||
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
|
||||
{name}
|
||||
</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}
|
||||
</Text>
|
||||
|
||||
{modcause.type === 'label' && (
|
||||
<>
|
||||
{modcause?.type === 'label' && (
|
||||
<View style={[a.pt_lg]}>
|
||||
<Divider />
|
||||
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
|
||||
{modcause.source.type === 'user' ? (
|
||||
@@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isNative && <View style={{height: 40}} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {ModerationCause, ModerationUI} from '@atproto/api'
|
||||
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
import * as Pills from '#/components/Pills'
|
||||
@@ -9,13 +9,15 @@ export function PostAlerts({
|
||||
modui,
|
||||
size = 'sm',
|
||||
style,
|
||||
additionalCauses,
|
||||
}: {
|
||||
modui: ModerationUI
|
||||
size?: Pills.CommonProps['size']
|
||||
includeMute?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
|
||||
}) {
|
||||
if (!modui.alert && !modui.inform) {
|
||||
if (!modui.alert && !modui.inform && !additionalCauses?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -37,6 +39,14 @@ export function PostAlerts({
|
||||
noBg={size === 'sm'}
|
||||
/>
|
||||
))}
|
||||
{additionalCauses?.map(cause => (
|
||||
<Pills.Label
|
||||
key={getModerationCauseKey(cause)}
|
||||
cause={cause}
|
||||
size={size}
|
||||
noBg={size === 'sm'}
|
||||
/>
|
||||
))}
|
||||
</Pills.Row>
|
||||
)
|
||||
}
|
||||
|
||||
+31
-17
@@ -23,6 +23,7 @@ type FeedSliceItem = {
|
||||
record: AppBskyFeedPost.Record
|
||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
isParentBlocked: boolean
|
||||
isParentNotFound: boolean
|
||||
}
|
||||
|
||||
type AuthorContext = {
|
||||
@@ -68,6 +69,7 @@ export class FeedViewPostsSlice {
|
||||
}
|
||||
const parent = reply?.parent
|
||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
|
||||
const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent)
|
||||
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||
parentAuthor = parent.author
|
||||
@@ -77,6 +79,7 @@ export class FeedViewPostsSlice {
|
||||
record: post.record,
|
||||
parentAuthor,
|
||||
isParentBlocked,
|
||||
isParentNotFound,
|
||||
})
|
||||
if (!reply || reason) {
|
||||
return
|
||||
@@ -89,23 +92,40 @@ export class FeedViewPostsSlice {
|
||||
this.isOrphan = true
|
||||
return
|
||||
}
|
||||
const root = reply.root
|
||||
const rootIsView =
|
||||
AppBskyFeedDefs.isPostView(root) ||
|
||||
AppBskyFeedDefs.isBlockedPost(root) ||
|
||||
AppBskyFeedDefs.isNotFoundPost(root)
|
||||
/*
|
||||
* If the parent is also the root, we just so happen to have the data we
|
||||
* need to compute if the parent's parent (grandparent) is blocked. This
|
||||
* doesn't always happen, of course, but we can take advantage of it when
|
||||
* it does.
|
||||
*/
|
||||
const grandparent =
|
||||
rootIsView && parent.record.reply?.parent.uri === root.uri
|
||||
? root
|
||||
: undefined
|
||||
const grandparentAuthor = reply.grandparentAuthor
|
||||
const isGrandparentBlocked = Boolean(
|
||||
grandparentAuthor?.viewer?.blockedBy ||
|
||||
grandparentAuthor?.viewer?.blocking ||
|
||||
grandparentAuthor?.viewer?.blockingByList,
|
||||
grandparent && AppBskyFeedDefs.isBlockedPost(grandparent),
|
||||
)
|
||||
const isGrandparentNotFound = Boolean(
|
||||
grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent),
|
||||
)
|
||||
this.items.unshift({
|
||||
post: parent,
|
||||
record: parent.record,
|
||||
parentAuthor: grandparentAuthor,
|
||||
isParentBlocked: isGrandparentBlocked,
|
||||
isParentNotFound: isGrandparentNotFound,
|
||||
})
|
||||
if (isGrandparentBlocked) {
|
||||
this.isOrphan = true
|
||||
// Keep going, it might still have a root.
|
||||
// Keep going, it might still have a root, and we need this for thread
|
||||
// de-deduping
|
||||
}
|
||||
const root = reply.root
|
||||
if (
|
||||
!AppBskyFeedDefs.isPostView(root) ||
|
||||
!AppBskyFeedPost.isRecord(root.record) ||
|
||||
@@ -121,6 +141,7 @@ export class FeedViewPostsSlice {
|
||||
post: root,
|
||||
record: root.record,
|
||||
isParentBlocked: false,
|
||||
isParentNotFound: false,
|
||||
parentAuthor: undefined,
|
||||
})
|
||||
if (parent.record.reply?.parent.uri !== root.uri) {
|
||||
@@ -371,27 +392,20 @@ export class FeedTuner {
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
): FeedViewPostsSlice[] => {
|
||||
const candidateSlices = slices.slice()
|
||||
|
||||
// early return if no languages have been specified
|
||||
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
|
||||
return slices
|
||||
}
|
||||
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
let hasPreferredLang = false
|
||||
for (const item of slices[i].items) {
|
||||
const candidateSlices = slices.filter(slice => {
|
||||
for (const item of slice.items) {
|
||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||
hasPreferredLang = true
|
||||
break
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// if item does not fit preferred language, remove it
|
||||
if (!hasPreferredLang) {
|
||||
candidateSlices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// if the language filter cleared out the entire page, return the original set
|
||||
// so that something always shows
|
||||
|
||||
+84
-63
@@ -3,15 +3,23 @@ import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedThreadgate,
|
||||
AppBskyEmbedVideo,
|
||||
AppBskyFeedPostgate,
|
||||
AtUri,
|
||||
BlobRef,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
|
||||
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 {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
|
||||
import {isNative} from 'platform/detection'
|
||||
@@ -37,14 +45,12 @@ interface PostOpts {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
video?: {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
video?: BlobRef
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
labels?: string[]
|
||||
threadgate?: ThreadgateSetting[]
|
||||
threadgate: ThreadgateAllowUISetting[]
|
||||
postgate: AppBskyFeedPostgate.Record
|
||||
onStateChange?: (state: string) => void
|
||||
langs?: string[]
|
||||
}
|
||||
@@ -54,18 +60,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
| AppBskyEmbedImages.Main
|
||||
| AppBskyEmbedExternal.Main
|
||||
| AppBskyEmbedRecord.Main
|
||||
| AppBskyEmbedVideo.Main
|
||||
| AppBskyEmbedRecordWithMedia.Main
|
||||
| undefined
|
||||
let reply
|
||||
let rt = new RichText(
|
||||
{text: opts.rawText.trimEnd()},
|
||||
{
|
||||
cleanNewlines: true,
|
||||
},
|
||||
)
|
||||
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
|
||||
|
||||
opts.onStateChange?.('Processing...')
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
|
||||
rt = shortenLinks(rt)
|
||||
rt = stripInvalidMentions(rt)
|
||||
|
||||
@@ -122,6 +126,25 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
}
|
||||
}
|
||||
|
||||
// add video embed if present
|
||||
if (opts.video) {
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.recordWithMedia',
|
||||
record: embed,
|
||||
media: {
|
||||
$type: 'app.bsky.embed.video',
|
||||
video: opts.video,
|
||||
} as AppBskyEmbedVideo.Main,
|
||||
} as AppBskyEmbedRecordWithMedia.Main
|
||||
} else {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.video',
|
||||
video: opts.video,
|
||||
} as AppBskyEmbedVideo.Main
|
||||
}
|
||||
}
|
||||
|
||||
// add external embed if present
|
||||
if (opts.extLink && !opts.images?.length) {
|
||||
if (opts.extLink.embed) {
|
||||
@@ -232,7 +255,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
labels,
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error(`Failed to create post: ${e.toString()}`)
|
||||
logger.error(`Failed to create post`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
if (isNetworkError(e)) {
|
||||
throw new Error(
|
||||
'Post failed to upload. Please check your Internet connection and try again.',
|
||||
@@ -242,56 +267,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: this needs to be batch-created with the post!
|
||||
if (opts.threadgate?.length) {
|
||||
await createThreadgate(agent, res.uri, opts.threadgate)
|
||||
if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
|
||||
try {
|
||||
// TODO: this needs to be batch-created with the post!
|
||||
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
|
||||
}
|
||||
|
||||
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.accountDid,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: postUrip.rkey,
|
||||
record: {
|
||||
$type: 'app.bsky.feed.threadgate',
|
||||
post: postUri,
|
||||
allow,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function cancelable<A, T>(
|
||||
f: (args: A) => Promise<T>,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
return (args: A) => {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new AbortError())
|
||||
})
|
||||
f(args).then(resolve, reject)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AbortError extends Error {
|
||||
constructor() {
|
||||
super('Aborted')
|
||||
this.name = 'AbortError'
|
||||
}
|
||||
}
|
||||
@@ -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_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
|
||||
export const STARTER_PACK_MAX_SIZE = 150
|
||||
|
||||
// HACK
|
||||
// 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
|
||||
// server route done.
|
||||
// -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`
|
||||
export function FEEDBACK_FORM_URL({
|
||||
|
||||
@@ -1,102 +1,213 @@
|
||||
import {describe, expect, it} from '@jest/globals'
|
||||
import {MessageDescriptor} from '@lingui/core'
|
||||
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
|
||||
|
||||
import {dateDiff} from '../useTimeAgo'
|
||||
|
||||
const lingui: any = (obj: MessageDescriptor) => obj.message
|
||||
|
||||
const base = new Date('2024-06-17T00:00:00Z')
|
||||
|
||||
describe('dateDiff', () => {
|
||||
it(`works with numbers`, () => {
|
||||
expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d')
|
||||
const earlier = subDays(base, 3)
|
||||
expect(dateDiff(earlier, Number(base))).toEqual({
|
||||
value: 3,
|
||||
unit: 'day',
|
||||
earlier,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`works with strings`, () => {
|
||||
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
|
||||
const earlier = subDays(base, 3)
|
||||
expect(dateDiff(earlier, base.toString())).toEqual({
|
||||
value: 3,
|
||||
unit: 'day',
|
||||
earlier,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`works with dates`, () => {
|
||||
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
|
||||
const earlier = subDays(base, 3)
|
||||
expect(dateDiff(earlier, base)).toEqual({
|
||||
value: 3,
|
||||
unit: 'day',
|
||||
earlier,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`equal values return now`, () => {
|
||||
expect(dateDiff(base, base, {lingui})).toEqual('now')
|
||||
expect(dateDiff(base, base)).toEqual({
|
||||
value: 0,
|
||||
unit: 'now',
|
||||
earlier: base,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`future dates return now`, () => {
|
||||
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
|
||||
const earlier = addDays(base, 3)
|
||||
expect(dateDiff(earlier, base)).toEqual({
|
||||
value: 0,
|
||||
unit: 'now',
|
||||
earlier,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 5 seconds ago return now`, () => {
|
||||
const then = subSeconds(base, 4)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('now')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 0,
|
||||
unit: 'now',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 5 seconds ago return seconds`, () => {
|
||||
const then = subSeconds(base, 5)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('5s')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 5,
|
||||
unit: 'second',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 1 min return seconds`, () => {
|
||||
const then = subSeconds(base, 59)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('59s')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 59,
|
||||
unit: 'second',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 1 min return minutes`, () => {
|
||||
const then = subSeconds(base, 60)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1m')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'minute',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`minutes round down`, () => {
|
||||
const then = subSeconds(base, 119)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1m')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'minute',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 1 hour return minutes`, () => {
|
||||
const then = subMinutes(base, 59)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('59m')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 59,
|
||||
unit: 'minute',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 1 hour return hours`, () => {
|
||||
const then = subMinutes(base, 60)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1h')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'hour',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`hours round down`, () => {
|
||||
const then = subMinutes(base, 119)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1h')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'hour',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 1 day return hours`, () => {
|
||||
const then = subHours(base, 23)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('23h')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 23,
|
||||
unit: 'hour',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 1 day return days`, () => {
|
||||
const then = subHours(base, 24)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1d')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'day',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`days round down`, () => {
|
||||
const then = subHours(base, 47)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1d')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'day',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 30 days return days`, () => {
|
||||
const then = subDays(base, 29)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('29d')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 29,
|
||||
unit: 'day',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 30 days return months`, () => {
|
||||
const then = subDays(base, 30)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`months round down`, () => {
|
||||
const then = subDays(base, 59)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('1mo')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 1,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values are rounded by increments of 30`, () => {
|
||||
const then = subDays(base, 61)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('2mo')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 2,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
|
||||
it(`values < 360 days return months`, () => {
|
||||
const then = subDays(base, 359)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual('11mo')
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 11,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
it(`values >= 360 days return the earlier value`, () => {
|
||||
const then = subDays(base, 360)
|
||||
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
|
||||
expect(dateDiff(then, base)).toEqual({
|
||||
value: 12,
|
||||
unit: 'month',
|
||||
earlier: then,
|
||||
later: base,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import React from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
|
||||
const MIN_POST_HEIGHT = 100
|
||||
|
||||
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
|
||||
return React.useMemo(() => {
|
||||
const screenHeight = Dimensions.get('window').height
|
||||
return Math.ceil(screenHeight / minItemHeight) + 1
|
||||
}, [minItemHeight])
|
||||
export function useInitialNumToRender({
|
||||
minItemHeight = MIN_POST_HEIGHT,
|
||||
screenHeightOffset = 0,
|
||||
}: {minItemHeight?: number; screenHeightOffset?: number} = {}) {
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const bottomBarHeight = useBottomBarOffset()
|
||||
|
||||
const finalHeight =
|
||||
screenHeight - screenHeightOffset - topInset - bottomBarHeight
|
||||
return Math.floor(finalHeight / minItemHeight) + 1
|
||||
}
|
||||
|
||||
@@ -6,16 +6,20 @@ import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
|
||||
|
||||
export function useMinimalShellHeaderTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
const {headerMode} = useMinimalShellMode()
|
||||
const {headerHeight} = useShellLayout()
|
||||
|
||||
const headerTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
pointerEvents: headerMode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - headerMode.value, 2),
|
||||
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() {
|
||||
const mode = useMinimalShellMode()
|
||||
const {footerMode} = useMinimalShellMode()
|
||||
const {footerHeight} = useShellLayout()
|
||||
|
||||
const footerTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
pointerEvents: footerMode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - footerMode.value, 2),
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
|
||||
translateY: interpolate(
|
||||
footerMode.value,
|
||||
[0, 1],
|
||||
[0, footerHeight.value],
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return footerTransform
|
||||
}
|
||||
|
||||
export function useMinimalShellFabTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
const {footerMode} = useMinimalShellMode()
|
||||
|
||||
const fabTransform = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(mode.value, [0, 1], [-44, 0]),
|
||||
translateY: interpolate(footerMode.value, [0, 1], [-44, 0]),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
+153
-61
@@ -1,25 +1,16 @@
|
||||
import {useCallback} from 'react'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {I18nContext, useLingui} from '@lingui/react'
|
||||
import {I18n} from '@lingui/core'
|
||||
import {defineMessage, msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {differenceInSeconds} from 'date-fns'
|
||||
|
||||
export type TimeAgoOptions = {
|
||||
lingui: I18nContext['_']
|
||||
format?: 'long' | 'short'
|
||||
}
|
||||
export type DateDiffFormat = 'long' | 'short'
|
||||
|
||||
export function useGetTimeAgo() {
|
||||
const {_} = useLingui()
|
||||
return useCallback(
|
||||
(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options?: Omit<TimeAgoOptions, 'lingui'>,
|
||||
) => {
|
||||
return dateDiff(earlier, later, {lingui: _, format: options?.format})
|
||||
},
|
||||
[_],
|
||||
)
|
||||
type DateDiff = {
|
||||
value: number
|
||||
unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month'
|
||||
earlier: Date
|
||||
later: Date
|
||||
}
|
||||
|
||||
const NOW = 5
|
||||
@@ -28,59 +19,160 @@ const HOUR = MINUTE * 60
|
||||
const DAY = HOUR * 24
|
||||
const MONTH_30 = DAY * 30
|
||||
|
||||
export function useGetTimeAgo() {
|
||||
const {i18n} = useLingui()
|
||||
return useCallback(
|
||||
(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options?: {format: DateDiffFormat},
|
||||
) => {
|
||||
const diff = dateDiff(earlier, later)
|
||||
return formatDateDiff({diff, i18n, format: options?.format})
|
||||
},
|
||||
[i18n],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the difference between `earlier` and `later` dates, formatted as a
|
||||
* natural language string.
|
||||
* Returns the difference between `earlier` and `later` dates, based on
|
||||
* opinionated rules.
|
||||
*
|
||||
* - All month are considered exactly 30 days.
|
||||
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
|
||||
* - All values round down
|
||||
*/
|
||||
export function dateDiff(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
): DateDiff {
|
||||
let diff = {
|
||||
value: 0,
|
||||
unit: 'now' as DateDiff['unit'],
|
||||
}
|
||||
const e = new Date(earlier)
|
||||
const l = new Date(later)
|
||||
const diffSeconds = differenceInSeconds(l, e)
|
||||
|
||||
if (diffSeconds < NOW) {
|
||||
diff = {
|
||||
value: 0,
|
||||
unit: 'now' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < MINUTE) {
|
||||
diff = {
|
||||
value: diffSeconds,
|
||||
unit: 'second' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < HOUR) {
|
||||
const value = Math.floor(diffSeconds / MINUTE)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'minute' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < DAY) {
|
||||
const value = Math.floor(diffSeconds / HOUR)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'hour' as DateDiff['unit'],
|
||||
}
|
||||
} else if (diffSeconds < MONTH_30) {
|
||||
const value = Math.floor(diffSeconds / DAY)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'day' as DateDiff['unit'],
|
||||
}
|
||||
} else {
|
||||
const value = Math.floor(diffSeconds / MONTH_30)
|
||||
diff = {
|
||||
value,
|
||||
unit: 'month' as DateDiff['unit'],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...diff,
|
||||
earlier: e,
|
||||
later: l,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a `DateDiff` and teturns the difference between `earlier` and
|
||||
* `later` dates, formatted as a natural language string.
|
||||
*
|
||||
* - All month are considered exactly 30 days.
|
||||
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
|
||||
* - Differences >= 360 days are returned as the "M/D/YYYY" string
|
||||
* - All values round down
|
||||
*/
|
||||
export function dateDiff(
|
||||
earlier: number | string | Date,
|
||||
later: number | string | Date,
|
||||
options: TimeAgoOptions,
|
||||
): string {
|
||||
const _ = options.lingui
|
||||
const format = options?.format || 'short'
|
||||
export function formatDateDiff({
|
||||
diff,
|
||||
format = 'short',
|
||||
i18n,
|
||||
}: {
|
||||
diff: DateDiff
|
||||
format?: DateDiffFormat
|
||||
i18n: I18n
|
||||
}): string {
|
||||
const long = format === 'long'
|
||||
const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
|
||||
|
||||
if (diffSeconds < NOW) {
|
||||
return _(msg`now`)
|
||||
} else if (diffSeconds < MINUTE) {
|
||||
return `${diffSeconds}${
|
||||
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
|
||||
}`
|
||||
} else if (diffSeconds < HOUR) {
|
||||
const diff = Math.floor(diffSeconds / MINUTE)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
|
||||
}`
|
||||
} else if (diffSeconds < DAY) {
|
||||
const diff = Math.floor(diffSeconds / HOUR)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
|
||||
}`
|
||||
} else if (diffSeconds < MONTH_30) {
|
||||
const diff = Math.floor(diffSeconds / DAY)
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
|
||||
}`
|
||||
} else {
|
||||
const diff = Math.floor(diffSeconds / MONTH_30)
|
||||
if (diff < 12) {
|
||||
return `${diff}${
|
||||
long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo'
|
||||
}`
|
||||
} else {
|
||||
const str = new Date(earlier).toLocaleDateString()
|
||||
|
||||
if (long) {
|
||||
return _(msg`on ${str}`)
|
||||
switch (diff.unit) {
|
||||
case 'now': {
|
||||
return i18n._(msg`now`)
|
||||
}
|
||||
case 'second': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# second', other: '# seconds'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}s`,
|
||||
comment: `How many seconds have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'minute': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# minute', other: '# minutes'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}m`,
|
||||
comment: `How many minutes have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'hour': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# hour', other: '# hours'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}h`,
|
||||
comment: `How many hours have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'day': {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# day', other: '# days'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}d`,
|
||||
comment: `How many days have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
case 'month': {
|
||||
if (diff.value < 12) {
|
||||
return long
|
||||
? i18n._(plural(diff.value, {one: '# month', other: '# months'}))
|
||||
: i18n._(
|
||||
defineMessage({
|
||||
message: `${diff.value}mo`,
|
||||
comment: `How many months have passed, displayed in a narrow form`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return str
|
||||
return i18n.date(new Date(diff.earlier))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,11 @@ export async function extractBskyMeta(
|
||||
return meta
|
||||
}
|
||||
|
||||
export class EmbeddingDisabledError extends Error {
|
||||
constructor() {
|
||||
super('Embedding is disabled for this record')
|
||||
}
|
||||
}
|
||||
export async function getPostAsQuote(
|
||||
getPost: ReturnType<typeof useGetPost>,
|
||||
url: string,
|
||||
@@ -115,6 +120,9 @@ export async function getPostAsQuote(
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
const post = await getPost({uri: uri})
|
||||
if (post.viewer?.embeddingDisabled) {
|
||||
throw new EmbeddingDisabledError()
|
||||
}
|
||||
return {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
|
||||
@@ -8,19 +8,25 @@ export type CompressedVideo = {
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
opts?: {
|
||||
getCancellationId?: (id: string) => void
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, getCancellationId} = opts || {}
|
||||
const {onProgress, signal} = opts || {}
|
||||
|
||||
const compressed = await Video.compress(
|
||||
file,
|
||||
{
|
||||
getCancellationId,
|
||||
compressionMethod: 'manual',
|
||||
bitrate: 3_000_000, // 3mbps
|
||||
maxSize: 1920,
|
||||
getCancellationId: id => {
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
Video.cancelCompression(id)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
onProgress,
|
||||
)
|
||||
|
||||
@@ -10,8 +10,9 @@ export type CompressedVideo = {
|
||||
// doesn't actually compress, but throws if >100MB
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
_callbacks?: {
|
||||
onProgress: (progress: number) => void
|
||||
_opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const blob = await fetch(file).then(res => res.blob())
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION.
|
||||
* @temporary
|
||||
* PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented.
|
||||
* Not joking, this is temporary.
|
||||
*/
|
||||
|
||||
export interface JobStatus {
|
||||
jobId: string
|
||||
did: string
|
||||
cid: string
|
||||
state: JobState
|
||||
progress?: number
|
||||
errorHuman?: string
|
||||
errorMachine?: string
|
||||
}
|
||||
|
||||
export enum JobState {
|
||||
JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED',
|
||||
JOB_STATE_CREATED = 'JOB_STATE_CREATED',
|
||||
JOB_STATE_ENCODING = 'JOB_STATE_ENCODING',
|
||||
JOB_STATE_ENCODED = 'JOB_STATE_ENCODED',
|
||||
JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING',
|
||||
JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED',
|
||||
JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING',
|
||||
JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED',
|
||||
JOB_STATE_FAILED = 'JOB_STATE_FAILED',
|
||||
JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED',
|
||||
}
|
||||
|
||||
export interface UploadVideoResponse {
|
||||
job_id: string
|
||||
did: string
|
||||
cid: string
|
||||
state: JobState
|
||||
}
|
||||
@@ -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 Options = Parameters<ModeratePost>[1]
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import {
|
||||
ModerationCause,
|
||||
ModerationUI,
|
||||
InterpretedLabelValueDefinition,
|
||||
LABELS,
|
||||
AppBskyLabelerDefs,
|
||||
BskyAgent,
|
||||
InterpretedLabelValueDefinition,
|
||||
LABELS,
|
||||
ModerationCause,
|
||||
ModerationOpts,
|
||||
ModerationUI,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
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 =
|
||||
cause.source.type === 'labeler'
|
||||
? cause.source.did
|
||||
|
||||
@@ -8,11 +8,13 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useLabelDefinitions} from '#/state/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
|
||||
import {getDefinition, getLabelStrings} from './useLabelInfo'
|
||||
|
||||
@@ -27,8 +29,9 @@ export interface ModerationCauseDescription {
|
||||
}
|
||||
|
||||
export function useModerationCauseDescription(
|
||||
cause: ModerationCause | undefined,
|
||||
cause: ModerationCause | AppModerationCause | undefined,
|
||||
): ModerationCauseDescription {
|
||||
const {currentAccount} = useSession()
|
||||
const {_, i18n} = useLingui()
|
||||
const {labelDefs, labelers} = useLabelDefinitions()
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
@@ -111,6 +114,18 @@ export function useModerationCauseDescription(
|
||||
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') {
|
||||
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
|
||||
const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
|
||||
@@ -150,5 +165,13 @@ export function useModerationCauseDescription(
|
||||
name: '',
|
||||
description: ``,
|
||||
}
|
||||
}, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
|
||||
}, [
|
||||
labelDefs,
|
||||
labelers,
|
||||
globalLabelStrings,
|
||||
cause,
|
||||
_,
|
||||
i18n.locale,
|
||||
currentAccount?.did,
|
||||
])
|
||||
}
|
||||
|
||||
+66
-1
@@ -2,18 +2,83 @@ import React, {useRef, useState} from 'react'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, QueryClient} from '@tanstack/react-query'
|
||||
import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
PersistQueryClientProvider,
|
||||
PersistQueryClientProviderProps,
|
||||
} from '@tanstack/react-query-persist-client'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
|
||||
|
||||
// any query keys in this array will be persisted to AsyncStorage
|
||||
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
|
||||
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
|
||||
|
||||
async function checkIsOnline(): Promise<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 => {
|
||||
if (isNative) {
|
||||
const subscription = AppState.addEventListener(
|
||||
|
||||
@@ -20,6 +20,7 @@ export type CommonNavigatorParams = {
|
||||
PostThread: {name: string; rkey: string}
|
||||
PostLikedBy: {name: string; rkey: string}
|
||||
PostRepostedBy: {name: string; rkey: string}
|
||||
PostQuotes: {name: string; rkey: string}
|
||||
ProfileFeed: {name: string; rkey: string}
|
||||
ProfileFeedLikedBy: {name: string; rkey: string}
|
||||
ProfileLabelerLikedBy: {name: string}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type LogEvents = {
|
||||
}
|
||||
'account:loggedOut': {
|
||||
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
|
||||
scope: 'current' | 'every'
|
||||
}
|
||||
'notifications:openApp': {}
|
||||
'notifications:request': {
|
||||
@@ -215,12 +216,6 @@ export type LogEvents = {
|
||||
|
||||
'profile:header:suggestedFollowsCard:press': {}
|
||||
|
||||
'debug:followingPrefs': {
|
||||
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
||||
followingRepliesMinLikePref: number
|
||||
}
|
||||
'debug:followingDisplayed': {}
|
||||
|
||||
'test:all:always': {}
|
||||
'test:all:sometimes': {}
|
||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'new_user_guided_tour'
|
||||
| 'fixed_bottom_bar'
|
||||
| 'onboarding_minimum_interests'
|
||||
| 'session_withproxy_fix'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'video_debug'
|
||||
| 'videos'
|
||||
|
||||
@@ -103,16 +103,21 @@ export function parseEmbedPlayerFromUrl(
|
||||
urlp.hostname === 'm.youtube.com' ||
|
||||
urlp.hostname === 'music.youtube.com'
|
||||
) {
|
||||
const [_, page, shortVideoId] = urlp.pathname.split('/')
|
||||
const [_, page, shortOrLiveVideoId] = urlp.pathname.split('/')
|
||||
|
||||
const isShorts = page === 'shorts'
|
||||
const isLive = page === 'live'
|
||||
const videoId =
|
||||
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
|
||||
isShorts || isLive
|
||||
? shortOrLiveVideoId
|
||||
: (urlp.searchParams.get('v') as string)
|
||||
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
|
||||
|
||||
if (videoId) {
|
||||
return {
|
||||
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
|
||||
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
|
||||
hideDetails: page === 'shorts' ? true : undefined,
|
||||
type: isShorts ? 'youtube_short' : 'youtube_video',
|
||||
source: isShorts ? 'youtubeShorts' : 'youtube',
|
||||
hideDetails: isShorts ? true : undefined,
|
||||
playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
export function niceDate(date: number | string | Date) {
|
||||
import {I18n} from '@lingui/core'
|
||||
|
||||
export function niceDate(i18n: I18n, date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
|
||||
return i18n.date(d, {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
export function getAge(birthDate: Date): number {
|
||||
|
||||
+94
-94
@@ -68,7 +68,7 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'alt', code2: '', name: 'Southern Altai'},
|
||||
{code3: 'amh', code2: 'am', name: 'Amharic'},
|
||||
{code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'},
|
||||
{code3: 'anp ', code2: 'Angika', name: 'Angika'},
|
||||
{code3: 'anp', code2: '', name: 'Angika'},
|
||||
{code3: 'apa', code2: '', name: 'Apache languages'},
|
||||
{code3: 'ara', code2: 'ar', name: 'Arabic'},
|
||||
{
|
||||
@@ -233,7 +233,7 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'gre', code2: 'el', name: 'Greek, Modern (1453-)'},
|
||||
{code3: 'grn', code2: 'gn', name: 'Guarani'},
|
||||
{code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian'},
|
||||
{code3: 'gujgu', code2: 'Gujarati', name: 'goudjrati'},
|
||||
{code3: 'guj', code2: 'gu', name: 'Gujarati'},
|
||||
{code3: 'gwi', code2: '', name: "Gwich'in"},
|
||||
{code3: 'hai', code2: '', name: 'Haida'},
|
||||
{code3: 'hat', code2: 'ht', name: 'Haitian; Haitian Creole'},
|
||||
@@ -339,8 +339,8 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'lun', code2: '', name: 'Lunda'},
|
||||
{
|
||||
code3: 'luo',
|
||||
code2: ' Luo (Kenya and Tanzania)',
|
||||
name: 'luo (Kenya et Tanzanie)',
|
||||
code2: '',
|
||||
name: 'Luo (Kenya and Tanzania)',
|
||||
},
|
||||
{code3: 'lus', code2: '', name: 'Lushai'},
|
||||
{code3: 'mac', code2: 'mk', name: 'Macedonian'},
|
||||
@@ -430,162 +430,162 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'oto', code2: '', name: 'Otomian languages'},
|
||||
{code3: 'paa', code2: '', name: 'Papuan languages'},
|
||||
{code3: 'pag', code2: '', name: 'Pangasinan'},
|
||||
{code3: 'pal', code2: ' ', name: 'Pahlavi'},
|
||||
{code3: 'pam', code2: ' ', name: 'Pampanga; Kapampangan'},
|
||||
{code3: 'pan', code2: 'paPanjabi; Punjabi', name: 'pendjabi'},
|
||||
{code3: 'pap', code2: ' ', name: 'Papiamento'},
|
||||
{code3: 'pau', code2: ' ', name: 'Palauan'},
|
||||
{code3: 'peo', code2: ' ', name: 'Persian, Old (ca.600-400 B.C.)'},
|
||||
{code3: 'pal', code2: '', name: 'Pahlavi'},
|
||||
{code3: 'pam', code2: '', name: 'Pampanga; Kapampangan'},
|
||||
{code3: 'pan', code2: 'pa', name: 'Panjabi; Punjabi'},
|
||||
{code3: 'pap', code2: '', name: 'Papiamento'},
|
||||
{code3: 'pau', code2: '', name: 'Palauan'},
|
||||
{code3: 'peo', code2: '', name: 'Persian, Old (ca.600-400 B.C.)'},
|
||||
{code3: 'per', code2: 'fa', name: 'Persian'},
|
||||
{code3: 'phi', code2: ' ', name: 'Philippine languages'},
|
||||
{code3: 'phn', code2: ' ', name: 'Phoenician'},
|
||||
{code3: 'phi', code2: '', name: 'Philippine languages'},
|
||||
{code3: 'phn', code2: '', name: 'Phoenician'},
|
||||
{code3: 'pli', code2: 'pi', name: 'Pali'},
|
||||
{code3: 'pol', code2: 'pl', name: 'Polish'},
|
||||
{code3: 'pon', code2: ' ', name: 'Pohnpeian'},
|
||||
{code3: 'pon', code2: '', name: 'Pohnpeian'},
|
||||
{code3: 'por', code2: 'pt', name: 'Portuguese'},
|
||||
{code3: 'pra', code2: ' ', name: 'Prakrit languages'},
|
||||
{code3: 'pra', code2: '', name: 'Prakrit languages'},
|
||||
{
|
||||
code3: 'pro',
|
||||
code2: ' ',
|
||||
code2: '',
|
||||
name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)',
|
||||
},
|
||||
{code3: 'pus', code2: 'ps', name: 'Pushto; Pashto'},
|
||||
{code3: 'que', code2: 'qu', name: 'Quechua'},
|
||||
{code3: 'raj', code2: ' ', name: 'Rajasthani'},
|
||||
{code3: 'rap', code2: ' ', name: 'Rapanui'},
|
||||
{code3: 'rar', code2: ' ', name: 'Rarotongan; Cook Islands Maori'},
|
||||
{code3: 'roa', code2: ' ', name: 'Romance languages'},
|
||||
{code3: 'raj', code2: '', name: 'Rajasthani'},
|
||||
{code3: 'rap', code2: '', name: 'Rapanui'},
|
||||
{code3: 'rar', code2: '', name: 'Rarotongan; Cook Islands Maori'},
|
||||
{code3: 'roa', code2: '', name: 'Romance languages'},
|
||||
{code3: 'roh', code2: 'rm', name: 'Romansh'},
|
||||
{code3: 'rom', code2: ' ', name: 'Romany'},
|
||||
{code3: 'rom', code2: '', name: 'Romany'},
|
||||
{code3: 'rum', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
|
||||
{code3: 'ron', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
|
||||
{code3: 'run', code2: 'rn', name: 'Rundi'},
|
||||
{code3: 'rup', code2: ' ', name: 'Aromanian; Arumanian; Macedo-Romanian'},
|
||||
{code3: 'rup', code2: '', name: 'Aromanian; Arumanian; Macedo-Romanian'},
|
||||
{code3: 'rus', code2: 'ru', name: 'Russian'},
|
||||
{code3: 'sad', code2: ' ', name: 'Sandawe'},
|
||||
{code3: 'sad', code2: '', name: 'Sandawe'},
|
||||
{code3: 'sag', code2: 'sg', name: 'Sango'},
|
||||
{code3: 'sah', code2: ' ', name: 'Yakut'},
|
||||
{code3: 'sai', code2: ' ', name: 'South American Indian languages'},
|
||||
{code3: 'sal', code2: ' ', name: 'Salishan languages'},
|
||||
{code3: 'sam', code2: ' ', name: 'Samaritan Aramaic'},
|
||||
{code3: 'sah', code2: '', name: 'Yakut'},
|
||||
{code3: 'sai', code2: '', name: 'South American Indian languages'},
|
||||
{code3: 'sal', code2: '', name: 'Salishan languages'},
|
||||
{code3: 'sam', code2: '', name: 'Samaritan Aramaic'},
|
||||
{code3: 'san', code2: 'sa', name: 'Sanskrit'},
|
||||
{code3: 'sas', code2: ' ', name: 'Sasak'},
|
||||
{code3: 'sat', code2: ' ', name: 'Santali'},
|
||||
{code3: 'scn', code2: ' ', name: 'Sicilian'},
|
||||
{code3: 'sco', code2: ' ', name: 'Scots'},
|
||||
{code3: 'sel', code2: ' ', name: 'Selkup'},
|
||||
{code3: 'sem', code2: ' ', name: 'Semitic languages'},
|
||||
{code3: 'sga', code2: ' ', name: 'Irish, Old (to 900)'},
|
||||
{code3: 'sgn', code2: ' ', name: 'Sign Languages'},
|
||||
{code3: 'shn', code2: ' ', name: 'Shan'},
|
||||
{code3: 'sid', code2: ' ', name: 'Sidamo'},
|
||||
{code3: 'sas', code2: '', name: 'Sasak'},
|
||||
{code3: 'sat', code2: '', name: 'Santali'},
|
||||
{code3: 'scn', code2: '', name: 'Sicilian'},
|
||||
{code3: 'sco', code2: '', name: 'Scots'},
|
||||
{code3: 'sel', code2: '', name: 'Selkup'},
|
||||
{code3: 'sem', code2: '', name: 'Semitic languages'},
|
||||
{code3: 'sga', code2: '', name: 'Irish, Old (to 900)'},
|
||||
{code3: 'sgn', code2: '', name: 'Sign Languages'},
|
||||
{code3: 'shn', code2: '', name: 'Shan'},
|
||||
{code3: 'sid', code2: '', name: 'Sidamo'},
|
||||
{code3: 'sin', code2: 'si', name: 'Sinhala; Sinhalese'},
|
||||
{code3: 'sio', code2: ' ', name: 'Siouan languages'},
|
||||
{code3: 'sit', code2: ' ', name: 'Sino-Tibetan languages'},
|
||||
{code3: 'sla', code2: ' ', name: 'Slavic languages'},
|
||||
{code3: 'sio', code2: '', name: 'Siouan languages'},
|
||||
{code3: 'sit', code2: '', name: 'Sino-Tibetan languages'},
|
||||
{code3: 'sla', code2: '', name: 'Slavic languages'},
|
||||
{code3: 'slo', code2: 'sk', name: 'Slovak'},
|
||||
{code3: 'slk', code2: 'sk', name: 'Slovak'},
|
||||
{code3: 'slv', code2: 'sl', name: 'Slovenian'},
|
||||
{code3: 'sma', code2: ' ', name: 'Southern Sami'},
|
||||
{code3: 'sma', code2: '', name: 'Southern Sami'},
|
||||
{code3: 'sme', code2: 'se', name: 'Northern Sami'},
|
||||
{code3: 'smi', code2: ' ', name: 'Sami languages'},
|
||||
{code3: 'smj', code2: ' ', name: 'Lule Sami'},
|
||||
{code3: 'smn', code2: ' ', name: 'Inari Sami'},
|
||||
{code3: 'smi', code2: '', name: 'Sami languages'},
|
||||
{code3: 'smj', code2: '', name: 'Lule Sami'},
|
||||
{code3: 'smn', code2: '', name: 'Inari Sami'},
|
||||
{code3: 'smo', code2: 'sm', name: 'Samoan'},
|
||||
{code3: 'sms', code2: ' ', name: 'Skolt Sami'},
|
||||
{code3: 'sms', code2: '', name: 'Skolt Sami'},
|
||||
{code3: 'sna', code2: 'sn', name: 'Shona'},
|
||||
{code3: 'snd', code2: 'sd', name: 'Sindhi'},
|
||||
{code3: 'snk', code2: ' ', name: 'Soninke'},
|
||||
{code3: 'sog', code2: ' ', name: 'Sogdian'},
|
||||
{code3: 'snk', code2: '', name: 'Soninke'},
|
||||
{code3: 'sog', code2: '', name: 'Sogdian'},
|
||||
{code3: 'som', code2: 'so', name: 'Somali'},
|
||||
{code3: 'son', code2: ' ', name: 'Songhai languages'},
|
||||
{code3: 'son', code2: '', name: 'Songhai languages'},
|
||||
{code3: 'sot', code2: 'st', name: 'Sotho, Southern'},
|
||||
{code3: 'spa', code2: 'es', name: 'Spanish'},
|
||||
{code3: 'sqi', code2: 'sq', name: 'Albanian'},
|
||||
{code3: 'srd', code2: 'sc', name: 'Sardinian'},
|
||||
{code3: 'srn', code2: ' ', name: 'Sranan Tongo'},
|
||||
{code3: 'srn', code2: '', name: 'Sranan Tongo'},
|
||||
{code3: 'srp', code2: 'sr', name: 'Serbian'},
|
||||
{code3: 'srr', code2: ' ', name: 'Serer'},
|
||||
{code3: 'ssa', code2: ' ', name: 'Nilo-Saharan languages'},
|
||||
{code3: 'srr', code2: '', name: 'Serer'},
|
||||
{code3: 'ssa', code2: '', name: 'Nilo-Saharan languages'},
|
||||
{code3: 'ssw', code2: 'ss', name: 'Swati'},
|
||||
{code3: 'suk', code2: ' ', name: 'Sukuma'},
|
||||
{code3: 'suk', code2: '', name: 'Sukuma'},
|
||||
{code3: 'sun', code2: 'su', name: 'Sundanese'},
|
||||
{code3: 'sus', code2: ' ', name: 'Susu'},
|
||||
{code3: 'sux', code2: ' ', name: 'Sumerian'},
|
||||
{code3: 'sus', code2: '', name: 'Susu'},
|
||||
{code3: 'sux', code2: '', name: 'Sumerian'},
|
||||
{code3: 'swa', code2: 'sw', name: 'Swahili'},
|
||||
{code3: 'swe', code2: 'sv', name: 'Swedish'},
|
||||
{code3: 'syc', code2: ' ', name: 'Classical Syriac'},
|
||||
{code3: 'syr', code2: ' ', name: 'Syriac'},
|
||||
{code3: 'syc', code2: '', name: 'Classical Syriac'},
|
||||
{code3: 'syr', code2: '', name: 'Syriac'},
|
||||
{code3: 'tah', code2: 'ty', name: 'Tahitian'},
|
||||
{code3: 'tai', code2: ' ', name: 'Tai languages'},
|
||||
{code3: 'tai', code2: '', name: 'Tai languages'},
|
||||
{code3: 'tam', code2: 'ta', name: 'Tamil'},
|
||||
{code3: 'tat', code2: 'tt', name: 'Tatar'},
|
||||
{code3: 'tel', code2: 'te', name: 'Telugu'},
|
||||
{code3: 'tem', code2: ' ', name: 'Timne'},
|
||||
{code3: 'ter', code2: ' ', name: 'Tereno'},
|
||||
{code3: 'tet', code2: ' ', name: 'Tetum'},
|
||||
{code3: 'tem', code2: '', name: 'Timne'},
|
||||
{code3: 'ter', code2: '', name: 'Tereno'},
|
||||
{code3: 'tet', code2: '', name: 'Tetum'},
|
||||
{code3: 'tgk', code2: 'tg', name: 'Tajik'},
|
||||
{code3: 'tgl', code2: 'tl', name: 'Tagalog'},
|
||||
{code3: 'tha', code2: 'th', name: 'Thai'},
|
||||
{code3: 'tib', code2: 'bo', name: 'Tibetan'},
|
||||
{code3: 'tig', code2: ' ', name: 'Tigre'},
|
||||
{code3: 'tig', code2: '', name: 'Tigre'},
|
||||
{code3: 'tir', code2: 'ti', name: 'Tigrinya'},
|
||||
{code3: 'tiv', code2: ' ', name: 'Tiv'},
|
||||
{code3: 'tkl', code2: ' ', name: 'Tokelau'},
|
||||
{code3: 'tlh', code2: ' ', name: 'Klingon; tlhIngan-Hol'},
|
||||
{code3: 'tli', code2: ' ', name: 'Tlingit'},
|
||||
{code3: 'tmh', code2: ' ', name: 'Tamashek'},
|
||||
{code3: 'tog', code2: ' ', name: 'Tonga (Nyasa)'},
|
||||
{code3: 'tiv', code2: '', name: 'Tiv'},
|
||||
{code3: 'tkl', code2: '', name: 'Tokelau'},
|
||||
{code3: 'tlh', code2: '', name: 'Klingon; tlhIngan-Hol'},
|
||||
{code3: 'tli', code2: '', name: 'Tlingit'},
|
||||
{code3: 'tmh', code2: '', name: 'Tamashek'},
|
||||
{code3: 'tog', code2: '', name: 'Tonga (Nyasa)'},
|
||||
{code3: 'ton', code2: 'to', name: 'Tonga (Tonga Islands)'},
|
||||
{code3: 'tpi', code2: ' ', name: 'Tok Pisin'},
|
||||
{code3: 'tsi', code2: ' ', name: 'Tsimshian'},
|
||||
{code3: 'tpi', code2: '', name: 'Tok Pisin'},
|
||||
{code3: 'tsi', code2: '', name: 'Tsimshian'},
|
||||
{code3: 'tsn', code2: 'tn', name: 'Tswana'},
|
||||
{code3: 'tso', code2: 'ts', name: 'Tsonga'},
|
||||
{code3: 'tuk', code2: 'tk', name: 'Turkmen'},
|
||||
{code3: 'tum', code2: ' ', name: 'Tumbuka'},
|
||||
{code3: 'tup', code2: ' ', name: 'Tupi languages'},
|
||||
{code3: 'tum', code2: '', name: 'Tumbuka'},
|
||||
{code3: 'tup', code2: '', name: 'Tupi languages'},
|
||||
{code3: 'tur', code2: 'tr', name: 'Turkish'},
|
||||
{code3: 'tut', code2: ' ', name: 'Altaic languages'},
|
||||
{code3: 'tvl', code2: ' ', name: 'Tuvalu'},
|
||||
{code3: 'tut', code2: '', name: 'Altaic languages'},
|
||||
{code3: 'tvl', code2: '', name: 'Tuvalu'},
|
||||
{code3: 'twi', code2: 'tw', name: 'Twi'},
|
||||
{code3: 'tyv', code2: ' ', name: 'Tuvinian'},
|
||||
{code3: 'udm', code2: ' ', name: 'Udmurt'},
|
||||
{code3: 'uga', code2: ' ', name: 'Ugaritic'},
|
||||
{code3: 'tyv', code2: '', name: 'Tuvinian'},
|
||||
{code3: 'udm', code2: '', name: 'Udmurt'},
|
||||
{code3: 'uga', code2: '', name: 'Ugaritic'},
|
||||
{code3: 'uig', code2: 'ug', name: 'Uighur; Uyghur'},
|
||||
{code3: 'ukr', code2: 'uk', name: 'Ukrainian'},
|
||||
{code3: 'umb', code2: ' ', name: 'Umbundu'},
|
||||
{code3: 'und', code2: ' ', name: 'Undetermined'},
|
||||
{code3: 'umb', code2: '', name: 'Umbundu'},
|
||||
{code3: 'und', code2: '', name: 'Undetermined'},
|
||||
{code3: 'urd', code2: 'ur', name: 'Urdu'},
|
||||
{code3: 'uzb', code2: 'uz', name: 'Uzbek'},
|
||||
{code3: 'vai', code2: ' ', name: 'Vai'},
|
||||
{code3: 'vai', code2: '', name: 'Vai'},
|
||||
{code3: 'ven', code2: 've', name: 'Venda'},
|
||||
{code3: 'vie', code2: 'vi', name: 'Vietnamese'},
|
||||
{code3: 'vol', code2: 'vo', name: 'Volapük'},
|
||||
{code3: 'vot', code2: ' ', name: 'Votic'},
|
||||
{code3: 'wak', code2: ' ', name: 'Wakashan languages'},
|
||||
{code3: 'wal', code2: ' ', name: 'Wolaitta; Wolaytta'},
|
||||
{code3: 'war', code2: ' ', name: 'Waray'},
|
||||
{code3: 'was', code2: ' ', name: 'Washo'},
|
||||
{code3: 'vot', code2: '', name: 'Votic'},
|
||||
{code3: 'wak', code2: '', name: 'Wakashan languages'},
|
||||
{code3: 'wal', code2: '', name: 'Wolaitta; Wolaytta'},
|
||||
{code3: 'war', code2: '', name: 'Waray'},
|
||||
{code3: 'was', code2: '', name: 'Washo'},
|
||||
{code3: 'wel', code2: 'cy', name: 'Welsh'},
|
||||
{code3: 'wen', code2: ' ', name: 'Sorbian languages'},
|
||||
{code3: 'wen', code2: '', name: 'Sorbian languages'},
|
||||
{code3: 'wln', code2: 'wa', name: 'Walloon'},
|
||||
{code3: 'wol', code2: 'wo', name: 'Wolof'},
|
||||
{code3: 'xal', code2: ' ', name: 'Kalmyk; Oirat'},
|
||||
{code3: 'xal', code2: '', name: 'Kalmyk; Oirat'},
|
||||
{code3: 'xho', code2: 'xh', name: 'Xhosa'},
|
||||
{code3: 'yao', code2: ' ', name: 'Yao'},
|
||||
{code3: 'yap', code2: ' ', name: 'Yapese'},
|
||||
{code3: 'yao', code2: '', name: 'Yao'},
|
||||
{code3: 'yap', code2: '', name: 'Yapese'},
|
||||
{code3: 'yid', code2: 'yi', name: 'Yiddish'},
|
||||
{code3: 'yor', code2: 'yo', name: 'Yoruba'},
|
||||
{code3: 'ypk', code2: ' ', name: 'Yupik languages'},
|
||||
{code3: 'zap', code2: ' ', name: 'Zapotec'},
|
||||
{code3: 'zbl', code2: ' ', name: 'Blissymbols; Blissymbolics; Bliss'},
|
||||
{code3: 'zen', code2: ' ', name: 'Zenaga'},
|
||||
{code3: 'zgh', code2: ' ', name: 'Standard Moroccan Tamazight'},
|
||||
{code3: 'ypk', code2: '', name: 'Yupik languages'},
|
||||
{code3: 'zap', code2: '', name: 'Zapotec'},
|
||||
{code3: 'zbl', code2: '', name: 'Blissymbols; Blissymbolics; Bliss'},
|
||||
{code3: 'zen', code2: '', name: 'Zenaga'},
|
||||
{code3: 'zgh', code2: '', name: 'Standard Moroccan Tamazight'},
|
||||
{code3: 'zha', code2: 'za', name: 'Zhuang; Chuang'},
|
||||
{code3: 'zho', code2: 'zh', name: 'Chinese'},
|
||||
{code3: 'znd', code2: ' ', name: 'Zande languages'},
|
||||
{code3: 'znd', code2: '', name: 'Zande languages'},
|
||||
{code3: 'zul', code2: 'zu', name: 'Zulu'},
|
||||
{code3: 'zun', code2: ' ', name: 'Zuni'},
|
||||
{code3: 'zun', code2: '', name: 'Zuni'},
|
||||
{
|
||||
code3: 'zza',
|
||||
code2: '',
|
||||
|
||||
+1576
-1006
File diff suppressed because it is too large
Load Diff
+1519
-949
File diff suppressed because it is too large
Load Diff
+1511
-941
File diff suppressed because it is too large
Load Diff
+1516
-946
File diff suppressed because it is too large
Load Diff
+1521
-951
File diff suppressed because it is too large
Load Diff
+1536
-974
File diff suppressed because it is too large
Load Diff
+1523
-953
File diff suppressed because it is too large
Load Diff
+1512
-942
File diff suppressed because it is too large
Load Diff
+1602
-1032
File diff suppressed because it is too large
Load Diff
+1522
-952
File diff suppressed because it is too large
Load Diff
+1440
-1040
File diff suppressed because it is too large
Load Diff
+1448
-1064
File diff suppressed because it is too large
Load Diff
+1522
-952
File diff suppressed because it is too large
Load Diff
+1515
-945
File diff suppressed because it is too large
Load Diff
+1521
-951
File diff suppressed because it is too large
Load Diff
+1436
-1036
File diff suppressed because it is too large
Load Diff
+1432
-1032
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ export const router = new Router({
|
||||
PostThread: '/profile/:name/post/:rkey',
|
||||
PostLikedBy: '/profile/:name/post/:rkey/liked-by',
|
||||
PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
|
||||
PostQuotes: '/profile/:name/post/:rkey/quotes',
|
||||
ProfileFeed: '/profile/:name/feed/:rkey',
|
||||
ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by',
|
||||
ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Deactivated() {
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const hasOtherAccounts = accounts.length > 1
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {logout} = useSessionApi()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const agent = useAgent()
|
||||
const [pending, setPending] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | undefined>()
|
||||
@@ -72,8 +72,8 @@ export function Deactivated() {
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
logout('Deactivated')
|
||||
}, [logout])
|
||||
logoutCurrentAccount('Deactivated')
|
||||
}, [logoutCurrentAccount])
|
||||
|
||||
const handleActivate = React.useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
)
|
||||
}, [_, t])
|
||||
|
||||
const initialNumToRender = useInitialNumToRender(80)
|
||||
const initialNumToRender = useInitialNumToRender({minItemHeight: 80})
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
|
||||
const {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {useProgressGuideControls} from '#/state/shell/progress-guide'
|
||||
import {uploadBlob} from 'lib/api'
|
||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||
import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
|
||||
import {getAllListMembers} from 'state/queries/list-members'
|
||||
import {
|
||||
useActiveStarterPack,
|
||||
useSetActiveStarterPack,
|
||||
@@ -43,7 +44,6 @@ import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TOURS, useSetQueuedTour} from '#/tours'
|
||||
|
||||
export function StepFinished() {
|
||||
const {_} = useLingui()
|
||||
@@ -58,7 +58,6 @@ export function StepFinished() {
|
||||
const activeStarterPack = useActiveStarterPack()
|
||||
const setActiveStarterPack = useSetActiveStarterPack()
|
||||
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
||||
const setQueuedTour = useSetQueuedTour()
|
||||
const {startProgressGuide} = useProgressGuideControls()
|
||||
|
||||
const finishOnboarding = React.useCallback(async () => {
|
||||
@@ -73,18 +72,20 @@ export function StepFinished() {
|
||||
starterPack: activeStarterPack.uri,
|
||||
})
|
||||
starterPack = spRes.data.starterPack
|
||||
|
||||
if (starterPack.list) {
|
||||
const listRes = await agent.app.bsky.graph.getList({
|
||||
list: starterPack.list.uri,
|
||||
limit: 50,
|
||||
})
|
||||
listItems = listRes.data.items
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to fetch starter pack', {safeMessage: e})
|
||||
// don't tell the user, just get them through onboarding.
|
||||
}
|
||||
try {
|
||||
if (starterPack?.list) {
|
||||
listItems = await getAllListMembers(agent, starterPack.list.uri)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to fetch starter pack list items', {
|
||||
safeMessage: e,
|
||||
})
|
||||
// don't tell the user, just get them through onboarding.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -186,7 +187,6 @@ export function StepFinished() {
|
||||
setSaving(false)
|
||||
setActiveStarterPack(undefined)
|
||||
setHasCheckedForStarterPack(true)
|
||||
setQueuedTour(TOURS.HOME)
|
||||
startProgressGuide('like-10-and-follow-7')
|
||||
dispatch({type: 'finish'})
|
||||
onboardDispatch({type: 'finish'})
|
||||
@@ -220,7 +220,6 @@ export function StepFinished() {
|
||||
requestNotificationsPermission,
|
||||
setActiveStarterPack,
|
||||
setHasCheckedForStarterPack,
|
||||
setQueuedTour,
|
||||
startProgressGuide,
|
||||
])
|
||||
|
||||
|
||||
@@ -143,7 +143,8 @@ export function StepInterests() {
|
||||
track('OnboardingV2:StepInterests:Start')
|
||||
}, [track])
|
||||
|
||||
const isMinimumInterestsEnabled = gate('onboarding_minimum_interests')
|
||||
const isMinimumInterestsEnabled =
|
||||
gate('onboarding_minimum_interests') && data?.interests.length !== 0
|
||||
const meetsMinimumRequirement = isMinimumInterestsEnabled
|
||||
? interests.length >= MIN_INTERESTS
|
||||
: true
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {TID} from '@atproto/common-web'
|
||||
import chunk from 'lodash.chunk'
|
||||
|
||||
import {until} from '#/lib/async/until'
|
||||
|
||||
@@ -29,10 +30,13 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
|
||||
value: r,
|
||||
}))
|
||||
|
||||
await agent.com.atproto.repo.applyWrites({
|
||||
repo: session.did,
|
||||
writes: followWrites,
|
||||
})
|
||||
const chunks = chunk(followWrites, 50)
|
||||
for (const chunk of chunks) {
|
||||
await agent.com.atproto.repo.applyWrites({
|
||||
repo: session.did,
|
||||
writes: chunk,
|
||||
})
|
||||
}
|
||||
await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length)
|
||||
|
||||
const followUris = new Map()
|
||||
|
||||
@@ -4,11 +4,12 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
|
||||
import {ViewHeader} from '../com/util/ViewHeader'
|
||||
import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
|
||||
export const PostLikedByScreen = ({route}: Props) => {
|
||||
@@ -24,7 +25,7 @@ export const PostLikedByScreen = ({route}: Props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<View style={a.flex_1}>
|
||||
<ViewHeader title={_(msg`Liked By`)} />
|
||||
<PostLikedByComponent uri={uri} />
|
||||
</View>
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostQuotes'>
|
||||
export const PostQuotesScreen = ({route}: Props) => {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {name, rkey} = route.params
|
||||
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
||||
const {_} = useLingui()
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
}, [setMinimalShellMode]),
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.flex_1}>
|
||||
<ViewHeader title={_(msg`Quotes`)} />
|
||||
<PostQuotesComponent uri={uri} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||
import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
|
||||
import {ViewHeader} from '../com/util/ViewHeader'
|
||||
import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy'
|
||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
|
||||
export const PostRepostedByScreen = ({route}: Props) => {
|
||||
@@ -24,7 +25,7 @@ export const PostRepostedByScreen = ({route}: Props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}}>
|
||||
<View style={a.flex_1}>
|
||||
<ViewHeader title={_(msg`Reposted By`)} />
|
||||
<PostRepostedByComponent uri={uri} />
|
||||
</View>
|
||||
@@ -17,9 +17,9 @@ export function ProfileHeaderMetrics({
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const {_, i18n} = useLingui()
|
||||
const following = formatCount(i18n, profile.followsCount || 0)
|
||||
const followers = formatCount(i18n, profile.followersCount || 0)
|
||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||
one: 'follower',
|
||||
other: 'followers',
|
||||
@@ -54,7 +54,7 @@ export function ProfileHeaderMetrics({
|
||||
</Text>
|
||||
</InlineLinkText>
|
||||
<Text style={[a.font_bold, t.atoms.text, a.text_md]}>
|
||||
{formatCount(profile.postsCount || 0)}{' '}
|
||||
{formatCount(i18n, profile.postsCount || 0)}{' '}
|
||||
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
|
||||
{plural(profile.postsCount || 0, {one: 'post', other: 'posts'})}
|
||||
</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {isNative} from '#/platform/detection'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
@@ -42,6 +43,10 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
const queryClient = useQueryClient()
|
||||
const [hasNew, setHasNew] = React.useState(false)
|
||||
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
||||
const shouldUseAdjustedNumToRender = feed.endsWith('posts_and_author_threads')
|
||||
const adjustedInitialNumToRender = useInitialNumToRender({
|
||||
screenHeightOffset: headerHeight,
|
||||
})
|
||||
|
||||
const onScrollToTop = React.useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
@@ -79,7 +84,9 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
headerOffset={headerHeight}
|
||||
renderEndOfFeed={ProfileEndOfFeed}
|
||||
ignoreFilterFor={ignoreFilterFor}
|
||||
outsideHeaderOffset={headerHeight}
|
||||
initialNumToRender={
|
||||
shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
|
||||
}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user