Compare commits

...

47 Commits

Author SHA1 Message Date
Eric Bailey 2f2eeaffd0 Revert ext change 2025-10-10 16:44:49 -05:00
Eric Bailey 330580bf82 Missing import 2025-10-10 16:43:44 -05:00
Eric Bailey f240730db4 Replace in initQuote handling, which isn't even used rn... 2025-10-10 16:42:09 -05:00
Eric Bailey 28a2365e0c Replace getPostThread in threadgate query 2025-10-10 16:41:40 -05:00
Eric Bailey c8ded6291f Add PostThreadContext, cache mutator for threadgates on threads, pipe it through 2025-10-10 16:20:42 -05:00
Eric Bailey 761fa0bde9 Remove remaining usages of old post thread query 2025-10-10 11:07:31 -05:00
Eric Bailey 5bf1141b55 Add validation to threadgate records, check for max hidden replies (#9178) 2025-10-10 10:03:47 -05:00
pfrazee e1ee85622d Nightly source-language update 2025-10-10 02:36:26 +00:00
easrng b1dc5179b6 reply button in feeds opens thread (#9143)
* the reply button should open the replies to a post, not present a text input for you to enter your Big Thought into. i think a lot of people don’t even realize that the point has already been written

* Feature gate, fix event

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-10-09 20:29:50 -05:00
Samuel Newman 96d77841e0 Fix language prompt text wrap issue (#9175) 2025-10-09 17:18:24 +03:00
Alex Benzer b38edc52b8 Auto-select search results tab (#9159)
When the user clicks the search button next to "Discover New Feeds" or "Suggested Accounts" on the Explore page, we'll auto-select the respective search results tab (feeds or users).
2025-10-09 05:02:38 -07:00
jim d68fc78e93 Merge pull request #9167 from internet-development/elijaharita/structured-data-name-fallback
fix: show both DisplayName and Handle in google structured data (or only Handle if DisplayName not set)
2025-10-08 14:38:07 -07:00
Elijah Seed-Arita 028ed2f8e2 fix: copy-paste mistake and more idiomatic structured data format 2025-10-08 10:45:30 -07:00
Elijah Seed-Arita 67049b98c9 fix: show both DisplayName and Handle in google structured data (or only Handle if DisplayName not set) 2025-10-08 10:29:44 -07:00
jim 07bcf7460a Merge pull request #9153 from bluesky-social/hailey/self-labels-check-embed
check for self labels when determining if we should hide embeds
2025-10-07 17:14:49 -07:00
Samuel Newman 54f70a576a [16KB] use 16kb-compatible fork of react-native-mmkv (#9150)
* use 16kb-compatible fork of react-native-mmkv

* avoid using package alias

* specify ndk version

* Revert "specify ndk version"

This reverts commit 577393518e.

* add compiler flags to mmkv build (bump version)

* move fork to bsky repo/npm org
2025-10-07 08:06:29 -07:00
Hailey 4a9185b0f5 nit 2025-10-06 20:41:04 -07:00
Hailey 1354bf987d check for self labels when determining if we should hide embeds 2025-10-06 20:39:43 -07:00
Elijah Seed-Arita 7f4e302b85 feat: hide embeds for moderated posts (#9152) 2025-10-06 19:34:26 -07:00
Samuel Newman 5e36992fba patch delete audio compression for 16kb compat (#9149) 2025-10-06 07:42:53 -07:00
Eduardo Rosendo 42a427cfd6 feat(link-meta): Tweaks getLinkMeta to whitelist trusted hostnames (#9139) 2025-10-06 16:36:33 +03:00
pfrazee 0c0c21a0aa Nightly source-language update 2025-10-04 02:30:44 +00:00
Samuel Newman f170313f70 patch sonner-native to remove exit animations (#9092) 2025-10-03 13:08:05 -07:00
Samuel Newman c8d0662690 remove gate (#9130) 2025-10-03 12:44:43 -07:00
Samuel Newman cf4cce2225 Embed testbed with tweaks (#9115)
* Nightly source-language update

* add: test embed page for bskyembeds

* run prettier

* wip

* feat: use local dev for bskyembed test.html

* feat: commit generated files which i'm not sure about

* chore: add basic readme to bskyembed

* Revert "feat: commit generated files which i'm not sure about"

This reverts commit b891efdaa1fd7ddd4417bfd5267e861a25a93b0e.

* chore: revert change to bskyembed/.eslintrc.cjs

* chore: remove generated file from bskyweb/static

* chore: revert messages.po changes

* add dev-snippet command, add dev router

* Update README.md

* Update bskyembed/test.html

* change IS_DEV check to allow localhost

---------

Co-authored-by: APiligrim <28320272+APiligrim@users.noreply.github.com>
Co-authored-by: Anastasiya Uraleva <anastasiya@Anastasiyas-MacBook-Pro.local>
Co-authored-by: Anastasiya Uraleva <anastasiya@Mac.localdomain>
Co-authored-by: Elijah Seed-Arita <elijaharita@gmail.com>
2025-10-03 12:15:26 -07:00
Samuel Newman 0e9e705d99 fix android onboarding dev crash (#9132) 2025-10-03 12:07:21 -07:00
Chenyu 12e09a9bd1 starter pack metrics (#9116)
* starter pack metrics

* emit starterpack:add/remove user event with starterpack uri in starter pack dialog

* rename metadata field
2025-10-03 10:17:59 -07:00
dan 885356256e [Fix Logouts] Persist accounts synchronously (#9109)
* Make persisting synchronous

* Initialize later so persisted is filled
2025-10-03 11:02:52 -05:00
pfrazee b68f80050c Nightly source-language update 2025-10-03 02:34:02 +00:00
dan 1354fd2661 [Fix Logouts] Remove buggy hackfix (#9108)
* Rip out the network hack in favor of bluesky-social/atproto#4238

* Bump api pkg

* Debug code

* Revert "Debug code"

This reverts commit 38445f31f4.

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-10-02 14:01:10 -05:00
Samuel Newman 77bf49f212 Tighten up eslint rule to catch unused const {_} = useLingui() (#9122)
* tighten eslint config to catch unused `useLingui`s

* fix now-invalid uses of _

* remove unused function that produces a ton of eslint warnings

* upgrade react compiler plugin
2025-10-02 09:59:05 -07:00
Eric Bailey 02b189a40e Language selection and suggestion UX improvements (#9067)
* feat: don't retain accepted language suggestion after finishing or exiting post (#8886)

* feat: don't retain accepted language suggestion after finishing or exiting post

* fix: rebase fixes

* fix: rebase fixes

* chore: lint

* Rename onChange for clarity

* Improve logic in composer

* Handle user override more explicitly

* Drill in onSelectLanguage callback into dialog too

* Fix typo

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Make text crystal clear

* Handle multiple languages

---------

Co-authored-by: Elijah Seed-Arita <elijaharita@gmail.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-10-02 09:18:52 -05:00
Daniel Holmgren 5fd52b3d30 add patent pledge link to readme (#9118) 2025-10-01 11:49:06 -07:00
Samuel Newman 1e6a44f2e8 Fix feedfeedback metrics not distinguishing which feed it's from (#9099)
* fix feedfeedback metrics being sent for all feeds

* remove `discover:` metrics
2025-10-01 06:09:12 -07:00
pfrazee 144d61ef76 Nightly source-language update 2025-10-01 02:40:10 +00:00
Eric Bailey 381feacd26 Add StackedButton component (#9086) 2025-09-30 16:18:36 -05:00
dan 5df6036c2c [Web] Fix thread jumps (#9111)
* [Web] Fix thread jumps

* Comment formatting

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-09-30 10:46:31 -05:00
Eric Bailey 3e55e52858 Button tweaks (#9106)
* Tweak button colors

* Semi bold only for tiny buttons
2025-09-30 08:40:21 -05:00
Chenyu f82a6188e6 Update admonition component (#9068)
* update admonition component

* fix linting, adominition alighment

* design tweak

* edge cases for admonition, update storybook

* Update src/components/Admonition.tsx

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* fix mobile version

* change button style

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-09-30 05:27:19 -07:00
Samuel Newman c7ce827bfe fix gap on profile (#9081) 2025-09-30 07:28:50 +03:00
pfrazee 9e0f192546 Nightly source-language update 2025-09-30 02:34:39 +00:00
Samuel Newman d3dbb94689 Catch errors on geolocation request, reduce Sentry logs (#9098) 2025-09-29 23:12:50 +03:00
Samuel Newman 031fa95715 remove root sibling library (#9097) 2025-09-29 14:31:55 -05:00
Samuel Newman ae2c9a832f don't send some "activity no longer available" errors (#9100) 2025-09-29 14:15:38 -05:00
Samuel Newman 02a25d2aa2 Log OTA errors properly (#9101)
* Log OTA errors properly

* filter out network errors
2025-09-29 14:14:39 -05:00
Samuel Newman 2b32fff1d0 Fix link crash (#9102)
* fix link crash

* fix link crash
2025-09-29 14:14:13 -05:00
dan 22bb9c599f Send inferrable interactions to third-party feeds (#9094) 2025-09-29 14:07:21 -05:00
89 changed files with 3686 additions and 1555 deletions
+2 -1
View File
@@ -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',
+2
View File
@@ -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!
+26
View File
@@ -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
+2
View File
@@ -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",
+11 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+32 -1
View File
@@ -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
View File
@@ -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==
+49 -13
View File
@@ -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
}
}
+5
View File
@@ -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 %}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+22
View File
@@ -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, {
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+10 -4
View File
@@ -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})
}
}
}
+44 -24
View File
@@ -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
View File
@@ -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>
</>
)
}
+2 -2
View File
@@ -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])
}
+4 -1
View File
@@ -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>
+1 -1
View File
@@ -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
+9 -4
View File
@@ -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})
}
}
}, [])
+4 -1
View File
@@ -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
}
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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'
+11 -11
View File
@@ -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}
-17
View File
@@ -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
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+17 -5
View File
@@ -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': {}
+3 -2
View File
@@ -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>
-2
View File
@@ -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
+4 -4
View File
@@ -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(
+4 -4
View File
@@ -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(
+4 -4
View File
@@ -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 (
<>
+20 -15
View File
@@ -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>
)
}
+23 -11
View File
@@ -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
+12 -2
View File
@@ -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',
)
}
/>
)}
+3 -1
View File
@@ -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>
))}
+61 -12
View File
@@ -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)
+1 -1
View File
@@ -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>
-6
View File
@@ -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
}
-2
View File
@@ -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
View File
@@ -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',
])
+1 -1
View File
@@ -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.
*/
+4
View File
@@ -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)
}
+5 -5
View File
@@ -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})
-631
View File
@@ -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)
},
}
}
+63 -47
View File
@@ -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>
)
}
+24 -15
View File
@@ -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,
])
}
+51 -1
View File
@@ -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],
)
}
-8
View File
@@ -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
View File
@@ -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(
() => ({
+1 -1
View File
@@ -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 -1
View File
@@ -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'
+65 -15
View File
@@ -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
+1 -1
View File
@@ -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()
+37 -19
View File
@@ -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 = () => {
+6 -1
View File
@@ -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)
-2
View File
@@ -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(() => {
+74 -3
View File
@@ -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 &rarr; Privacy and Security
</InlineLinkText>
.
</Trans>
</AdmonitionText>
</AdmonitionContent>
</AdmonitionRow>
</AdmonitionOuter>
</View>
)
}
+25
View File
@@ -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',
+1 -4
View File
@@ -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} />
)}
+53 -22
View File
@@ -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"