Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f2eeaffd0 | |||
| 330580bf82 | |||
| f240730db4 | |||
| 28a2365e0c | |||
| c8ded6291f | |||
| 761fa0bde9 | |||
| 5bf1141b55 | |||
| e1ee85622d | |||
| b1dc5179b6 | |||
| 96d77841e0 | |||
| b38edc52b8 | |||
| d68fc78e93 | |||
| 028ed2f8e2 | |||
| 67049b98c9 | |||
| 07bcf7460a | |||
| 54f70a576a | |||
| 4a9185b0f5 | |||
| 1354bf987d | |||
| 7f4e302b85 | |||
| 5e36992fba | |||
| 42a427cfd6 | |||
| 0c0c21a0aa | |||
| f170313f70 | |||
| c8d0662690 | |||
| cf4cce2225 | |||
| 0e9e705d99 | |||
| 12e09a9bd1 | |||
| 885356256e | |||
| b68f80050c | |||
| 1354fd2661 | |||
| 77bf49f212 | |||
| 02b189a40e | |||
| 5fd52b3d30 | |||
| 1e6a44f2e8 | |||
| 144d61ef76 | |||
| 381feacd26 | |||
| 5df6036c2c | |||
| 3e55e52858 | |||
| f82a6188e6 | |||
| c7ce827bfe | |||
| 9e0f192546 | |||
| d3dbb94689 | |||
| 031fa95715 | |||
| ae2c9a832f | |||
| 02a25d2aa2 | |||
| 2b32fff1d0 | |||
| 22bb9c599f |
+2
-1
@@ -37,6 +37,7 @@ module.exports = {
|
||||
'Toast.Action',
|
||||
'AgeAssuranceAdmonition',
|
||||
'Span',
|
||||
'StackedButton',
|
||||
],
|
||||
impliedTextProps: [],
|
||||
suggestedTextWrappers: {
|
||||
@@ -88,7 +89,7 @@ module.exports = {
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{argsIgnorePattern: '^_', varsIgnorePattern: '^_'},
|
||||
{argsIgnorePattern: '^_', varsIgnorePattern: '^_.+'},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
|
||||
@@ -70,6 +70,8 @@ Bluesky is an open social network built on the AT Protocol, a flexible technolog
|
||||
|
||||
See [./LICENSE](./LICENSE) for the full license.
|
||||
|
||||
Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see [the original announcement](https://bsky.social/about/blog/10-01-2025-patent-pledge).
|
||||
|
||||
## P.S.
|
||||
|
||||
We ❤️ you and all of the ways you support us. Thank you for making Bluesky a great place!
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
## Run / Test
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
cd bskyembed
|
||||
yarn
|
||||
```
|
||||
|
||||
Run the dev server:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
You can see the embed homepage at http://localhost:5173
|
||||
|
||||
### Testbed
|
||||
|
||||
In another terminal window, run the snippet dev script:
|
||||
|
||||
```bash
|
||||
yarn dev-snippet
|
||||
```
|
||||
|
||||
You can then see the testbed page at http://localhost:5173/test
|
||||
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev-snippet": "tsc --project tsconfig.snippet.json && serve -s dist -p 3000 -n",
|
||||
"build": "tsc && vite build",
|
||||
"build-snippet": "tsc --project tsconfig.snippet.json",
|
||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src",
|
||||
@@ -21,6 +22,7 @@
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"postcss": "^8.4.38",
|
||||
"serve": "^14.2.5",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"terser": "^5.43.1",
|
||||
"typescript": "^5.8.3",
|
||||
|
||||
@@ -3,9 +3,19 @@ interface Window {
|
||||
bluesky: {
|
||||
scan: (element?: Pick<Element, 'querySelectorAll'>) => void
|
||||
}
|
||||
BSKY_DEV_EMBED_URL?: string
|
||||
}
|
||||
|
||||
const EMBED_URL = 'https://embed.bsky.app'
|
||||
/**
|
||||
* Allow url to be overwritten during development
|
||||
*/
|
||||
const IS_DEV =
|
||||
window.location.protocol === 'file:' ||
|
||||
window.location.hostname === 'localhost'
|
||||
const EMBED_URL =
|
||||
IS_DEV && window.BSKY_DEV_EMBED_URL
|
||||
? window.BSKY_DEV_EMBED_URL
|
||||
: 'https://embed.bsky.app'
|
||||
|
||||
window.bluesky = window.bluesky || {
|
||||
scan,
|
||||
|
||||
+1054
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,40 @@
|
||||
import fs from 'node:fs'
|
||||
import {resolve} from 'node:path'
|
||||
|
||||
import preact from '@preact/preset-vite'
|
||||
import legacy from '@vitejs/plugin-legacy'
|
||||
import type {UserConfig} from 'vite'
|
||||
import type {Plugin, UserConfig} from 'vite'
|
||||
import paths from 'vite-tsconfig-paths'
|
||||
|
||||
/**
|
||||
* World's hackiest router, for dev only. Serves `/post.html` to requests that start with `/embed/`
|
||||
*/
|
||||
function devOnlyRouter(): Plugin {
|
||||
return {
|
||||
name: 'embed-to-post-html',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const url = req.url || ''
|
||||
if (!url.startsWith('/embed/')) return next()
|
||||
|
||||
const html = fs.readFileSync(
|
||||
resolve(process.cwd(), 'post.html'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
server
|
||||
.transformIndexHtml(url, html)
|
||||
.then(transformed => {
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.end(transformed)
|
||||
})
|
||||
.catch(next)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const config: UserConfig = {
|
||||
plugins: [
|
||||
preact(),
|
||||
@@ -12,6 +42,7 @@ const config: UserConfig = {
|
||||
legacy({
|
||||
targets: ['defaults', 'not IE 11'],
|
||||
}),
|
||||
devOnlyRouter(),
|
||||
],
|
||||
build: {
|
||||
assetsDir: 'static',
|
||||
|
||||
+376
-5
@@ -1447,6 +1447,11 @@
|
||||
regenerator-runtime "^0.14.1"
|
||||
systemjs "^6.15.1"
|
||||
|
||||
"@zeit/schemas@2.36.0":
|
||||
version "2.36.0"
|
||||
resolved "https://registry.yarnpkg.com/@zeit/schemas/-/schemas-2.36.0.tgz#7a1b53f4091e18d0b404873ea3e3c83589c765f2"
|
||||
integrity sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==
|
||||
|
||||
acorn-jsx@^5.3.2:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||
@@ -1462,6 +1467,16 @@ acorn@^8.9.0:
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
|
||||
integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
|
||||
|
||||
ajv@8.12.0:
|
||||
version "8.12.0"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1"
|
||||
integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==
|
||||
dependencies:
|
||||
fast-deep-equal "^3.1.1"
|
||||
json-schema-traverse "^1.0.0"
|
||||
require-from-string "^2.0.2"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ajv@^6.12.4:
|
||||
version "6.12.6"
|
||||
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
|
||||
@@ -1472,6 +1487,13 @@ ajv@^6.12.4:
|
||||
json-schema-traverse "^0.4.1"
|
||||
uri-js "^4.2.2"
|
||||
|
||||
ansi-align@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59"
|
||||
integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==
|
||||
dependencies:
|
||||
string-width "^4.1.0"
|
||||
|
||||
ansi-regex@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
@@ -1507,7 +1529,12 @@ anymatch@~3.1.2:
|
||||
normalize-path "^3.0.0"
|
||||
picomatch "^2.0.4"
|
||||
|
||||
arg@^5.0.2:
|
||||
arch@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11"
|
||||
integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==
|
||||
|
||||
arg@5.0.2, arg@^5.0.2:
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
|
||||
integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==
|
||||
@@ -1684,6 +1711,20 @@ boolbase@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
|
||||
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
|
||||
|
||||
boxen@7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/boxen/-/boxen-7.0.0.tgz#9e5f8c26e716793fc96edcf7cf754cdf5e3fbf32"
|
||||
integrity sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==
|
||||
dependencies:
|
||||
ansi-align "^3.0.1"
|
||||
camelcase "^7.0.0"
|
||||
chalk "^5.0.1"
|
||||
cli-boxes "^3.0.0"
|
||||
string-width "^5.1.2"
|
||||
type-fest "^2.13.0"
|
||||
widest-line "^4.0.1"
|
||||
wrap-ansi "^8.0.1"
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||
@@ -1738,6 +1779,16 @@ buffer-from@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
|
||||
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
|
||||
|
||||
bytes@3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
|
||||
integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==
|
||||
|
||||
bytes@3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
|
||||
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
|
||||
|
||||
call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
|
||||
@@ -1759,6 +1810,11 @@ camelcase-css@^2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
|
||||
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
|
||||
|
||||
camelcase@^7.0.0:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-7.0.1.tgz#f02e50af9fd7782bc8b88a3558c32fd3a388f048"
|
||||
integrity sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==
|
||||
|
||||
caniuse-lite@^1.0.30001524:
|
||||
version "1.0.30001606"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001606.tgz#b4d5f67ab0746a3b8b5b6d1f06e39c51beb39a9e"
|
||||
@@ -1769,7 +1825,19 @@ caniuse-lite@^1.0.30001587, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.300017
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz#22e9706422ad37aa50556af8c10e40e2d93a8b85"
|
||||
integrity sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==
|
||||
|
||||
chalk@^4.0.0:
|
||||
chalk-template@0.4.0:
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/chalk-template/-/chalk-template-0.4.0.tgz#692c034d0ed62436b9062c1707fadcd0f753204b"
|
||||
integrity sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==
|
||||
dependencies:
|
||||
chalk "^4.1.2"
|
||||
|
||||
chalk@5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.0.1.tgz#ca57d71e82bb534a296df63bbacc4a1c22b2a4b6"
|
||||
integrity sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==
|
||||
|
||||
chalk@^4.0.0, chalk@^4.1.2:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
|
||||
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
|
||||
@@ -1777,6 +1845,11 @@ chalk@^4.0.0:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
chalk@^5.0.1:
|
||||
version "5.6.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea"
|
||||
integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==
|
||||
|
||||
chokidar@^3.5.3:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
|
||||
@@ -1792,6 +1865,20 @@ chokidar@^3.5.3:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
cli-boxes@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-3.0.0.tgz#71a10c716feeba005e4504f36329ef0b17cf3145"
|
||||
integrity sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==
|
||||
|
||||
clipboardy@3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/clipboardy/-/clipboardy-3.0.0.tgz#f3876247404d334c9ed01b6f269c11d09a5e3092"
|
||||
integrity sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==
|
||||
dependencies:
|
||||
arch "^2.2.0"
|
||||
execa "^5.1.1"
|
||||
is-wsl "^2.2.0"
|
||||
|
||||
color-convert@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
||||
@@ -1814,11 +1901,36 @@ commander@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
||||
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
|
||||
|
||||
compressible@~2.0.18:
|
||||
version "2.0.18"
|
||||
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
|
||||
integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
|
||||
dependencies:
|
||||
mime-db ">= 1.43.0 < 2"
|
||||
|
||||
compression@1.8.1:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79"
|
||||
integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==
|
||||
dependencies:
|
||||
bytes "3.1.2"
|
||||
compressible "~2.0.18"
|
||||
debug "2.6.9"
|
||||
negotiator "~0.6.4"
|
||||
on-headers "~1.1.0"
|
||||
safe-buffer "5.2.1"
|
||||
vary "~1.1.2"
|
||||
|
||||
concat-map@0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
|
||||
|
||||
content-disposition@0.5.2:
|
||||
version "0.5.2"
|
||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4"
|
||||
integrity sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==
|
||||
|
||||
convert-source-map@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
|
||||
@@ -1845,6 +1957,15 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2:
|
||||
shebang-command "^2.0.0"
|
||||
which "^2.0.1"
|
||||
|
||||
cross-spawn@^7.0.3:
|
||||
version "7.0.6"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
|
||||
dependencies:
|
||||
path-key "^3.1.0"
|
||||
shebang-command "^2.0.0"
|
||||
which "^2.0.1"
|
||||
|
||||
css-select@^5.1.0:
|
||||
version "5.2.2"
|
||||
resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e"
|
||||
@@ -1893,6 +2014,13 @@ data-view-byte-offset@^1.0.0:
|
||||
es-errors "^1.3.0"
|
||||
is-data-view "^1.0.1"
|
||||
|
||||
debug@2.6.9:
|
||||
version "2.6.9"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||
integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
|
||||
dependencies:
|
||||
ms "2.0.0"
|
||||
|
||||
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4, debug@^4.4.1:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b"
|
||||
@@ -1907,6 +2035,11 @@ debug@^4.3.2:
|
||||
dependencies:
|
||||
ms "2.1.2"
|
||||
|
||||
deep-extend@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
|
||||
integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
|
||||
|
||||
deep-is@^0.1.3:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
|
||||
@@ -2356,6 +2489,21 @@ esutils@^2.0.2:
|
||||
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||
|
||||
execa@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
|
||||
integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==
|
||||
dependencies:
|
||||
cross-spawn "^7.0.3"
|
||||
get-stream "^6.0.0"
|
||||
human-signals "^2.1.0"
|
||||
is-stream "^2.0.0"
|
||||
merge-stream "^2.0.0"
|
||||
npm-run-path "^4.0.1"
|
||||
onetime "^5.1.2"
|
||||
signal-exit "^3.0.3"
|
||||
strip-final-newline "^2.0.0"
|
||||
|
||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
@@ -2496,6 +2644,11 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@
|
||||
has-symbols "^1.0.3"
|
||||
hasown "^2.0.0"
|
||||
|
||||
get-stream@^6.0.0:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
|
||||
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
|
||||
|
||||
get-symbol-description@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5"
|
||||
@@ -2631,6 +2784,11 @@ he@1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
|
||||
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
|
||||
|
||||
human-signals@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
|
||||
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
|
||||
|
||||
ignore@^5.2.0:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef"
|
||||
@@ -2662,6 +2820,11 @@ inherits@2:
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
||||
ini@~1.3.0:
|
||||
version "1.3.8"
|
||||
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
|
||||
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
|
||||
|
||||
internal-slot@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802"
|
||||
@@ -2741,6 +2904,11 @@ is-date-object@^1.0.1, is-date-object@^1.0.5:
|
||||
dependencies:
|
||||
has-tostringtag "^1.0.0"
|
||||
|
||||
is-docker@^2.0.0:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
|
||||
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
|
||||
|
||||
is-extglob@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
||||
@@ -2799,6 +2967,11 @@ is-path-inside@^3.0.3:
|
||||
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
|
||||
integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
|
||||
|
||||
is-port-reachable@4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-port-reachable/-/is-port-reachable-4.0.0.tgz#dac044091ef15319c8ab2f34604d8794181f8c2d"
|
||||
integrity sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==
|
||||
|
||||
is-regex@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
|
||||
@@ -2819,6 +2992,11 @@ is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3:
|
||||
dependencies:
|
||||
call-bind "^1.0.7"
|
||||
|
||||
is-stream@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
|
||||
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
|
||||
|
||||
is-string@^1.0.5, is-string@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd"
|
||||
@@ -2860,6 +3038,13 @@ is-weakset@^2.0.3:
|
||||
call-bind "^1.0.7"
|
||||
get-intrinsic "^1.2.4"
|
||||
|
||||
is-wsl@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
|
||||
integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
|
||||
dependencies:
|
||||
is-docker "^2.0.0"
|
||||
|
||||
isarray@^2.0.5:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
|
||||
@@ -2932,6 +3117,11 @@ json-schema-traverse@^0.4.1:
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
|
||||
|
||||
json-schema-traverse@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
|
||||
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
|
||||
|
||||
json-stable-stringify-without-jsonify@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||
@@ -3047,6 +3237,11 @@ meow@^13.0.0:
|
||||
resolved "https://registry.yarnpkg.com/meow/-/meow-13.2.0.tgz#6b7d63f913f984063b3cc261b6e8800c4cd3474f"
|
||||
integrity sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==
|
||||
|
||||
merge-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
|
||||
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
|
||||
|
||||
merge2@^1.3.0, merge2@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
|
||||
@@ -3060,7 +3255,29 @@ micromatch@^4.0.4, micromatch@^4.0.5:
|
||||
braces "^3.0.2"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
"mime-db@>= 1.43.0 < 2":
|
||||
version "1.54.0"
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5"
|
||||
integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==
|
||||
|
||||
mime-db@~1.33.0:
|
||||
version "1.33.0"
|
||||
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db"
|
||||
integrity sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==
|
||||
|
||||
mime-types@2.1.18:
|
||||
version "2.1.18"
|
||||
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8"
|
||||
integrity sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==
|
||||
dependencies:
|
||||
mime-db "~1.33.0"
|
||||
|
||||
mimic-fn@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
|
||||
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
|
||||
|
||||
minimatch@3.1.2, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
|
||||
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
|
||||
@@ -3074,11 +3291,21 @@ minimatch@^9.0.1:
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minimist@^1.2.0:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.4:
|
||||
version "7.0.4"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c"
|
||||
integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==
|
||||
|
||||
ms@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
|
||||
|
||||
ms@2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||
@@ -3118,6 +3345,11 @@ natural-compare@^1.4.0:
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||
|
||||
negotiator@~0.6.4:
|
||||
version "0.6.4"
|
||||
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7"
|
||||
integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==
|
||||
|
||||
node-html-parser@^6.1.12:
|
||||
version "6.1.13"
|
||||
resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-6.1.13.tgz#a1df799b83df5c6743fcd92740ba14682083b7e4"
|
||||
@@ -3141,6 +3373,13 @@ normalize-range@^0.1.2:
|
||||
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
|
||||
integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
|
||||
|
||||
npm-run-path@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
|
||||
integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
|
||||
dependencies:
|
||||
path-key "^3.0.0"
|
||||
|
||||
nth-check@^2.0.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
|
||||
@@ -3215,6 +3454,11 @@ object.values@^1.1.6, object.values@^1.1.7:
|
||||
define-properties "^1.2.1"
|
||||
es-object-atoms "^1.0.0"
|
||||
|
||||
on-headers@~1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.1.0.tgz#59da4f91c45f5f989c6e4bcedc5a3b0aed70ff65"
|
||||
integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==
|
||||
|
||||
once@^1.3.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
|
||||
@@ -3222,6 +3466,13 @@ once@^1.3.0:
|
||||
dependencies:
|
||||
wrappy "1"
|
||||
|
||||
onetime@^5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
|
||||
integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
|
||||
dependencies:
|
||||
mimic-fn "^2.1.0"
|
||||
|
||||
optionator@^0.9.3:
|
||||
version "0.9.3"
|
||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64"
|
||||
@@ -3265,7 +3516,12 @@ path-is-absolute@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
|
||||
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
|
||||
|
||||
path-key@^3.1.0:
|
||||
path-is-inside@1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
|
||||
integrity sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==
|
||||
|
||||
path-key@^3.0.0, path-key@^3.1.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
||||
@@ -3283,6 +3539,11 @@ path-scurry@^1.10.2:
|
||||
lru-cache "^10.2.0"
|
||||
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
|
||||
path-to-regexp@3.3.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b"
|
||||
integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==
|
||||
|
||||
path-type@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
|
||||
@@ -3409,6 +3670,21 @@ queue-microtask@^1.2.2:
|
||||
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
|
||||
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
|
||||
|
||||
range-parser@1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e"
|
||||
integrity sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==
|
||||
|
||||
rc@^1.0.1, rc@^1.1.6:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
|
||||
integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
|
||||
dependencies:
|
||||
deep-extend "^0.6.0"
|
||||
ini "~1.3.0"
|
||||
minimist "^1.2.0"
|
||||
strip-json-comments "~2.0.1"
|
||||
|
||||
react-is@^16.13.1:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
@@ -3480,6 +3756,21 @@ regexpu-core@^6.2.0:
|
||||
unicode-match-property-ecmascript "^2.0.0"
|
||||
unicode-match-property-value-ecmascript "^2.1.0"
|
||||
|
||||
registry-auth-token@3.3.2:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.2.tgz#851fd49038eecb586911115af845260eec983f20"
|
||||
integrity sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==
|
||||
dependencies:
|
||||
rc "^1.1.6"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
registry-url@3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
|
||||
integrity sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==
|
||||
dependencies:
|
||||
rc "^1.0.1"
|
||||
|
||||
regjsgen@^0.8.0:
|
||||
version "0.8.0"
|
||||
resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.8.0.tgz#df23ff26e0c5b300a6470cad160a9d090c3a37ab"
|
||||
@@ -3492,6 +3783,11 @@ regjsparser@^0.12.0:
|
||||
dependencies:
|
||||
jsesc "~3.0.2"
|
||||
|
||||
require-from-string@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
|
||||
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
|
||||
|
||||
resolve-from@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
|
||||
@@ -3582,6 +3878,11 @@ safe-array-concat@^1.1.2:
|
||||
has-symbols "^1.0.3"
|
||||
isarray "^2.0.5"
|
||||
|
||||
safe-buffer@5.2.1, safe-buffer@^5.0.1:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
|
||||
safe-regex-test@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377"
|
||||
@@ -3603,6 +3904,36 @@ semver@^7.3.7, semver@^7.5.4:
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
serve-handler@6.1.6:
|
||||
version "6.1.6"
|
||||
resolved "https://registry.yarnpkg.com/serve-handler/-/serve-handler-6.1.6.tgz#50803c1d3e947cd4a341d617f8209b22bd76cfa1"
|
||||
integrity sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==
|
||||
dependencies:
|
||||
bytes "3.0.0"
|
||||
content-disposition "0.5.2"
|
||||
mime-types "2.1.18"
|
||||
minimatch "3.1.2"
|
||||
path-is-inside "1.0.2"
|
||||
path-to-regexp "3.3.0"
|
||||
range-parser "1.2.0"
|
||||
|
||||
serve@^14.2.5:
|
||||
version "14.2.5"
|
||||
resolved "https://registry.yarnpkg.com/serve/-/serve-14.2.5.tgz#569e333b99a484b3a6d25acce4a569c8c4f96373"
|
||||
integrity sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==
|
||||
dependencies:
|
||||
"@zeit/schemas" "2.36.0"
|
||||
ajv "8.12.0"
|
||||
arg "5.0.2"
|
||||
boxen "7.0.0"
|
||||
chalk "5.0.1"
|
||||
chalk-template "0.4.0"
|
||||
clipboardy "3.0.0"
|
||||
compression "1.8.1"
|
||||
is-port-reachable "4.0.0"
|
||||
serve-handler "6.1.6"
|
||||
update-check "1.5.4"
|
||||
|
||||
set-function-length@^1.2.1:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449"
|
||||
@@ -3647,6 +3978,11 @@ side-channel@^1.0.4, side-channel@^1.0.6:
|
||||
get-intrinsic "^1.2.4"
|
||||
object-inspect "^1.13.1"
|
||||
|
||||
signal-exit@^3.0.3:
|
||||
version "3.0.7"
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
|
||||
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
||||
|
||||
signal-exit@^4.0.1:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04"
|
||||
@@ -3791,11 +4127,21 @@ strip-ansi@^7.0.1:
|
||||
dependencies:
|
||||
ansi-regex "^6.0.1"
|
||||
|
||||
strip-final-newline@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
|
||||
integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
|
||||
|
||||
strip-json-comments@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
|
||||
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
|
||||
|
||||
strip-json-comments@~2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
|
||||
integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
|
||||
|
||||
sucrase@^3.32.0:
|
||||
version "3.35.0"
|
||||
resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263"
|
||||
@@ -3937,6 +4283,11 @@ type-fest@^0.20.2:
|
||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
|
||||
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
||||
|
||||
type-fest@^2.13.0:
|
||||
version "2.19.0"
|
||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b"
|
||||
integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==
|
||||
|
||||
typed-array-buffer@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3"
|
||||
@@ -4034,6 +4385,14 @@ update-browserslist-db@^1.0.13, update-browserslist-db@^1.1.3:
|
||||
escalade "^3.2.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
update-check@1.5.4:
|
||||
version "1.5.4"
|
||||
resolved "https://registry.yarnpkg.com/update-check/-/update-check-1.5.4.tgz#5b508e259558f1ad7dbc8b4b0457d4c9d28c8743"
|
||||
integrity sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==
|
||||
dependencies:
|
||||
registry-auth-token "3.3.2"
|
||||
registry-url "3.1.0"
|
||||
|
||||
uri-js@^4.2.2:
|
||||
version "4.4.1"
|
||||
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
|
||||
@@ -4046,6 +4405,11 @@ util-deprecate@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
|
||||
|
||||
vary@~1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
||||
|
||||
vite-prerender-plugin@^0.5.3:
|
||||
version "0.5.11"
|
||||
resolved "https://registry.yarnpkg.com/vite-prerender-plugin/-/vite-prerender-plugin-0.5.11.tgz#83e4f29e03269dceb763fb5ec2376dcc502aa79f"
|
||||
@@ -4138,6 +4502,13 @@ which@^2.0.1:
|
||||
dependencies:
|
||||
isexe "^2.0.0"
|
||||
|
||||
widest-line@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-4.0.1.tgz#a0fc673aaba1ea6f0a0d35b3c2795c9a9cc2ebf2"
|
||||
integrity sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==
|
||||
dependencies:
|
||||
string-width "^5.0.1"
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
@@ -4147,7 +4518,7 @@ which@^2.0.1:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
|
||||
|
||||
@@ -458,6 +458,18 @@ func (srv *Server) WebHome(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "home.html", data)
|
||||
}
|
||||
|
||||
// Posts that include these labels will not have embeds passed to the metadata
|
||||
// template.
|
||||
var hideEmbedLabels = map[string]bool{
|
||||
"nudity": true,
|
||||
"porn": true,
|
||||
"sexual": true,
|
||||
"sexual-figurative": true,
|
||||
"graphic-media": true,
|
||||
"self-harm": true,
|
||||
"sensitive": true,
|
||||
}
|
||||
|
||||
func (srv *Server) WebPost(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
data := srv.NewTemplateContext()
|
||||
@@ -512,19 +524,15 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
|
||||
data["postView"] = postView
|
||||
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
|
||||
if postView.Embed != nil {
|
||||
if postView.Embed.EmbedImages_View != nil {
|
||||
var thumbUrls []string
|
||||
for i := range postView.Embed.EmbedImages_View.Images {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil {
|
||||
var thumbUrls []string
|
||||
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
|
||||
// If any undesirable labels are set, the embed will not be included in
|
||||
// metadata
|
||||
isEmbedHidden := false
|
||||
for _, label := range postView.Labels {
|
||||
isNeg := label.Neg != nil && *label.Neg
|
||||
if hideEmbedLabels[label.Val] && !isNeg {
|
||||
isEmbedHidden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,6 +540,34 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
postRecord, ok := postView.Record.Val.(*appbsky.FeedPost)
|
||||
if ok {
|
||||
data["postText"] = ExpandPostText(postRecord)
|
||||
|
||||
if !isEmbedHidden && postRecord.Labels != nil && postRecord.Labels.LabelDefs_SelfLabels != nil {
|
||||
for _, label := range postRecord.Labels.LabelDefs_SelfLabels.Values {
|
||||
if hideEmbedLabels[label.Val] {
|
||||
isEmbedHidden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if postView.Embed != nil && !isEmbedHidden {
|
||||
hasImages := postView.Embed.EmbedImages_View != nil
|
||||
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
|
||||
|
||||
if hasImages {
|
||||
var thumbUrls []string
|
||||
for i := range postView.Embed.EmbedImages_View.Images {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
} else if hasMedia {
|
||||
var thumbUrls []string
|
||||
for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
|
||||
thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
|
||||
}
|
||||
data["imgThumbUrls"] = thumbUrls
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,12 @@
|
||||
"@type": "DiscussionForumPosting",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
{%- if postView.Author.DisplayName %}
|
||||
"name": "{{ postView.Author.DisplayName }}",
|
||||
"alternateName": "@{{ postView.Author.Handle }}",
|
||||
{% else %}
|
||||
"name": "@{{ postView.Author.Handle }}",
|
||||
{% endif -%}
|
||||
"url": "https://bsky.app/profile/{{ postView.Author.Handle }}"
|
||||
},
|
||||
{%- if postText %}
|
||||
|
||||
@@ -59,8 +59,12 @@
|
||||
"dateCreated": "{{ profileView.CreatedAt }}",
|
||||
"mainEntity": {
|
||||
"@type": "Person",
|
||||
{%- if profileView.DisplayName %}
|
||||
"name": "{{ profileView.DisplayName }}",
|
||||
"alternateName": "@{{ profileView.Handle }}",
|
||||
{% else %}
|
||||
"name": "@{{ profileView.Handle }}",
|
||||
{% endif -%}
|
||||
"identifier": "{{ profileView.Did }}",
|
||||
"description": "{{ profileView.Description }}",
|
||||
"image": "{{ profileView.Avatar }}",
|
||||
|
||||
+8
-12
@@ -1,22 +1,18 @@
|
||||
import React from 'react'
|
||||
import {render} from '@testing-library/react-native'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootStoreProvider, RootStoreModel} from '../src/state'
|
||||
import {render} from '@testing-library/react-native'
|
||||
|
||||
import {ThemeProvider} from '../src/lib/ThemeContext'
|
||||
import {type RootStoreModel, RootStoreProvider} from '../src/state'
|
||||
|
||||
const customRender = (ui: any, rootStore: RootStoreModel) =>
|
||||
render(
|
||||
// eslint-disable-next-line react-native/no-inline-styles
|
||||
<GestureHandlerRootView style={{flex: 1}}>
|
||||
<RootSiblingParent>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<ThemeProvider theme="light">
|
||||
<SafeAreaProvider>{ui}</SafeAreaProvider>
|
||||
</ThemeProvider>
|
||||
</RootStoreProvider>
|
||||
</RootSiblingParent>
|
||||
<RootStoreProvider value={rootStore}>
|
||||
<ThemeProvider theme="light">
|
||||
<SafeAreaProvider>{ui}</SafeAreaProvider>
|
||||
</ThemeProvider>
|
||||
</RootStoreProvider>
|
||||
</GestureHandlerRootView>,
|
||||
)
|
||||
|
||||
|
||||
+4
-5
@@ -71,7 +71,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.16.7",
|
||||
"@atproto/api": "^0.17.0",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.2",
|
||||
@@ -192,12 +192,11 @@
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-keyboard-controller": "1.18.5",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "^3.19.1",
|
||||
"react-native-root-siblings": "^5.0.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
@@ -247,7 +246,7 @@
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.1",
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
||||
"babel-preset-expo": "~54.0.0",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-plugin-bsky-internal": "link:./eslint",
|
||||
@@ -255,7 +254,7 @@
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-lingui": "^0.2.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.1",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-native-a11y": "^3.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"file-loader": "6.2.0",
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
diff --git a/node_modules/react-native-compressor/android/build.gradle b/node_modules/react-native-compressor/android/build.gradle
|
||||
index 5071139..84bee34 100644
|
||||
--- a/node_modules/react-native-compressor/android/build.gradle
|
||||
+++ b/node_modules/react-native-compressor/android/build.gradle
|
||||
@@ -115,7 +115,6 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
|
||||
implementation 'org.mp4parser:isoparser:1.9.56'
|
||||
- implementation 'com.github.banketree:AndroidLame-kotlin:v0.0.1'
|
||||
implementation 'javazoom:jlayer:1.0.1'
|
||||
}
|
||||
|
||||
diff --git a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt b/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
|
||||
deleted file mode 100644
|
||||
index 9292d3e..0000000
|
||||
--- a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioCompressor.kt
|
||||
+++ /dev/null
|
||||
@@ -1,264 +0,0 @@
|
||||
-package com.reactnativecompressor.Audio
|
||||
-
|
||||
-
|
||||
-import android.annotation.SuppressLint
|
||||
-import com.facebook.react.bridge.Promise
|
||||
-import com.facebook.react.bridge.ReactApplicationContext
|
||||
-import com.facebook.react.bridge.ReadableMap
|
||||
-import com.naman14.androidlame.LameBuilder
|
||||
-import com.naman14.androidlame.WaveReader
|
||||
-import com.reactnativecompressor.Utils.MediaCache
|
||||
-import com.reactnativecompressor.Utils.Utils
|
||||
-import com.reactnativecompressor.Utils.Utils.addLog
|
||||
-import javazoom.jl.converter.Converter
|
||||
-import javazoom.jl.decoder.JavaLayerException
|
||||
-import java.io.BufferedOutputStream
|
||||
-import java.io.File
|
||||
-import java.io.FileNotFoundException
|
||||
-import java.io.FileOutputStream
|
||||
-import java.io.IOException
|
||||
-
|
||||
-class AudioCompressor {
|
||||
- companion object {
|
||||
- val TAG="AudioMain"
|
||||
- private const val OUTPUT_STREAM_BUFFER = 8192
|
||||
-
|
||||
- var outputStream: BufferedOutputStream? = null
|
||||
- var waveReader: WaveReader? = null
|
||||
- @JvmStatic
|
||||
- fun CompressAudio(
|
||||
- fileUrl: String,
|
||||
- optionMap: ReadableMap,
|
||||
- context: ReactApplicationContext,
|
||||
- promise: Promise,
|
||||
- ) {
|
||||
- val realPath = Utils.getRealPath(fileUrl, context)
|
||||
- var _fileUrl=realPath
|
||||
- val filePathWithoutFileUri = realPath!!.replace("file://", "")
|
||||
- try {
|
||||
- var wavPath=filePathWithoutFileUri;
|
||||
- var isNonWav:Boolean=false
|
||||
- if (fileUrl.endsWith(".mp4", ignoreCase = true))
|
||||
- {
|
||||
- addLog("mp4 file found")
|
||||
- val mp3Path= Utils.generateCacheFilePath("mp3", context)
|
||||
- AudioExtractor().genVideoUsingMuxer(fileUrl, mp3Path, -1, -1, true, false)
|
||||
- _fileUrl=Utils.slashifyFilePath(mp3Path)
|
||||
- wavPath= Utils.generateCacheFilePath("wav", context)
|
||||
- try {
|
||||
- val converter = Converter()
|
||||
- converter.convert(mp3Path, wavPath)
|
||||
- } catch (e: JavaLayerException) {
|
||||
- addLog("JavaLayerException error"+e.localizedMessage)
|
||||
- e.printStackTrace();
|
||||
- }
|
||||
- isNonWav=true
|
||||
- }
|
||||
- else if (!fileUrl.endsWith(".wav", ignoreCase = true))
|
||||
- {
|
||||
- addLog("non wav file found")
|
||||
- wavPath= Utils.generateCacheFilePath("wav", context)
|
||||
- try {
|
||||
- val converter = Converter()
|
||||
- converter.convert(filePathWithoutFileUri, wavPath)
|
||||
- } catch (e: JavaLayerException) {
|
||||
- addLog("JavaLayerException error"+e.localizedMessage)
|
||||
- e.printStackTrace();
|
||||
- }
|
||||
- isNonWav=true
|
||||
- }
|
||||
-
|
||||
-
|
||||
- autoCompressHelper(wavPath,filePathWithoutFileUri, optionMap,context) { mp3Path, finished ->
|
||||
- if (finished) {
|
||||
- val returnableFilePath:String="file://$mp3Path"
|
||||
- addLog("finished: " + returnableFilePath)
|
||||
- MediaCache.removeCompletedImagePath(fileUrl)
|
||||
- if(isNonWav)
|
||||
- {
|
||||
- File(wavPath).delete()
|
||||
- }
|
||||
- promise.resolve(returnableFilePath)
|
||||
- } else {
|
||||
- addLog("error: "+mp3Path)
|
||||
- promise.resolve(_fileUrl)
|
||||
- }
|
||||
- }
|
||||
- } catch (e: Exception) {
|
||||
- promise.resolve(_fileUrl)
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- @SuppressLint("WrongConstant")
|
||||
- private fun autoCompressHelper(
|
||||
- fileUrl: String,
|
||||
- actualFileUrl: String,
|
||||
- optionMap: ReadableMap,
|
||||
- context: ReactApplicationContext,
|
||||
- completeCallback: (String, Boolean) -> Unit
|
||||
- ) {
|
||||
-
|
||||
- val options = AudioHelper.fromMap(optionMap)
|
||||
- val quality = options.quality
|
||||
-
|
||||
- var isCompletedCallbackTriggered:Boolean=false
|
||||
- try {
|
||||
- var mp3Path = Utils.generateCacheFilePath("mp3", context)
|
||||
- val input = File(fileUrl)
|
||||
- val output = File(mp3Path)
|
||||
-
|
||||
- val CHUNK_SIZE = 8192
|
||||
- addLog("Initialising wav reader")
|
||||
-
|
||||
- waveReader = WaveReader(input)
|
||||
-
|
||||
- try {
|
||||
- waveReader!!.openWave()
|
||||
- } catch (e: IOException) {
|
||||
- e.printStackTrace()
|
||||
- }
|
||||
-
|
||||
- addLog("Intitialising encoder")
|
||||
-
|
||||
-
|
||||
- // for bitrate
|
||||
- var audioBitrate:Int
|
||||
- if(options.bitrate != -1)
|
||||
- {
|
||||
- audioBitrate= options.bitrate/1000
|
||||
- }
|
||||
- else
|
||||
- {
|
||||
- audioBitrate=AudioHelper.getDestinationBitrateByQuality(actualFileUrl, quality!!)
|
||||
- Utils.addLog("dest bitrate: $audioBitrate")
|
||||
- }
|
||||
-
|
||||
- var androidLame = LameBuilder();
|
||||
- androidLame.setOutBitrate(audioBitrate)
|
||||
-
|
||||
- // for channels
|
||||
- var audioChannels:Int
|
||||
- if(options.channels != -1){
|
||||
- audioChannels= options.channels!!
|
||||
- }
|
||||
- else
|
||||
- {
|
||||
- audioChannels=waveReader!!.channels
|
||||
- }
|
||||
- androidLame.setOutChannels(audioChannels)
|
||||
-
|
||||
- // for sample rate
|
||||
- androidLame.setInSampleRate(waveReader!!.sampleRate)
|
||||
- var audioSampleRate:Int
|
||||
- if(options.samplerate != -1){
|
||||
- audioSampleRate= options.samplerate!!
|
||||
- }
|
||||
- else
|
||||
- {
|
||||
- audioSampleRate=waveReader!!.sampleRate
|
||||
- }
|
||||
- androidLame.setOutSampleRate(audioSampleRate)
|
||||
- val androidLameBuild=androidLame.build()
|
||||
-
|
||||
- try {
|
||||
- outputStream = BufferedOutputStream(FileOutputStream(output), OUTPUT_STREAM_BUFFER)
|
||||
- } catch (e: FileNotFoundException) {
|
||||
- e.printStackTrace()
|
||||
- }
|
||||
-
|
||||
- var bytesRead = 0
|
||||
-
|
||||
- val buffer_l = ShortArray(CHUNK_SIZE)
|
||||
- val buffer_r = ShortArray(CHUNK_SIZE)
|
||||
- val mp3Buf = ByteArray(CHUNK_SIZE)
|
||||
-
|
||||
- val channels = waveReader!!.channels
|
||||
-
|
||||
- addLog("started encoding")
|
||||
- while (true) {
|
||||
- try {
|
||||
- if (channels == 2) {
|
||||
-
|
||||
- bytesRead = waveReader!!.read(buffer_l, buffer_r, CHUNK_SIZE)
|
||||
- addLog("bytes read=$bytesRead")
|
||||
-
|
||||
- if (bytesRead > 0) {
|
||||
-
|
||||
- var bytesEncoded = 0
|
||||
- bytesEncoded = androidLameBuild.encode(buffer_l, buffer_r, bytesRead, mp3Buf)
|
||||
- addLog("bytes encoded=$bytesEncoded")
|
||||
-
|
||||
- if (bytesEncoded > 0) {
|
||||
- try {
|
||||
- addLog("writing mp3 buffer to outputstream with $bytesEncoded bytes")
|
||||
- outputStream!!.write(mp3Buf, 0, bytesEncoded)
|
||||
- } catch (e: IOException) {
|
||||
- e.printStackTrace()
|
||||
- }
|
||||
-
|
||||
- }
|
||||
-
|
||||
- } else
|
||||
- break
|
||||
- } else {
|
||||
-
|
||||
- bytesRead = waveReader!!.read(buffer_l, CHUNK_SIZE)
|
||||
- addLog("bytes read=$bytesRead")
|
||||
-
|
||||
- if (bytesRead > 0) {
|
||||
- var bytesEncoded = 0
|
||||
-
|
||||
- bytesEncoded = androidLameBuild.encode(buffer_l, buffer_l, bytesRead, mp3Buf)
|
||||
- addLog("bytes encoded=$bytesEncoded")
|
||||
-
|
||||
- if (bytesEncoded > 0) {
|
||||
- try {
|
||||
- addLog("writing mp3 buffer to outputstream with $bytesEncoded bytes")
|
||||
- outputStream!!.write(mp3Buf, 0, bytesEncoded)
|
||||
- } catch (e: IOException) {
|
||||
- e.printStackTrace()
|
||||
- }
|
||||
-
|
||||
- }
|
||||
-
|
||||
- } else
|
||||
- break
|
||||
- }
|
||||
-
|
||||
-
|
||||
- } catch (e: IOException) {
|
||||
- e.printStackTrace()
|
||||
- }
|
||||
-
|
||||
- }
|
||||
-
|
||||
- addLog("flushing final mp3buffer")
|
||||
- val outputMp3buf = androidLameBuild.flush(mp3Buf)
|
||||
- addLog("flushed $outputMp3buf bytes")
|
||||
- if (outputMp3buf > 0) {
|
||||
- try {
|
||||
- addLog("writing final mp3buffer to outputstream")
|
||||
- outputStream!!.write(mp3Buf, 0, outputMp3buf)
|
||||
- addLog("closing output stream")
|
||||
- outputStream!!.close()
|
||||
- completeCallback(output.absolutePath, true)
|
||||
- isCompletedCallbackTriggered=true
|
||||
- } catch (e: IOException) {
|
||||
- completeCallback(e.localizedMessage, false)
|
||||
- e.printStackTrace()
|
||||
- }
|
||||
- }
|
||||
-
|
||||
- } catch (e: IOException) {
|
||||
- completeCallback(e.localizedMessage, false)
|
||||
- }
|
||||
- if(!isCompletedCallbackTriggered)
|
||||
- {
|
||||
- completeCallback("something went wrong", false)
|
||||
- }
|
||||
- }
|
||||
-
|
||||
-
|
||||
-
|
||||
- }
|
||||
-}
|
||||
diff --git a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt b/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt
|
||||
deleted file mode 100644
|
||||
index c655182..0000000
|
||||
--- a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioExtractor.kt
|
||||
+++ /dev/null
|
||||
@@ -1,112 +0,0 @@
|
||||
-package com.reactnativecompressor.Audio
|
||||
-
|
||||
-import android.annotation.SuppressLint
|
||||
-import android.media.MediaCodec
|
||||
-import android.media.MediaExtractor
|
||||
-import android.media.MediaFormat
|
||||
-import android.media.MediaMetadataRetriever
|
||||
-import android.media.MediaMuxer
|
||||
-import android.util.Log
|
||||
-import java.io.IOException
|
||||
-import java.nio.ByteBuffer
|
||||
-
|
||||
-
|
||||
-class AudioExtractor {
|
||||
- /**
|
||||
- * @param srcPath the path of source video file.
|
||||
- * @param dstPath the path of destination video file.
|
||||
- * @param startMs starting time in milliseconds for trimming. Set to
|
||||
- * negative if starting from beginning.
|
||||
- * @param endMs end time for trimming in milliseconds. Set to negative if
|
||||
- * no trimming at the end.
|
||||
- * @param useAudio true if keep the audio track from the source.
|
||||
- * @param useVideo true if keep the video track from the source.
|
||||
- * @throws IOException
|
||||
- */
|
||||
- @SuppressLint("NewApi", "WrongConstant")
|
||||
- @Throws(IOException::class)
|
||||
- fun genVideoUsingMuxer(srcPath: String?, dstPath: String?, startMs: Int, endMs: Int, useAudio: Boolean, useVideo: Boolean) {
|
||||
- // Set up MediaExtractor to read from the source.
|
||||
- val extractor = MediaExtractor()
|
||||
- extractor.setDataSource(srcPath!!)
|
||||
- val trackCount = extractor.trackCount
|
||||
- // Set up MediaMuxer for the destination.
|
||||
- val muxer: MediaMuxer
|
||||
- muxer = MediaMuxer(dstPath!!, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
- // Set up the tracks and retrieve the max buffer size for selected
|
||||
- // tracks.
|
||||
- val indexMap = HashMap<Int, Int>(trackCount)
|
||||
- var bufferSize = -1
|
||||
- for (i in 0 until trackCount) {
|
||||
- val format = extractor.getTrackFormat(i)
|
||||
- val mime = format.getString(MediaFormat.KEY_MIME)
|
||||
- var selectCurrentTrack = false
|
||||
- if (mime!!.startsWith("audio/") && useAudio) {
|
||||
- selectCurrentTrack = true
|
||||
- } else if (mime.startsWith("video/") && useVideo) {
|
||||
- selectCurrentTrack = true
|
||||
- }
|
||||
- if (selectCurrentTrack) {
|
||||
- extractor.selectTrack(i)
|
||||
- val dstIndex = muxer.addTrack(format)
|
||||
- indexMap[i] = dstIndex
|
||||
- if (format.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
- val newSize = format.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE)
|
||||
- bufferSize = if (newSize > bufferSize) newSize else bufferSize
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- if (bufferSize < 0) {
|
||||
- bufferSize = DEFAULT_BUFFER_SIZE
|
||||
- }
|
||||
- // Set up the orientation and starting time for extractor.
|
||||
- val retrieverSrc = MediaMetadataRetriever()
|
||||
- retrieverSrc.setDataSource(srcPath)
|
||||
- val degreesString = retrieverSrc.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
- if (degreesString != null) {
|
||||
- val degrees = degreesString.toInt()
|
||||
- if (degrees >= 0) {
|
||||
- muxer.setOrientationHint(degrees)
|
||||
- }
|
||||
- }
|
||||
- if (startMs > 0) {
|
||||
- extractor.seekTo((startMs * 1000).toLong(), MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
||||
- }
|
||||
- // Copy the samples from MediaExtractor to MediaMuxer. We will loop
|
||||
- // for copying each sample and stop when we get to the end of the source
|
||||
- // file or exceed the end time of the trimming.
|
||||
- val offset = 0
|
||||
- var trackIndex = -1
|
||||
- val dstBuf = ByteBuffer.allocate(bufferSize)
|
||||
- val bufferInfo = MediaCodec.BufferInfo()
|
||||
- muxer.start()
|
||||
- while (true) {
|
||||
- bufferInfo.offset = offset
|
||||
- bufferInfo.size = extractor.readSampleData(dstBuf, offset)
|
||||
- if (bufferInfo.size < 0) {
|
||||
- Log.d(TAG, "Saw input EOS.")
|
||||
- bufferInfo.size = 0
|
||||
- break
|
||||
- } else {
|
||||
- bufferInfo.presentationTimeUs = extractor.sampleTime
|
||||
- if (endMs > 0 && bufferInfo.presentationTimeUs > endMs * 1000) {
|
||||
- Log.d(TAG, "The current sample is over the trim end time.")
|
||||
- break
|
||||
- } else {
|
||||
- bufferInfo.flags = extractor.sampleFlags
|
||||
- trackIndex = extractor.sampleTrackIndex
|
||||
- muxer.writeSampleData(indexMap[trackIndex]!!, dstBuf, bufferInfo)
|
||||
- extractor.advance()
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- muxer.stop()
|
||||
- muxer.release()
|
||||
- return
|
||||
- }
|
||||
-
|
||||
- companion object {
|
||||
- private const val DEFAULT_BUFFER_SIZE = 1 * 1024 * 1024
|
||||
- private const val TAG = "AudioExtractorDecoder"
|
||||
- }
|
||||
-}
|
||||
diff --git a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt b/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt
|
||||
deleted file mode 100644
|
||||
index 42040b4..0000000
|
||||
--- a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioHelper.kt
|
||||
+++ /dev/null
|
||||
@@ -1,72 +0,0 @@
|
||||
-package com.reactnativecompressor.Audio
|
||||
-
|
||||
-import android.media.MediaExtractor
|
||||
-import android.media.MediaFormat
|
||||
-import com.facebook.react.bridge.ReadableMap
|
||||
-import com.reactnativecompressor.Utils.Utils
|
||||
-import java.io.File
|
||||
-import java.io.IOException
|
||||
-
|
||||
-
|
||||
-class AudioHelper {
|
||||
-
|
||||
- var quality: String? = "medium"
|
||||
- var bitrate: Int = -1
|
||||
- var samplerate: Int = -1
|
||||
- var channels: Int = -1
|
||||
- var progressDivider: Int? = 0
|
||||
-
|
||||
- companion object {
|
||||
- fun fromMap(map: ReadableMap): AudioHelper {
|
||||
- val options = AudioHelper()
|
||||
- val iterator = map.keySetIterator()
|
||||
- while (iterator.hasNextKey()) {
|
||||
- val key = iterator.nextKey()
|
||||
- when (key) {
|
||||
- "quality" -> options.quality = map.getString(key)
|
||||
- "bitrate" -> {
|
||||
- val bitrate = map.getInt(key)
|
||||
- options.bitrate = if (bitrate > 320000 || bitrate < 64000) 64000 else bitrate
|
||||
- }
|
||||
- "samplerate" -> options.samplerate = map.getInt(key)
|
||||
- "channels" -> options.channels = map.getInt(key)
|
||||
- }
|
||||
- }
|
||||
- return options
|
||||
- }
|
||||
-
|
||||
-
|
||||
- fun getAudioBitrate(path: String): Int {
|
||||
- val file = File(path)
|
||||
- val fileSize = file.length() * 8 // size in bits
|
||||
-
|
||||
- val mex = MediaExtractor()
|
||||
- try {
|
||||
- mex.setDataSource(path)
|
||||
- } catch (e: IOException) {
|
||||
- e.printStackTrace()
|
||||
- }
|
||||
-
|
||||
- val mf = mex.getTrackFormat(0)
|
||||
- val durationUs = mf.getLong(MediaFormat.KEY_DURATION)
|
||||
- val durationSec = durationUs / 1_000_000.0 // convert duration to seconds
|
||||
-
|
||||
- return (fileSize / durationSec).toInt()/1000 // bitrate in bits per second
|
||||
- }
|
||||
- fun getDestinationBitrateByQuality(path: String, quality: String): Int {
|
||||
- val originalBitrate = getAudioBitrate(path)
|
||||
- var destinationBitrate = originalBitrate
|
||||
- Utils.addLog("source bitrate: $originalBitrate")
|
||||
-
|
||||
- when (quality.lowercase()) {
|
||||
- "low" -> destinationBitrate = maxOf(64, (originalBitrate * 0.3).toInt())
|
||||
- "medium" -> destinationBitrate = (originalBitrate * 0.5).toInt()
|
||||
- "high" -> destinationBitrate = minOf(320, (originalBitrate * 0.7).toInt())
|
||||
- else -> Utils.addLog("Invalid quality level. Please enter 'low', 'medium', or 'high'.")
|
||||
- }
|
||||
-
|
||||
- return destinationBitrate
|
||||
- }
|
||||
-
|
||||
- }
|
||||
-}
|
||||
diff --git a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt b/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
|
||||
index 446d4fb..f021909 100644
|
||||
--- a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
|
||||
+++ b/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Audio/AudioMain.kt
|
||||
@@ -11,7 +11,9 @@ class AudioMain(private val reactContext: ReactApplicationContext) {
|
||||
promise: Promise) {
|
||||
try {
|
||||
|
||||
- AudioCompressor.CompressAudio(fileUrl,optionMap,reactContext,promise)
|
||||
+ // Skip compression on Android to avoid libandroidlame dependency
|
||||
+ // Return the original file URL without compression
|
||||
+ promise.resolve(fileUrl)
|
||||
} catch (ex: Exception) {
|
||||
promise.reject(ex)
|
||||
}
|
||||
diff --git a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt b/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
|
||||
index c14b727..1198908 100644
|
||||
--- a/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
|
||||
+++ b/node_modules/react-native-compressor/android/src/main/java/com/reactnativecompressor/Utils/Utils.kt
|
||||
@@ -7,7 +7,6 @@ import android.provider.OpenableColumns
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
-import com.reactnativecompressor.Audio.AudioCompressor
|
||||
import com.reactnativecompressor.Video.VideoCompressor.CompressionListener
|
||||
import com.reactnativecompressor.Video.VideoCompressor.VideoCompressorClass
|
||||
import java.io.FileNotFoundException
|
||||
@@ -152,10 +151,6 @@ object Utils {
|
||||
}
|
||||
}
|
||||
|
||||
- fun addLog(log: String) {
|
||||
- Log.d(AudioCompressor.TAG, log)
|
||||
- }
|
||||
-
|
||||
val exifAttributes = arrayOf(
|
||||
"FNumber",
|
||||
"ApertureValue",
|
||||
@@ -0,0 +1,5 @@
|
||||
# react-native-compressor
|
||||
|
||||
Patch file taken from https://github.com/numandev1/react-native-compressor/pull/355#issuecomment-3180870738
|
||||
|
||||
This patch removes the audio compression feature on Android from the library. This is because `libandroidlame.so`, the native dependency, does not support 16kb page sizes, and the Play Store has made this mandatory as of 1st Nov 2025.
|
||||
@@ -0,0 +1,22 @@
|
||||
diff --git a/node_modules/sonner-native/lib/commonjs/toast.js b/node_modules/sonner-native/lib/commonjs/toast.js
|
||||
index 121816a..0c3c7bd 100644
|
||||
--- a/node_modules/sonner-native/lib/commonjs/toast.js
|
||||
+++ b/node_modules/sonner-native/lib/commonjs/toast.js
|
||||
@@ -264,7 +264,7 @@ const Toast = exports.Toast = /*#__PURE__*/React.forwardRef(({
|
||||
...toastSwipeHandlerProps,
|
||||
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
|
||||
entering: entering,
|
||||
- exiting: exiting,
|
||||
+ exiting: _reactNative.Platform.OS === 'android' ? undefined : exiting,
|
||||
children: jsx
|
||||
})
|
||||
});
|
||||
@@ -274,7 +274,7 @@ const Toast = exports.Toast = /*#__PURE__*/React.forwardRef(({
|
||||
children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
|
||||
style: [unstyled ? undefined : elevationStyle, defaultStyles.toast, toastStyleCtx, styles?.toast, style, wiggleAnimationStyle],
|
||||
entering: entering,
|
||||
- exiting: exiting,
|
||||
+ exiting: _reactNative.Platform.OS === 'android' ? undefined : exiting,
|
||||
children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
|
||||
style: [defaultStyles.toastContent, toastContentStyleCtx, styles?.toastContent],
|
||||
children: [promiseOptions || variant === 'loading' ? 'loading' in icons ? icons.loading : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ActivityIndicator, {}) : icon ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
||||
@@ -0,0 +1,3 @@
|
||||
# sonner-native+0.21.0.patch
|
||||
|
||||
Removes Reanimated exit layout animations from the toasts. This was causing crashes if the toast was hidden while you were scrolling a flatlist.
|
||||
+61
-60
@@ -4,7 +4,6 @@ import '#/view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
@@ -84,7 +83,11 @@ if (isIOS) {
|
||||
}
|
||||
if (isAndroid) {
|
||||
// iOS is handled by the config plugin -sfn
|
||||
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP)
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
).catch(error =>
|
||||
logger.debug('Could not lock orientation', {safeMessage: error}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,64 +136,62 @@ function InnerApp() {
|
||||
<ThemeProvider theme={theme}>
|
||||
<ContextMenuProvider>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<RootSiblingParent>
|
||||
<VideoVolumeProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</VideoVolumeProvider>
|
||||
</RootSiblingParent>
|
||||
<VideoVolumeProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceAccountManager>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<GlobalGestureEventsProvider>
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
</GestureHandlerRootView>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceAccountManager>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</VideoVolumeProvider>
|
||||
</Splash>
|
||||
</ContextMenuProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
+54
-57
@@ -3,7 +3,6 @@ import '#/view/icons'
|
||||
import './style.css'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -111,62 +110,60 @@ function InnerApp() {
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ContextMenuProvider>
|
||||
<RootSiblingParent>
|
||||
<VideoVolumeProvider>
|
||||
<ActiveVideoProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</ActiveVideoProvider>
|
||||
</VideoVolumeProvider>
|
||||
</RootSiblingParent>
|
||||
<VideoVolumeProvider>
|
||||
<ActiveVideoProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PolicyUpdateOverlayProvider>
|
||||
<StatsigProvider>
|
||||
<AgeAssuranceProvider>
|
||||
<ComposerProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<HomeBadgeProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<ServiceConfigProvider>
|
||||
<EmailVerificationProvider>
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
</EmailVerificationProvider>
|
||||
</ServiceConfigProvider>
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HomeBadgeProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</ComposerProvider>
|
||||
</AgeAssuranceProvider>
|
||||
</StatsigProvider>
|
||||
</PolicyUpdateOverlayProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</ActiveVideoProvider>
|
||||
</VideoVolumeProvider>
|
||||
</ContextMenuProvider>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
import {type Theme} from '@bsky.app/alf'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
|
||||
export function setSystemUITheme(themeType: 'theme' | 'lightbox', t: Theme) {
|
||||
if (isAndroid) {
|
||||
if (themeType === 'theme') {
|
||||
SystemUI.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
|
||||
} else {
|
||||
SystemUI.setBackgroundColorAsync('black')
|
||||
try {
|
||||
if (themeType === 'theme') {
|
||||
SystemUI.setBackgroundColorAsync(t.atoms.bg.backgroundColor)
|
||||
} else {
|
||||
SystemUI.setBackgroundColorAsync('black')
|
||||
}
|
||||
} catch (error) {
|
||||
// Can reject with 'The current activity is no longer available' - no big deal
|
||||
logger.debug('Could not set system UI theme', {safeMessage: error})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,13 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button as BaseButton, type ButtonProps} from '#/components/Button'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
|
||||
import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
|
||||
import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||
import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {Text as BaseText, type TextProps} from '#/components/Typography'
|
||||
|
||||
export const colors = {
|
||||
warning: {
|
||||
light: '#DFBC00',
|
||||
dark: '#BFAF1F',
|
||||
},
|
||||
warning: '#FFC404',
|
||||
}
|
||||
|
||||
type Context = {
|
||||
@@ -29,29 +25,44 @@ export function Icon() {
|
||||
const t = useTheme()
|
||||
const {type} = useContext(Context)
|
||||
const Icon = {
|
||||
info: InfoIcon,
|
||||
tip: TipIcon,
|
||||
info: CircleInfoIcon,
|
||||
tip: CircleInfoIcon,
|
||||
warning: WarningIcon,
|
||||
error: ErrorIcon,
|
||||
error: CircleXIcon,
|
||||
}[type]
|
||||
const fill = {
|
||||
info: t.atoms.text_contrast_medium.color,
|
||||
tip: t.palette.primary_500,
|
||||
warning: colors.warning.light,
|
||||
warning: colors.warning,
|
||||
error: t.palette.negative_500,
|
||||
}[type]
|
||||
return <Icon fill={fill} size="md" />
|
||||
}
|
||||
|
||||
export function Content({
|
||||
children,
|
||||
style,
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[a.gap_sm, a.flex_1, {minHeight: 20}, a.justify_center, style]}
|
||||
{...rest}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Text({
|
||||
children,
|
||||
style,
|
||||
...rest
|
||||
}: Pick<TextProps, 'children' | 'style'>) {
|
||||
return (
|
||||
<BaseText
|
||||
{...rest}
|
||||
style={[a.flex_1, a.text_sm, a.leading_snug, a.pr_md, style]}>
|
||||
<BaseText {...rest} style={[a.text_sm, a.leading_snug, a.pr_md, style]}>
|
||||
{children}
|
||||
</BaseText>
|
||||
)
|
||||
@@ -60,17 +71,23 @@ export function Text({
|
||||
export function Button({
|
||||
children,
|
||||
...props
|
||||
}: Omit<ButtonProps, 'size' | 'variant' | 'color'>) {
|
||||
}: Omit<ButtonProps, 'size' | 'variant'>) {
|
||||
return (
|
||||
<BaseButton size="tiny" variant="outline" color="secondary" {...props}>
|
||||
<BaseButton size="tiny" {...props}>
|
||||
{children}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export function Row({children}: {children: React.ReactNode}) {
|
||||
export function Row({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
return (
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.align_start, a.gap_sm, style]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -88,19 +105,20 @@ export function Outer({
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const borderColor = {
|
||||
info: t.atoms.border_contrast_low.borderColor,
|
||||
tip: t.atoms.border_contrast_low.borderColor,
|
||||
warning: t.atoms.border_contrast_low.borderColor,
|
||||
error: t.atoms.border_contrast_low.borderColor,
|
||||
info: t.atoms.border_contrast_high.borderColor,
|
||||
tip: t.palette.primary_500,
|
||||
warning: colors.warning,
|
||||
error: t.palette.negative_500,
|
||||
}[type]
|
||||
return (
|
||||
<Context.Provider value={{type}}>
|
||||
<View
|
||||
style={[
|
||||
gtMobile ? a.p_md : a.p_sm,
|
||||
a.p_md,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg,
|
||||
{borderColor},
|
||||
style,
|
||||
]}>
|
||||
@@ -123,7 +141,9 @@ export function Admonition({
|
||||
<Outer type={type} style={style}>
|
||||
<Row>
|
||||
<Icon />
|
||||
<Text>{children}</Text>
|
||||
<Content>
|
||||
<Text>{children}</Text>
|
||||
</Content>
|
||||
</Row>
|
||||
</Outer>
|
||||
)
|
||||
|
||||
+53
-41
@@ -274,18 +274,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
} else if (color === 'primary_subtle') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.primary_50,
|
||||
dim: t.palette.primary_100,
|
||||
dark: t.palette.primary_100,
|
||||
}),
|
||||
backgroundColor: t.palette.primary_50,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.primary_100,
|
||||
dim: t.palette.primary_200,
|
||||
dark: t.palette.primary_200,
|
||||
}),
|
||||
backgroundColor: t.palette.primary_100,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
@@ -295,18 +287,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
} else if (color === 'negative_subtle') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.negative_50,
|
||||
dim: t.palette.negative_100,
|
||||
dark: t.palette.negative_100,
|
||||
}),
|
||||
backgroundColor: t.palette.negative_50,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.negative_100,
|
||||
dim: t.palette.negative_200,
|
||||
dark: t.palette.negative_200,
|
||||
}),
|
||||
backgroundColor: t.palette.negative_100,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
@@ -618,37 +602,21 @@ export function useSharedButtonTextStyles() {
|
||||
} else if (color === 'primary_subtle') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: select(t.name, {
|
||||
light: t.palette.primary_600,
|
||||
dim: t.palette.primary_800,
|
||||
dark: t.palette.primary_800,
|
||||
}),
|
||||
color: t.palette.primary_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: select(t.name, {
|
||||
light: t.palette.primary_200,
|
||||
dim: t.palette.primary_200,
|
||||
dark: t.palette.primary_200,
|
||||
}),
|
||||
color: t.palette.primary_200,
|
||||
})
|
||||
}
|
||||
} else if (color === 'negative_subtle') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: select(t.name, {
|
||||
light: t.palette.negative_600,
|
||||
dim: t.palette.negative_800,
|
||||
dark: t.palette.negative_800,
|
||||
}),
|
||||
color: t.palette.negative_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: select(t.name, {
|
||||
light: t.palette.negative_200,
|
||||
dim: t.palette.negative_200,
|
||||
dark: t.palette.negative_200,
|
||||
}),
|
||||
color: t.palette.negative_200,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -755,7 +723,7 @@ export function useSharedButtonTextStyles() {
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.text_sm, a.leading_snug, a.font_medium)
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push(a.text_xs, a.leading_snug, a.font_medium)
|
||||
baseStyles.push(a.text_xs, a.leading_snug, a.font_semi_bold)
|
||||
}
|
||||
|
||||
return StyleSheet.flatten(baseStyles)
|
||||
@@ -869,3 +837,47 @@ export function ButtonIcon({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export type StackedButtonProps = Omit<
|
||||
ButtonProps,
|
||||
keyof VariantProps | 'children'
|
||||
> &
|
||||
Pick<VariantProps, 'color'> & {
|
||||
children: React.ReactNode
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
}
|
||||
|
||||
export function StackedButton({children, ...props}: StackedButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
size="tiny"
|
||||
style={[
|
||||
a.flex_col,
|
||||
{
|
||||
height: 72,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
gap: 4,
|
||||
},
|
||||
props.style,
|
||||
]}>
|
||||
<StackedButtonInnerText icon={props.icon}>
|
||||
{children}
|
||||
</StackedButtonInnerText>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function StackedButtonInnerText({
|
||||
children,
|
||||
icon: Icon,
|
||||
}: Pick<StackedButtonProps, 'icon' | 'children'>) {
|
||||
const textStyles = useSharedButtonTextStyles()
|
||||
return (
|
||||
<>
|
||||
<Icon width={24} fill={textStyles.color} />
|
||||
<ButtonText>{children}</ButtonText>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,8 +165,8 @@ export function useLink({
|
||||
if (isNative && screen !== 'NotFound') {
|
||||
const state = navigation.getState()
|
||||
// if screen is not in the current navigator, it means it's
|
||||
// most likely a tab screen
|
||||
if (!state.routeNames.includes(screen)) {
|
||||
// most likely a tab screen. note: state can be undefined
|
||||
if (!state?.routeNames.includes(screen)) {
|
||||
const parent = navigation.getParent()
|
||||
if (
|
||||
parent &&
|
||||
|
||||
@@ -46,7 +46,12 @@ import {
|
||||
useProfileBlockMutationQueue,
|
||||
useProfileMuteMutationQueue,
|
||||
} from '#/state/queries/profile'
|
||||
import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
|
||||
import {
|
||||
InvalidInteractionSettingsError,
|
||||
MAX_HIDDEN_REPLIES,
|
||||
MaxHiddenRepliesError,
|
||||
useToggleReplyVisibilityMutation,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {useRequireAuth, useSession} from '#/state/session'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -339,10 +344,30 @@ let PostMenuItems = ({
|
||||
: _(msg({message: 'Reply visibility updated', context: 'toast'})),
|
||||
)
|
||||
} catch (e: any) {
|
||||
Toast.show(
|
||||
_(msg({message: 'Updating reply visibility failed', context: 'toast'})),
|
||||
)
|
||||
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
|
||||
if (e instanceof MaxHiddenRepliesError) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg({
|
||||
message: `You can hide a maximum of ${MAX_HIDDEN_REPLIES} replies.`,
|
||||
context: 'toast',
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else if (e instanceof InvalidInteractionSettingsError) {
|
||||
Toast.show(
|
||||
_(msg({message: 'Invalid interaction settings.', context: 'toast'})),
|
||||
)
|
||||
} else {
|
||||
Toast.show(
|
||||
_(
|
||||
msg({
|
||||
message: 'Updating reply visibility failed',
|
||||
context: 'toast',
|
||||
}),
|
||||
),
|
||||
)
|
||||
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function RecentChats({postUri}: {postUri: string}) {
|
||||
const control = useDialogContext()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {data} = useListConvosQuery({status: 'accepted'})
|
||||
const convos = data?.pages[0]?.convos?.slice(0, 10)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {useLingui} from '@lingui/react'
|
||||
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 {logger} from '#/logger'
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {
|
||||
@@ -150,8 +151,10 @@ export function WizardProfileCard({
|
||||
if (profile.did === targetProfileDid) return
|
||||
|
||||
if (!included) {
|
||||
logger.metric('starterPack:addUser', {})
|
||||
dispatch({type: 'AddProfile', profile})
|
||||
} else {
|
||||
logger.metric('starterPack:removeUser', {})
|
||||
dispatch({type: 'RemoveProfile', profileDid: profile.did})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import isEqual from 'lodash.isequal'
|
||||
import {logger} from '#/logger'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useMyListsQuery} from '#/state/queries/my-lists'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {
|
||||
createPostgateQueryKey,
|
||||
getPostgateRecord,
|
||||
@@ -25,12 +26,15 @@ import {
|
||||
} from '#/state/queries/postgate/util'
|
||||
import {
|
||||
createThreadgateViewQueryKey,
|
||||
getThreadgateView,
|
||||
type ThreadgateAllowUISetting,
|
||||
threadgateViewToAllowUISetting,
|
||||
useSetThreadgateAllowMutation,
|
||||
useThreadgateViewQuery,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {
|
||||
PostThreadContextProvider,
|
||||
usePostThreadContext,
|
||||
} from '#/state/queries/usePostThread'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -133,10 +137,13 @@ export type PostInteractionSettingsDialogProps = {
|
||||
export function PostInteractionSettingsDialog(
|
||||
props: PostInteractionSettingsDialogProps,
|
||||
) {
|
||||
const postThreadContext = usePostThreadContext()
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
<PostInteractionSettingsDialogControlledInner {...props} />
|
||||
<PostThreadContextProvider context={postThreadContext}>
|
||||
<PostInteractionSettingsDialogControlledInner {...props} />
|
||||
</PostThreadContextProvider>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -558,6 +565,7 @@ export function usePrefetchPostInteractionSettings({
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const getPost = useGetPost()
|
||||
|
||||
return React.useCallback(async () => {
|
||||
try {
|
||||
@@ -570,7 +578,10 @@ export function usePrefetchPostInteractionSettings({
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: createThreadgateViewQueryKey(rootPostUri),
|
||||
queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
|
||||
queryFn: async () => {
|
||||
const post = await getPost({uri: rootPostUri})
|
||||
return post.threadgate ?? null
|
||||
},
|
||||
staleTime: STALE.SECONDS.THIRTY,
|
||||
}),
|
||||
])
|
||||
@@ -579,5 +590,5 @@ export function usePrefetchPostInteractionSettings({
|
||||
safeMessage: e.message,
|
||||
})
|
||||
}
|
||||
}, [queryClient, agent, postUri, rootPostUri])
|
||||
}, [queryClient, agent, postUri, rootPostUri, getPost])
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
invalidateActorStarterPacksWithMembershipQuery,
|
||||
@@ -47,7 +48,6 @@ export function StarterPackDialog({
|
||||
targetDid,
|
||||
enabled,
|
||||
}: StarterPackDialogProps) {
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const requireEmailVerification = useRequireEmailVerification()
|
||||
|
||||
@@ -295,6 +295,7 @@ function StarterPackItem({
|
||||
if (!starterPack.list?.uri || isPendingRefresh) return
|
||||
|
||||
const listUri = starterPack.list.uri
|
||||
const starterPackUri = starterPack.uri
|
||||
|
||||
setIsPendingRefresh(true)
|
||||
|
||||
@@ -303,6 +304,7 @@ function StarterPackItem({
|
||||
listUri: listUri,
|
||||
actorDid: targetDid,
|
||||
})
|
||||
logger.metric('starterPack:addUser', {starterPack: starterPackUri})
|
||||
} else {
|
||||
if (!starterPackWithMembership.listItem?.uri) {
|
||||
console.error('Cannot remove: missing membership URI')
|
||||
@@ -314,6 +316,7 @@ function StarterPackItem({
|
||||
actorDid: targetDid,
|
||||
membershipUri: starterPackWithMembership.listItem.uri,
|
||||
})
|
||||
logger.metric('starterPack:removeUser', {starterPack: starterPackUri})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ export interface LabelsOnMeDialogProps {
|
||||
|
||||
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Outer
|
||||
control={props.control}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<LabelsOnMeDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
|
||||
@@ -24,7 +24,9 @@ export interface ModerationDetailsDialogProps {
|
||||
|
||||
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Outer
|
||||
control={props.control}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<ModerationDetailsDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as Pills from '#/components/Pills'
|
||||
|
||||
export function ProfileHeaderAlerts({
|
||||
moderation,
|
||||
style,
|
||||
}: {
|
||||
moderation: ModerationDecision
|
||||
style?: StyleProp<ViewStyle>
|
||||
@@ -16,7 +17,7 @@ export function ProfileHeaderAlerts({
|
||||
}
|
||||
|
||||
return (
|
||||
<Pills.Row size="lg">
|
||||
<Pills.Row size="lg" style={style}>
|
||||
{modui.alerts.filter(unique).map(cause => (
|
||||
<Pills.Label
|
||||
size="lg"
|
||||
|
||||
@@ -219,10 +219,13 @@ function Inner(props: ReportDialogProps) {
|
||||
<Admonition.Outer type="error">
|
||||
<Admonition.Row>
|
||||
<Admonition.Icon />
|
||||
<Admonition.Text>
|
||||
<Trans>Something went wrong, please try again</Trans>
|
||||
</Admonition.Text>
|
||||
<Admonition.Content>
|
||||
<Admonition.Text>
|
||||
<Trans>Something went wrong, please try again</Trans>
|
||||
</Admonition.Text>
|
||||
</Admonition.Content>
|
||||
<Admonition.Button
|
||||
color="negative_subtle"
|
||||
label={_(msg`Retry loading report options`)}
|
||||
onPress={() => refetchLabelers()}>
|
||||
<ButtonText>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function useIntentHandler() {
|
||||
}
|
||||
|
||||
const urlp = new URL(url)
|
||||
const [_, intent, intentType] = urlp.pathname.split('/')
|
||||
const [__, intent, intentType] = urlp.pathname.split('/')
|
||||
|
||||
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
||||
// intent check. On web, we have to check the first part of the path since we have an actual hostname
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useUpdates,
|
||||
} from 'expo-updates'
|
||||
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {IS_TESTFLIGHT} from '#/env'
|
||||
@@ -145,8 +146,10 @@ export function useOTAUpdates() {
|
||||
} else {
|
||||
logger.debug('No update available.')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('OTA Update Error', {error: `${e}`})
|
||||
} catch (err) {
|
||||
if (!isNetworkError(err)) {
|
||||
logger.error('OTA Update Error', {safeMessage: err})
|
||||
}
|
||||
}
|
||||
}, 10e3)
|
||||
}, [])
|
||||
@@ -154,8 +157,10 @@ export function useOTAUpdates() {
|
||||
const onIsTestFlight = React.useCallback(async () => {
|
||||
try {
|
||||
await updateTestflight()
|
||||
} catch (e: any) {
|
||||
logger.error('Internal OTA Update Error', {error: `${e}`})
|
||||
} catch (err: any) {
|
||||
if (!isNetworkError(err)) {
|
||||
logger.error('Internal OTA Update Error', {safeMessage: err})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -62,7 +62,10 @@ export async function getLinkMeta(
|
||||
likelyType,
|
||||
url,
|
||||
}
|
||||
if (likelyType !== LikelyType.HTML) {
|
||||
const htmlExemptedHostnames: string[] = ['storage.courtlistener.com']
|
||||
const isExemptedFromHtmlCheck = htmlExemptedHostnames.includes(urlp.hostname)
|
||||
// Skip early return only for hosts exempted from the HTML check
|
||||
if (likelyType !== LikelyType.HTML && !isExemptedFromHtmlCheck) {
|
||||
return meta
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export type CommonNavigatorParams = {
|
||||
InterestsSettings: undefined
|
||||
AboutSettings: undefined
|
||||
AppIconSettings: undefined
|
||||
Search: {q?: string}
|
||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
||||
Hashtag: {tag: string; author?: string}
|
||||
Topic: {topic: string}
|
||||
MessagesConversation: {conversation: string; embed?: string; accept?: true}
|
||||
@@ -102,7 +102,7 @@ export type HomeTabNavigatorParams = CommonNavigatorParams & {
|
||||
}
|
||||
|
||||
export type SearchTabNavigatorParams = CommonNavigatorParams & {
|
||||
Search: {q?: string}
|
||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
||||
}
|
||||
|
||||
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -119,7 +119,7 @@ export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Home: undefined
|
||||
Search: {q?: string}
|
||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
||||
@@ -129,7 +129,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
HomeTab: undefined
|
||||
Home: undefined
|
||||
SearchTab: undefined
|
||||
Search: {q?: string}
|
||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
||||
Feeds: undefined
|
||||
NotificationsTab: undefined
|
||||
Notifications: undefined
|
||||
|
||||
@@ -7,6 +7,7 @@ export type Gate =
|
||||
| 'debug_subscriptions'
|
||||
| 'disable_onboarding_policy_update_notice'
|
||||
| 'explore_show_suggested_feeds'
|
||||
| 'feed_reply_button_open_thread'
|
||||
| 'old_postonboarding'
|
||||
| 'onboarding_add_video_feed'
|
||||
| 'onboarding_suggested_accounts'
|
||||
@@ -15,4 +16,3 @@ export type Gate =
|
||||
| 'remove_show_latest_button'
|
||||
| 'test_gate_1'
|
||||
| 'test_gate_2'
|
||||
| 'welcome_modal'
|
||||
|
||||
@@ -105,7 +105,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
urlp.hostname === 'm.youtube.com' ||
|
||||
urlp.hostname === 'music.youtube.com'
|
||||
) {
|
||||
const [_, page, shortOrLiveVideoId] = urlp.pathname.split('/')
|
||||
const [__, page, shortOrLiveVideoId] = urlp.pathname.split('/')
|
||||
|
||||
const isShorts = page === 'shorts'
|
||||
const isLive = page === 'live'
|
||||
@@ -137,7 +137,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
window.location.hostname
|
||||
: 'localhost'
|
||||
|
||||
const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
|
||||
const [__, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
|
||||
|
||||
if (channelOrVideo === 'videos') {
|
||||
return {
|
||||
@@ -162,7 +162,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
|
||||
// spotify
|
||||
if (urlp.hostname === 'open.spotify.com') {
|
||||
const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
|
||||
const [__, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
|
||||
|
||||
if (idOrType) {
|
||||
if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
|
||||
@@ -210,7 +210,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
urlp.hostname === 'soundcloud.com' ||
|
||||
urlp.hostname === 'www.soundcloud.com'
|
||||
) {
|
||||
const [_, user, trackOrSets, set] = urlp.pathname.split('/')
|
||||
const [__, user, trackOrSets, set] = urlp.pathname.split('/')
|
||||
|
||||
if (user && trackOrSets) {
|
||||
if (trackOrSets === 'sets' && set) {
|
||||
@@ -270,7 +270,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
}
|
||||
|
||||
if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') {
|
||||
const [_, videoId] = urlp.pathname.split('/')
|
||||
const [__, videoId] = urlp.pathname.split('/')
|
||||
if (videoId) {
|
||||
return {
|
||||
type: 'vimeo_video',
|
||||
@@ -281,7 +281,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
}
|
||||
|
||||
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
|
||||
const [_, gifs, nameAndId] = urlp.pathname.split('/')
|
||||
const [__, gifs, nameAndId] = urlp.pathname.split('/')
|
||||
|
||||
/*
|
||||
* nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
|
||||
@@ -309,7 +309,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
// These can include (presumably) a tracking id in the path name, so we have to check for that as well
|
||||
if (giphyRegex.test(urlp.hostname)) {
|
||||
// We can link directly to the gif, if its a proper link
|
||||
const [_, media, trackingOrId, idOrFilename, filename] =
|
||||
const [__, media, trackingOrId, idOrFilename, filename] =
|
||||
urlp.pathname.split('/')
|
||||
|
||||
if (media === 'media') {
|
||||
@@ -338,7 +338,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
// Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
|
||||
// be .webp
|
||||
if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
|
||||
const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
|
||||
const [__, mediaOrFilename, filename] = urlp.pathname.split('/')
|
||||
|
||||
if (mediaOrFilename === 'media' && filename) {
|
||||
const gifId = filename.split('.')[0]
|
||||
@@ -389,7 +389,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
const path_components = urlp.pathname.slice(1, i + 1).split('/')
|
||||
if (path_components.length === 4) {
|
||||
// discard username - it's not relevant
|
||||
const [photos, _, albums, id] = path_components
|
||||
const [photos, __, albums, id] = path_components
|
||||
if (photos === 'photos' && albums === 'albums') {
|
||||
// this at least has the shape of a valid photo-album URL!
|
||||
return {
|
||||
@@ -417,7 +417,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
// link shortened flickr path
|
||||
if (urlp.hostname === 'flic.kr') {
|
||||
const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
let [_, type, idBase58Enc] = urlp.pathname.split('/')
|
||||
let [__, type, idBase58Enc] = urlp.pathname.split('/')
|
||||
let id = 0n
|
||||
for (const char of idBase58Enc) {
|
||||
const nextIdx = b58alph.indexOf(char)
|
||||
@@ -528,7 +528,7 @@ export function parseTenorGif(urlp: URL):
|
||||
return {success: false}
|
||||
}
|
||||
|
||||
let [_, id, filename] = urlp.pathname.split('/')
|
||||
let [__, id, filename] = urlp.pathname.split('/')
|
||||
|
||||
if (!id || !filename) {
|
||||
return {success: false}
|
||||
|
||||
@@ -62,23 +62,6 @@ export function useWarnMaxGraphemeCount({
|
||||
}, [splitter, maxCount, text])
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/52171480
|
||||
export function toHashCode(str: string, seed = 0): number {
|
||||
let h1 = 0xdeadbeef ^ seed,
|
||||
h2 = 0x41c6ce57 ^ seed
|
||||
for (let i = 0, ch; i < str.length; i++) {
|
||||
ch = str.charCodeAt(i)
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761)
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677)
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
|
||||
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
|
||||
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
|
||||
|
||||
return 4294967296 * (2097151 & h2) + (h1 >>> 0)
|
||||
}
|
||||
|
||||
export function countLines(str: string | undefined): number {
|
||||
if (!str) return 0
|
||||
return str.match(/\n/g)?.length ?? 0
|
||||
|
||||
@@ -46,7 +46,7 @@ export function parseStarterPackUri(uri?: string): {
|
||||
} else {
|
||||
const url = new URL(uri)
|
||||
const parts = url.pathname.split('/')
|
||||
const [_, path, name, rkey] = parts
|
||||
const [__, path, name, rkey] = parts
|
||||
|
||||
if (parts.length !== 4) return null
|
||||
if (path !== 'starter-pack' && path !== 'start') return null
|
||||
|
||||
+311
-301
File diff suppressed because it is too large
Load Diff
+17
-5
@@ -175,19 +175,24 @@ export type MetricEvents = {
|
||||
'feed:suggestion:press': {
|
||||
feedUrl: string
|
||||
}
|
||||
'discover:showMore': {
|
||||
'feed:showMore': {
|
||||
feed: string
|
||||
feedContext: string
|
||||
}
|
||||
'discover:showLess': {
|
||||
'feed:showLess': {
|
||||
feed: string
|
||||
feedContext: string
|
||||
}
|
||||
'discover:clickthrough': {
|
||||
'feed:clickthrough': {
|
||||
feed: string
|
||||
count: number
|
||||
}
|
||||
'discover:engaged': {
|
||||
'feed:engaged': {
|
||||
feed: string
|
||||
count: number
|
||||
}
|
||||
'discover:seen': {
|
||||
'feed:seen': {
|
||||
feed: string
|
||||
count: number
|
||||
}
|
||||
|
||||
@@ -321,6 +326,12 @@ export type MetricEvents = {
|
||||
| 'ChatsList'
|
||||
| 'SendViaChatDialog'
|
||||
}
|
||||
'starterPack:addUser': {
|
||||
starterPack?: string
|
||||
}
|
||||
'starterPack:removeUser': {
|
||||
starterPack?: string
|
||||
}
|
||||
'starterPack:share': {
|
||||
starterPack: string
|
||||
shareType: 'link' | 'qrcode'
|
||||
@@ -352,6 +363,7 @@ export type MetricEvents = {
|
||||
'feed:interstitial:feedCard:press': {}
|
||||
|
||||
'profile:header:suggestedFollowsCard:press': {}
|
||||
'profile:addToStarterPack': {}
|
||||
|
||||
'test:all:always': {}
|
||||
'test:all:sometimes': {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {useState} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {Dimensions} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -77,10 +78,10 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
{
|
||||
left: '50%',
|
||||
left: isWeb ? '50%' : Dimensions.get('window').width / 2 - 45,
|
||||
top: insets.top + 2,
|
||||
transform: [{translateX: '-50%'}],
|
||||
},
|
||||
web({transform: [{translateX: '-50%'}]}),
|
||||
]}>
|
||||
<ButtonText>[DEV] Clear</ButtonText>
|
||||
</Button>
|
||||
|
||||
@@ -69,7 +69,6 @@ import {Text} from '#/components/Typography'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function StepFinished() {
|
||||
const {_} = useLingui()
|
||||
const {state, dispatch} = useContext(Context)
|
||||
const onboardDispatch = useOnboardingDispatch()
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -495,7 +494,6 @@ function ValueProposition({
|
||||
|
||||
function Dot({active}: {active: boolean}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {usePostThreadQuery} from '#/state/queries/post-thread'
|
||||
import {usePostQuery} from '#/state/queries/post'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy'
|
||||
import * as Layout from '#/components/Layout'
|
||||
@@ -17,11 +17,11 @@ export const PostLikedByScreen = ({route}: Props) => {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {name, rkey} = route.params
|
||||
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
||||
const {data: post} = usePostThreadQuery(uri)
|
||||
const {data: post} = usePostQuery(uri)
|
||||
|
||||
let likeCount
|
||||
if (post?.thread.type === 'post') {
|
||||
likeCount = post.thread.post.likeCount
|
||||
if (post) {
|
||||
likeCount = post.likeCount
|
||||
}
|
||||
|
||||
useFocusEffect(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {usePostThreadQuery} from '#/state/queries/post-thread'
|
||||
import {usePostQuery} from '#/state/queries/post'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes'
|
||||
import * as Layout from '#/components/Layout'
|
||||
@@ -17,11 +17,11 @@ export const PostQuotesScreen = ({route}: Props) => {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {name, rkey} = route.params
|
||||
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
||||
const {data: post} = usePostThreadQuery(uri)
|
||||
const {data: post} = usePostQuery(uri)
|
||||
|
||||
let quoteCount
|
||||
if (post?.thread.type === 'post') {
|
||||
quoteCount = post.thread.post.quoteCount
|
||||
if (post) {
|
||||
quoteCount = post.quoteCount
|
||||
}
|
||||
|
||||
useFocusEffect(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {makeRecordUri} from '#/lib/strings/url-helpers'
|
||||
import {usePostThreadQuery} from '#/state/queries/post-thread'
|
||||
import {usePostQuery} from '#/state/queries/post'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy'
|
||||
import * as Layout from '#/components/Layout'
|
||||
@@ -17,11 +17,11 @@ export const PostRepostedByScreen = ({route}: Props) => {
|
||||
const {name, rkey} = route.params
|
||||
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {data: post} = usePostThreadQuery(uri)
|
||||
const {data: post} = usePostQuery(uri)
|
||||
|
||||
let quoteCount
|
||||
if (post?.thread.type === 'post') {
|
||||
quoteCount = post.thread.post.repostCount
|
||||
if (post) {
|
||||
quoteCount = post.repostCount
|
||||
}
|
||||
|
||||
useFocusEffect(
|
||||
|
||||
@@ -621,7 +621,7 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
|
||||
|
||||
if (!isBackdated) return null
|
||||
|
||||
const orange = t.name === 'light' ? colors.warning.dark : colors.warning.light
|
||||
const orange = colors.warning
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -7,7 +7,11 @@ import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {useFeedFeedback} from '#/state/feed-feedback'
|
||||
import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences'
|
||||
import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread'
|
||||
import {
|
||||
PostThreadContextProvider,
|
||||
type ThreadItem,
|
||||
usePostThread,
|
||||
} from '#/state/queries/usePostThread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {type OnPostSuccessData} from '#/state/shell/composer'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
@@ -148,7 +152,9 @@ export function PostThread({uri}: {uri: string}) {
|
||||
*/
|
||||
const shouldHandleScroll = useRef(true)
|
||||
/**
|
||||
* Called any time the content size of the list changes, _just_ before paint.
|
||||
* Called any time the content size of the list changes. Could be a fresh
|
||||
* render, items being added to the list, or any resize that changes the
|
||||
* scrollable size of the content.
|
||||
*
|
||||
* We want this to fire every time we change params (which will reset
|
||||
* `deferParents` via `onLayout` on the anchor post, due to the key change),
|
||||
@@ -193,24 +199,23 @@ export function PostThread({uri}: {uri: string}) {
|
||||
* will give us a _positive_ offset, which will scroll the anchor post
|
||||
* back _up_ to the top of the screen.
|
||||
*/
|
||||
list.scrollToOffset({
|
||||
offset: anchorOffsetTop - headerHeight,
|
||||
})
|
||||
const offset = anchorOffsetTop - headerHeight
|
||||
list.scrollToOffset({offset})
|
||||
|
||||
/*
|
||||
* After the second pass, `deferParents` will be `false`, and we need
|
||||
* to ensure this doesn't run again until scroll handling is requested
|
||||
* again via `shouldHandleScroll.current === true` and a params
|
||||
* change via `prepareForParamsUpdate`.
|
||||
* After we manage to do a positive adjustment, we need to ensure this
|
||||
* doesn't run again until scroll handling is requested again via
|
||||
* `shouldHandleScroll.current === true` and a params change via
|
||||
* `prepareForParamsUpdate`.
|
||||
*
|
||||
* The `isRoot` here is needed because if we're looking at the anchor
|
||||
* post, this handler will not fire after `deferParents` is set to
|
||||
* `false`, since there are no parents to render above it. In this case,
|
||||
* we want to make sure `shouldHandleScroll` is set to `false` so that
|
||||
* subsequent size changes unrelated to a params change (like pagination)
|
||||
* do not affect scroll.
|
||||
* we want to make sure `shouldHandleScroll` is set to `false` right away
|
||||
* so that subsequent size changes unrelated to a params change (like
|
||||
* pagination) do not affect scroll.
|
||||
*/
|
||||
if (!deferParents || isRoot) shouldHandleScroll.current = false
|
||||
if (offset > 0 || isRoot) shouldHandleScroll.current = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -494,7 +499,7 @@ export function PostThread({uri}: {uri: string}) {
|
||||
const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<PostThreadContextProvider context={thread.context}>
|
||||
<Layout.Header.Outer headerRef={headerRef}>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content>
|
||||
@@ -577,7 +582,7 @@ export function PostThread({uri}: {uri: string}) {
|
||||
{!gtMobile && canReply && hasSession && (
|
||||
<MobileComposePrompt onPressReply={onReplyToAnchor} />
|
||||
)}
|
||||
</>
|
||||
</PostThreadContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -209,17 +209,29 @@ let ProfileHeaderShell = ({
|
||||
|
||||
{children}
|
||||
|
||||
{!isPlaceholderProfile && (
|
||||
<View
|
||||
style={[a.px_lg, a.pt_xs, a.pb_sm]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
{isMe ? (
|
||||
<LabelsOnMe type="account" labels={profile.labels} />
|
||||
) : (
|
||||
<ProfileHeaderAlerts moderation={moderation} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{!isPlaceholderProfile &&
|
||||
(isMe ? (
|
||||
<LabelsOnMe
|
||||
type="account"
|
||||
labels={profile.labels}
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.pt_xs,
|
||||
a.pb_sm,
|
||||
isIOS ? a.pointer_events_auto : {pointerEvents: 'box-none'},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<ProfileHeaderAlerts
|
||||
moderation={moderation}
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.pt_xs,
|
||||
a.pb_sm,
|
||||
isIOS ? a.pointer_events_auto : {pointerEvents: 'box-none'},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
|
||||
<GrowableAvatar style={[a.absolute, {top: 104, left: 10}]}>
|
||||
<TouchableWithoutFeedback
|
||||
|
||||
@@ -726,7 +726,12 @@ export function Explore({
|
||||
<ModuleHeader.SearchButton
|
||||
{...item.searchButton}
|
||||
onPress={() =>
|
||||
focusSearchInput(item.searchButton?.tab || 'user')
|
||||
focusSearchInput(
|
||||
(item.searchButton?.tab || 'user') as
|
||||
| 'user'
|
||||
| 'profile'
|
||||
| 'feed',
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -743,7 +748,12 @@ export function Explore({
|
||||
<ModuleHeader.SearchButton
|
||||
{...item.searchButton}
|
||||
onPress={() =>
|
||||
focusSearchInput(item.searchButton?.tab || 'user')
|
||||
focusSearchInput(
|
||||
(item.searchButton?.tab || 'user') as
|
||||
| 'user'
|
||||
| 'profile'
|
||||
| 'feed',
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -30,12 +30,14 @@ let SearchResults = ({
|
||||
activeTab,
|
||||
onPageSelected,
|
||||
headerHeight,
|
||||
initialPage = 0,
|
||||
}: {
|
||||
query: string
|
||||
queryWithParams: string
|
||||
activeTab: number
|
||||
onPageSelected: (page: number) => void
|
||||
headerHeight: number
|
||||
initialPage?: number
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -89,7 +91,7 @@ let SearchResults = ({
|
||||
<TabBar items={sections.map(section => section.title)} {...props} />
|
||||
</Layout.Center>
|
||||
)}
|
||||
initialPage={0}>
|
||||
initialPage={initialPage}>
|
||||
{sections.map((section, i) => (
|
||||
<View key={i}>{section.component}</View>
|
||||
))}
|
||||
|
||||
@@ -190,15 +190,19 @@ export function SearchScreenShell({
|
||||
setShowAutocomplete(false)
|
||||
if (isWeb) {
|
||||
// Empty params resets the URL to be /search rather than /search?q=
|
||||
|
||||
const {q: _q, ...parameters} = (route.params ?? {}) as {
|
||||
// Also clear the tab parameter
|
||||
const {
|
||||
q: _q,
|
||||
tab: _tab,
|
||||
...parameters
|
||||
} = (route.params ?? {}) as {
|
||||
[key: string]: string
|
||||
}
|
||||
// @ts-expect-error route is not typesafe
|
||||
navigation.replace(route.name, parameters)
|
||||
} else {
|
||||
setSearchText('')
|
||||
navigation.setParams({q: ''})
|
||||
navigation.setParams({q: '', tab: undefined})
|
||||
}
|
||||
}, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
|
||||
|
||||
@@ -236,15 +240,19 @@ export function SearchScreenShell({
|
||||
const onSoftReset = useCallback(() => {
|
||||
if (isWeb) {
|
||||
// Empty params resets the URL to be /search rather than /search?q=
|
||||
|
||||
const {q: _q, ...parameters} = (route.params ?? {}) as {
|
||||
// Also clear the tab parameter when soft resetting
|
||||
const {
|
||||
q: _q,
|
||||
tab: _tab,
|
||||
...parameters
|
||||
} = (route.params ?? {}) as {
|
||||
[key: string]: string
|
||||
}
|
||||
// @ts-expect-error route is not typesafe
|
||||
navigation.replace(route.name, parameters)
|
||||
} else {
|
||||
setSearchText('')
|
||||
navigation.setParams({q: ''})
|
||||
navigation.setParams({q: '', tab: undefined})
|
||||
textInput.current?.focus()
|
||||
}
|
||||
}, [navigation, route])
|
||||
@@ -268,9 +276,21 @@ export function SearchScreenShell({
|
||||
}
|
||||
}, [setShowAutocomplete])
|
||||
|
||||
const focusSearchInput = useCallback(() => {
|
||||
textInput.current?.focus()
|
||||
}, [])
|
||||
const focusSearchInput = useCallback(
|
||||
(tab?: 'user' | 'profile' | 'feed') => {
|
||||
textInput.current?.focus()
|
||||
|
||||
// If a tab is specified, set the tab parameter
|
||||
if (tab) {
|
||||
if (isWeb) {
|
||||
navigation.setParams({...route.params, tab})
|
||||
} else {
|
||||
navigation.setParams({tab})
|
||||
}
|
||||
}
|
||||
},
|
||||
[navigation, route],
|
||||
)
|
||||
|
||||
const showHeader = !gtMobile || navButton !== 'menu'
|
||||
|
||||
@@ -421,14 +441,42 @@ let SearchScreenInner = ({
|
||||
query: string
|
||||
queryWithParams: string
|
||||
headerHeight: number
|
||||
focusSearchInput: () => void
|
||||
focusSearchInput: (tab?: 'user' | 'profile' | 'feed') => void
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {hasSession} = useSession()
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const [activeTab, setActiveTab] = useState(0)
|
||||
const {_} = useLingui()
|
||||
const route = useRoute()
|
||||
|
||||
// Get tab parameter from route params
|
||||
const tabParam = (
|
||||
route.params as {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
||||
)?.tab
|
||||
|
||||
// Map tab parameter to tab index
|
||||
const getInitialTabIndex = useCallback(() => {
|
||||
if (!tabParam) return 0
|
||||
switch (tabParam) {
|
||||
case 'user':
|
||||
case 'profile':
|
||||
return 2 // People tab
|
||||
case 'feed':
|
||||
return 3 // Feeds tab
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}, [tabParam])
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getInitialTabIndex())
|
||||
|
||||
// Update activeTab when tabParam changes
|
||||
useLayoutEffect(() => {
|
||||
const newTabIndex = getInitialTabIndex()
|
||||
if (newTabIndex !== activeTab) {
|
||||
setActiveTab(newTabIndex)
|
||||
}
|
||||
}, [tabParam, activeTab, getInitialTabIndex])
|
||||
|
||||
const onPageSelected = useCallback(
|
||||
(index: number) => {
|
||||
@@ -445,6 +493,7 @@ let SearchScreenInner = ({
|
||||
activeTab={activeTab}
|
||||
headerHeight={headerHeight}
|
||||
onPageSelected={onPageSelected}
|
||||
initialPage={activeTab}
|
||||
/>
|
||||
) : hasSession ? (
|
||||
<Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} />
|
||||
|
||||
@@ -31,7 +31,6 @@ const FEED_PARAMS: {
|
||||
}
|
||||
|
||||
export function ExploreTrendingVideos() {
|
||||
const {_} = useLingui()
|
||||
const gutters = useGutters([0, 'base'])
|
||||
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ function AppPasswordCard({
|
||||
</View>
|
||||
{appPassword.privileged && (
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
|
||||
<WarningIcon style={[{color: colors.warning[t.scheme]}]} />
|
||||
<WarningIcon style={[{color: colors.warning}]} />
|
||||
<Text style={t.atoms.text_contrast_high}>
|
||||
<Trans>Allows access to direct messages</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -134,7 +134,7 @@ export function ActivityNotificationSettingsScreen({}: Props) {
|
||||
<Admonition.Outer type="tip">
|
||||
<Admonition.Row>
|
||||
<Admonition.Icon />
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<Admonition.Content>
|
||||
<Admonition.Text>
|
||||
<Trans>
|
||||
Enable notifications for an account by visiting their
|
||||
@@ -166,7 +166,7 @@ export function ActivityNotificationSettingsScreen({}: Props) {
|
||||
.
|
||||
</Trans>
|
||||
</Admonition.Text>
|
||||
</View>
|
||||
</Admonition.Content>
|
||||
</Admonition.Row>
|
||||
</Admonition.Outer>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyNotificationDeclaration} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -112,7 +111,7 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
|
||||
<Admonition.Outer type="tip" style={[a.flex_1]}>
|
||||
<Admonition.Row>
|
||||
<Admonition.Icon />
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<Admonition.Content>
|
||||
<Admonition.Text>
|
||||
<Trans>
|
||||
Note: Bluesky is an open and public network. This setting
|
||||
@@ -131,7 +130,7 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
|
||||
<Trans>Learn more about what is public on Bluesky.</Trans>
|
||||
</InlineLinkText>
|
||||
</Admonition.Text>
|
||||
</View>
|
||||
</Admonition.Content>
|
||||
</Admonition.Row>
|
||||
</Admonition.Outer>
|
||||
</SettingsList.Item>
|
||||
|
||||
Vendored
-6
@@ -12,7 +12,6 @@ import {findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData} f
|
||||
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/queries/notifications/feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
|
||||
import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '#/state/queries/post-thread'
|
||||
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
|
||||
import {findAllPostsInQueryData as findAllPostsInThreadV2QueryData} from '#/state/queries/usePostThread/queryCache'
|
||||
import {castAsShadow, type Shadow} from './types'
|
||||
@@ -176,11 +175,6 @@ function* findPostsInCache(
|
||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||
yield post
|
||||
}
|
||||
for (let node of findAllPostsInThreadQueryData(queryClient, uri)) {
|
||||
if (node.type === 'post') {
|
||||
yield node.post
|
||||
}
|
||||
}
|
||||
for (let post of findAllPostsInThreadV2QueryData(queryClient, uri)) {
|
||||
yield post
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -16,7 +16,6 @@ import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '#/s
|
||||
import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '#/state/queries/post-liked-by'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '#/state/queries/post-quotes'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '#/state/queries/post-reposted-by'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInPostThreadQueryData} from '#/state/queries/post-thread'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '#/state/queries/profile'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '#/state/queries/profile-followers'
|
||||
import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '#/state/queries/profile-follows'
|
||||
@@ -173,7 +172,6 @@ function* findProfilesInCache(
|
||||
yield* findAllProfilesInActorSearchQueryData(queryClient, did)
|
||||
yield* findAllProfilesInListConvosQueryData(queryClient, did)
|
||||
yield* findAllProfilesInFeedsQueryData(queryClient, did)
|
||||
yield* findAllProfilesInPostThreadQueryData(queryClient, did)
|
||||
yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
|
||||
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
|
||||
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
|
||||
|
||||
+41
-15
@@ -12,7 +12,6 @@ import throttle from 'lodash.throttle'
|
||||
|
||||
import {PROD_FEEDS, STAGING_FEEDS} from '#/lib/constants'
|
||||
import {isNetworkError} from '#/lib/hooks/useCleanError'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {Logger} from '#/logger'
|
||||
import {
|
||||
type FeedSourceFeedInfo,
|
||||
@@ -28,9 +27,21 @@ import {useAgent} from './session'
|
||||
|
||||
export const FEEDBACK_FEEDS = [...PROD_FEEDS, ...STAGING_FEEDS]
|
||||
|
||||
export const DIRECT_FEEDBACK_INTERACTIONS = new Set<
|
||||
export const THIRD_PARTY_ALLOWED_INTERACTIONS = new Set<
|
||||
AppBskyFeedDefs.Interaction['event']
|
||||
>(['app.bsky.feed.defs#requestLess', 'app.bsky.feed.defs#requestMore'])
|
||||
>([
|
||||
// These are explicit actions and are therefore fine to send.
|
||||
'app.bsky.feed.defs#requestLess',
|
||||
'app.bsky.feed.defs#requestMore',
|
||||
// These can be inferred from the firehose and are therefore fine to send.
|
||||
'app.bsky.feed.defs#interactionLike',
|
||||
'app.bsky.feed.defs#interactionQuote',
|
||||
'app.bsky.feed.defs#interactionReply',
|
||||
'app.bsky.feed.defs#interactionRepost',
|
||||
// This can be inferred from pagination requests for everything except the very last page
|
||||
// so it is fine to send. It is crucial for third party algorithmic feeds to receive these.
|
||||
'app.bsky.feed.defs#interactionSeen',
|
||||
])
|
||||
|
||||
const logger = Logger.create(Logger.Context.FeedFeedback)
|
||||
|
||||
@@ -78,11 +89,19 @@ export function useFeedFeedback(
|
||||
const aggregatedStats = useRef<AggregatedStats | null>(null)
|
||||
const throttledFlushAggregatedStats = useMemo(
|
||||
() =>
|
||||
throttle(() => flushToStatsig(aggregatedStats.current), 45e3, {
|
||||
leading: true, // The outer call is already throttled somewhat.
|
||||
trailing: true,
|
||||
}),
|
||||
[],
|
||||
throttle(
|
||||
() =>
|
||||
flushToStatsig(
|
||||
aggregatedStats.current,
|
||||
feed?.feedDescriptor ?? 'unknown',
|
||||
),
|
||||
45e3,
|
||||
{
|
||||
leading: true, // The outer call is already throttled somewhat.
|
||||
trailing: true,
|
||||
},
|
||||
),
|
||||
[feed?.feedDescriptor],
|
||||
)
|
||||
|
||||
const sendToFeedNoDelay = useCallback(() => {
|
||||
@@ -123,6 +142,7 @@ export function useFeedFeedback(
|
||||
sendOrAggregateInteractionsForStats(
|
||||
aggregatedStats.current,
|
||||
interactionsToSend,
|
||||
feed?.feedDescriptor ?? 'unknown',
|
||||
)
|
||||
throttledFlushAggregatedStats()
|
||||
logger.debug('flushed')
|
||||
@@ -228,7 +248,7 @@ function isInteractionAllowed(
|
||||
return false
|
||||
}
|
||||
const isDiscover = isDiscoverFeed(feed.feedDescriptor)
|
||||
return isDiscover ? true : DIRECT_FEEDBACK_INTERACTIONS.has(interaction)
|
||||
return isDiscover ? true : THIRD_PARTY_ALLOWED_INTERACTIONS.has(interaction)
|
||||
}
|
||||
|
||||
function toString(interaction: AppBskyFeedDefs.Interaction): string {
|
||||
@@ -259,19 +279,22 @@ function createAggregatedStats(): AggregatedStats {
|
||||
function sendOrAggregateInteractionsForStats(
|
||||
stats: AggregatedStats,
|
||||
interactions: AppBskyFeedDefs.Interaction[],
|
||||
feed: string,
|
||||
) {
|
||||
for (let interaction of interactions) {
|
||||
switch (interaction.event) {
|
||||
// Pressing "Show more" / "Show less" is relatively uncommon so we won't aggregate them.
|
||||
// This lets us send the feed context together with them.
|
||||
case 'app.bsky.feed.defs#requestLess': {
|
||||
logEvent('discover:showLess', {
|
||||
logger.metric('feed:showLess', {
|
||||
feed,
|
||||
feedContext: interaction.feedContext ?? '',
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'app.bsky.feed.defs#requestMore': {
|
||||
logEvent('discover:showMore', {
|
||||
logger.metric('feed:showMore', {
|
||||
feed,
|
||||
feedContext: interaction.feedContext ?? '',
|
||||
})
|
||||
break
|
||||
@@ -301,28 +324,31 @@ function sendOrAggregateInteractionsForStats(
|
||||
}
|
||||
}
|
||||
|
||||
function flushToStatsig(stats: AggregatedStats | null) {
|
||||
function flushToStatsig(stats: AggregatedStats | null, feedDescriptor: string) {
|
||||
if (stats === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (stats.clickthroughCount > 0) {
|
||||
logEvent('discover:clickthrough', {
|
||||
logger.metric('feed:clickthrough', {
|
||||
count: stats.clickthroughCount,
|
||||
feed: feedDescriptor,
|
||||
})
|
||||
stats.clickthroughCount = 0
|
||||
}
|
||||
|
||||
if (stats.engagedCount > 0) {
|
||||
logEvent('discover:engaged', {
|
||||
logger.metric('feed:engaged', {
|
||||
count: stats.engagedCount,
|
||||
feed: feedDescriptor,
|
||||
})
|
||||
stats.engagedCount = 0
|
||||
}
|
||||
|
||||
if (stats.seenCount > 0) {
|
||||
logEvent('discover:seen', {
|
||||
logger.metric('feed:seen', {
|
||||
count: stats.seenCount,
|
||||
feed: feedDescriptor,
|
||||
})
|
||||
stats.seenCount = 0
|
||||
}
|
||||
|
||||
@@ -1,10 +1,45 @@
|
||||
import {useEffect, useRef} from 'react'
|
||||
import * as Location from 'expo-location'
|
||||
import {createPermissionHook} from 'expo-modules-core'
|
||||
|
||||
import {logger} from '#/state/geolocation/logger'
|
||||
import {getDeviceGeolocation} from '#/state/geolocation/util'
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
/**
|
||||
* Location.useForegroundPermissions on web just errors if the navigator.permissions API is not available.
|
||||
* We need to catch and ignore it, since it's effectively denied.
|
||||
* @see https://github.com/expo/expo/blob/72f1562ed9cce5ff6dfe04aa415b71632a3d4b87/packages/expo-location/src/Location.ts#L290-L293
|
||||
*/
|
||||
const useForegroundPermissions = createPermissionHook({
|
||||
getMethod: () =>
|
||||
Location.getForegroundPermissionsAsync().catch(error => {
|
||||
logger.debug(
|
||||
'useForegroundPermission: error getting location permissions',
|
||||
{safeMessage: error},
|
||||
)
|
||||
return {
|
||||
status: Location.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 0,
|
||||
}
|
||||
}),
|
||||
requestMethod: () =>
|
||||
Location.requestForegroundPermissionsAsync().catch(error => {
|
||||
logger.debug(
|
||||
'useForegroundPermission: error requesting location permissions',
|
||||
{safeMessage: error},
|
||||
)
|
||||
return {
|
||||
status: Location.PermissionStatus.DENIED,
|
||||
granted: false,
|
||||
canAskAgain: false,
|
||||
expires: 0,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* Hook to get and sync the device geolocation from the device GPS and store it
|
||||
* using device storage. If permissions are not granted, it will clear any cached
|
||||
@@ -12,7 +47,7 @@ import {device, useStorage} from '#/storage'
|
||||
*/
|
||||
export function useSyncedDeviceGeolocation() {
|
||||
const synced = useRef(false)
|
||||
const [status] = Location.useForegroundPermissions()
|
||||
const [status] = useForegroundPermissions()
|
||||
const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [
|
||||
'deviceGeolocation',
|
||||
])
|
||||
|
||||
@@ -71,7 +71,7 @@ const schema = z.object({
|
||||
contentLanguages: z.array(z.string()),
|
||||
/**
|
||||
* The language(s) the user is currently posting in, configured within the
|
||||
* composer. Multiple languages are psearate by commas.
|
||||
* composer. Multiple languages are separated by commas.
|
||||
*
|
||||
* BCP-47 2-letter language code without region.
|
||||
*/
|
||||
|
||||
@@ -156,6 +156,10 @@ export function toPostLanguages(postLanguage: string): string[] {
|
||||
return postLanguage.split(',').filter(Boolean)
|
||||
}
|
||||
|
||||
export function fromPostLanguages(languages: string[]): string {
|
||||
return languages.filter(Boolean).join(',')
|
||||
}
|
||||
|
||||
export function hasPostLanguage(postLanguage: string, code2: string): boolean {
|
||||
return toPostLanguages(postLanguage).includes(code2)
|
||||
}
|
||||
|
||||
@@ -492,23 +492,23 @@ function createApi({
|
||||
}
|
||||
}
|
||||
} else if (feedDesc.startsWith('author')) {
|
||||
const [_, actor, filter] = feedDesc.split('|')
|
||||
const [__, actor, filter] = feedDesc.split('|')
|
||||
return new AuthorFeedAPI({agent, feedParams: {actor, filter}})
|
||||
} else if (feedDesc.startsWith('likes')) {
|
||||
const [_, actor] = feedDesc.split('|')
|
||||
const [__, actor] = feedDesc.split('|')
|
||||
return new LikesFeedAPI({agent, feedParams: {actor}})
|
||||
} else if (feedDesc.startsWith('feedgen')) {
|
||||
const [_, feed] = feedDesc.split('|')
|
||||
const [__, feed] = feedDesc.split('|')
|
||||
return new CustomFeedAPI({
|
||||
agent,
|
||||
feedParams: {feed},
|
||||
userInterests,
|
||||
})
|
||||
} else if (feedDesc.startsWith('list')) {
|
||||
const [_, list] = feedDesc.split('|')
|
||||
const [__, list] = feedDesc.split('|')
|
||||
return new ListFeedAPI({agent, feedParams: {list}})
|
||||
} else if (feedDesc.startsWith('posts')) {
|
||||
const [_, uriList] = feedDesc.split('|')
|
||||
const [__, uriList] = feedDesc.split('|')
|
||||
return new PostListFeedAPI({agent, feedParams: {uris: uriList.split(',')}})
|
||||
} else if (feedDesc === 'demo') {
|
||||
return new DemoFeedAPI({agent})
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyEmbedRecord,
|
||||
AppBskyFeedDefs,
|
||||
type AppBskyFeedGetPostThread,
|
||||
AppBskyFeedPost,
|
||||
AtUri,
|
||||
moderatePost,
|
||||
type ModerationDecision,
|
||||
type ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
findAllPostsInQueryData as findAllPostsInExploreFeedPreviewsQueryData,
|
||||
findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData,
|
||||
} from '#/state/queries/explore-feed-previews'
|
||||
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
|
||||
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {
|
||||
findAllPostsInQueryData as findAllPostsInSearchQueryData,
|
||||
findAllProfilesInQueryData as findAllProfilesInSearchQueryData,
|
||||
} from '#/state/queries/search-posts'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {
|
||||
findAllPostsInQueryData as findAllPostsInNotifsQueryData,
|
||||
findAllProfilesInQueryData as findAllProfilesInNotifsQueryData,
|
||||
} from './notifications/feed'
|
||||
import {
|
||||
findAllPostsInQueryData as findAllPostsInFeedQueryData,
|
||||
findAllProfilesInQueryData as findAllProfilesInFeedQueryData,
|
||||
} from './post-feed'
|
||||
import {
|
||||
didOrHandleUriMatches,
|
||||
embedViewRecordToPostView,
|
||||
getEmbeddedPost,
|
||||
} from './util'
|
||||
|
||||
const REPLY_TREE_DEPTH = 10
|
||||
export const RQKEY_ROOT = 'post-thread'
|
||||
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
|
||||
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
|
||||
|
||||
export interface ThreadCtx {
|
||||
depth: number
|
||||
isHighlightedPost?: boolean
|
||||
hasMore?: boolean
|
||||
isParentLoading?: boolean
|
||||
isChildLoading?: boolean
|
||||
isSelfThread?: boolean
|
||||
hasMoreSelfThread?: boolean
|
||||
}
|
||||
|
||||
export type ThreadPost = {
|
||||
type: 'post'
|
||||
_reactKey: string
|
||||
uri: string
|
||||
post: AppBskyFeedDefs.PostView
|
||||
record: AppBskyFeedPost.Record
|
||||
parent: ThreadNode | undefined
|
||||
replies: ThreadNode[] | undefined
|
||||
hasOPLike: boolean | undefined
|
||||
ctx: ThreadCtx
|
||||
}
|
||||
|
||||
export type ThreadNotFound = {
|
||||
type: 'not-found'
|
||||
_reactKey: string
|
||||
uri: string
|
||||
ctx: ThreadCtx
|
||||
}
|
||||
|
||||
export type ThreadBlocked = {
|
||||
type: 'blocked'
|
||||
_reactKey: string
|
||||
uri: string
|
||||
ctx: ThreadCtx
|
||||
}
|
||||
|
||||
export type ThreadUnknown = {
|
||||
type: 'unknown'
|
||||
uri: string
|
||||
}
|
||||
|
||||
export type ThreadNode =
|
||||
| ThreadPost
|
||||
| ThreadNotFound
|
||||
| ThreadBlocked
|
||||
| ThreadUnknown
|
||||
|
||||
export type ThreadModerationCache = WeakMap<ThreadNode, ModerationDecision>
|
||||
|
||||
export type PostThreadQueryData = {
|
||||
thread: ThreadNode
|
||||
threadgate?: AppBskyFeedDefs.ThreadgateView
|
||||
}
|
||||
|
||||
export function usePostThreadQuery(uri: string | undefined) {
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
return useQuery<PostThreadQueryData, Error>({
|
||||
gcTime: 0,
|
||||
queryKey: RQKEY(uri || ''),
|
||||
async queryFn() {
|
||||
const res = await agent.getPostThread({
|
||||
uri: uri!,
|
||||
depth: REPLY_TREE_DEPTH,
|
||||
})
|
||||
if (res.success) {
|
||||
const thread = responseToThreadNodes(res.data.thread)
|
||||
annotateSelfThread(thread)
|
||||
return {
|
||||
thread,
|
||||
threadgate: res.data.threadgate as
|
||||
| AppBskyFeedDefs.ThreadgateView
|
||||
| undefined,
|
||||
}
|
||||
}
|
||||
return {thread: {type: 'unknown', uri: uri!}}
|
||||
},
|
||||
enabled: !!uri,
|
||||
placeholderData: () => {
|
||||
if (!uri) return
|
||||
const post = findPostInQueryData(queryClient, uri)
|
||||
if (post) {
|
||||
return {thread: post}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function fillThreadModerationCache(
|
||||
cache: ThreadModerationCache,
|
||||
node: ThreadNode,
|
||||
moderationOpts: ModerationOpts,
|
||||
) {
|
||||
if (node.type === 'post') {
|
||||
cache.set(node, moderatePost(node.post, moderationOpts))
|
||||
if (node.parent) {
|
||||
fillThreadModerationCache(cache, node.parent, moderationOpts)
|
||||
}
|
||||
if (node.replies) {
|
||||
for (const reply of node.replies) {
|
||||
fillThreadModerationCache(cache, reply, moderationOpts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sortThread(
|
||||
node: ThreadNode,
|
||||
opts: UsePreferencesQueryResponse['threadViewPrefs'],
|
||||
modCache: ThreadModerationCache,
|
||||
currentDid: string | undefined,
|
||||
justPostedUris: Set<string>,
|
||||
threadgateRecordHiddenReplies: Set<string>,
|
||||
fetchedAtCache: Map<string, number>,
|
||||
fetchedAt: number,
|
||||
randomCache: Map<string, number>,
|
||||
): ThreadNode {
|
||||
if (node.type !== 'post') {
|
||||
return node
|
||||
}
|
||||
if (node.replies) {
|
||||
node.replies.sort((a: ThreadNode, b: ThreadNode) => {
|
||||
if (a.type !== 'post') {
|
||||
return 1
|
||||
}
|
||||
if (b.type !== 'post') {
|
||||
return -1
|
||||
}
|
||||
|
||||
if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
|
||||
const aIsJustPosted =
|
||||
a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
|
||||
const bIsJustPosted =
|
||||
b.post.author.did === currentDid && justPostedUris.has(b.post.uri)
|
||||
if (aIsJustPosted && bIsJustPosted) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsJustPosted) {
|
||||
return -1 // reply while onscreen
|
||||
} else if (bIsJustPosted) {
|
||||
return 1 // reply while onscreen
|
||||
}
|
||||
}
|
||||
|
||||
const aIsByOp = a.post.author.did === node.post?.author.did
|
||||
const bIsByOp = b.post.author.did === node.post?.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsByOp) {
|
||||
return -1 // op's own reply
|
||||
} else if (bIsByOp) {
|
||||
return 1 // op's own reply
|
||||
}
|
||||
|
||||
const aIsBySelf = a.post.author.did === currentDid
|
||||
const bIsBySelf = b.post.author.did === currentDid
|
||||
if (aIsBySelf && bIsBySelf) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsBySelf) {
|
||||
return -1 // current account's reply
|
||||
} else if (bIsBySelf) {
|
||||
return 1 // current account's reply
|
||||
}
|
||||
|
||||
const aHidden = threadgateRecordHiddenReplies.has(a.uri)
|
||||
const bHidden = threadgateRecordHiddenReplies.has(b.uri)
|
||||
if (aHidden && !aIsBySelf && !bHidden) {
|
||||
return 1
|
||||
} else if (bHidden && !bIsBySelf && !aHidden) {
|
||||
return -1
|
||||
}
|
||||
|
||||
const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
|
||||
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
|
||||
if (aBlur !== bBlur) {
|
||||
if (aBlur) {
|
||||
return 1
|
||||
}
|
||||
if (bBlur) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
const aPin = Boolean(a.record.text.trim() === '📌')
|
||||
const bPin = Boolean(b.record.text.trim() === '📌')
|
||||
if (aPin !== bPin) {
|
||||
if (aPin) {
|
||||
return 1
|
||||
}
|
||||
if (bPin) {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.prioritizeFollowedUsers) {
|
||||
const af = a.post.author.viewer?.following
|
||||
const bf = b.post.author.viewer?.following
|
||||
if (af && !bf) {
|
||||
return -1
|
||||
} else if (!af && bf) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// Split items from different fetches into separate generations.
|
||||
let aFetchedAt = fetchedAtCache.get(a.uri)
|
||||
if (aFetchedAt === undefined) {
|
||||
fetchedAtCache.set(a.uri, fetchedAt)
|
||||
aFetchedAt = fetchedAt
|
||||
}
|
||||
let bFetchedAt = fetchedAtCache.get(b.uri)
|
||||
if (bFetchedAt === undefined) {
|
||||
fetchedAtCache.set(b.uri, fetchedAt)
|
||||
bFetchedAt = fetchedAt
|
||||
}
|
||||
|
||||
if (aFetchedAt !== bFetchedAt) {
|
||||
return aFetchedAt - bFetchedAt // older fetches first
|
||||
} else if (opts.sort === 'hotness') {
|
||||
const aHotness = getHotness(a, aFetchedAt)
|
||||
const bHotness = getHotness(b, bFetchedAt /* same as aFetchedAt */)
|
||||
return bHotness - aHotness
|
||||
} else if (opts.sort === 'oldest') {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt)
|
||||
} else if (opts.sort === 'newest') {
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||
} else if (opts.sort === 'most-likes') {
|
||||
if (a.post.likeCount === b.post.likeCount) {
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest
|
||||
} else {
|
||||
return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes
|
||||
}
|
||||
} else if (opts.sort === 'random') {
|
||||
let aRandomScore = randomCache.get(a.uri)
|
||||
if (aRandomScore === undefined) {
|
||||
aRandomScore = Math.random()
|
||||
randomCache.set(a.uri, aRandomScore)
|
||||
}
|
||||
let bRandomScore = randomCache.get(b.uri)
|
||||
if (bRandomScore === undefined) {
|
||||
bRandomScore = Math.random()
|
||||
randomCache.set(b.uri, bRandomScore)
|
||||
}
|
||||
// this is vaguely criminal but we can get away with it
|
||||
return aRandomScore - bRandomScore
|
||||
} else {
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||
}
|
||||
})
|
||||
node.replies.forEach(reply =>
|
||||
sortThread(
|
||||
reply,
|
||||
opts,
|
||||
modCache,
|
||||
currentDid,
|
||||
justPostedUris,
|
||||
threadgateRecordHiddenReplies,
|
||||
fetchedAtCache,
|
||||
fetchedAt,
|
||||
randomCache,
|
||||
),
|
||||
)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// internal methods
|
||||
// =
|
||||
|
||||
// Inspired by https://join-lemmy.org/docs/contributors/07-ranking-algo.html
|
||||
// We want to give recent comments a real chance (and not bury them deep below the fold)
|
||||
// while also surfacing well-liked comments from the past. In the future, we can explore
|
||||
// something more sophisticated, but we don't have much data on the client right now.
|
||||
function getHotness(threadPost: ThreadPost, fetchedAt: number) {
|
||||
const {post, hasOPLike} = threadPost
|
||||
const hoursAgo = Math.max(
|
||||
0,
|
||||
(new Date(fetchedAt).getTime() - new Date(post.indexedAt).getTime()) /
|
||||
(1000 * 60 * 60),
|
||||
)
|
||||
const likeCount = post.likeCount ?? 0
|
||||
const likeOrder = Math.log(3 + likeCount) * (hasOPLike ? 1.45 : 1.0)
|
||||
const timePenaltyExponent = 1.5 + 1.5 / (1 + Math.log(1 + likeCount))
|
||||
const opLikeBoost = hasOPLike ? 0.8 : 1.0
|
||||
const timePenalty = Math.pow(hoursAgo + 2, timePenaltyExponent * opLikeBoost)
|
||||
return likeOrder / timePenalty
|
||||
}
|
||||
|
||||
function responseToThreadNodes(
|
||||
node: ThreadViewNode,
|
||||
depth = 0,
|
||||
direction: 'up' | 'down' | 'start' = 'start',
|
||||
): ThreadNode {
|
||||
if (
|
||||
AppBskyFeedDefs.isThreadViewPost(node) &&
|
||||
bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
node.post.record,
|
||||
AppBskyFeedPost.isRecord,
|
||||
)
|
||||
) {
|
||||
const post = node.post
|
||||
// These should normally be present. They're missing only for
|
||||
// posts that were *just* created. Ideally, the backend would
|
||||
// know to return zeros. Fill them in manually to compensate.
|
||||
post.replyCount ??= 0
|
||||
post.likeCount ??= 0
|
||||
post.repostCount ??= 0
|
||||
return {
|
||||
type: 'post',
|
||||
_reactKey: node.post.uri,
|
||||
uri: node.post.uri,
|
||||
post: post,
|
||||
record: node.post.record,
|
||||
parent:
|
||||
node.parent && direction !== 'down'
|
||||
? responseToThreadNodes(node.parent, depth - 1, 'up')
|
||||
: undefined,
|
||||
replies:
|
||||
node.replies?.length && direction !== 'up'
|
||||
? node.replies
|
||||
.map(reply => responseToThreadNodes(reply, depth + 1, 'down'))
|
||||
// do not show blocked posts in replies
|
||||
.filter(node => node.type !== 'blocked')
|
||||
: undefined,
|
||||
hasOPLike: Boolean(node?.threadContext?.rootAuthorLike),
|
||||
ctx: {
|
||||
depth,
|
||||
isHighlightedPost: depth === 0,
|
||||
hasMore:
|
||||
direction === 'down' && !node.replies?.length && !!post.replyCount,
|
||||
isSelfThread: false, // populated `annotateSelfThread`
|
||||
hasMoreSelfThread: false, // populated in `annotateSelfThread`
|
||||
},
|
||||
}
|
||||
} else if (AppBskyFeedDefs.isBlockedPost(node)) {
|
||||
return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
|
||||
} else if (AppBskyFeedDefs.isNotFoundPost(node)) {
|
||||
return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}}
|
||||
} else {
|
||||
return {type: 'unknown', uri: ''}
|
||||
}
|
||||
}
|
||||
|
||||
function annotateSelfThread(thread: ThreadNode) {
|
||||
if (thread.type !== 'post') {
|
||||
return
|
||||
}
|
||||
const selfThreadNodes: ThreadPost[] = [thread]
|
||||
|
||||
let parent: ThreadNode | undefined = thread.parent
|
||||
while (parent) {
|
||||
if (
|
||||
parent.type !== 'post' ||
|
||||
parent.post.author.did !== thread.post.author.did
|
||||
) {
|
||||
// not a self-thread
|
||||
return
|
||||
}
|
||||
selfThreadNodes.unshift(parent)
|
||||
parent = parent.parent
|
||||
}
|
||||
|
||||
let node = thread
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const reply = node.replies?.find(
|
||||
r => r.type === 'post' && r.post.author.did === thread.post.author.did,
|
||||
)
|
||||
if (reply?.type !== 'post') {
|
||||
break
|
||||
}
|
||||
selfThreadNodes.push(reply)
|
||||
node = reply
|
||||
}
|
||||
|
||||
if (selfThreadNodes.length > 1) {
|
||||
for (const selfThreadNode of selfThreadNodes) {
|
||||
selfThreadNode.ctx.isSelfThread = true
|
||||
}
|
||||
const last = selfThreadNodes[selfThreadNodes.length - 1]
|
||||
if (
|
||||
last &&
|
||||
last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth
|
||||
last.post.replyCount && // has replies
|
||||
!last.replies?.length // replies were not hydrated
|
||||
) {
|
||||
last.ctx.hasMoreSelfThread = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findPostInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): ThreadNode | void {
|
||||
let partial
|
||||
for (let item of findAllPostsInQueryData(queryClient, uri)) {
|
||||
if (item.type === 'post') {
|
||||
// Currently, the backend doesn't send full post info in some cases
|
||||
// (for example, for quoted posts). We use missing `likeCount`
|
||||
// as a way to detect that. In the future, we should fix this on
|
||||
// the backend, which will let us always stop on the first result.
|
||||
const hasAllInfo = item.post.likeCount != null
|
||||
if (hasAllInfo) {
|
||||
return item
|
||||
} else {
|
||||
partial = item
|
||||
// Keep searching, we might still find a full post in the cache.
|
||||
}
|
||||
}
|
||||
}
|
||||
return partial
|
||||
}
|
||||
|
||||
export function* findAllPostsInQueryData(
|
||||
queryClient: QueryClient,
|
||||
uri: string,
|
||||
): Generator<ThreadNode, void> {
|
||||
const atUri = new AtUri(uri)
|
||||
|
||||
const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) {
|
||||
continue
|
||||
}
|
||||
const {thread} = queryData
|
||||
for (const item of traverseThread(thread)) {
|
||||
if (item.type === 'post' && didOrHandleUriMatches(atUri, item.post)) {
|
||||
const placeholder = threadNodeToPlaceholderThread(item)
|
||||
if (placeholder) {
|
||||
yield placeholder
|
||||
}
|
||||
}
|
||||
const quotedPost =
|
||||
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
|
||||
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
|
||||
yield embedViewRecordToPlaceholderThread(quotedPost)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||
// Check notifications first. If you have a post in notifications,
|
||||
// it's often due to a like or a repost, and we want to prioritize
|
||||
// a post object with >0 likes/reposts over a stale version with no
|
||||
// metrics in order to avoid a notification->post scroll jump.
|
||||
yield postViewToPlaceholderThread(post)
|
||||
}
|
||||
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
||||
yield postViewToPlaceholderThread(post)
|
||||
}
|
||||
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
||||
yield postViewToPlaceholderThread(post)
|
||||
}
|
||||
for (let post of findAllPostsInSearchQueryData(queryClient, uri)) {
|
||||
yield postViewToPlaceholderThread(post)
|
||||
}
|
||||
for (let post of findAllPostsInExploreFeedPreviewsQueryData(
|
||||
queryClient,
|
||||
uri,
|
||||
)) {
|
||||
yield postViewToPlaceholderThread(post)
|
||||
}
|
||||
}
|
||||
|
||||
export function* findAllProfilesInQueryData(
|
||||
queryClient: QueryClient,
|
||||
did: string,
|
||||
): Generator<AppBskyActorDefs.ProfileViewBasic, void> {
|
||||
const queryDatas = queryClient.getQueriesData<PostThreadQueryData>({
|
||||
queryKey: [RQKEY_ROOT],
|
||||
})
|
||||
for (const [_queryKey, queryData] of queryDatas) {
|
||||
if (!queryData) {
|
||||
continue
|
||||
}
|
||||
const {thread} = queryData
|
||||
for (const item of traverseThread(thread)) {
|
||||
if (item.type === 'post' && item.post.author.did === did) {
|
||||
yield item.post.author
|
||||
}
|
||||
const quotedPost =
|
||||
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
|
||||
if (quotedPost?.author.did === did) {
|
||||
yield quotedPost?.author
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let profile of findAllProfilesInFeedQueryData(queryClient, did)) {
|
||||
yield profile
|
||||
}
|
||||
for (let profile of findAllProfilesInNotifsQueryData(queryClient, did)) {
|
||||
yield profile
|
||||
}
|
||||
for (let profile of findAllProfilesInSearchQueryData(queryClient, did)) {
|
||||
yield profile
|
||||
}
|
||||
for (let profile of findAllProfilesInExploreFeedPreviewsQueryData(
|
||||
queryClient,
|
||||
did,
|
||||
)) {
|
||||
yield profile
|
||||
}
|
||||
}
|
||||
|
||||
function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
|
||||
if (node.type === 'post') {
|
||||
if (node.parent) {
|
||||
yield* traverseThread(node.parent)
|
||||
}
|
||||
yield node
|
||||
if (node.replies?.length) {
|
||||
for (const reply of node.replies) {
|
||||
yield* traverseThread(reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function threadNodeToPlaceholderThread(
|
||||
node: ThreadNode,
|
||||
): ThreadNode | undefined {
|
||||
if (node.type !== 'post') {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
type: node.type,
|
||||
_reactKey: node._reactKey,
|
||||
uri: node.uri,
|
||||
post: node.post,
|
||||
record: node.record,
|
||||
parent: undefined,
|
||||
replies: undefined,
|
||||
hasOPLike: undefined,
|
||||
ctx: {
|
||||
depth: 0,
|
||||
isHighlightedPost: true,
|
||||
hasMore: false,
|
||||
isParentLoading: !!node.record.reply,
|
||||
isChildLoading: !!node.post.replyCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function postViewToPlaceholderThread(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
): ThreadNode {
|
||||
return {
|
||||
type: 'post',
|
||||
_reactKey: post.uri,
|
||||
uri: post.uri,
|
||||
post: post,
|
||||
record: post.record as AppBskyFeedPost.Record, // validated in notifs
|
||||
parent: undefined,
|
||||
replies: undefined,
|
||||
hasOPLike: undefined,
|
||||
ctx: {
|
||||
depth: 0,
|
||||
isHighlightedPost: true,
|
||||
hasMore: false,
|
||||
isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply,
|
||||
isChildLoading: true, // assume yes (show the spinner) just in case
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function embedViewRecordToPlaceholderThread(
|
||||
record: AppBskyEmbedRecord.ViewRecord,
|
||||
): ThreadNode {
|
||||
return {
|
||||
type: 'post',
|
||||
_reactKey: record.uri,
|
||||
uri: record.uri,
|
||||
post: embedViewRecordToPostView(record),
|
||||
record: record.value as AppBskyFeedPost.Record, // validated in getEmbeddedPost
|
||||
parent: undefined,
|
||||
replies: undefined,
|
||||
hasOPLike: undefined,
|
||||
ctx: {
|
||||
depth: 0,
|
||||
isHighlightedPost: true,
|
||||
hasMore: false,
|
||||
isParentLoading: !!(record.value as AppBskyFeedPost.Record).reply,
|
||||
isChildLoading: true, // not available, so assume yes (to show the spinner)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
type AppBskyFeedGetPostThread,
|
||||
type AppBskyFeedDefs,
|
||||
AppBskyFeedThreadgate,
|
||||
AtUri,
|
||||
type BskyAgent,
|
||||
@@ -8,9 +7,8 @@ import {
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {networkRetry, retry} from '#/lib/async/retry'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread'
|
||||
import {useGetPost} from '#/state/queries/post'
|
||||
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
|
||||
import {
|
||||
createThreadgateRecord,
|
||||
@@ -18,6 +16,7 @@ import {
|
||||
threadgateAllowUISettingToAllowRecordValue,
|
||||
threadgateViewToAllowUISetting,
|
||||
} from '#/state/queries/threadgate/util'
|
||||
import {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies'
|
||||
import * as bsky from '#/types/bsky'
|
||||
@@ -25,6 +24,11 @@ import * as bsky from '#/types/bsky'
|
||||
export * from '#/state/queries/threadgate/types'
|
||||
export * from '#/state/queries/threadgate/util'
|
||||
|
||||
/**
|
||||
* Must match the threadgate lexicon record definition.
|
||||
*/
|
||||
export const MAX_HIDDEN_REPLIES = 300
|
||||
|
||||
export const threadgateRecordQueryKeyRoot = 'threadgate-record'
|
||||
export const createThreadgateRecordQueryKey = (uri: string) => [
|
||||
threadgateRecordQueryKeyRoot,
|
||||
@@ -66,7 +70,7 @@ export function useThreadgateViewQuery({
|
||||
postUri?: string
|
||||
initialData?: AppBskyFeedDefs.ThreadgateView
|
||||
} = {}) {
|
||||
const agent = useAgent()
|
||||
const getPost = useGetPost()
|
||||
|
||||
return useQuery({
|
||||
enabled: !!postUri,
|
||||
@@ -74,33 +78,12 @@ export function useThreadgateViewQuery({
|
||||
placeholderData: initialData,
|
||||
staleTime: STALE.MINUTES.ONE,
|
||||
async queryFn() {
|
||||
return getThreadgateView({
|
||||
agent,
|
||||
postUri: postUri!,
|
||||
})
|
||||
const post = await getPost({uri: postUri!})
|
||||
return post.threadgate ?? null
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getThreadgateView({
|
||||
agent,
|
||||
postUri,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
postUri: string
|
||||
}) {
|
||||
const {data} = await agent.app.bsky.feed.getPostThread({
|
||||
uri: postUri!,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
if (AppBskyFeedDefs.isThreadViewPost(data.thread)) {
|
||||
return data.thread.post.threadgate ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getThreadgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
@@ -205,6 +188,7 @@ export async function upsertThreadgate(
|
||||
})
|
||||
const next = await callback(prev)
|
||||
if (!next) return
|
||||
validateThreadgateRecordOrThrow(next)
|
||||
await writeThreadgateRecord({
|
||||
agent,
|
||||
postUri,
|
||||
@@ -242,6 +226,8 @@ export async function updateThreadgateAllow({
|
||||
export function useSetThreadgateAllowMutation() {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const getPost = useGetPost()
|
||||
const updatePostThreadThreadgate = useUpdatePostThreadThreadgateQueryCache()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
@@ -266,30 +252,32 @@ export function useSetThreadgateAllowMutation() {
|
||||
})
|
||||
},
|
||||
async onSuccess(_, {postUri, allow}) {
|
||||
await until(
|
||||
const data = await retry<AppBskyFeedDefs.ThreadgateView | undefined>(
|
||||
5, // 5 tries
|
||||
1e3, // 1s delay between tries
|
||||
(res: AppBskyFeedGetPostThread.Response) => {
|
||||
const thread = res.data.thread
|
||||
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
|
||||
const fetchedSettings = threadgateViewToAllowUISetting(
|
||||
thread.post.threadgate,
|
||||
_e => true,
|
||||
async () => {
|
||||
const post = await getPost({uri: postUri})
|
||||
const threadgate = post.threadgate
|
||||
if (!threadgate) {
|
||||
throw new Error(
|
||||
`useSetThreadgateAllowMutation: could not fetch threadgate, appview may not be ready yet`,
|
||||
)
|
||||
return JSON.stringify(fetchedSettings) === JSON.stringify(allow)
|
||||
}
|
||||
return false
|
||||
const fetchedSettings = threadgateViewToAllowUISetting(threadgate)
|
||||
const isReady =
|
||||
JSON.stringify(fetchedSettings) === JSON.stringify(allow)
|
||||
if (!isReady) {
|
||||
throw new Error(
|
||||
`useSetThreadgateAllowMutation: appview isn't ready yet`,
|
||||
) // try again
|
||||
}
|
||||
return threadgate
|
||||
},
|
||||
() => {
|
||||
return agent.app.bsky.feed.getPostThread({
|
||||
uri: postUri,
|
||||
depth: 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
1e3, // 1s delay between tries
|
||||
).catch(() => {})
|
||||
|
||||
if (data) updatePostThreadThreadgate(data)
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [postThreadQueryKeyRoot],
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [threadgateRecordQueryKeyRoot],
|
||||
})
|
||||
@@ -358,3 +346,31 @@ export function useToggleReplyVisibilityMutation() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export class MaxHiddenRepliesError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message || 'Maximum number of hidden replies reached')
|
||||
this.name = 'MaxHiddenRepliesError'
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidInteractionSettingsError extends Error {
|
||||
constructor(message?: string) {
|
||||
super(message || 'Invalid interaction settings')
|
||||
this.name = 'InvalidInteractionSettingsError'
|
||||
}
|
||||
}
|
||||
|
||||
export function validateThreadgateRecordOrThrow(
|
||||
record: AppBskyFeedThreadgate.Record,
|
||||
) {
|
||||
const result = AppBskyFeedThreadgate.validateRecord(record)
|
||||
|
||||
if (result.success) {
|
||||
if ((result.value.hiddenReplies?.length ?? 0) > MAX_HIDDEN_REPLIES) {
|
||||
throw new MaxHiddenRepliesError()
|
||||
}
|
||||
} else {
|
||||
throw new InvalidInteractionSettingsError()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function* findAllProfilesInQueryData(
|
||||
queryClient.getQueriesData<AppBskyUnspeccedGetSuggestedUsers.OutputSchema>({
|
||||
queryKey: [getSuggestedUsersQueryKeyRoot],
|
||||
})
|
||||
for (const [_, response] of responses) {
|
||||
for (const [_key, response] of responses) {
|
||||
if (!response) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {createContext, useContext} from 'react'
|
||||
|
||||
import {
|
||||
type createPostThreadOtherQueryKey,
|
||||
type createPostThreadQueryKey,
|
||||
} from '#/state/queries/usePostThread/types'
|
||||
|
||||
/**
|
||||
* Contains static metadata about the post thread query, suitable for
|
||||
* context e.g. query keys and other things that don't update frequently.
|
||||
*
|
||||
* Be careful adding things here, as it could cause unnecessary re-renders.
|
||||
*/
|
||||
export type PostThreadContextType = {
|
||||
postThreadQueryKey: ReturnType<typeof createPostThreadQueryKey>
|
||||
postThreadOtherQueryKey: ReturnType<typeof createPostThreadOtherQueryKey>
|
||||
}
|
||||
|
||||
const PostThreadContext = createContext<PostThreadContextType | undefined>(
|
||||
undefined,
|
||||
)
|
||||
|
||||
/**
|
||||
* Use the current {@link PostThreadContext}, if one is available. If not,
|
||||
* returns `undefined`.
|
||||
*/
|
||||
export function usePostThreadContext() {
|
||||
return useContext(PostThreadContext)
|
||||
}
|
||||
|
||||
export function PostThreadContextProvider({
|
||||
children,
|
||||
context,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
context?: PostThreadContextType
|
||||
}) {
|
||||
return (
|
||||
<PostThreadContext.Provider value={context}>
|
||||
{children}
|
||||
</PostThreadContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TREE_VIEW_BELOW_DESKTOP,
|
||||
TREE_VIEW_BF,
|
||||
} from '#/state/queries/usePostThread/const'
|
||||
import {type PostThreadContextType} from '#/state/queries/usePostThread/context'
|
||||
import {
|
||||
createCacheMutator,
|
||||
getThreadPlaceholder,
|
||||
@@ -31,6 +32,8 @@ import {useAgent, useSession} from '#/state/session'
|
||||
import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {useBreakpoints} from '#/alf'
|
||||
|
||||
export * from '#/state/queries/usePostThread/context'
|
||||
export {useUpdatePostThreadThreadgateQueryCache} from '#/state/queries/usePostThread/queryCache'
|
||||
export * from '#/state/queries/usePostThread/types'
|
||||
|
||||
export function usePostThread({anchor}: {anchor?: string}) {
|
||||
@@ -277,8 +280,13 @@ export function usePostThread({anchor}: {anchor?: string}) {
|
||||
setOtherItemsVisible,
|
||||
])
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
return useMemo(() => {
|
||||
const context: PostThreadContextType = {
|
||||
postThreadQueryKey,
|
||||
postThreadOtherQueryKey,
|
||||
}
|
||||
return {
|
||||
context,
|
||||
state: {
|
||||
/*
|
||||
* Copy in any query state that is useful
|
||||
@@ -309,17 +317,18 @@ export function usePostThread({anchor}: {anchor?: string}) {
|
||||
setSort,
|
||||
setView,
|
||||
},
|
||||
}),
|
||||
[
|
||||
query,
|
||||
mutator.insertReplies,
|
||||
otherItemsVisible,
|
||||
sort,
|
||||
view,
|
||||
setSort,
|
||||
setView,
|
||||
threadgate,
|
||||
items,
|
||||
],
|
||||
)
|
||||
}
|
||||
}, [
|
||||
query,
|
||||
mutator.insertReplies,
|
||||
otherItemsVisible,
|
||||
sort,
|
||||
view,
|
||||
setSort,
|
||||
setView,
|
||||
threadgate,
|
||||
items,
|
||||
postThreadQueryKey,
|
||||
postThreadOtherQueryKey,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useCallback} from 'react'
|
||||
import {
|
||||
type $Typed,
|
||||
type AppBskyActorDefs,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
} from '@atproto/api'
|
||||
import {type QueryClient} from '@tanstack/react-query'
|
||||
import {type QueryClient, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
dangerousGetPostShadow,
|
||||
@@ -18,6 +19,7 @@ import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '#/state/
|
||||
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '#/state/queries/post-feed'
|
||||
import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '#/state/queries/post-quotes'
|
||||
import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '#/state/queries/search-posts'
|
||||
import {usePostThreadContext} from '#/state/queries/usePostThread'
|
||||
import {getBranch} from '#/state/queries/usePostThread/traversal'
|
||||
import {
|
||||
type ApiThreadItem,
|
||||
@@ -322,3 +324,51 @@ export function* findAllProfilesInQueryData(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdatePostThreadThreadgateQueryCache() {
|
||||
const qc = useQueryClient()
|
||||
const context = usePostThreadContext()
|
||||
|
||||
return useCallback(
|
||||
(threadgate: AppBskyFeedDefs.ThreadgateView) => {
|
||||
if (!context) return
|
||||
|
||||
function mutator<T>(thread: ApiThreadItem[]): T[] {
|
||||
for (let i = 0; i < thread.length; i++) {
|
||||
const item = thread[i]
|
||||
|
||||
if (!AppBskyUnspeccedDefs.isThreadItemPost(item.value)) continue
|
||||
|
||||
if (item.depth === 0) {
|
||||
thread.splice(i, 1, {
|
||||
...item,
|
||||
value: {
|
||||
...item.value,
|
||||
post: {
|
||||
...item.value.post,
|
||||
threadgate,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return thread as T[]
|
||||
}
|
||||
|
||||
qc.setQueryData<AppBskyUnspeccedGetPostThreadV2.OutputSchema>(
|
||||
context.postThreadQueryKey,
|
||||
data => {
|
||||
if (!data) return
|
||||
return {
|
||||
...data,
|
||||
thread: mutator<AppBskyUnspeccedGetPostThreadV2.ThreadItem>([
|
||||
...data.thread,
|
||||
]),
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
[qc, context],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -321,15 +321,7 @@ class BskyAppAgent extends BskyAgent {
|
||||
|
||||
// Now the agent is ready.
|
||||
const account = agentToSessionAccountOrThrow(this)
|
||||
let lastSession = this.sessionManager.session
|
||||
this.persistSessionHandler = event => {
|
||||
if (this.sessionManager.session) {
|
||||
lastSession = this.sessionManager.session
|
||||
} else if (event === 'network-error') {
|
||||
// Put it back, we'll try again later.
|
||||
this.sessionManager.session = lastSession
|
||||
}
|
||||
|
||||
onSessionChange(this, account.did, event)
|
||||
if (event !== 'create' && event !== 'update') {
|
||||
addSessionErrorLog(account.did, event)
|
||||
|
||||
+62
-39
@@ -14,7 +14,7 @@ import {
|
||||
createAgentAndResume,
|
||||
sessionAccountToSession,
|
||||
} from './agent'
|
||||
import {getInitialState, reducer} from './reducer'
|
||||
import {type Action, getInitialState, reducer, type State} from './reducer'
|
||||
|
||||
export {isSignupQueued} from './util'
|
||||
import {addSessionDebugLog} from './logging'
|
||||
@@ -46,13 +46,51 @@ const ApiContext = React.createContext<SessionApiContext>({
|
||||
})
|
||||
ApiContext.displayName = 'SessionApiContext'
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const cancelPendingTask = useOneTaskAtATime()
|
||||
const [state, dispatch] = React.useReducer(reducer, null, () => {
|
||||
class SessionStore {
|
||||
private state: State
|
||||
private listeners = new Set<() => void>()
|
||||
|
||||
constructor() {
|
||||
// Careful: By the time this runs, `persisted` needs to already be filled.
|
||||
const initialState = getInitialState(persisted.get('session').accounts)
|
||||
addSessionDebugLog({type: 'reducer:init', state: initialState})
|
||||
return initialState
|
||||
})
|
||||
this.state = initialState
|
||||
}
|
||||
|
||||
getState = (): State => {
|
||||
return this.state
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
dispatch = (action: Action) => {
|
||||
const nextState = reducer(this.state, action)
|
||||
this.state = nextState
|
||||
// Persist synchronously without waiting for the React render cycle.
|
||||
if (nextState.needsPersist) {
|
||||
nextState.needsPersist = false
|
||||
const persistedData = {
|
||||
accounts: nextState.accounts,
|
||||
currentAccount: nextState.accounts.find(
|
||||
a => a.did === nextState.currentAgentState.did,
|
||||
),
|
||||
}
|
||||
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
|
||||
persisted.write('session', persistedData)
|
||||
}
|
||||
this.listeners.forEach(listener => listener())
|
||||
}
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const cancelPendingTask = useOneTaskAtATime()
|
||||
const [store] = React.useState(() => new SessionStore())
|
||||
const state = React.useSyncExternalStore(store.subscribe, store.getState)
|
||||
|
||||
const onAgentSessionChange = React.useCallback(
|
||||
(agent: BskyAgent, accountDid: string, sessionEvent: AtpSessionEvent) => {
|
||||
@@ -60,7 +98,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
if (sessionEvent === 'expired' || sessionEvent === 'create-failed') {
|
||||
emitSessionDropped()
|
||||
}
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
type: 'received-agent-event',
|
||||
agent,
|
||||
refreshedAccount,
|
||||
@@ -68,7 +106,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
sessionEvent,
|
||||
})
|
||||
},
|
||||
[],
|
||||
[store],
|
||||
)
|
||||
|
||||
const createAccount = React.useCallback<SessionApiContext['createAccount']>(
|
||||
@@ -84,7 +122,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newAccount: account,
|
||||
@@ -92,7 +130,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
logger.metric('account:create:success', metrics, {statsig: true})
|
||||
addSessionDebugLog({type: 'method:end', method: 'createAccount', account})
|
||||
},
|
||||
[onAgentSessionChange, cancelPendingTask],
|
||||
[store, onAgentSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const login = React.useCallback<SessionApiContext['login']>(
|
||||
@@ -107,7 +145,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newAccount: account,
|
||||
@@ -119,7 +157,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'login', account})
|
||||
},
|
||||
[onAgentSessionChange, cancelPendingTask],
|
||||
[store, onAgentSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const logoutCurrentAccount = React.useCallback<
|
||||
@@ -128,7 +166,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
logContext => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'logout'})
|
||||
cancelPendingTask()
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
type: 'logged-out-current-account',
|
||||
})
|
||||
logger.metric(
|
||||
@@ -138,7 +176,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
},
|
||||
[cancelPendingTask],
|
||||
[store, cancelPendingTask],
|
||||
)
|
||||
|
||||
const logoutEveryAccount = React.useCallback<
|
||||
@@ -147,7 +185,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
logContext => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'logout'})
|
||||
cancelPendingTask()
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
type: 'logged-out-every-account',
|
||||
})
|
||||
logger.metric(
|
||||
@@ -157,7 +195,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
},
|
||||
[cancelPendingTask],
|
||||
[store, cancelPendingTask],
|
||||
)
|
||||
|
||||
const resumeSession = React.useCallback<SessionApiContext['resumeSession']>(
|
||||
@@ -176,14 +214,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent,
|
||||
newAccount: account,
|
||||
})
|
||||
addSessionDebugLog({type: 'method:end', method: 'resumeSession', account})
|
||||
},
|
||||
[onAgentSessionChange, cancelPendingTask],
|
||||
[store, onAgentSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const partialRefreshSession = React.useCallback<
|
||||
@@ -193,7 +231,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const signal = cancelPendingTask()
|
||||
const {data} = await agent.com.atproto.server.getSession()
|
||||
if (signal.aborted) return
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
type: 'partial-refresh-session',
|
||||
accountDid: agent.session!.did,
|
||||
patch: {
|
||||
@@ -201,7 +239,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
emailAuthFactor: data.emailAuthFactor,
|
||||
},
|
||||
})
|
||||
}, [state, cancelPendingTask])
|
||||
}, [store, state, cancelPendingTask])
|
||||
|
||||
const removeAccount = React.useCallback<SessionApiContext['removeAccount']>(
|
||||
account => {
|
||||
@@ -211,34 +249,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
account,
|
||||
})
|
||||
cancelPendingTask()
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
type: 'removed-account',
|
||||
accountDid: account.did,
|
||||
})
|
||||
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
|
||||
},
|
||||
[cancelPendingTask],
|
||||
[store, cancelPendingTask],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state.needsPersist) {
|
||||
state.needsPersist = false
|
||||
const persistedData = {
|
||||
accounts: state.accounts,
|
||||
currentAccount: state.accounts.find(
|
||||
a => a.did === state.currentAgentState.did,
|
||||
),
|
||||
}
|
||||
addSessionDebugLog({type: 'persisted:broadcast', data: persistedData})
|
||||
persisted.write('session', persistedData)
|
||||
}
|
||||
}, [state])
|
||||
|
||||
React.useEffect(() => {
|
||||
return persisted.onUpdate('session', nextSession => {
|
||||
const synced = nextSession
|
||||
addSessionDebugLog({type: 'persisted:receive', data: synced})
|
||||
dispatch({
|
||||
store.dispatch({
|
||||
type: 'synced-accounts',
|
||||
syncedAccounts: synced.accounts,
|
||||
syncedCurrentDid: synced.currentAccount?.did,
|
||||
@@ -262,7 +285,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [state, resumeSession])
|
||||
}, [store, state, resumeSession])
|
||||
|
||||
const stateContext = React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import {beforeEach, expect, jest, test} from '@jest/globals'
|
||||
|
||||
import {Storage} from '#/storage'
|
||||
|
||||
jest.mock('react-native-mmkv', () => ({
|
||||
jest.mock('@bsky.app/react-native-mmkv', () => ({
|
||||
MMKV: class MMKVMock {
|
||||
_store = new Map()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
|
||||
import {type Account, type Device} from '#/storage/schema'
|
||||
|
||||
|
||||
@@ -44,9 +44,8 @@ import Animated, {
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
type AppBskyFeedGetPostThread,
|
||||
AppBskyUnspeccedDefs,
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
type BskyAgent,
|
||||
type RichText,
|
||||
@@ -88,6 +87,7 @@ import {
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||
import {
|
||||
fromPostLanguages,
|
||||
toPostLanguages,
|
||||
useLanguagePrefs,
|
||||
useLanguagePrefsApi,
|
||||
@@ -197,6 +197,44 @@ export const ComposePost = ({
|
||||
const [publishingStage, setPublishingStage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
/**
|
||||
* A temporary local reference to a language suggestion that the user has
|
||||
* accepted. This overrides the global post language preference, but is not
|
||||
* stored permanently.
|
||||
*/
|
||||
const [acceptedLanguageSuggestion, setAcceptedLanguageSuggestion] = useState<
|
||||
string | null
|
||||
>(null)
|
||||
|
||||
/**
|
||||
* The language(s) of the post being replied to.
|
||||
*/
|
||||
const [replyToLanguages, setReplyToLanguages] = useState<string[]>(
|
||||
replyTo?.langs || [],
|
||||
)
|
||||
|
||||
/**
|
||||
* The currently selected languages of the post. Prefer local temporary
|
||||
* language suggestion over global lang prefs, if available.
|
||||
*/
|
||||
const currentLanguages = useMemo(
|
||||
() =>
|
||||
acceptedLanguageSuggestion
|
||||
? [acceptedLanguageSuggestion]
|
||||
: toPostLanguages(langPrefs.postLanguage),
|
||||
[acceptedLanguageSuggestion, langPrefs.postLanguage],
|
||||
)
|
||||
|
||||
/**
|
||||
* When the user selects a language from the composer language selector,
|
||||
* clear any temporary language suggestions they may have selected
|
||||
* previously, and any we might try to suggest to them.
|
||||
*/
|
||||
const onSelectLanguage = () => {
|
||||
setAcceptedLanguageSuggestion(null)
|
||||
setReplyToLanguages([])
|
||||
}
|
||||
|
||||
const [composerState, composerDispatch] = useReducer(
|
||||
composerReducer,
|
||||
{
|
||||
@@ -414,7 +452,7 @@ export const ComposePost = ({
|
||||
thread,
|
||||
replyTo: replyTo?.uri,
|
||||
onStateChange: setPublishingStage,
|
||||
langs: toPostLanguages(langPrefs.postLanguage),
|
||||
langs: currentLanguages,
|
||||
})
|
||||
).uris[0]
|
||||
|
||||
@@ -490,7 +528,7 @@ export const ComposePost = ({
|
||||
isPartOfThread: thread.posts.length > 1,
|
||||
hasLink: !!post.embed.link,
|
||||
hasQuote: !!post.embed.quote,
|
||||
langs: langPrefs.postLanguage,
|
||||
langs: fromPostLanguages(currentLanguages),
|
||||
logContext: 'Composer',
|
||||
})
|
||||
index++
|
||||
@@ -510,10 +548,10 @@ export const ComposePost = ({
|
||||
if (initQuote) {
|
||||
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
|
||||
whenAppViewReady(agent, initQuote.uri, res => {
|
||||
const quotedThread = res.data.thread
|
||||
const anchor = res.data.thread.at(0)
|
||||
if (
|
||||
AppBskyFeedDefs.isThreadViewPost(quotedThread) &&
|
||||
quotedThread.post.quoteCount !== initQuote.quoteCount
|
||||
AppBskyUnspeccedDefs.isThreadItemPost(anchor?.value) &&
|
||||
anchor.value.post.quoteCount !== initQuote.quoteCount
|
||||
) {
|
||||
onPost?.(postUri)
|
||||
onPostSuccess?.(postSuccessData)
|
||||
@@ -557,7 +595,7 @@ export const ComposePost = ({
|
||||
thread,
|
||||
canPost,
|
||||
isPublishing,
|
||||
langPrefs.postLanguage,
|
||||
currentLanguages,
|
||||
onClose,
|
||||
onPost,
|
||||
onPostSuccess,
|
||||
@@ -654,8 +692,9 @@ export const ComposePost = ({
|
||||
<>
|
||||
<SuggestedLanguage
|
||||
text={activePost.richtext.text}
|
||||
// NOTE(@elijaharita): currently just choosing the first language if any exists
|
||||
replyToLanguage={replyTo?.langs?.[0]}
|
||||
replyToLanguages={replyToLanguages}
|
||||
currentLanguages={currentLanguages}
|
||||
onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion}
|
||||
/>
|
||||
<ComposerPills
|
||||
isReply={!!replyTo}
|
||||
@@ -678,6 +717,8 @@ export const ComposePost = ({
|
||||
type: 'add_post',
|
||||
})
|
||||
}}
|
||||
currentLanguages={currentLanguages}
|
||||
onSelectLanguage={onSelectLanguage}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -1289,6 +1330,8 @@ function ComposerFooter({
|
||||
onEmojiButtonPress,
|
||||
onSelectVideo,
|
||||
onAddPost,
|
||||
currentLanguages,
|
||||
onSelectLanguage,
|
||||
}: {
|
||||
post: PostDraft
|
||||
dispatch: (action: PostAction) => void
|
||||
@@ -1297,6 +1340,8 @@ function ComposerFooter({
|
||||
onError: (error: string) => void
|
||||
onSelectVideo: (postId: string, asset: ImagePickerAsset) => void
|
||||
onAddPost: () => void
|
||||
currentLanguages: string[]
|
||||
onSelectLanguage?: (language: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -1450,7 +1495,10 @@ function ComposerFooter({
|
||||
<PlusIcon size="lg" />
|
||||
</Button>
|
||||
)}
|
||||
<PostLanguageSelect />
|
||||
<PostLanguageSelect
|
||||
currentLanguages={currentLanguages}
|
||||
onSelectLanguage={onSelectLanguage}
|
||||
/>
|
||||
<CharProgress
|
||||
count={post.shortenedGraphemeLength}
|
||||
style={{width: 65}}
|
||||
@@ -1612,16 +1660,18 @@ function useKeyboardVerticalOffset() {
|
||||
async function whenAppViewReady(
|
||||
agent: BskyAgent,
|
||||
uri: string,
|
||||
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
|
||||
fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean,
|
||||
) {
|
||||
await until(
|
||||
5, // 5 tries
|
||||
1e3, // 1s delay between tries
|
||||
fn,
|
||||
() =>
|
||||
agent.app.bsky.feed.getPostThread({
|
||||
uri,
|
||||
depth: 0,
|
||||
agent.app.bsky.unspecced.getPostThreadV2({
|
||||
anchor: uri,
|
||||
above: false,
|
||||
below: 0,
|
||||
branchingFactor: 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,7 +116,6 @@ function EditImageInner({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
const source = image.source
|
||||
|
||||
@@ -17,7 +17,13 @@ import * as Menu from '#/components/Menu'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PostLanguageSelectDialog} from './PostLanguageSelectDialog'
|
||||
|
||||
export function PostLanguageSelect() {
|
||||
export function PostLanguageSelect({
|
||||
currentLanguages: currentLanguagesProp,
|
||||
onSelectLanguage,
|
||||
}: {
|
||||
currentLanguages?: string[]
|
||||
onSelectLanguage?: (language: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
@@ -27,6 +33,9 @@ export function PostLanguageSelect() {
|
||||
new Set([...langPrefs.postLanguageHistory, langPrefs.postLanguage]),
|
||||
)
|
||||
|
||||
const currentLanguages =
|
||||
currentLanguagesProp ?? toPostLanguages(langPrefs.postLanguage)
|
||||
|
||||
if (
|
||||
dedupedHistory.length === 1 &&
|
||||
dedupedHistory[0] === langPrefs.postLanguage
|
||||
@@ -34,7 +43,10 @@ export function PostLanguageSelect() {
|
||||
return (
|
||||
<>
|
||||
<LanguageBtn onPress={languageDialogControl.open} />
|
||||
<PostLanguageSelectDialog control={languageDialogControl} />
|
||||
<PostLanguageSelectDialog
|
||||
control={languageDialogControl}
|
||||
currentLanguages={currentLanguages}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -43,7 +55,9 @@ export function PostLanguageSelect() {
|
||||
<>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Select post language`)}>
|
||||
{({props}) => <LanguageBtn {...props} />}
|
||||
{({props}) => (
|
||||
<LanguageBtn currentLanguages={currentLanguages} {...props} />
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
@@ -56,10 +70,13 @@ export function PostLanguageSelect() {
|
||||
<Menu.Item
|
||||
key={historyItem}
|
||||
label={_(msg`Select ${langName}`)}
|
||||
onPress={() => setLangPrefs.setPostLanguage(historyItem)}>
|
||||
onPress={() => {
|
||||
setLangPrefs.setPostLanguage(historyItem)
|
||||
onSelectLanguage?.(historyItem)
|
||||
}}>
|
||||
<Menu.ItemText>{langName}</Menu.ItemText>
|
||||
<Menu.ItemRadio
|
||||
selected={historyItem === langPrefs.postLanguage}
|
||||
selected={currentLanguages.includes(historyItem)}
|
||||
/>
|
||||
</Menu.Item>
|
||||
)
|
||||
@@ -77,17 +94,26 @@ export function PostLanguageSelect() {
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<PostLanguageSelectDialog control={languageDialogControl} />
|
||||
<PostLanguageSelectDialog
|
||||
control={languageDialogControl}
|
||||
currentLanguages={currentLanguages}
|
||||
onSelectLanguage={onSelectLanguage}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LanguageBtn(props: Omit<ButtonProps, 'label' | 'children'>) {
|
||||
function LanguageBtn(
|
||||
props: Omit<ButtonProps, 'label' | 'children'> & {
|
||||
currentLanguages?: string[]
|
||||
},
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const t = useTheme()
|
||||
|
||||
const postLanguagesPref = toPostLanguages(langPrefs.postLanguage)
|
||||
const currentLanguages = props.currentLanguages ?? postLanguagesPref
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -106,7 +132,7 @@ function LanguageBtn(props: Omit<ButtonProps, 'label' | 'children'>) {
|
||||
{({pressed, hovered}) => {
|
||||
const color =
|
||||
pressed || hovered ? t.palette.primary_300 : t.palette.primary_500
|
||||
if (postLanguagesPref.length > 0) {
|
||||
if (currentLanguages.length > 0) {
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
@@ -117,7 +143,7 @@ function LanguageBtn(props: Omit<ButtonProps, 'label' | 'children'>) {
|
||||
{maxWidth: 100},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{postLanguagesPref
|
||||
{currentLanguages
|
||||
.map(lang => codeToLanguageName(lang, langPrefs.appLanguage))
|
||||
.join(', ')}
|
||||
</Text>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {languageName} from '#/locale/helpers'
|
||||
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
toPostLanguages,
|
||||
useLanguagePrefs,
|
||||
useLanguagePrefsApi,
|
||||
} from '#/state/preferences/languages'
|
||||
@@ -23,8 +24,16 @@ import {Text} from '#/components/Typography'
|
||||
|
||||
export function PostLanguageSelectDialog({
|
||||
control,
|
||||
/**
|
||||
* Optionally can be passed to show different values than what is saved in
|
||||
* langPrefs.
|
||||
*/
|
||||
currentLanguages,
|
||||
onSelectLanguage,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
currentLanguages?: string[]
|
||||
onSelectLanguage?: (language: string) => void
|
||||
}) {
|
||||
const {height} = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
@@ -40,13 +49,22 @@ export function PostLanguageSelectDialog({
|
||||
nativeOptions={{minHeight: height - insets.top}}>
|
||||
<Dialog.Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<DialogInner />
|
||||
<DialogInner
|
||||
currentLanguages={currentLanguages}
|
||||
onSelectLanguage={onSelectLanguage}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogInner() {
|
||||
export function DialogInner({
|
||||
currentLanguages,
|
||||
onSelectLanguage,
|
||||
}: {
|
||||
currentLanguages?: string[]
|
||||
onSelectLanguage?: (language: string) => void
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
|
||||
@@ -63,8 +81,11 @@ export function DialogInner() {
|
||||
}, [])
|
||||
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const postLanguagesPref =
|
||||
currentLanguages ?? toPostLanguages(langPrefs.postLanguage)
|
||||
|
||||
const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>(
|
||||
langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage],
|
||||
postLanguagesPref || [langPrefs.primaryLanguage],
|
||||
)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
@@ -79,6 +100,7 @@ export function DialogInner() {
|
||||
langsString = langPrefs.primaryLanguage
|
||||
}
|
||||
setLangPrefs.setPostLanguage(langsString)
|
||||
onSelectLanguage?.(langsString)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Text as RNText, View} from 'react-native'
|
||||
import {parseLanguage} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import lande from 'lande'
|
||||
|
||||
import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers'
|
||||
import {
|
||||
toPostLanguages,
|
||||
useLanguagePrefs,
|
||||
useLanguagePrefsApi,
|
||||
} from '#/state/preferences/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences/languages'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe'
|
||||
@@ -22,28 +18,42 @@ const cancelIdle = globalThis.cancelIdleCallback || clearTimeout
|
||||
|
||||
export function SuggestedLanguage({
|
||||
text,
|
||||
replyToLanguage: replyToLanguageProp,
|
||||
replyToLanguages: replyToLanguagesProp,
|
||||
currentLanguages,
|
||||
onAcceptSuggestedLanguage,
|
||||
}: {
|
||||
text: string
|
||||
replyToLanguage?: string
|
||||
/**
|
||||
* All languages associated with the post being replied to.
|
||||
*/
|
||||
replyToLanguages: string[]
|
||||
/**
|
||||
* All languages currently selected for the post being composed.
|
||||
*/
|
||||
currentLanguages: string[]
|
||||
/**
|
||||
* Called when the user accepts a suggested language. We only pass a single
|
||||
* language here. If the post being replied to has multiple languages, we
|
||||
* only suggest the first one.
|
||||
*/
|
||||
onAcceptSuggestedLanguage: (language: string | null) => void
|
||||
}) {
|
||||
const replyToLanguage = cleanUpLanguage(replyToLanguageProp)
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const replyToLanguages = replyToLanguagesProp
|
||||
.map(lang => cleanUpLanguage(lang))
|
||||
.filter(Boolean) as string[]
|
||||
const [hasInteracted, setHasInteracted] = useState(false)
|
||||
const [suggestedLanguage, setSuggestedLanguage] = useState<
|
||||
string | undefined
|
||||
>(text.length === 0 ? replyToLanguage : undefined)
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
// For replies, suggest the language of the post being replied to if no text
|
||||
// has been typed yet
|
||||
if (replyToLanguage && text.length === 0) {
|
||||
setSuggestedLanguage(replyToLanguage)
|
||||
return
|
||||
if (text.length > 0 && !hasInteracted) {
|
||||
setHasInteracted(true)
|
||||
}
|
||||
}, [text, hasInteracted])
|
||||
|
||||
useEffect(() => {
|
||||
const textTrimmed = text.trim()
|
||||
|
||||
// Don't run the language model on small posts, the results are likely
|
||||
@@ -58,55 +68,121 @@ export function SuggestedLanguage({
|
||||
})
|
||||
|
||||
return () => cancelIdle(idle)
|
||||
}, [text, replyToLanguage])
|
||||
}, [text])
|
||||
|
||||
if (
|
||||
suggestedLanguage &&
|
||||
!toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage)
|
||||
) {
|
||||
/*
|
||||
* We've detected a language, and the user hasn't already selected it.
|
||||
*/
|
||||
const hasLanguageSuggestion =
|
||||
suggestedLanguage && !currentLanguages.includes(suggestedLanguage)
|
||||
/*
|
||||
* We have not detected a different language, and the user is not already
|
||||
* using or has not already selected one of the languages of the post they
|
||||
* are replying to.
|
||||
*/
|
||||
const hasSuggestedReplyLanguage =
|
||||
!hasInteracted &&
|
||||
!suggestedLanguage &&
|
||||
replyToLanguages.length &&
|
||||
!replyToLanguages.some(l => currentLanguages.includes(l))
|
||||
|
||||
if (hasLanguageSuggestion) {
|
||||
const suggestedLanguageName = codeToLanguageName(
|
||||
suggestedLanguage,
|
||||
langPrefs.appLanguage,
|
||||
)
|
||||
|
||||
return (
|
||||
<LanguageSuggestionButton
|
||||
label={
|
||||
<RNText>
|
||||
<Trans>
|
||||
Are you writing in{' '}
|
||||
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
|
||||
</Trans>
|
||||
</RNText>
|
||||
}
|
||||
value={suggestedLanguage}
|
||||
onAccept={onAcceptSuggestedLanguage}
|
||||
/>
|
||||
)
|
||||
} else if (hasSuggestedReplyLanguage) {
|
||||
const suggestedLanguageName = codeToLanguageName(
|
||||
replyToLanguages[0],
|
||||
langPrefs.appLanguage,
|
||||
)
|
||||
|
||||
return (
|
||||
<LanguageSuggestionButton
|
||||
label={
|
||||
<RNText>
|
||||
<Trans>
|
||||
The post you're replying to was marked as being written in{' '}
|
||||
{suggestedLanguageName} by its author. Would you like to reply in{' '}
|
||||
<Text style={[a.font_bold]}>{suggestedLanguageName}</Text>?
|
||||
</Trans>
|
||||
</RNText>
|
||||
}
|
||||
value={replyToLanguages[0]}
|
||||
onAccept={onAcceptSuggestedLanguage}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function LanguageSuggestionButton({
|
||||
label,
|
||||
value,
|
||||
onAccept,
|
||||
}: {
|
||||
label: React.ReactNode
|
||||
value: string
|
||||
onAccept: (language: string | null) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.px_lg, a.py_sm]}>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.border_contrast_low,
|
||||
a.gap_sm,
|
||||
a.gap_md,
|
||||
a.border,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_sm,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.mx_md,
|
||||
a.my_sm,
|
||||
a.p_md,
|
||||
a.pl_lg,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<EarthIcon />
|
||||
<Text style={[a.flex_1]}>
|
||||
<Trans>
|
||||
Are you writing in{' '}
|
||||
<Text style={[a.font_semi_bold]}>{suggestedLanguageName}</Text>?
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[
|
||||
a.leading_snug,
|
||||
{
|
||||
maxWidth: 400,
|
||||
},
|
||||
]}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
color="secondary"
|
||||
size="small"
|
||||
variant="solid"
|
||||
onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)}
|
||||
label={_(msg`Change post language to ${suggestedLanguageName}`)}>
|
||||
color="secondary"
|
||||
onPress={() => onAccept(value)}
|
||||
label={_(msg`Accept this language suggestion`)}>
|
||||
<ButtonText>
|
||||
<Trans>Yes</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,7 @@ function getDecorations(doc: ProsemirrorNode) {
|
||||
|
||||
let match
|
||||
while ((match = regex.exec(textContent))) {
|
||||
const [matchedString, _, tag] = match
|
||||
const [matchedString, __, tag] = match
|
||||
|
||||
if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64)
|
||||
continue
|
||||
|
||||
@@ -126,7 +126,7 @@ function FeedgenErrorMessage({
|
||||
})[knownError],
|
||||
[_l, knownError],
|
||||
)
|
||||
const [_, uri] = feedDesc.split('|')
|
||||
const [__, uri] = feedDesc.split('|')
|
||||
const [ownerDid] = safeParseFeedgenUri(uri)
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const {mutateAsync: removeFeed} = useRemoveFeedMutation()
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useActorStatus} from '#/lib/actor-status'
|
||||
@@ -19,6 +20,8 @@ import {MAX_POST_LINES} from '#/lib/constants'
|
||||
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {countLines} from '#/lib/strings/helpers'
|
||||
@@ -167,35 +170,50 @@ let FeedItemInner = ({
|
||||
}): React.ReactNode => {
|
||||
const queryClient = useQueryClient()
|
||||
const {openComposer} = useOpenComposer()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const pal = usePalette('default')
|
||||
const gate = useGate()
|
||||
const {_} = useLingui()
|
||||
|
||||
const [hover, setHover] = useState(false)
|
||||
|
||||
const href = useMemo(() => {
|
||||
const [href, rkey] = useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||
return [makeProfileLink(post.author, 'post', urip.rkey), urip.rkey]
|
||||
}, [post.uri, post.author])
|
||||
const {sendInteraction, feedSourceInfo} = useFeedFeedbackContext()
|
||||
|
||||
const onPressReply = () => {
|
||||
sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#interactionReply',
|
||||
feedContext,
|
||||
reqId,
|
||||
})
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: record.text || '',
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation,
|
||||
langs: record.langs,
|
||||
},
|
||||
})
|
||||
if (gate('feed_reply_button_open_thread')) {
|
||||
sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#clickthroughItem',
|
||||
feedContext,
|
||||
reqId,
|
||||
})
|
||||
navigation.navigate('PostThread', {
|
||||
name: post.author.did,
|
||||
rkey,
|
||||
})
|
||||
} else {
|
||||
sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#interactionReply',
|
||||
feedContext,
|
||||
reqId,
|
||||
})
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: post.uri,
|
||||
cid: post.cid,
|
||||
text: record.text || '',
|
||||
author: post.author,
|
||||
embed: post.embed,
|
||||
moderation,
|
||||
langs: record.langs,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenAuthor = () => {
|
||||
|
||||
@@ -105,6 +105,11 @@ let ProfileMenu = ({
|
||||
})
|
||||
}, [queryClient, profile.did])
|
||||
|
||||
const onPressAddToStarterPacks = React.useCallback(() => {
|
||||
logger.metric('profile:addToStarterPack', {})
|
||||
addToStarterPacksDialogControl.open()
|
||||
}, [addToStarterPacksDialogControl])
|
||||
|
||||
const onPressShare = React.useCallback(() => {
|
||||
shareUrl(toShareUrl(makeProfileLink(profile)))
|
||||
}, [profile])
|
||||
@@ -306,7 +311,7 @@ let ProfileMenu = ({
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownStarterPackAddRemoveBtn"
|
||||
label={_(msg`Add to starter packs`)}
|
||||
onPress={addToStarterPacksDialogControl.open}>
|
||||
onPress={onPressAddToStarterPacks}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Add to starter packs</Trans>
|
||||
</Menu.ItemText>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {useCallback, useMemo, useState} from 'react'
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {type AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
@@ -27,7 +26,6 @@ type Props = NativeStackScreenProps<
|
||||
export function ModerationMutedAccounts({}: Props) {
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {_} = useLingui()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback} from 'react'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {
|
||||
@@ -17,7 +16,6 @@ export const ProfileFeedLikedByScreen = ({route}: Props) => {
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {name, rkey} = route.params
|
||||
const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey)
|
||||
const {_} = useLingui()
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import {View} from 'react-native'
|
||||
import {Text as RNText, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
Admonition,
|
||||
Button as AdmonitionButton,
|
||||
Content as AdmonitionContent,
|
||||
Icon as AdmonitionIcon,
|
||||
Outer as AdmonitionOuter,
|
||||
Row as AdmonitionRow,
|
||||
Text as AdmonitionText,
|
||||
} from '#/components/Admonition'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon} from '#/components/icons/BellRinging'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {H1} from '#/components/Typography'
|
||||
|
||||
export function Admonitions() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_md]}>
|
||||
<H1>Admonitions</H1>
|
||||
@@ -30,6 +46,61 @@ export function Admonitions() {
|
||||
<Admonition type="error">
|
||||
The quick brown fox jumps over the lazy dog.
|
||||
</Admonition>
|
||||
|
||||
<AdmonitionOuter type="error">
|
||||
<AdmonitionRow>
|
||||
<AdmonitionIcon />
|
||||
<AdmonitionContent>
|
||||
<AdmonitionText>
|
||||
<Trans>Something went wrong, please try again</Trans>
|
||||
</AdmonitionText>
|
||||
</AdmonitionContent>
|
||||
<AdmonitionButton
|
||||
color="negative_subtle"
|
||||
label={_(msg`Retry loading report options`)}
|
||||
onPress={() => {}}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={Retry} />
|
||||
</AdmonitionButton>
|
||||
</AdmonitionRow>
|
||||
</AdmonitionOuter>
|
||||
|
||||
<AdmonitionOuter type="tip">
|
||||
<AdmonitionRow>
|
||||
<AdmonitionIcon />
|
||||
<AdmonitionContent>
|
||||
<AdmonitionText>
|
||||
<Trans>
|
||||
Enable notifications for an account by visiting their profile
|
||||
and pressing the{' '}
|
||||
<RNText style={[a.font_bold, t.atoms.text_contrast_high]}>
|
||||
bell icon
|
||||
</RNText>{' '}
|
||||
<BellRingingFilledIcon
|
||||
size="xs"
|
||||
style={t.atoms.text_contrast_high}
|
||||
/>
|
||||
.
|
||||
</Trans>
|
||||
</AdmonitionText>
|
||||
<AdmonitionText>
|
||||
<Trans>
|
||||
If you want to restrict who can receive notifications for your
|
||||
account's activity, you can change this in{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`Privacy and Security settings`)}
|
||||
to={{screen: 'ActivityPrivacySettings'}}
|
||||
style={[a.font_bold]}>
|
||||
Settings → Privacy and Security
|
||||
</InlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</AdmonitionText>
|
||||
</AdmonitionContent>
|
||||
</AdmonitionRow>
|
||||
</AdmonitionOuter>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ButtonIcon,
|
||||
type ButtonSize,
|
||||
ButtonText,
|
||||
StackedButton,
|
||||
} from '#/components/Button'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
@@ -18,6 +19,30 @@ export function Buttons() {
|
||||
<View style={[a.gap_md]}>
|
||||
<Text style={[a.font_bold, a.text_5xl]}>Buttons</Text>
|
||||
|
||||
<View style={[a.flex_row, a.gap_md, a.align_start, {maxWidth: 350}]}>
|
||||
<StackedButton
|
||||
label="stacked"
|
||||
icon={Globe}
|
||||
color="secondary"
|
||||
style={[a.flex_1]}>
|
||||
Bop it
|
||||
</StackedButton>
|
||||
<StackedButton
|
||||
label="stacked"
|
||||
icon={Globe}
|
||||
color="negative_subtle"
|
||||
style={[a.flex_1]}>
|
||||
Twist it
|
||||
</StackedButton>
|
||||
<StackedButton
|
||||
label="stacked"
|
||||
icon={Globe}
|
||||
color="primary"
|
||||
style={[a.flex_1]}>
|
||||
Pull it
|
||||
</StackedButton>
|
||||
</View>
|
||||
|
||||
{[
|
||||
'primary',
|
||||
'secondary',
|
||||
|
||||
@@ -8,7 +8,6 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
|
||||
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useGeolocationStatus} from '#/state/geolocation'
|
||||
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
|
||||
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
||||
@@ -46,7 +45,6 @@ function ShellInner() {
|
||||
const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer)
|
||||
const {state: policyUpdateState} = usePolicyUpdateContext()
|
||||
const welcomeModalControl = useWelcomeModal()
|
||||
const gate = useGate()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (showDrawer !== showDrawerDelayedExit) {
|
||||
@@ -85,8 +83,7 @@ function ShellInner() {
|
||||
<LinkWarningDialog />
|
||||
<Lightbox />
|
||||
|
||||
{/* Show welcome modal if the gate is enabled */}
|
||||
{welcomeModalControl.isOpen && gate('welcome_modal') && (
|
||||
{welcomeModalControl.isOpen && (
|
||||
<WelcomeModal control={welcomeModalControl} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -77,6 +77,20 @@
|
||||
tlds "^1.234.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/api@^0.17.0":
|
||||
version "0.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.17.0.tgz#1fe87ef703f8020dbe00bb5e5cc18622b8b91f4a"
|
||||
integrity sha512-FNS9SW7/3kslAnJH7F4fO9/jPjXzC0NMD6u9NjJ/h4EnaIEpWHZQPkmD9Q2hvAwD6+Uo2boYZEPKkOa55Lr5Dg==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.3"
|
||||
"@atproto/lexicon" "^0.5.1"
|
||||
"@atproto/syntax" "^0.4.1"
|
||||
"@atproto/xrpc" "^0.7.5"
|
||||
await-lock "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
tlds "^1.234.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/aws@^0.2.28":
|
||||
version "0.2.28"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.28.tgz#17bd88a6276e323ebb094a3f01bd94b1173a29a4"
|
||||
@@ -170,6 +184,16 @@
|
||||
uint8arrays "3.0.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/common-web@^0.4.3":
|
||||
version "0.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.4.3.tgz#b4480220b5682db09da45f4ef906eb7619c838b5"
|
||||
integrity sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==
|
||||
dependencies:
|
||||
graphemer "^1.4.0"
|
||||
multiformats "^9.9.0"
|
||||
uint8arrays "3.0.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/common@0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210"
|
||||
@@ -303,6 +327,17 @@
|
||||
multiformats "^9.9.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/lexicon@^0.5.1":
|
||||
version "0.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.5.1.tgz#e9b7d5c70dc5a38518a8069cd80fea77ab526947"
|
||||
integrity sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.4.3"
|
||||
"@atproto/syntax" "^0.4.1"
|
||||
iso-datestring-validator "^2.2.2"
|
||||
multiformats "^9.9.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/oauth-provider-api@0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.3.0.tgz#c53a6f2584e6e53746b6cdf233be591fdf7d4355"
|
||||
@@ -516,6 +551,14 @@
|
||||
"@atproto/lexicon" "^0.5.0"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@atproto/xrpc@^0.7.5":
|
||||
version "0.7.5"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.5.tgz#40cef1a657b5f28af8ebec9e3dac5872e58e88ea"
|
||||
integrity sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA==
|
||||
dependencies:
|
||||
"@atproto/lexicon" "^0.5.1"
|
||||
zod "^3.23.8"
|
||||
|
||||
"@aws-crypto/crc32@3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"
|
||||
@@ -3642,6 +3685,11 @@
|
||||
dependencies:
|
||||
react-responsive "^10.0.1"
|
||||
|
||||
"@bsky.app/react-native-mmkv@2.12.5":
|
||||
version "2.12.5"
|
||||
resolved "https://registry.yarnpkg.com/@bsky.app/react-native-mmkv/-/react-native-mmkv-2.12.5.tgz#eb17d31a6158c74393f617a1763ac223ff3f83a6"
|
||||
integrity sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==
|
||||
|
||||
"@bufbuild/protobuf@^1.5.0":
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-1.7.0.tgz#cecddc8162a231642b410bc7b99309cd5969733c"
|
||||
@@ -8603,14 +8651,7 @@ babel-plugin-polyfill-regenerator@^0.6.1:
|
||||
dependencies:
|
||||
"@babel/helper-define-polyfill-provider" "^0.6.3"
|
||||
|
||||
babel-plugin-react-compiler@^19.1.0-rc.1:
|
||||
version "19.1.0-rc.1"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.1.tgz#99d131be61017e40abbaedd98321069bf8b7e54a"
|
||||
integrity sha512-M4fpG+Hfq5gWzsJeeMErdRokzg0fdJ8IAk+JDhfB/WLT+U3WwJWR8edphypJrk447/JEvYu6DBFwsTn10bMW4Q==
|
||||
dependencies:
|
||||
"@babel/types" "^7.26.0"
|
||||
|
||||
babel-plugin-react-compiler@^19.1.0-rc.2:
|
||||
babel-plugin-react-compiler@^19.1.0-rc.2, babel-plugin-react-compiler@^19.1.0-rc.3:
|
||||
version "19.1.0-rc.3"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.3.tgz#45e5a282a2460b3701971e5eb8310a90a7919022"
|
||||
integrity sha512-mjRn69WuTz4adL0bXGx8Rsyk1086zFJeKmes6aK0xPuK3aaXmDJdLHqwKKMrpm6KAI1MCoUK72d2VeqQbu8YIA==
|
||||
@@ -10824,10 +10865,10 @@ eslint-plugin-lingui@^0.2.0:
|
||||
dependencies:
|
||||
"@typescript-eslint/utils" "^5.61.0"
|
||||
|
||||
eslint-plugin-react-compiler@^19.1.0-rc.1:
|
||||
version "19.1.0-rc.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.1.tgz#e974ba9541c9a4464d77723e0505b5742bc22e56"
|
||||
integrity sha512-3umw5eqZXapBl7aQGmvcjheKhUbsElb9jTETxRZg371e1LG4EPs/zCHt2JzP+wNcdaZWzjU/R730zPUJblY2zw==
|
||||
eslint-plugin-react-compiler@^19.1.0-rc.2:
|
||||
version "19.1.0-rc.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz#83343e7422e00fa61e729af8e8468f0ddec37925"
|
||||
integrity sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw==
|
||||
dependencies:
|
||||
"@babel/core" "^7.24.4"
|
||||
"@babel/parser" "^7.24.4"
|
||||
@@ -17029,11 +17070,6 @@ react-native-keyboard-controller@1.18.5:
|
||||
dependencies:
|
||||
react-native-is-edge-to-edge "^1.2.1"
|
||||
|
||||
react-native-mmkv@^2.12.2:
|
||||
version "2.12.2"
|
||||
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-2.12.2.tgz#4bba0f5f04e2cf222494cce3a9794ba6a4894dee"
|
||||
integrity sha512-6058Aq0p57chPrUutLGe9fYoiDVDNMU2PKV+lLFUJ3GhoHvUrLdsS1PDSCLr00yqzL4WJQ7TTzH+V8cpyrNcfg==
|
||||
|
||||
react-native-pager-view@6.8.0:
|
||||
version "6.8.0"
|
||||
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.8.0.tgz#5bac05203d911bf9bf039d47db41b1313dbd1a7a"
|
||||
@@ -17071,11 +17107,6 @@ react-native-reanimated@^3.19.1:
|
||||
invariant "^2.2.4"
|
||||
react-native-is-edge-to-edge "1.1.7"
|
||||
|
||||
react-native-root-siblings@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-root-siblings/-/react-native-root-siblings-5.0.1.tgz#97e050e5155228f65810fb1c466ff8e769c5272c"
|
||||
integrity sha512-Ay3k/fBj6ReUkWX5WNS+oEAcgPLEGOK8n7K/L7D85mf3xvd8rm/b4spsv26E4HlFzluVx5HKbxEt9cl0wQ1u3g==
|
||||
|
||||
react-native-safe-area-context@~5.6.0:
|
||||
version "5.6.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-5.6.1.tgz#cb4d249ef1a6f7e8fd0cfdfa9764838dffda26b6"
|
||||
|
||||
Reference in New Issue
Block a user