Compare commits

...

73 Commits

Author SHA1 Message Date
Eric Bailey 4e087f6190 Add A/A test 2026-02-20 13:03:29 -06:00
Samuel Newman f3cc850b62 Fix up typescript config (#9910) 2026-02-20 18:07:17 +00:00
Samuel Newman 35f9ed82b9 Fix string concat bug with trending topics muteword moderation (#9914) 2026-02-20 11:29:08 -06:00
Samuel Newman 8c4fc087f8 Add ability to convert starter pack to list (#9675)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Samuel Newman <mozzius@users.noreply.github.com>
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2026-02-20 11:27:55 -06:00
Samuel Newman f3ddc074a6 Fork MCEmojiPicker (#9763) 2026-02-20 10:39:48 -06:00
Spence Pope 5e0ffc0917 [APP-1809] add cleanup button for unavailable moderation services (#9901)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-20 10:23:01 -06:00
Samuel Newman 3fae42c077 Remove hardcoded warning color, add yellow and pink to ALF palette (#9911) 2026-02-20 10:17:11 -06:00
Sylwia 80c0aa35e2 Add CORS headers to /oembed API (#9845) 2026-02-19 22:07:49 -08:00
Eric Bailey 4e3c3e9905 [APP-1805] Analytics tweaks and tracking callbacks (#9861) 2026-02-19 15:31:35 -06:00
pfrazee 1565e071c1 Nightly source-language update 2026-02-19 03:10:25 +00:00
Samuel Newman c906fea77a Fix webpack dev server freezing after repeated refreshes (#9865) 2026-02-18 11:03:23 -08:00
Samuel Newman 775a041b71 Add chat data export to Export My Data dialog (#9900)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:01:58 -08:00
Samuel Newman 98d17fd950 Fix native-only function being called on web (#9904) 2026-02-18 09:46:45 -08:00
Samuel Newman 41b3742365 Dynamically load date-fns locale data (#9535) 2026-02-18 11:30:32 -06:00
Samuel Newman 512ce3d5d0 ALF content language dialog (#9471)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-18 08:57:32 -08:00
Samuel Newman bdf143ce79 Restructure notification/linking handling to use synchronous APIs (#9497)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-18 08:20:39 -08:00
Samuel Newman adc079e095 Get rid of Sentry Replay from web bundle (#9534) 2026-02-18 10:20:05 -06:00
DS Boyce 74330500de Improve error handling on Explore page (#9890) 2026-02-18 09:57:31 -06:00
DS Boyce ac939dc84e Update onboarding user suggestions with new endpoint (#9872) 2026-02-18 09:35:00 -06:00
Samuel Newman b12d703353 Enable profile hover card on quoted post authors (#9896)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 06:35:38 -08:00
DS Boyce c188fd4f04 Log onboarding errors for suggested accounts (#9892) 2026-02-18 00:18:22 -08:00
Samuel Newman a1cda126e6 ALF feed error screen (#9895) 2026-02-18 00:16:12 -08:00
pfrazee ae115ca17e Nightly source-language update 2026-02-18 03:10:48 +00:00
Spence Pope 197c069416 Clear link and gif caches to avoid pointing at missing files (#9898)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:39:34 -05:00
pfrazee e67d81f269 Nightly source-language update 2026-02-17 03:09:32 +00:00
DS Boyce 7c08bba6cf Use Prompt.Action for delete account buttons (#9889) 2026-02-16 16:23:27 -08:00
DS Boyce 29bc0f1372 Add optional props to Prompt.Action (#9887) 2026-02-16 13:29:25 -08:00
surfdude29 82f42e734c Wrap interaction counts with Trans to add translation flexibility (#9036) 2026-02-16 21:12:26 +00:00
surfdude29 3b9f0b0a68 Add plural formatting for several strings (#9576) 2026-02-16 21:10:57 +00:00
Samuel Newman a6ba0810b7 Be more descriptive with video errors, log less (#9886) 2026-02-16 21:09:49 +00:00
DS Boyce 49253dbd88 Refactor delete account dialog using ALF (#9863) 2026-02-16 12:00:46 -08:00
Eric Bailey cc2255cc49 [APP-1776] Refactor Live Now config, reorg files (#9871) 2026-02-16 11:44:22 -06:00
Eric Bailey 8ee9709fbb [APP-1764] Improve Age Assurance copy (#9885)
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2026-02-16 11:03:59 -06:00
Samuel Newman 90d650def2 Use video thumbnail as og:image for posts with video embeds (#9877) 2026-02-16 15:35:29 +00:00
Samuel Newman 79e27e303b Fix missing pixel row in text rendering on iOS (#9882)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:41:35 -08:00
Spence Pope 6be7751769 have back button dismiss lightbox on web (#9874) 2026-02-16 08:39:53 -05:00
Samuel Newman 9b830fd425 Use scroll position to gate post-created feed refresh (#9881)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:39:29 -08:00
Samuel Newman 4ebeb94b41 Add Renew menu for expired mute words (#9883)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:30:07 -08:00
Samuel Newman b6b364700e Make profile description text selectable (#9884)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:29:59 -08:00
pfrazee 95f9cc1ed5 Nightly source-language update 2026-02-16 03:11:39 +00:00
surfdude29 06fcea988e 'Log in' to 'Sign in' (#9875) 2026-02-15 19:02:01 +00:00
pfrazee b166d395d9 Nightly source-language update 2026-02-15 03:11:50 +00:00
Samuel Newman 1228306309 Fix quote embed clickability in composer on web (#9876) 2026-02-14 22:29:59 +00:00
pfrazee bc001168f3 Nightly source-language update 2026-02-14 03:05:51 +00:00
Spence Pope b4cb4c4d13 Revert "Add --what-to-test to iOS submissions (#9856)" (#9873) 2026-02-13 13:24:06 -08:00
Samuel Newman 2e34f965fc [Auth rework] Add a header to login flow (#9368) 2026-02-13 20:15:36 +00:00
Spence Pope 9cdfb40ce7 dedupe trending topics (#9870) 2026-02-13 13:03:36 -05:00
Samuel Newman c83dddce57 Bandcamp embed (#9445) 2026-02-13 13:30:10 +00:00
pfrazee 795428971d Nightly source-language update 2026-02-13 03:12:46 +00:00
Spence Pope 82fea6da7d [APP-1846] Remove trending post counts until we can improve accuracy (#9867) 2026-02-12 19:34:50 +00:00
Spence Pope 2f156bb906 [APP-1833] Handle errors on saving a draft over char limit (#9850)
* show composer error if draft over char limit

* adjust cancel discarding if over the limit

* use richtext for validation

* don't allow saving a draft that can't actually be saved

* account for 1k chars

* update discard sheet

* show composer error if draft over char limit

* adjust cancel discarding if over the limit

* use richtext for validation

* don't allow saving a draft that can't actually be saved

* account for 1k chars

* update discard sheet

* pr comment fixes

* pluralization
2026-02-12 09:24:38 -06:00
Samuel Newman c46219bc4d Fix keyboard flicker when selecting a draft in composer (#9864) 2026-02-12 15:07:26 +00:00
pfrazee 6d68a5bd21 Nightly source-language update 2026-02-12 03:12:33 +00:00
surfdude29 cbac380c96 Add profile error messages to cleanError (#9842) 2026-02-11 19:55:54 -06:00
Samuel Newman 8c4820b1f1 [Web] Fix splash flicker and add animation (#9860)
* manually hide non-React splash logo

* tweak comment

* animate out splash
2026-02-11 19:52:22 -06:00
Samuel Newman 93d2f3a8db Germ DM button (#9848)
* germ link (wip)

* add todos

* just yeet declaration for now

* ensure not proxied

* allow undo delete

* tweak styles

* ignore unknown values

* tweak styles

* fix boolean logic

* skip IAB

* fix mutationFn

* add link warning interstitial

* Logging and metrics

* Little more error handling

* Update copy

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2026-02-11 17:51:19 -06:00
Eric Bailey 3a9526d55e Bump API SDK (#9862)
* Bump API SDK

* Bump dev-env
2026-02-11 17:41:34 -06:00
Spence Pope 830f305d2d Merge pull request #9823 from bluesky-social/app-1788
[APP-1788] update design for stacked avatars
2026-02-11 15:57:07 -05:00
DS Boyce 8a088e8710 Merge pull request #9853 from bluesky-social/d/profile-screen
Use Tanstack Query isPending over isLoading
2026-02-11 10:27:13 -08:00
Spence Pope 8cbe3985fa Update src/components/ProgressGuide/List.tsx
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-11 13:11:30 -05:00
Spence Pope 1ec70420e6 Update src/components/ProgressGuide/List.tsx
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-11 13:11:24 -05:00
Spence Pope 63fbf577d9 Update src/components/ProgressGuide/List.tsx
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-02-11 13:11:15 -05:00
DS Boyce 355868e01e Address feedback and lint warnings 2026-02-11 09:46:47 -08:00
Samuel Newman 37e5a0f7a4 Clean up starter packs dialog styles + add optimistic updates (#9469) 2026-02-11 17:46:28 +00:00
DS Boyce 4356ff0040 Merge branch 'main' into d/profile-screen 2026-02-11 09:43:55 -08:00
Samuel Newman 4eca13941a Fix GIFs breaking mute state/pausing phone audio (#9859)
* fix operator precedence mistake

* ensure gifs can't change the global mute state
2026-02-11 08:51:23 -08:00
Samuel Newman 8580e4a00e absolutely position header, set a headerheight (#9858) 2026-02-11 07:22:13 -08:00
Samuel Newman 8581285d34 simplify some conditionals 2026-02-11 16:20:48 +02:00
Samuel Newman 57997d6d16 Add --what-to-test to iOS submissions (#9856)
Sets the "What to test" field in TestFlight to distinguish between
TestFlight and Production builds.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 06:01:27 -08:00
DS Boyce 5459857112 Use Tanstack Query status over isPending 2026-02-10 13:27:18 -08:00
vineyardbovines 3a28a07081 Merge branch 'main' into app-1788 2026-02-05 08:59:22 -05:00
vineyardbovines a57faedbae update icon 2026-02-05 08:51:53 -05:00
vineyardbovines a148b2477b update design for stacked avatars 2026-02-04 15:06:42 -05:00
160 changed files with 5165 additions and 3386 deletions
+3
View File
@@ -46,3 +46,6 @@ GEOLOCATION_DEV_URL=
# live-events web worker URL
LIVE_EVENTS_DEV_URL=
# app-config web worker URL
APP_CONFIG_DEV_URL=
+1
View File
@@ -24,6 +24,7 @@ yarn android # Run on Android
yarn ios # Run on iOS
# Testing & Quality
# IMPORTANT: Always use these yarn scripts, never call the underlying tools directly
yarn test # Run Jest tests
yarn lint # Run ESLint
yarn typecheck # Run TypeScript type checking
+24
View File
@@ -437,6 +437,13 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://www.flickr.com/groups/898944@N23/',
'https://www.flickr.com/groups',
'https://maxblansjaar.bandcamp.com/album/false-comforts',
'https://grmnygrmny.bandcamp.com/track/fluid',
'https://sufjanstevens.bandcamp.com/',
'https://sufjanstevens.bandcamp.com',
'https://bandcamp.com/',
'https://bandcamp.com',
]
const outputs = [
@@ -815,6 +822,23 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined,
undefined,
{
type: 'bandcamp_album',
source: 'bandcamp',
playerUri:
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fmaxblansjaar.bandcamp.com%2Falbum%2Ffalse-comforts/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
},
{
type: 'bandcamp_track',
source: 'bandcamp',
playerUri:
'https://bandcamp.com/EmbeddedPlayer/url=https%3A%2F%2Fgrmnygrmny.bandcamp.com%2Ftrack%2Ffluid/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/',
},
undefined,
undefined,
undefined,
undefined,
]
it('correctly grabs the correct id from uri', () => {
+7
View File
@@ -256,6 +256,13 @@ module.exports = function (_config) {
deploymentTarget: '15.1',
buildReactNativeFromSource: true,
ccacheEnabled: IS_DEV,
extraPods: [
{
name: 'MCEmojiPicker',
git: 'https://github.com/bluesky-social/MCEmojiPicker.git',
branch: 'main',
},
],
},
android: {
compileSdkVersion: 35,
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867Zm0 10.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z"/></svg>

After

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+21 -14
View File
@@ -6,21 +6,26 @@ To build the SPA bundle (`bundle.web.js`), first get a JavaScript development
environment set up. Either follow the top-level README, or something quick
like:
# install nodejs
nvm install
nvm use
npm install --global yarn
```bash
# install nodejs
nvm install
nvm use
npm install --global yarn
# setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile
# setup tools and deps (in top level of this repo)
yarn install --frozen-lockfile
# run yarn web dev server, if you wanted
yarn web
# run yarn web dev server, if you wanted
yarn web
```
Then build and copy over the big 'ol `bundle.web.js` file:
# in the top level of this repo
yarn build-web
```bash
# in the top level of this repo
yarn build-web
```
### Golang Daemon
@@ -28,11 +33,13 @@ Install golang. We generally develop against the current stable release of the l
In this directory (`bskyweb/`):
# re-build and run daemon
go run ./cmd/bskyweb serve
```bash
# re-build and run daemon
go run ./cmd/bskyweb serve
# build and output a binary
go build -o bskyweb ./cmd/bskyweb/
# build and output a binary
go build -o bskyweb ./cmd/bskyweb/
```
The easiest way to configure the daemon is to copy `example.env` to `.env` and
fill in auth values there.
+13 -2
View File
@@ -574,7 +574,10 @@ func (srv *Server) WebPost(c echo.Context) error {
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
hasVideo := postView.Embed.EmbedVideo_View != nil
hasMedia := postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil
hasMediaImages := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil
hasMediaVideo := hasMedia && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil
if hasImages {
var thumbUrls []string
@@ -582,12 +585,20 @@ func (srv *Server) WebPost(c echo.Context) error {
thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
}
data["imgThumbUrls"] = thumbUrls
} else if hasMedia {
} else if hasVideo {
if postView.Embed.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedVideo_View.Thumbnail}
}
} else if hasMediaImages {
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
} else if hasMediaVideo {
if postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail != nil {
data["imgThumbUrls"] = []string{*postView.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View.Thumbnail}
}
}
}
+8 -1
View File
@@ -178,6 +178,13 @@ func serve(cctx *cli.Context) error {
return http.FS(fsys)
}())
// Create CORS middleware for oembed
oembedCORS := middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
})
e.GET("/robots.txt", echo.WrapHandler(staticHandler))
e.GET("/ips-v4", echo.WrapHandler(staticHandler))
e.GET("/ips-v6", echo.WrapHandler(staticHandler))
@@ -205,7 +212,7 @@ func serve(cctx *cli.Context) error {
e.GET("/", server.WebHome)
e.GET("/iframe-resize.js", echo.WrapHandler(staticHandler))
e.GET("/embed.js", echo.WrapHandler(staticHandler))
e.GET("/oembed", server.WebOEmbed)
e.GET("/oembed", server.WebOEmbed, oembedCORS)
e.GET("/embed/:did/app.bsky.feed.post/:rkey", server.WebPostEmbed)
// Start the server.
+4 -4
View File
@@ -148,11 +148,11 @@
</head>
<body>
{%- block body_all %}
<div id="splash">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
</div>
<div id="root">
<div id="splash">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 57"><path fill="#006AFF" d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"/></svg>
</div>
</div>
<noscript>
@@ -16,8 +16,10 @@ import {
type BottomSheetState,
type BottomSheetViewProps,
} from './BottomSheet.types'
import {BottomSheetPortalProvider} from './BottomSheetPortal'
import {Context as PortalContext} from './BottomSheetPortal'
import {
BottomSheetPortalProvider,
Context as PortalContext,
} from './BottomSheetPortal'
const screenHeight = Dimensions.get('screen').height
@@ -14,7 +14,7 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'MCEmojiPicker', '1.2.3'
s.dependency 'MCEmojiPicker'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
+7 -6
View File
@@ -73,10 +73,10 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.18.20",
"@atproto/api": "^0.18.21",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.6",
"@bsky.app/alf": "^0.1.7",
"@bsky.app/expo-image-crop-tool": "^0.5.0",
"@bsky.app/react-native-mmkv": "2.12.5",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -93,7 +93,8 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@growthbook/growthbook-react": "^1.6.2",
"@growthbook/growthbook": "^1.6.5",
"@growthbook/growthbook-react": "^1.6.5",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
@@ -229,7 +230,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.208",
"@atproto/dev-env": "^0.3.209",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
@@ -281,8 +282,8 @@
"svgo": "^3.3.2",
"ts-node": "^10.9.1",
"ts-plugin-sort-import-suggestions": "^1.0.4",
"typescript": "^5.9.2",
"typescript-eslint": "^8.53.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.0",
"webpack-bundle-analyzer": "^4.10.1"
},
"resolutions": {
+5 -3
View File
@@ -1,11 +1,11 @@
diff --git a/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift b/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
index c34ba71..13d576a 100644
index c34ba71..3602856 100644
--- a/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
+++ b/node_modules/react-native-uitextview/ios/RNUITextViewShadow.swift
@@ -159,13 +159,23 @@ class RNUITextViewShadow: RCTShadowView {
@@ -159,13 +159,25 @@ class RNUITextViewShadow: RCTShadowView {
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
- var totalLines = self.lineHeight == 0.0 ? 0 : Int(ceil(textSize.height / self.lineHeight))
-
- if self.numberOfLines != 0, totalLines > self.numberOfLines {
@@ -27,6 +27,8 @@ index c34ba71..13d576a 100644
}
- self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
+ finalHeight = ceil(finalHeight)
+
+ self.frameSize = CGSize(width: CGFloat(maxWidth), height: finalHeight)
return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
}
+39 -32
View File
@@ -22,6 +22,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {
prefetchAppConfig,
Provider as AppConfigProvider,
} from '#/state/appConfig'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
@@ -103,6 +107,7 @@ if (IS_ANDROID) {
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -228,38 +233,40 @@ function App() {
*/
return (
<Geo.Provider>
<A11yProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
<AppConfigProvider>
<A11yProvider>
<KeyboardControllerProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</KeyboardControllerProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider>
)
}
+93 -87
View File
@@ -2,7 +2,7 @@ import '#/logger/sentry/setup' // must be near top
import '#/view/icons'
import './style.css'
import React, {useEffect, useState} from 'react'
import {Fragment, useEffect, useState} from 'react'
import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -13,6 +13,10 @@ import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {
prefetchAppConfig,
Provider as AppConfigProvider,
} from '#/state/appConfig'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {Provider as EmailVerificationProvider} from '#/state/email-verification'
@@ -79,9 +83,10 @@ import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottom
Geo.resolve()
prefetchAgeAssuranceConfig()
prefetchLiveEvents()
prefetchAppConfig()
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
const [isReady, setIsReady] = useState(false)
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
@@ -116,68 +121,67 @@ function InnerApp() {
})
}, [_])
// wait for session to resume
if (!isReady || !hasCheckedReferrer) return <Splash isReady />
return (
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<ContextMenuProvider>
<VideoVolumeProvider>
<ActiveVideoProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</React.Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
<Splash isReady={isReady && hasCheckedReferrer}>
<VideoVolumeProvider>
<ActiveVideoProvider>
<Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</AnalyticsFeaturesContext>
</Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
</Splash>
</ContextMenuProvider>
</ThemeProvider>
</Alf>
@@ -187,14 +191,14 @@ function InnerApp() {
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
useEffect(() => {
Promise.all([initPersistedState(), Geo.resolve(), setupDeviceId]).then(() =>
setReady(true),
)
}, [])
if (!isReady) {
return <Splash isReady />
return null
}
/*
@@ -203,31 +207,33 @@ function App() {
*/
return (
<Geo.Provider>
<A11yProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
<AppConfigProvider>
<A11yProvider>
<OnboardingProvider>
<AnalyticsContext>
<SessionProvider>
<PrefsStateProvider>
<I18nProvider>
<ShellStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</ShellStateProvider>
</I18nProvider>
</PrefsStateProvider>
</SessionProvider>
</AnalyticsContext>
</OnboardingProvider>
</A11yProvider>
</AppConfigProvider>
</Geo.Provider>
)
}
+23 -23
View File
@@ -1,5 +1,5 @@
import {type JSX, useCallback, useRef} from 'react'
import {Linking} from 'react-native'
import * as Linking from 'expo-linking'
import * as Notifications from 'expo-notifications'
import {i18n, type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/macro'
@@ -874,11 +874,6 @@ const LINKING = {
},
} satisfies LinkingOptions<AllNavigatorParams>
/**
* Used to ensure we don't handle the same notification twice
*/
let lastHandledNotificationDateDedupe: number | undefined
function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const ax = useAnalytics()
const notyLogger = ax.logger.useChild(ax.logger.Context.Notifications)
@@ -889,6 +884,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
const previousScreen = useRef<string | undefined>(undefined)
const emailDialogControl = useEmailDialogControl()
const closeAllActiveElements = useCloseAllActiveElements()
const linkingUrl = Linking.useLinkingURL()
/**
* Handle navigation to a conversation, or prepares for account switch.
@@ -923,29 +919,30 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
},
)
async function handlePushNotificationEntry() {
function handlePushNotificationEntry() {
if (!IS_NATIVE) return
// deep links take precedence - on android,
// getLastNotificationResponseAsync returns a "notification"
// that is actually a deep link. avoid handling it twice -sfn
if (await Linking.getInitialURL()) {
return
}
// intent urls are handled by `useIntentHandler`
if (linkingUrl) return
/**
* The notification that caused the app to open, if applicable
*/
const response = await Notifications.getLastNotificationResponseAsync()
const notificationResponse = Notifications.getLastNotificationResponse()
if (response) {
notyLogger.debug(`handlePushNotificationEntry: response`, {response})
if (notificationResponse) {
notyLogger.debug(`handlePushNotificationEntry: response`, {
response: notificationResponse,
})
if (response.notification.date === lastHandledNotificationDateDedupe)
return
lastHandledNotificationDateDedupe = response.notification.date
// Clear the last notification response to ensure it's not used again
try {
Notifications.clearLastNotificationResponse()
} catch (error) {
notyLogger.error(
`handlePushNotificationEntry: error clearing notification response`,
{error},
)
}
const payload = getNotificationPayload(response.notification)
const payload = getNotificationPayload(notificationResponse.notification)
if (payload) {
ax.metric('notifications:openApp', {
@@ -1011,6 +1008,9 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
})
}
}
// temp, just testing
void ax.features.enabled(ax.features.AATest)
})
return (
+82 -14
View File
@@ -4,26 +4,94 @@
* the app is ready to go.
*/
import {View} from 'react-native'
import {useEffect, useRef, useState} from 'react'
import Svg, {Path} from 'react-native-svg'
import {atoms as a} from '#/alf'
import {atoms as a, flatten} from '#/alf'
const size = 100
const ratio = 57 / 64
export function Splash() {
export function Splash({
isReady,
children,
}: React.PropsWithChildren<{
isReady: boolean
}>) {
const [isAnimationComplete, setIsAnimationComplete] = useState(false)
const splashRef = useRef<HTMLDivElement>(null)
// hide the static one that's baked into the HTML - gets replaced by our React version below
useEffect(() => {
// double rAF ensures that the React version gets painted first
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const splash = document.getElementById('splash')
if (splash) {
splash.remove()
}
})
})
}, [])
// when ready, we fade/scale out
useEffect(() => {
if (!isReady) return
const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches
const node = splashRef.current
if (!node || reduceMotion) {
setIsAnimationComplete(true)
return
}
const animation = node.animate(
[
{opacity: 1, transform: 'scale(1)'},
{opacity: 0, transform: 'scale(1.5)'},
],
{
duration: 300,
easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
fill: 'forwards',
},
)
animation.onfinish = () => setIsAnimationComplete(true)
return () => {
animation.cancel()
}
}, [isReady])
return (
<View style={[a.fixed, a.inset_0, a.align_center, a.justify_center]}>
<Svg
fill="none"
viewBox="0 0 64 57"
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
<Path
fill="#006AFF"
d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"
/>
</Svg>
</View>
<>
{isReady && children}
{!isAnimationComplete && (
<div
ref={splashRef}
style={flatten([
a.fixed,
a.inset_0,
a.flex,
a.align_center,
a.justify_center,
// to compensate for the `top: -50px` below
{transformOrigin: 'center calc(50% - 50px)'},
])}>
<Svg
fill="none"
viewBox="0 0 64 57"
style={[a.relative, {width: size, height: size * ratio, top: -50}]}>
<Path
fill="#006AFF"
d="M13.873 3.805C21.21 9.332 29.103 20.537 32 26.55v15.882c0-.338-.13.044-.41.867-1.512 4.456-7.418 21.847-20.923 7.944-7.111-7.32-3.819-14.64 9.125-16.85-7.405 1.264-15.73-.825-18.014-9.015C1.12 23.022 0 8.51 0 6.55 0-3.268 8.579-.182 13.873 3.805ZM50.127 3.805C42.79 9.332 34.897 20.537 32 26.55v15.882c0-.338.13.044.41.867 1.512 4.456 7.418 21.847 20.923 7.944 7.111-7.32 3.819-14.64-9.125-16.85 7.405 1.264 15.73-.825 18.014-9.015C62.88 23.022 64 8.51 64 6.55c0-9.818-8.578-6.732-13.873-2.745Z"
/>
</Svg>
</div>
)}
</>
)
}
+16
View File
@@ -108,6 +108,22 @@ export const atoms = {
animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`,
}),
/**
* Visually hidden but available to screen readers (web).
* Use for live regions or off-screen labels (e.g. "Image 1 of 3").
*/
sr_only: web({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0,0,0,0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}),
/**
* {@link Layout.SCROLLBAR_OFFSET}
*/
+2
View File
@@ -9,4 +9,6 @@ export enum Features {
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
AATest = 'aa-test',
}
+10
View File
@@ -18,6 +18,16 @@ export function getInitialSessionId() {
return sessionId
}
/**
* Gets the current session ID. Freshness depends on `useSessionId` being
* mounted, which handles refreshing this value between foreground/background
* transitions. Since that's mounted in `analytics/index.tsx`, this value can
* generally be trusted to be up to date.
*/
export function getSessionId() {
return device.get(['nativeSessionId'])
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
+10
View File
@@ -21,6 +21,16 @@ export function getInitialSessionId() {
return sessionId
}
/**
* Gets the current session ID. Freshness depends on `useSessionId` being
* mounted, which handles refreshing this value between foreground/background
* transitions. Since that's mounted in `analytics/index.tsx`, this value can
* generally be trusted to be up to date.
*/
export function getSessionId() {
return window.sessionStorage.getItem(SESSION_ID_KEY)
}
export function useSessionId() {
const [id, setId] = useState(() => sessionId)
+20 -15
View File
@@ -1,4 +1,4 @@
import {createContext, useContext, useEffect, useMemo} from 'react'
import {createContext, useContext, useMemo} from 'react'
import {Platform} from 'react-native'
import {Logger} from '#/logger'
@@ -24,7 +24,7 @@ import {
import {type Metrics, metrics} from '#/analytics/metrics'
import * as refParams from '#/analytics/misc/refParams'
import * as env from '#/env'
import {useGeolocation} from '#/geolocation'
import {useGeolocationServiceResponse} from '#/geolocation/service'
import {device} from '#/storage'
export * as utils from '#/analytics/utils'
@@ -104,7 +104,7 @@ const Context = createContext<AnalyticsBaseContextType>({
referrerSrc: refParams.src,
referrerUrl: refParams.url,
},
geolocation: device.get(['mergedGeolocation']) || {
geolocation: device.get(['geolocationServiceResponse']) || {
countryCode: '',
regionCode: '',
},
@@ -137,7 +137,7 @@ export function AnalyticsContext({
}
}
const sessionId = useSessionId()
const geolocation = useGeolocation()
const geolocation = useGeolocationServiceResponse()
const parentContext = useContext(Context)
const childContext = useMemo(() => {
const combinedMetadata = {
@@ -181,20 +181,25 @@ export function AnalyticsFeaturesContext({
const parentContext = useContext(Context)
/**
* Side-effect: we need to synchronously set this during the
* same render cycle. It does not trigger a re-render, it just
* sets properties on the singleton GrowthBook instance.
* Side-effects: we need to synchronously set these during the same render
* cycle. These calls do not trigger re-renders, they just set properties on
* the singleton GrowthBook instance.
*/
setAttributes(parentContext.metadata)
useEffect(() => {
feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
}, [parentContext.metric])
})
feats.setFeatureUsageCallback((feature, result) => {
parentContext.metric('feature:viewed', {
featureId: feature,
featureResultValue: result.value,
experimentId: result.experiment?.key,
variationId: result.experimentResult?.key,
})
})
const childContext = useMemo<AnalyticsContextType>(() => {
return {
+3 -1
View File
@@ -4,6 +4,7 @@ import {Logger} from '#/logger'
import * as env from '#/env'
type Event<M extends Record<string, any>> = {
source: 'app'
time: number
event: keyof M
payload: M[keyof M]
@@ -43,7 +44,8 @@ export class MetricsClient<M extends Record<string, any>> {
) {
this.start()
const e = {
const e: Event<M> = {
source: 'app',
time: Date.now(),
event,
payload,
+17
View File
@@ -15,6 +15,14 @@ export type Events = {
experimentId: string
variationId: string
}
'feature:viewed': {
featureId: string
featureResultValue: unknown
/** Only available if feature has experiment rules applied */
experimentId?: string
/** Only available if feature has experiment rules applied */
variationId?: string
}
'account:loggedIn': {
logContext:
@@ -563,6 +571,10 @@ export type Events = {
profilesCount: number
feedsCount: number
}
'starterPack:convertToList': {
starterPack: string
memberCount: number
}
'starterPack:ctaPress': {
starterPack: string
}
@@ -875,4 +887,9 @@ export type Events = {
'liveEvents:unhideAllFeedBanners': {
context: LiveEventFeedMetricContext
}
'profile:associated:germ:click-to-chat': {}
'profile:associated:germ:click-self-info': {}
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
}
+1 -1
View File
@@ -4,7 +4,6 @@ import {type AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {isJwtExpired} from '#/lib/jwt'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -19,6 +18,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/
import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {useActorStatus} from '#/features/liveNow'
export function AccountList({
onSelectAccount,
+2 -6
View File
@@ -9,10 +9,6 @@ import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons
import {Text as BaseText, type TextProps} from '#/components/Typography'
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSadIcon} from './icons/Emoji'
export const colors = {
warning: '#FFC404',
}
type Context = {
type: 'info' | 'tip' | 'warning' | 'error' | 'apology'
}
@@ -35,7 +31,7 @@ export function Icon() {
const fill = {
info: t.atoms.text_contrast_medium.color,
tip: t.palette.primary_500,
warning: colors.warning,
warning: t.palette.yellow,
error: t.palette.negative_500,
apology: t.atoms.text_contrast_medium.color,
}[type]
@@ -110,7 +106,7 @@ export function Outer({
const borderColor = {
info: t.atoms.border_contrast_high.borderColor,
tip: t.palette.primary_500,
warning: colors.warning,
warning: t.palette.yellow,
error: t.palette.negative_500,
apology: t.atoms.border_contrast_high.borderColor,
}[type]
+8 -1
View File
@@ -6,7 +6,13 @@ import {useResolveLinkQuery} from '#/state/queries/resolve-link'
import {atoms as a, useTheme} from '#/alf'
import {QuoteEmbed} from '#/components/Post/Embed'
export function LazyQuoteEmbed({uri}: {uri: string}) {
export function LazyQuoteEmbed({
uri,
linkDisabled,
}: {
uri: string
linkDisabled?: boolean
}) {
const t = useTheme()
const {data} = useResolveLinkQuery(uri)
@@ -21,6 +27,7 @@ export function LazyQuoteEmbed({uri}: {uri: string}) {
type: 'post',
view,
}}
linkDisabled={linkDisabled}
/>
) : (
<View
@@ -58,7 +58,7 @@ export function VideoEmbedInnerNative({
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
beginMuted={isGif || autoplayDisabled ? false : muted}
beginMuted={isGif || (autoplayDisabled ? false : muted)}
style={[a.rounded_sm]}
onActiveChange={e => {
setIsActive(e.nativeEvent.isActive)
@@ -67,7 +67,9 @@ export function VideoEmbedInnerNative({
setIsLoading(e.nativeEvent.isLoading)
}}
onMutedChange={e => {
setMuted(e.nativeEvent.isMuted)
if (!isGif) {
setMuted(e.nativeEvent.isMuted)
}
}}
onStatusChange={e => {
setStatus(e.nativeEvent.status)
@@ -72,19 +72,7 @@ export function VideoEmbedInnerWeb({
loop={loop}
/>
{embed.alt && (
<figcaption
id={figId}
style={{
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}}>
<figcaption id={figId} style={a.sr_only}>
{embed.alt}
</figcaption>
)}
+56 -45
View File
@@ -225,11 +225,13 @@ export function QuoteEmbed({
embed,
onOpen,
style,
linkDisabled,
isWithinQuote: parentIsWithinQuote,
allowNestedQuotes: parentAllowNestedQuotes,
}: Omit<CommonProps, 'viewContext'> & {
embed: EmbedType<'post'>
viewContext?: QuoteEmbedViewContext
linkDisabled?: boolean
}) {
const moderationOpts = useModerationOpts()
const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>(
@@ -280,11 +282,47 @@ export function QuoteEmbed({
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const contents = (
<>
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
linkDisabled
/>
{moderation ? (
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</>
)
return (
<View
style={[a.mt_sm]}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}>
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
@@ -292,56 +330,29 @@ export function QuoteEmbed({
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && (
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
<View pointerEvents="none">
<PostMeta
author={quote.author}
moderation={moderation}
showAvatar
postHref={itemHref}
timestamp={quote.indexedAt}
/>
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
{moderation ? (
<PostAlerts
modui={moderation.ui('contentView')}
style={[a.py_xs]}
/>
) : null}
{richText ? (
<RichText
value={richText}
style={a.text_md}
numberOfLines={20}
disableLinks
/>
) : null}
{quote.embed && (
<Embed
embed={quote.embed}
moderation={moderation}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
/>
)}
</Link>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
@@ -13,7 +13,7 @@ import {
AtUri,
type RichText as RichTextAPI,
} from '@atproto/api'
import {msg} from '@lingui/macro'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -384,9 +384,8 @@ let PostMenuItems = ({
if (e instanceof MaxHiddenRepliesError) {
Toast.show(
_(
msg({
message: `You can hide a maximum of ${MAX_HIDDEN_REPLIES} replies.`,
context: 'toast',
plural(MAX_HIDDEN_REPLIES, {
other: 'You can hide a maximum of # replies.',
}),
),
)
-1
View File
@@ -15,7 +15,6 @@ export function useFormatPostStatCount() {
return i18n.number(postStatCount, {
notation: 'compact',
maximumFractionDigits: isOver10k ? 0 : 1,
// @ts-expect-error - roundingMode not in the types
roundingMode: 'trunc',
})
},
+1 -1
View File
@@ -14,7 +14,6 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
@@ -47,6 +46,7 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {type Metrics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import type * as bsky from '#/types/bsky'
export function Default({
@@ -10,7 +10,6 @@ import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
@@ -34,7 +33,6 @@ import {
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import {InlineLinkText, Link} from '#/components/Link'
import {LiveStatus} from '#/components/live/LiveStatusDialog'
import {Loader} from '#/components/Loader'
import * as Pills from '#/components/Pills'
import {Portal} from '#/components/Portal'
@@ -43,6 +41,8 @@ import {Text} from '#/components/Typography'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
import {IS_WEB_TOUCH_DEVICE} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {LiveStatus} from '#/features/liveNow/components/LiveStatusDialog'
import {type ProfileHoverCardProps} from './types'
const floatingMiddlewares = [
+79 -63
View File
@@ -1,4 +1,10 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {useState} from 'react'
import {
type LayoutChangeEvent,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -11,7 +17,7 @@ import {
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Person_Stroke2_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
@@ -51,7 +57,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
a.flex_col,
a.gap_md,
a.rounded_md,
t.atoms.bg_contrast_25,
t.atoms.bg_contrast_50,
a.p_lg,
style,
]}>
@@ -81,8 +87,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
a.justify_between,
a.gap_sm,
]
: a.flex_col,
!inlineLayout && a.gap_md,
: [a.flex_col, a.gap_md],
]}>
<StackedAvatars follows={follows?.pages?.[0]?.follows} />
<FollowDialog guide={guide} showArrow={inlineLayout} />
@@ -112,70 +117,81 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) {
const t = useTheme()
const {centerColumnOffset} = useLayoutBreakpoints()
const [containerWidth, setContainerWidth] = useState(0)
// Smaller avatars for narrower viewport
const avatarSize = centerColumnOffset ? 30 : 37
const overlap = centerColumnOffset ? 9 : 11
const iconSize = centerColumnOffset ? 14 : 18
const onLayout = (e: LayoutChangeEvent) => {
setContainerWidth(e.nativeEvent.layout.width)
}
// Overlap ratio (22% of avatar size)
const overlapRatio = 0.22
// Calculate avatar size to fill container width
// Formula: containerWidth = avatarSize * count - overlap * (count - 1)
// Where overlap = avatarSize * overlapRatio
const visiblePortions = TOTAL_AVATARS - overlapRatio * (TOTAL_AVATARS - 1)
const avatarSize = containerWidth > 0 ? containerWidth / visiblePortions : 0
const overlap = avatarSize * overlapRatio
const iconSize = avatarSize * 0.5
// Use actual follows count, not the guide's event counter
const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? []
const remainingSlots = TOTAL_AVATARS - followedAvatars.length
// Total width calculation: first avatar + (remaining * visible portion)
const totalWidth = avatarSize + (TOTAL_AVATARS - 1) * (avatarSize - overlap)
return (
<View style={[a.flex_row, a.self_start, {width: totalWidth}]}>
{/* Show followed user avatars */}
{followedAvatars.map((follow, i) => (
<View
key={follow.did}
style={[
a.rounded_full,
{
marginLeft: i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - i,
borderWidth: 2,
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
]}>
<UserAvatar
type="user"
size={avatarSize - 4}
avatar={follow.avatar}
/>
</View>
))}
{/* Show placeholder avatars for remaining slots */}
{Array(remainingSlots)
.fill(0)
.map((_, i) => (
<View
key={`placeholder-${i}`}
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_100,
{
width: avatarSize,
height: avatarSize,
marginLeft:
followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - followedAvatars.length - i,
borderWidth: 2,
borderColor: t.atoms.bg_contrast_25.backgroundColor,
},
]}>
<PersonIcon
width={iconSize}
height={iconSize}
fill={t.atoms.text_contrast_low.color}
/>
</View>
))}
<View style={[a.flex_row, a.flex_1]} onLayout={onLayout}>
{containerWidth > 0 && (
<>
{/* Show followed user avatars */}
{followedAvatars.map((follow, i) => (
<View
key={follow.did}
style={[
a.rounded_full,
a.border,
t.atoms.border_contrast_low,
{
marginLeft: i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - i,
},
]}>
<UserAvatar
type="user"
size={avatarSize - 2}
avatar={follow.avatar}
noBorder
/>
</View>
))}
{/* Show placeholder avatars for remaining slots */}
{Array(remainingSlots)
.fill(0)
.map((_, i) => (
<View
key={`placeholder-${i}`}
style={[
a.align_center,
a.justify_center,
a.rounded_full,
t.atoms.bg_contrast_300,
a.border,
t.atoms.border_contrast_low,
{
width: avatarSize,
height: avatarSize,
marginLeft:
followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
zIndex: TOTAL_AVATARS - followedAvatars.length - i,
},
]}>
<PersonIcon
width={iconSize}
height={iconSize}
fill={t.atoms.bg_contrast_50.backgroundColor}
/>
</View>
))}
</>
)}
</View>
)
}
+30 -5
View File
@@ -4,8 +4,14 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme, type ViewStyleProp, web} from '#/alf'
import {Button, type ButtonColor, ButtonText} from '#/components/Button'
import {
Button,
type ButtonColor,
ButtonIcon,
ButtonText,
} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
import {type BottomSheetViewProps} from '../../modules/bottom-sheet'
@@ -126,7 +132,7 @@ export function Cancel({
<Button
variant="solid"
color="secondary"
size={'large'}
size="large"
label={cta || _(msg`Cancel`)}
onPress={onPress}>
<ButtonText>{cta || _(msg`Cancel`)}</ButtonText>
@@ -138,6 +144,9 @@ export function Action({
onPress,
color = 'primary',
cta,
disabled = false,
icon,
shouldCloseOnPress,
testID,
}: {
/**
@@ -153,25 +162,41 @@ export function Action({
* Optional i18n string. If undefined, it will default to "Confirm".
*/
cta?: string
/**
* If undefined, it will default to false.
*/
disabled?: boolean
icon?: React.ComponentType<SVGIconProps>
/**
* Optionally close dialog automatically on press. If undefined, it will
* default to true.
*/
shouldCloseOnPress?: boolean
testID?: string
}) {
const {_} = useLingui()
const {close} = Dialog.useDialogContext()
const handleOnPress = useCallback(
(e: GestureResponderEvent) => {
close(() => onPress?.(e))
if (shouldCloseOnPress) {
close(() => onPress?.(e))
} else {
onPress?.(e)
}
},
[close, onPress],
[close, onPress, shouldCloseOnPress],
)
return (
<Button
color={color}
size={'large'}
disabled={disabled}
size="large"
label={cta || _(msg`Confirm`)}
onPress={handleOnPress}
testID={testID}>
<ButtonText>{cta || _(msg`Confirm`)}</ButtonText>
{icon && <ButtonIcon icon={icon} />}
</Button>
)
}
+2 -2
View File
@@ -103,9 +103,9 @@ export function Trigger({children, label}: TriggerProps) {
<Button
label={label}
onPress={control.open}
style={[a.flex_1, a.justify_between]}
style={[a.flex_1, a.justify_between, a.pl_lg, a.pr_md]}
color="secondary"
size="small"
size="large"
shape="rectangular">
<>{children}</>
</Button>
@@ -56,6 +56,7 @@ function Inner({style}: ViewStyleProp & {}) {
const {status, lastInitiatedAt} = aa.state
const isBlocked = status === aa.Status.Blocked
const hasInitiated = !!lastInitiatedAt
const hasCompletedFlow = status === aa.Status.Assured
const timeAgo = lastInitiatedAt
? getTimeAgo(lastInitiatedAt, new Date())
: null
@@ -85,8 +86,25 @@ function Inner({style}: ViewStyleProp & {}) {
</View>
</View>
<View style={[a.pb_md, a.gap_xs]}>
<View style={[a.pb_md, a.gap_sm]}>
<Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text>
{hasCompletedFlow && (
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
If you are 18 years of age or older and want to try again,
click the button below and use a different verification method
if one is available in your region. If you have questions or
concerns,{' '}
<InlineLinkText
label={_(msg`Contact our support team`)}
{...createStaticClick(() => {
appealControl.open()
})}>
our support team can help.
</InlineLinkText>
</Trans>
</Text>
)}
{IS_NATIVE && (
<>
@@ -59,7 +59,7 @@ export function AgeRestrictedScreen({
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_xl, a.leading_snug, a.font_bold]}>
<Trans>
You must complete age assurance in order to access this screen.
We're sorry, you cannot access this screen at this time.
</Trans>
</Text>
@@ -2,20 +2,29 @@ import {useMemo} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useAgeAssurance} from '#/ageAssurance'
export function useAgeAssuranceCopy() {
const {_} = useLingui()
const aa = useAgeAssurance()
const hasCompletedFlow = aa.state.status === aa.Status.Assured
return useMemo(() => {
return {
notice: _(
msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`,
),
notice: hasCompletedFlow
? _(
msg`You have completed the Age Assurance process, but based on the results, we cannot be sure that you are 18 years of age or older. Due to laws in your region, certain features on Bluesky must remain restricted until you're able to verify you're an adult.`,
)
: _(
msg`Due to laws in your region, certain features on Bluesky are currently restricted until you're able to verify you're an adult.`,
),
banner: _(
msg`The laws in your location require you to verify you're an adult to access certain features. Tap to learn more.`,
msg`The laws in your region require you to verify you're an adult to access certain features. Tap to learn more.`,
),
chatsInfoText: _(
msg`Don't worry! All existing messages and settings are saved and will be available after you verify you're an adult.`,
),
}
}, [_])
}, [_, hasCompletedFlow])
}
+2 -2
View File
@@ -72,9 +72,9 @@ export function BirthDateSettingsDialog({
) : isUsingAppPassword ? (
<Admonition type="info">
<Trans>
Hmm, it looks like you're logged in with an{' '}
Hmm, it looks like you're signed in with an{' '}
<Span style={[a.italic]}>App Password</Span>. To set your
birthdate, you'll need to log in with your main account
birthdate, you'll need to sign in with your main account
password, or ask whomever controls this account to do so.
</Trans>
</Admonition>
@@ -32,8 +32,11 @@ export function TokenField({
<TextField.Root>
<TextField.Icon icon={Shield} />
<TextField.Input
autoComplete="off"
autoCorrect={false}
isInvalid={isInvalid}
label={_(msg`Confirmation code`)}
maxLength={11}
placeholder="XXXXX-XXXXX"
value={value}
onChangeText={handleOnChangeText}
+9 -14
View File
@@ -9,7 +9,8 @@ import {
externalEmbedLabels,
} from '#/lib/strings/embed-player'
import {useSetExternalEmbedPref} from '#/state/preferences'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
@@ -24,9 +25,7 @@ export function EmbedConsentDialog({
onAccept: () => void
}) {
const {_} = useLingui()
const t = useTheme()
const setExternalEmbedPref = useSetExternalEmbedPref()
const {gtMobile} = useBreakpoints()
const onShowAllPress = useCallback(() => {
for (const key of embedPlayerSources) {
@@ -52,50 +51,46 @@ export function EmbedConsentDialog({
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`External Media`)}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
style={web({maxWidth: 400})}>
<View style={a.gap_sm}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>External Media</Trans>
</Text>
<View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}>
<Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
This content is hosted by {externalEmbedLabels[source]}. Do you
want to enable external media?
</Trans>
</Text>
<Text style={t.atoms.text_contrast_medium}>
<Admonition type="info">
<Trans>
External media may allow websites to collect information about
you and your device. No information is sent or requested until
you press the "play" button.
</Trans>
</Text>
</Admonition>
</View>
</View>
<View style={a.gap_md}>
<Button
style={gtMobile && a.flex_1}
label={_(msg`Enable external media`)}
onPress={onShowAllPress}
onAccessibilityEscape={control.close}
color="primary"
size="large"
variant="solid">
size="large">
<ButtonText>
<Trans>Enable external media</Trans>
</ButtonText>
</Button>
<Button
style={gtMobile && a.flex_1}
label={_(msg`Enable this source only`)}
onPress={onShowPress}
onAccessibilityEscape={control.close}
color="secondary"
size="large"
variant="solid">
size="large">
<ButtonText>
<Trans>Enable {externalEmbedLabels[source]} only</Trans>
</ButtonText>
@@ -6,11 +6,7 @@ import {useLingui} from '@lingui/react'
import {languageName} from '#/locale/helpers'
import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages'
import {
toPostLanguages,
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {useLanguagePrefs} from '#/state/preferences/languages'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {atoms as a, useTheme, web} from '#/alf'
@@ -22,18 +18,27 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti
import {Text} from '#/components/Typography'
import {IS_NATIVE, IS_WEB} from '#/env'
export function PostLanguageSelectDialog({
export function LanguageSelectDialog({
titleText,
subtitleText,
control,
/**
* Optionally can be passed to show different values than what is saved in
* langPrefs.
*/
currentLanguages,
onSelectLanguage,
onSelectLanguages,
maxLanguages,
}: {
control: Dialog.DialogControlProps
titleText?: React.ReactNode
subtitleText?: React.ReactNode
/**
* Defaults to the primary language
*/
currentLanguages?: string[]
onSelectLanguage?: (language: string) => void
onSelectLanguages: (languages: string[]) => void
maxLanguages?: number
}) {
const {height} = useWindowDimensions()
const insets = useSafeAreaInsets()
@@ -50,8 +55,11 @@ export function PostLanguageSelectDialog({
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<DialogInner
titleText={titleText}
subtitleText={subtitleText}
currentLanguages={currentLanguages}
onSelectLanguage={onSelectLanguage}
onSelectLanguages={onSelectLanguages}
maxLanguages={maxLanguages}
/>
</ErrorBoundary>
</Dialog.Outer>
@@ -59,11 +67,17 @@ export function PostLanguageSelectDialog({
}
export function DialogInner({
titleText,
subtitleText,
currentLanguages,
onSelectLanguage,
onSelectLanguages,
maxLanguages,
}: {
titleText?: React.ReactNode
subtitleText?: React.ReactNode
currentLanguages?: string[]
onSelectLanguage?: (language: string) => void
onSelectLanguages?: (languages: string[]) => void
maxLanguages?: number
}) {
const control = Dialog.useDialogContext()
const [headerHeight, setHeaderHeight] = useState(0)
@@ -81,26 +95,17 @@ export function DialogInner({
}, [])
const langPrefs = useLanguagePrefs()
const postLanguagesPref =
currentLanguages ?? toPostLanguages(langPrefs.postLanguage)
const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>(
postLanguagesPref || [langPrefs.primaryLanguage],
currentLanguages || [langPrefs.primaryLanguage],
)
const [search, setSearch] = useState('')
const setLangPrefs = useLanguagePrefsApi()
const t = useTheme()
const {_} = useLingui()
const handleClose = () => {
control.close(() => {
let langsString = checkedLanguagesCode2.join(',')
if (!langsString) {
langsString = langPrefs.primaryLanguage
}
setLangPrefs.setPostLanguage(langsString)
onSelectLanguage?.(langsString)
onSelectLanguages?.(checkedLanguagesCode2)
})
}
@@ -181,18 +186,20 @@ export function DialogInner({
a.text_xl,
a.mb_sm,
]}>
<Trans>Choose Post Languages</Trans>
</Text>
<Text
nativeID="dialog-description"
style={[
t.atoms.text_contrast_medium,
a.text_left,
a.text_md,
a.mb_lg,
]}>
<Trans>Select up to 3 languages used in this post</Trans>
{titleText ?? <Trans>Choose languages</Trans>}
</Text>
{subtitleText && (
<Text
nativeID="dialog-description"
style={[
t.atoms.text_contrast_medium,
a.text_left,
a.text_md,
a.mb_lg,
]}>
{subtitleText}
</Text>
)}
</View>
{IS_WEB && (
@@ -244,7 +251,7 @@ export function DialogInner({
values={checkedLanguagesCode2}
onChange={setCheckedLanguagesCode2}
type="checkbox"
maxSelections={3}
maxSelections={maxLanguages}
label={_(msg`Select languages`)}
style={web([a.contents])}>
<Dialog.InnerFlatList
+17 -2
View File
@@ -22,12 +22,27 @@ export function LinkWarningDialog() {
webOptions={{alignCenter: true}}
onClose={linkWarningDialogControl.clear}>
<Dialog.Handle />
<InAppBrowserConsentInner link={linkWarningDialogControl.value} />
<LinkWarningDialogInner link={linkWarningDialogControl.value} />
</Dialog.Outer>
)
}
function InAppBrowserConsentInner({
export function CustomLinkWarningDialog({
control,
link,
}: {
control: Dialog.DialogControlProps
link?: {href: string; displayText: string; share?: boolean}
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<LinkWarningDialogInner link={link} />
</Dialog.Outer>
)
}
function LinkWarningDialogInner({
link,
}: {
link?: {href: string; displayText: string; share?: boolean}
+107 -26
View File
@@ -8,6 +8,7 @@ import {logger} from '#/logger'
import {
usePreferencesQuery,
useRemoveMutedWordMutation,
useUpdateMutedWordMutation,
useUpsertMutedWordsMutation,
} from '#/state/queries/preferences'
import {
@@ -29,6 +30,7 @@ import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/P
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
@@ -421,6 +423,7 @@ function MutedWordRow({
const t = useTheme()
const {_} = useLingui()
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
const {mutateAsync: updateMutedWord} = useUpdateMutedWordMutation()
const control = Prompt.usePromptControl()
const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
const isExpired = expiryDate && expiryDate < new Date()
@@ -431,6 +434,15 @@ function MutedWordRow({
removeMutedWord(word)
}, [removeMutedWord, word, control])
const renew = (days?: number) => {
updateMutedWord({
...word,
expiresAt: days
? new Date(Date.now() + days * ONE_DAY).toISOString()
: undefined,
})
}
return (
<>
<Prompt.Basic
@@ -493,35 +505,104 @@ function MutedWordRow({
</View>
{(expiryDate || word.actorTarget === 'exclude-following') && (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<Text
style={[
a.flex_1,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{expiryDate && (
<View style={[a.flex_1, a.flex_row, a.align_center, a.flex_wrap]}>
{expiryDate &&
(isExpired ? (
<>
{isExpired ? (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>Expired</Trans>
) : (
<Trans>
Expires{' '}
{formatDistance(expiryDate, new Date(), {
addSuffix: true,
})}
</Trans>
)}
</Text>
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{' · '}
</Text>
<Menu.Root>
<Menu.Trigger label={_(msg`Renew mute word`)}>
{({props}) => (
<Text
{...props}
style={[
a.text_xs,
a.leading_snug,
a.font_semi_bold,
{color: t.palette.primary_500},
]}>
<Trans>Renew</Trans>
</Text>
)}
</Menu.Trigger>
<Menu.Outer>
<Menu.LabelText>
<Trans>Renew duration</Trans>
</Menu.LabelText>
<Menu.Group>
<Menu.Item
label={_(msg`24 hours`)}
onPress={() => renew(1)}>
<Menu.ItemText>
<Trans>24 hours</Trans>
</Menu.ItemText>
</Menu.Item>
<Menu.Item
label={_(msg`7 days`)}
onPress={() => renew(7)}>
<Menu.ItemText>
<Trans>7 days</Trans>
</Menu.ItemText>
</Menu.Item>
<Menu.Item
label={_(msg`30 days`)}
onPress={() => renew(30)}>
<Menu.ItemText>
<Trans>30 days</Trans>
</Menu.ItemText>
</Menu.Item>
<Menu.Item
label={_(msg`Forever`)}
onPress={() => renew()}>
<Menu.ItemText>
<Trans>Forever</Trans>
</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
</>
)}
{word.actorTarget === 'exclude-following' && (
<>
{' '}
<Trans>Excludes users you follow</Trans>
</>
)}
</Text>
) : (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Expires{' '}
{formatDistance(expiryDate, new Date(), {
addSuffix: true,
})}
</Trans>
</Text>
))}
{word.actorTarget === 'exclude-following' && (
<Text
style={[
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{expiryDate ? ' · ' : ''}
<Trans>Excludes users you follow</Trans>
</Text>
)}
</View>
)}
</View>
+56 -58
View File
@@ -1,4 +1,4 @@
import {useCallback, useState} from 'react'
import {useCallback} from 'react'
import {View} from 'react-native'
import {
type AppBskyGraphGetStarterPacksWithMembership,
@@ -7,20 +7,18 @@ import {
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type NavigationProp} from '#/lib/routes/types'
import {
invalidateActorStarterPacksWithMembershipQuery,
useActorStarterPacksWithMembershipsQuery,
} from '#/state/queries/actor-starter-packs'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useActorStarterPacksWithMembershipsQuery} from '#/state/queries/actor-starter-packs'
import {
useListMembershipAddMutation,
useListMembershipRemoveMutation,
} from '#/state/queries/list-memberships'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {useProfileQuery} from '#/state/queries/profile'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -29,6 +27,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/
import {StarterPack} from '#/components/icons/StarterPack'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
@@ -131,6 +130,7 @@ function StarterPackList({
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const {data: subject} = useProfileQuery({did: targetDid})
const {
data,
@@ -155,9 +155,13 @@ function StarterPackList({
const renderItem = useCallback(
({item}: {item: StarterPackWithMembership}) => (
<StarterPackItem starterPackWithMembership={item} targetDid={targetDid} />
<StarterPackItem
starterPackWithMembership={item}
targetDid={targetDid}
subject={subject}
/>
),
[targetDid],
[targetDid, subject],
)
const onClose = useCallback(() => {
@@ -168,9 +172,11 @@ function StarterPackList({
<>
<View
style={[
{justifyContent: 'space-between', flexDirection: 'row'},
IS_WEB ? a.mb_2xl : a.my_lg,
a.justify_between,
a.align_center,
a.flex_row,
a.pb_lg,
native(a.pt_lg),
]}>
<Text style={[a.text_lg, a.font_semi_bold]}>
<Trans>Add to starter packs</Trans>
@@ -181,7 +187,8 @@ function StarterPackList({
variant="ghost"
color="secondary"
size="small"
shape="round">
shape="round"
style={{margin: -8}}>
<ButtonIcon icon={XIcon} />
</Button>
</View>
@@ -232,7 +239,10 @@ function StarterPackList({
onEndReachedThreshold={0.1}
ListHeaderComponent={listHeader}
ListEmptyComponent={<Empty onStartWizard={onStartWizard} />}
style={IS_WEB ? [a.px_md, {minHeight: 500}] : [a.px_2xl, a.pt_lg]}
style={platform({
web: [a.px_2xl, {minHeight: 500}],
native: [a.px_2xl, a.pt_lg],
})}
/>
)
}
@@ -240,66 +250,54 @@ function StarterPackList({
function StarterPackItem({
starterPackWithMembership,
targetDid,
subject,
}: {
starterPackWithMembership: StarterPackWithMembership
targetDid: string
subject?: bsky.profile.AnyProfileView
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const queryClient = useQueryClient()
const starterPack = starterPackWithMembership.starterPack
const isInPack = !!starterPackWithMembership.listItem
const [isPendingRefresh, setIsPendingRefresh] = useState(false)
const {mutate: addMembership, isPending: isPendingAdd} =
useListMembershipAddMutation({
subject,
onSuccess: () => {
Toast.show(_(msg`Added to starter pack`))
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to add to starter pack', {safeMessage: err})
}
Toast.show(_(msg`Failed to add to starter pack`), {type: 'error'})
},
})
const {mutate: addMembership} = useListMembershipAddMutation({
onSuccess: () => {
Toast.show(_(msg`Added to starter pack`))
// Use a timeout to wait for the appview to update, matching the pattern
// in list-memberships.ts
setTimeout(() => {
invalidateActorStarterPacksWithMembershipQuery({
queryClient,
did: targetDid,
})
setIsPendingRefresh(false)
}, 1e3)
},
onError: () => {
Toast.show(_(msg`Failed to add to starter pack`), 'xmark')
setIsPendingRefresh(false)
},
})
const {mutate: removeMembership, isPending: isPendingRemove} =
useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from starter pack`))
},
onError: err => {
if (!isNetworkError(err)) {
logger.error('Failed to remove from starter pack', {safeMessage: err})
}
Toast.show(_(msg`Failed to remove from starter pack`), {type: 'error'})
},
})
const {mutate: removeMembership} = useListMembershipRemoveMutation({
onSuccess: () => {
Toast.show(_(msg`Removed from starter pack`))
// Use a timeout to wait for the appview to update, matching the pattern
// in list-memberships.ts
setTimeout(() => {
invalidateActorStarterPacksWithMembershipQuery({
queryClient,
did: targetDid,
})
setIsPendingRefresh(false)
}, 1e3)
},
onError: () => {
Toast.show(_(msg`Failed to remove from starter pack`), 'xmark')
setIsPendingRefresh(false)
},
})
const isPending = isPendingAdd || isPendingRemove
const handleToggleMembership = () => {
if (!starterPack.list?.uri || isPendingRefresh) return
if (!starterPack.list?.uri || isPending) return
const listUri = starterPack.list.uri
const starterPackUri = starterPack.uri
setIsPendingRefresh(true)
if (!isInPack) {
addMembership({
listUri: listUri,
@@ -309,7 +307,6 @@ function StarterPackItem({
} else {
if (!starterPackWithMembership.listItem?.uri) {
console.error('Cannot remove: missing membership URI')
setIsPendingRefresh(false)
return
}
removeMembership({
@@ -344,7 +341,7 @@ function StarterPackItem({
starterPack.listItemsSample.length > 0 && (
<>
<AvatarStack
size={32}
size={24}
profiles={starterPack.listItemsSample
?.slice(0, 4)
.map(p => p.subject)}
@@ -375,8 +372,9 @@ function StarterPackItem({
label={isInPack ? _(msg`Remove`) : _(msg`Add`)}
color={isInPack ? 'secondary' : 'primary_subtle'}
size="tiny"
disabled={isPendingRefresh}
disabled={isPending}
onPress={handleToggleMembership}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
{isInPack ? <Trans>Remove</Trans> : <Trans>Add</Trans>}
</ButtonText>
+2 -2
View File
@@ -43,10 +43,10 @@ export function SwitchAccountDialog({
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
<Dialog.ScrollableInner label={_(msg`Switch account`)}>
<View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
<Trans>Switch Account</Trans>
<Trans>Switch account</Trans>
</Text>
<AccountList
@@ -0,0 +1,234 @@
import {View} from 'react-native'
import {
type $Typed,
type AppBskyGraphDefs,
type AppBskyGraphListitem,
type AppBskyGraphStarterpack,
AtUri,
type ComAtprotoRepoApplyWrites,
} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import chunk from 'lodash.chunk'
import {until} from '#/lib/async/until'
import {wait} from '#/lib/async/wait'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {getAllListMembers} from '#/state/queries/list-members'
import {useAgent, useSession} from '#/state/session'
import {atoms as a, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {CreateOrEditListDialog} from './CreateOrEditListDialog'
export function CreateListFromStarterPackDialog({
control,
starterPack,
}: {
control: Dialog.DialogControlProps
starterPack: AppBskyGraphDefs.StarterPackView
}) {
const {_} = useLingui()
const t = useTheme()
const agent = useAgent()
const ax = useAnalytics()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const queryClient = useQueryClient()
const createDialogControl = Dialog.useDialogControl()
const loadingDialogControl = Dialog.useDialogControl()
const record = starterPack.record as AppBskyGraphStarterpack.Record
const onPressCreate = () => {
control.close(() => createDialogControl.open())
}
const addMembersAndNavigate = async (listUri: string) => {
const navigateToList = () => {
const urip = new AtUri(listUri)
navigation.navigate('ProfileList', {
name: urip.hostname,
rkey: urip.rkey,
})
}
if (!starterPack.list || !currentAccount) {
loadingDialogControl.close(navigateToList)
return
}
try {
// Fetch all members and add them, with minimum 3s duration for UX
const listItems = await wait(
3000,
(async () => {
const items = await getAllListMembers(agent, starterPack.list!.uri)
if (items.length > 0) {
const listitemWrites: $Typed<ComAtprotoRepoApplyWrites.Create>[] =
items.map(item => {
const listitemRecord: $Typed<AppBskyGraphListitem.Record> = {
$type: 'app.bsky.graph.listitem',
subject: item.subject.did,
list: listUri,
createdAt: new Date().toISOString(),
}
return {
$type: 'com.atproto.repo.applyWrites#create',
collection: 'app.bsky.graph.listitem',
rkey: TID.nextStr(),
value: listitemRecord,
}
})
const chunks = chunk(listitemWrites, 50)
for (const c of chunks) {
await agent.com.atproto.repo.applyWrites({
repo: currentAccount.did,
writes: c,
})
}
await until(
5,
1e3,
(res: {data: {items: unknown[]}}) => res.data.items.length > 0,
() =>
agent.app.bsky.graph.getList({
list: listUri,
limit: 1,
}),
)
}
return items
})(),
)
queryClient.invalidateQueries({queryKey: ['list-members', listUri]})
ax.metric('starterPack:convertToList', {
starterPack: starterPack.uri,
memberCount: listItems.length,
})
} catch (e) {
logger.error('Failed to add members to list', {safeMessage: e})
Toast.show(_(msg`List created, but failed to add some members`), {
type: 'error',
})
}
loadingDialogControl.close(navigateToList)
}
const onListCreated = (listUri: string) => {
loadingDialogControl.open()
addMembersAndNavigate(listUri)
}
return (
<>
<Dialog.Outer
control={control}
testID="createListFromStarterPackDialog"
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Create list from starter pack`)}
style={web({maxWidth: 400})}>
<View style={[a.gap_lg]}>
<Text style={[a.text_xl, a.font_bold]}>
<Trans>Create list from starter pack</Trans>
</Text>
<Text
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
<Trans>
This will create a new list with the same name, description, and
members as this starter pack.
</Trans>
</Text>
<Admonition type="tip">
Changes to the starter pack will not be reflected in the list
after creation. The list will be an independent copy.
</Admonition>
<View
style={[
platform({
web: [a.flex_row_reverse],
native: [a.flex_col],
}),
a.gap_md,
a.pt_sm,
]}>
<Button
label={_(msg`Create list`)}
onPress={onPressCreate}
size={platform({
web: 'small',
native: 'large',
})}
color="primary">
<ButtonText>
<Trans>Create list</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Cancel`)}
onPress={() => control.close()}
size={platform({
web: 'small',
native: 'large',
})}
color="secondary">
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
<CreateOrEditListDialog
control={createDialogControl}
purpose="app.bsky.graph.defs#curatelist"
onSave={onListCreated}
initialValues={{
name: record.name,
description: record.description,
avatar: starterPack.list?.avatar,
}}
/>
<Dialog.Outer
control={loadingDialogControl}
nativeOptions={{preventDismiss: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Adding members to list...`)}
style={web({maxWidth: 400})}>
<View style={[a.align_center, a.gap_lg, a.py_5xl]}>
<Loader size="xl" />
<Text style={[a.text_lg, t.atoms.text_contrast_high]}>
<Trans>Adding members to list...</Trans>
</Text>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
@@ -30,16 +30,24 @@ import {IS_WEB} from '#/env'
const DISPLAY_NAME_MAX_GRAPHEMES = 64
const DESCRIPTION_MAX_GRAPHEMES = 300
export type InitialListValues = {
name?: string
description?: string
avatar?: string
}
export function CreateOrEditListDialog({
control,
list,
purpose,
onSave,
initialValues,
}: {
control: Dialog.DialogControlProps
list?: AppBskyGraphDefs.ListView
purpose?: AppBskyGraphDefs.ListPurpose
onSave?: (uri: string) => void
initialValues?: InitialListValues
}) {
const {_} = useLingui()
const cancelControl = Dialog.useDialogControl()
@@ -82,6 +90,7 @@ export function CreateOrEditListDialog({
onSave={onSave}
setDirty={setDirty}
onPressCancel={onPressCancel}
initialValues={initialValues}
/>
<Prompt.Basic
@@ -102,12 +111,14 @@ function DialogInner({
onSave,
setDirty,
onPressCancel,
initialValues,
}: {
list?: AppBskyGraphDefs.ListView
purpose?: AppBskyGraphDefs.ListPurpose
onSave?: (uri: string) => void
setDirty: (dirty: boolean) => void
onPressCancel: () => void
initialValues?: InitialListValues
}) {
const activePurpose = useMemo(() => {
if (list?.purpose) {
@@ -138,11 +149,12 @@ function DialogInner({
} = useListMetadataMutation()
const [imageError, setImageError] = useState('')
const [displayNameTooShort, setDisplayNameTooShort] = useState(false)
const initialDisplayName = list?.name || ''
const initialDisplayName = list?.name || initialValues?.name || ''
const [displayName, setDisplayName] = useState(initialDisplayName)
const initialDescription = list?.description || ''
const initialDescription =
list?.description || initialValues?.description || ''
const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => {
const text = list?.description
const text = list?.description ?? initialValues?.description
const facets = list?.descriptionFacets
if (!text || !facets) {
@@ -159,17 +171,22 @@ function DialogInner({
return richText
})
const initialAvatar = list?.avatar ?? initialValues?.avatar
const [listAvatar, setListAvatar] = useState<string | undefined | null>(
list?.avatar,
initialAvatar,
)
const [newListAvatar, setNewListAvatar] = useState<
ImageMeta | undefined | null
>()
// When creating with pre-filled values (from starter pack), consider dirty
// immediately so the Save button is enabled
const hasInitialValuesForCreate = !list && initialValues != null
const dirty =
hasInitialValuesForCreate ||
displayName !== initialDisplayName ||
descriptionRt.text !== initialDescription ||
listAvatar !== list?.avatar
listAvatar !== initialAvatar
useEffect(() => {
setDirty(dirty)
+7 -93
View File
@@ -7,107 +7,21 @@
* {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md}
*/
import React from 'react'
import {formatDistance, type Locale} from 'date-fns'
import {
ca,
cy,
da,
de,
el,
enGB,
eo,
es,
eu,
fi,
fr,
fy,
gd,
gl,
hi,
hu,
id,
it,
ja,
km,
ko,
nl,
pl,
pt,
ptBR,
ro,
ru,
sv,
th,
tr,
uk,
vi,
zhCN,
zhHK,
zhTW,
} from 'date-fns/locale'
import {useCallback} from 'react'
import {formatDistance} from 'date-fns'
import {type AppLanguage} from '#/locale/languages'
import {useLanguagePrefs} from '#/state/preferences'
/**
* {@link AppLanguage}
*/
const locales: Record<AppLanguage, Locale | undefined> = {
en: undefined,
an: undefined,
ast: undefined,
ca,
cy,
da,
de,
el,
['en-GB']: enGB,
eo,
es,
eu,
fi,
fr,
fy,
ga: undefined,
gd,
gl,
hi,
hu,
ia: undefined,
id,
it,
ja,
km,
ko,
ne: undefined,
nl,
pl,
['pt-PT']: pt,
['pt-BR']: ptBR,
ro,
ru,
sv,
th,
tr,
uk,
vi,
['zh-Hans-CN']: zhCN,
['zh-Hant-HK']: zhHK,
['zh-Hant-TW']: zhTW,
}
import {useDateLocale} from '#/locale/i18nProvider'
/**
* Returns a localized `formatDistance` function.
* {@link formatDistance}
*/
export function useFormatDistance() {
const {appLanguage} = useLanguagePrefs()
return React.useCallback<typeof formatDistance>(
const locale = useDateLocale()
return useCallback<typeof formatDistance>(
(date, baseDate, options) => {
const locale = locales[appLanguage as AppLanguage]
return formatDistance(date, baseDate, {...options, locale: locale})
return formatDistance(date, baseDate, {...options, locale})
},
[appLanguage],
[locale],
)
}
+4
View File
@@ -36,3 +36,7 @@ export const PersonPlus_Stroke2_Corner2_Rounded = createSinglePathSVG({
export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm7.301 9.7c-.836-2.6-2.88-3.503-4.575-3.111a1 1 0 0 1-.451-1.949c2.815-.651 5.81.966 6.93 4.448a2.49 2.49 0 0 1-.506 2.43A2.92 2.92 0 0 1 20 20h-2a1 1 0 1 1 0-2h2a.92.92 0 0 0 .69-.295.49.49 0 0 0 .112-.505ZM8 14c-1.865 0-3.878 1.274-4.681 4.151a.57.57 0 0 0 .132.55c.15.171.4.299.695.299h7.708a.93.93 0 0 0 .695-.299.57.57 0 0 0 .132-.55C11.878 15.274 9.865 14 8 14Zm0-2c2.87 0 5.594 1.98 6.607 5.613.53 1.9-1.09 3.387-2.753 3.387H4.146c-1.663 0-3.283-1.487-2.753-3.387C2.406 13.981 5.129 12 8 12Z',
})
export const Person_Filled_Corner2_Rounded = createSinglePathSVG({
path: 'M12.233 2a4.433 4.433 0 1 0 0 8.867 4.433 4.433 0 0 0 0-8.867ZM12.233 12.133c-3.888 0-6.863 2.263-8.071 5.435-.346.906-.11 1.8.44 2.436.535.619 1.36.996 2.25.996h10.762c.89 0 1.716-.377 2.25-.996.55-.636.786-1.53.441-2.436-1.208-3.173-4.184-5.435-8.072-5.435Z',
})
+10
View File
@@ -141,3 +141,13 @@ export const LIVE_EVENTS_PROD_URL = `https://live-events.workers.bsky.app`
export const LIVE_EVENTS_URL = IS_DEV
? (LIVE_EVENTS_DEV_URL ?? LIVE_EVENTS_PROD_URL)
: LIVE_EVENTS_PROD_URL
/**
* URLs for the app-config web worker. Can be a
* locally running server, see `env.example` for more.
*/
export const APP_CONFIG_DEV_URL = process.env.APP_CONFIG_DEV_URL
export const APP_CONFIG_PROD_URL = `https://app-config.workers.bsky.app`
export const APP_CONFIG_URL = IS_DEV
? (APP_CONFIG_DEV_URL ?? APP_CONFIG_PROD_URL)
: APP_CONFIG_PROD_URL
@@ -9,6 +9,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInMinutes} from 'date-fns'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {cleanError} from '#/lib/strings/errors'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useTickEveryMinute} from '#/state/shell'
@@ -20,13 +21,13 @@ import * as TextField from '#/components/forms/TextField'
import {Clock_Stroke2_Corner0_Rounded as ClockIcon} from '#/components/icons/Clock'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {LinkPreview} from './LinkPreview'
import {
displayDuration,
useLiveLinkMetaQuery,
useRemoveLiveStatusMutation,
useUpsertLiveStatusMutation,
} from './queries'
import {displayDuration, useDebouncedValue} from './utils'
} from '#/features/liveNow'
import {LinkPreview} from '#/features/liveNow/components/LinkPreview'
export function EditLiveDialog({
control,
@@ -3,28 +3,29 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {cleanError} from '#/lib/strings/errors'
import {definitelyUrl} from '#/lib/strings/url-helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
import {atoms as a, ios, native, platform, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {
displayDuration,
getLiveServiceNames,
useDebouncedValue,
} from '#/components/live/utils'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Select from '#/components/Select'
import {Text} from '#/components/Typography'
import {
displayDuration,
getLiveServiceNames,
useLiveLinkMetaQuery,
useLiveNowConfig,
useUpsertLiveStatusMutation,
} from '#/features/liveNow'
import type * as bsky from '#/types/bsky'
import {LinkPreview} from './LinkPreview'
import {useLiveLinkMetaQuery, useUpsertLiveStatusMutation} from './queries'
export function GoLiveDialog({
control,
@@ -17,15 +17,15 @@ import {android, atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Globe_Stroke2_Corner0_Rounded} from '#/components/icons/Globe'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '#/components/icons/SquareArrowTopRight'
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {useGlobalReportDialogControl} from '#/components/moderation/ReportDialog'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
import type * as bsky from '#/types/bsky'
import {Globe_Stroke2_Corner0_Rounded} from '../icons/Globe'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRightIcon} from '../icons/SquareArrowTopRight'
import {LiveIndicator} from './LiveIndicator'
export function LiveStatusDialog({
control,
@@ -1,24 +1,166 @@
import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
type AppBskyActorStatus,
type AppBskyEmbedExternal,
AppBskyEmbedExternal,
AtUri,
ComAtprotoRepoPutRecord,
} from '@atproto/api'
import {retry} from '@atproto/common-web'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {isAfter, parseISO} from 'date-fns'
import {uploadBlob} from '#/lib/api'
import {imageToThumb} from '#/lib/api/resolve'
import {getLinkMeta, type LinkMeta} from '#/lib/link-meta/link-meta'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLiveNowConfig} from '#/state/service-config'
import {useAppConfig} from '#/state/appConfig'
import {
updateProfileShadow,
useMaybeProfileShadow,
} from '#/state/cache/profile-shadow'
import {useAgent, useSession} from '#/state/session'
import {useTickEveryMinute} from '#/state/shell'
import * as Toast from '#/view/com/util/Toast'
import {useDialogContext} from '#/components/Dialog'
import {getLiveServiceNames} from '#/components/live/utils'
import {useAnalytics} from '#/analytics'
import {getLiveNowHost, getLiveServiceNames} from '#/features/liveNow/utils'
import type * as bsky from '#/types/bsky'
export * from '#/features/liveNow/utils'
export const DEFAULT_ALLOWED_DOMAINS = [
'twitch.tv',
'stream.place',
'bluecast.app',
]
export type LiveNowConfig = {
canGoLive: boolean
currentAccountAllowedHosts: Set<string>
defaultAllowedHosts: Set<string>
allowedHostsExceptionsByDid: Map<string, Set<string>>
}
export function useLiveNowConfig(): LiveNowConfig {
const ax = useAnalytics()
const {liveNow} = useAppConfig()
const {currentAccount} = useSession()
return useMemo(() => {
const disabled = ax.features.enabled(ax.features.LiveNowBetaDisable)
const defaultAllowedHosts = new Set(
DEFAULT_ALLOWED_DOMAINS.concat(liveNow.allow),
)
const allowedHostsExceptionsByDid = new Map<string, Set<string>>()
for (const ex of liveNow.exceptions) {
allowedHostsExceptionsByDid.set(
ex.did,
new Set(DEFAULT_ALLOWED_DOMAINS.concat(ex.allow)),
)
}
if (!currentAccount?.did || disabled) {
return {
canGoLive: false,
currentAccountAllowedHosts: new Set(),
defaultAllowedHosts,
allowedHostsExceptionsByDid,
}
}
return {
canGoLive: true,
currentAccountAllowedHosts:
allowedHostsExceptionsByDid.get(currentAccount.did) ??
defaultAllowedHosts,
defaultAllowedHosts,
allowedHostsExceptionsByDid,
}
}, [ax, liveNow, currentAccount])
}
export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
const shadowed = useMaybeProfileShadow(actor)
const tick = useTickEveryMinute()
const config = useLiveNowConfig()
return useMemo(() => {
void tick // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = isStatusValidForViewers(shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt)
if (isValid && !isDisabled && isActive) {
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled: false,
isActive: true,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
}
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled,
isActive: false,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
} else {
return {
status: '',
isDisabled: false,
isActive: false,
record: {},
} satisfies AppBskyActorDefs.StatusView
}
}, [shadowed, config, tick])
}
export function isStatusStillActive(timeStr: string | undefined) {
if (!timeStr) return false
const now = new Date()
const expiry = parseISO(timeStr)
return isAfter(expiry, now)
}
/**
* Validates whether the live status is valid for display in the app. Does NOT
* validate if the status is valid for the acting user e.g. as they go live.
*/
export function isStatusValidForViewers(
status: AppBskyActorDefs.StatusView,
config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
if (!status.uri) return false // should not happen, just backwards compat
try {
const {host: liveDid} = new AtUri(status.uri)
if (AppBskyEmbedExternal.isView(status.embed)) {
const host = getLiveNowHost(status.embed.external.uri)
const exception = config.allowedHostsExceptionsByDid.get(liveDid)
const isValidException = exception ? exception.has(host) : false
const isValidForAnyone = config.defaultAllowedHosts.has(host)
return isValidException || isValidForAnyone
} else {
return false
}
} catch {
return false
}
}
export function useLiveLinkMetaQuery(url: string | null) {
const liveNowConfig = useLiveNowConfig()
@@ -30,8 +172,8 @@ export function useLiveLinkMetaQuery(url: string | null) {
queryKey: ['link-meta', url],
queryFn: async () => {
if (!url) return undefined
const urlp = new URL(url)
if (!liveNowConfig.currentAccountAllowedHosts.has(urlp.hostname)) {
const host = getLiveNowHost(url)
if (!liveNowConfig.currentAccountAllowedHosts.has(host)) {
const {formatted} = getLiveServiceNames(
liveNowConfig.currentAccountAllowedHosts,
)
@@ -1,6 +1,6 @@
import {useEffect, useState} from 'react'
import {type I18n} from '@lingui/core'
import {plural} from '@lingui/macro'
import psl from 'psl'
export function displayDuration(i18n: I18n, durationInMinutes: number) {
const roundedDurationInMinutes = Math.round(durationInMinutes)
@@ -24,41 +24,49 @@ export function displayDuration(i18n: I18n, durationInMinutes: number) {
: minutesString
}
// Trailing debounce
export function useDebouncedValue<T>(val: T, delayMs: number): T {
const [prev, setPrev] = useState(val)
useEffect(() => {
const timeout = setTimeout(() => setPrev(val), delayMs)
return () => clearTimeout(timeout)
}, [val, delayMs])
return prev
}
const serviceUrlToNameMap: Record<string, string> = {
'twitch.tv': 'Twitch',
'www.twitch.tv': 'Twitch',
'youtube.com': 'YouTube',
'www.youtube.com': 'YouTube',
'youtu.be': 'YouTube',
'nba.com': 'NBA',
'www.nba.com': 'NBA',
'nba.smart.link': 'nba.smart.link',
'espn.com': 'ESPN',
'www.espn.com': 'ESPN',
'stream.place': 'Streamplace',
'skylight.social': 'Skylight',
'bluecast.app': 'Bluecast',
'www.bluecast.app': 'Bluecast',
}
export function getLiveServiceNames(domains: Set<string>) {
const names = Array.from(
new Set(Array.from(domains.values()).map(d => serviceUrlToNameMap[d] || d)),
new Set(
Array.from(domains.values())
.map(d => sanitizeLiveNowHost(d))
.map(d => serviceUrlToNameMap[d] || d),
),
)
return {
names,
formatted: names.join(', '),
}
}
export function sanitizeLiveNowHost(hostname: string) {
// special case this one
if (hostname === 'nba.smart.link') {
return hostname
}
const parsed = psl.parse(hostname)
if (parsed.error || !parsed.listed || !parsed.domain) {
// fall back to dumb version
return hostname.replace(/^www\./, '')
}
return parsed.domain
}
/**
* Extracts the apex domain from a given URL, for use when matching allowed
* Live Now hosts.
*/
export function getLiveNowHost(url: string) {
const {hostname} = new URL(url)
return sanitizeLiveNowHost(hostname)
}
+24
View File
@@ -3,6 +3,7 @@ import {type LocationGeocodedAddress} from 'expo-location'
import {IS_ANDROID} from '#/env'
import {logger} from '#/geolocation/logger'
import {type Geolocation} from '#/geolocation/types'
import {device} from '#/storage'
/**
* Maps full US region names to their short codes.
@@ -125,3 +126,26 @@ export function mergeGeolocations(
})
return geolocation
}
/**
* Gets the IP-based geolocation as a string in the format of
* "countryCode-regionCode", or just "countryCode" if regionCode is not
* available.
*
* IMPORTANT: this method should only return IP-based data, not the user's GPS
* based data. IP-based data we can already infer from requests, but for
* consistency between frontend and backend, we sometimes want to share the
* value we have on the frontend with the backend.
*/
export function getIPGeolocationString() {
const geo = device.get(['geolocationServiceResponse'])
if (!geo) return
const {countryCode, regionCode} = geo
if (countryCode) {
if (regionCode) {
return `${countryCode}-${regionCode}`
} else {
return countryCode
}
}
}
-92
View File
@@ -1,92 +0,0 @@
import {useMemo} from 'react'
import {
type $Typed,
type AppBskyActorDefs,
AppBskyEmbedExternal,
AtUri,
} from '@atproto/api'
import {isAfter, parseISO} from 'date-fns'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {type LiveNowConfig, useLiveNowConfig} from '#/state/service-config'
import {useTickEveryMinute} from '#/state/shell'
import type * as bsky from '#/types/bsky'
export function useActorStatus(actor?: bsky.profile.AnyProfileView) {
const shadowed = useMaybeProfileShadow(actor)
const tick = useTickEveryMinute()
const config = useLiveNowConfig()
return useMemo(() => {
void tick // revalidate every minute
if (shadowed && 'status' in shadowed && shadowed.status) {
const isValid = isStatusValidForViewers(shadowed.status, config)
const isDisabled = shadowed.status.isDisabled || false
const isActive = isStatusStillActive(shadowed.status.expiresAt)
if (isValid && !isDisabled && isActive) {
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled: false,
isActive: true,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
}
return {
uri: shadowed.status.uri,
cid: shadowed.status.cid,
isDisabled,
isActive: false,
status: 'app.bsky.actor.status#live',
embed: shadowed.status.embed as $Typed<AppBskyEmbedExternal.View>, // temp_isStatusValid asserts this
expiresAt: shadowed.status.expiresAt!, // isStatusStillActive asserts this
record: shadowed.status.record,
} satisfies AppBskyActorDefs.StatusView
} else {
return {
status: '',
isDisabled: false,
isActive: false,
record: {},
} satisfies AppBskyActorDefs.StatusView
}
}, [shadowed, config, tick])
}
export function isStatusStillActive(timeStr: string | undefined) {
if (!timeStr) return false
const now = new Date()
const expiry = parseISO(timeStr)
return isAfter(expiry, now)
}
/**
* Validates whether the live status is valid for display in the app. Does NOT
* validate if the status is valid for the acting user e.g. as they go live.
*/
export function isStatusValidForViewers(
status: AppBskyActorDefs.StatusView,
config: LiveNowConfig,
) {
if (status.status !== 'app.bsky.actor.status#live') return false
if (!status.uri) return false // should not happen, just backwards compat
try {
const {host: liveDid} = new AtUri(status.uri)
if (AppBskyEmbedExternal.isView(status.embed)) {
const url = new URL(status.embed.external.uri)
const exception = config.allowedHostsExceptionsByDid.get(liveDid)
const isValidException = exception ? exception.has(url.hostname) : false
const isValidForAnyone = config.defaultAllowedHosts.has(url.hostname)
return isValidException || isValidForAnyone
} else {
return false
}
} catch {
return false
}
}
+2 -1
View File
@@ -29,6 +29,7 @@ export async function retry<P>(
export async function networkRetry<P>(
retries: number,
fn: () => Promise<P>,
delay?: number,
): Promise<P> {
return retry(retries, isNetworkError, fn)
return retry(retries, isNetworkError, fn, delay)
}
+2
View File
@@ -62,6 +62,8 @@ export const MAX_DESCRIPTION = 256
export const MAX_GRAPHEME_LENGTH = 300
export const MAX_DRAFT_GRAPHEME_LENGTH = 1000
export const MAX_DM_GRAPHEME_LENGTH = 1000
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
+16
View File
@@ -0,0 +1,16 @@
import {useEffect, useState} from 'react'
/**
* Returns a debounced version of the input value that only updates after the
* specified delay has passed without any changes to the input value.
*/
export function useDebouncedValue<T>(val: T, delayMs: number): T {
const [prev, setPrev] = useState(val)
useEffect(() => {
const timeout = setTimeout(() => setPrev(val), delayMs)
return () => clearTimeout(timeout)
}, [val, delayMs])
return prev
}
+33
View File
@@ -24,6 +24,7 @@ export const embedPlayerSources = [
'giphy',
'tenor',
'flickr',
'bandcamp',
] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
@@ -44,6 +45,8 @@ export type EmbedPlayerType =
| 'giphy_gif'
| 'tenor_gif'
| 'flickr_album'
| 'bandcamp_album'
| 'bandcamp_track'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube',
@@ -56,6 +59,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
appleMusic: 'Apple Music',
soundcloud: 'SoundCloud',
flickr: 'Flickr',
bandcamp: 'Bandcamp',
}
export interface EmbedPlayerParams {
@@ -459,6 +463,32 @@ export function parseEmbedPlayerFromUrl(
return undefined
}
}
const bandcampRegex = /^[a-z\d][a-z\d-]{2,}[a-z\d]\.bandcamp\.com$/i
if (bandcampRegex.test(urlp.hostname)) {
const pathComponents = urlp.pathname.split('/')
switch (pathComponents[1]) {
case 'album':
return {
type: 'bandcamp_album',
source: 'bandcamp',
playerUri: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(
urlp.href,
)}/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/`,
}
case 'track':
return {
type: 'bandcamp_track',
source: 'bandcamp',
playerUri: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(
urlp.href,
)}/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/`,
}
default:
return undefined
}
}
}
export function getPlayerAspect({
@@ -498,6 +528,9 @@ export function getPlayerAspect({
return {height: 165}
case 'apple_music_song':
return {height: 150}
case 'bandcamp_album':
case 'bandcamp_track':
return {aspectRatio: 1}
default:
return {aspectRatio: 16 / 9}
}
+13
View File
@@ -30,6 +30,18 @@ export function cleanError(str: any): string {
if (str.includes('Bad token scope') || str.includes('Bad token method')) {
return t`This feature is not available while using an App Password. Please sign in with your main password.`
}
if (str.includes('Account has been suspended')) {
return t`Account has been suspended`
}
if (str.includes('Account is deactivated')) {
return t`Account is deactivated`
}
if (str.includes('Profile not found')) {
return t`Profile not found`
}
if (str.includes('Unable to resolve handle')) {
return t`Unable to resolve handle`
}
if (str.startsWith('Error: ')) {
return str.slice('Error: '.length)
}
@@ -42,6 +54,7 @@ const NETWORK_ERRORS = [
'Failed to fetch',
'Load failed',
'Upstream service unreachable',
'NetworkError when attempting to fetch resource',
]
export function isNetworkError(e: unknown) {
+124 -80
View File
@@ -8,8 +8,9 @@ import '@formatjs/intl-pluralrules/locale-data/en'
import '@formatjs/intl-numberformat/locale-data/en'
import '@formatjs/intl-displaynames/locale-data/en'
import {useEffect} from 'react'
import {useEffect, useState} from 'react'
import {i18n} from '@lingui/core'
import defaultLocale from 'date-fns/locale/en-US'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -63,130 +64,144 @@ export async function dynamicActivate(locale: AppLanguage) {
switch (locale) {
case AppLanguage.an: {
i18n.loadAndActivate({locale, messages: messagesAn})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/an'),
import('@formatjs/intl-numberformat/locale-data/es'),
import('@formatjs/intl-displaynames/locale-data/es'),
])
break
return dateLocale
}
case AppLanguage.ast: {
i18n.loadAndActivate({locale, messages: messagesAst})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/ast'),
import('@formatjs/intl-numberformat/locale-data/ast'),
import('@formatjs/intl-displaynames/locale-data/ast'),
])
break
return dateLocale
}
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ca'),
import('@formatjs/intl-pluralrules/locale-data/ca'),
import('@formatjs/intl-numberformat/locale-data/ca'),
import('@formatjs/intl-displaynames/locale-data/ca'),
])
break
return dateLocale
}
case AppLanguage.cy: {
i18n.loadAndActivate({locale, messages: messagesCy})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/cy'),
import('@formatjs/intl-pluralrules/locale-data/cy'),
import('@formatjs/intl-numberformat/locale-data/cy'),
import('@formatjs/intl-displaynames/locale-data/cy'),
])
break
return dateLocale
}
case AppLanguage.da: {
i18n.loadAndActivate({locale, messages: messagesDa})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/da'),
import('@formatjs/intl-pluralrules/locale-data/da'),
import('@formatjs/intl-numberformat/locale-data/da'),
import('@formatjs/intl-displaynames/locale-data/da'),
])
break
return dateLocale
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/de'),
import('@formatjs/intl-pluralrules/locale-data/de'),
import('@formatjs/intl-numberformat/locale-data/de'),
import('@formatjs/intl-displaynames/locale-data/de'),
])
break
return dateLocale
}
case AppLanguage.el: {
i18n.loadAndActivate({locale, messages: messagesEl})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/el'),
import('@formatjs/intl-pluralrules/locale-data/el'),
import('@formatjs/intl-numberformat/locale-data/el'),
import('@formatjs/intl-displaynames/locale-data/el'),
])
break
return dateLocale
}
case AppLanguage.en_GB: {
i18n.loadAndActivate({locale, messages: messagesEn_GB})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/en-GB'),
import('@formatjs/intl-pluralrules/locale-data/en'),
import('@formatjs/intl-numberformat/locale-data/en-GB'),
import('@formatjs/intl-displaynames/locale-data/en-GB'),
])
break
return dateLocale
}
case AppLanguage.eo: {
i18n.loadAndActivate({locale, messages: messagesEo})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/eo'),
import('@formatjs/intl-pluralrules/locale-data/eo'),
import('@formatjs/intl-numberformat/locale-data/eo'),
// borked, see https://github.com/bluesky-social/social-app/pull/9574
// import('@formatjs/intl-displaynames/locale-data/eo'),
])
break
return dateLocale
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/es'),
import('@formatjs/intl-numberformat/locale-data/es'),
import('@formatjs/intl-displaynames/locale-data/es'),
])
break
return dateLocale
}
case AppLanguage.eu: {
i18n.loadAndActivate({locale, messages: messagesEu})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/eu'),
import('@formatjs/intl-pluralrules/locale-data/eu'),
import('@formatjs/intl-numberformat/locale-data/eu'),
import('@formatjs/intl-displaynames/locale-data/eu'),
])
break
return dateLocale
}
case AppLanguage.fi: {
i18n.loadAndActivate({locale, messages: messagesFi})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fi'),
import('@formatjs/intl-pluralrules/locale-data/fi'),
import('@formatjs/intl-numberformat/locale-data/fi'),
import('@formatjs/intl-displaynames/locale-data/fi'),
])
break
return dateLocale
}
case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fr'),
import('@formatjs/intl-pluralrules/locale-data/fr'),
import('@formatjs/intl-numberformat/locale-data/fr'),
import('@formatjs/intl-displaynames/locale-data/fr'),
])
break
return dateLocale
}
case AppLanguage.fy: {
i18n.loadAndActivate({locale, messages: messagesFy})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fy'),
import('@formatjs/intl-pluralrules/locale-data/fy'),
import('@formatjs/intl-numberformat/locale-data/fy'),
import('@formatjs/intl-displaynames/locale-data/fy'),
])
break
return dateLocale
}
case AppLanguage.ga: {
i18n.loadAndActivate({locale, messages: messagesGa})
@@ -195,43 +210,47 @@ export async function dynamicActivate(locale: AppLanguage) {
import('@formatjs/intl-numberformat/locale-data/ga'),
import('@formatjs/intl-displaynames/locale-data/ga'),
])
break
return undefined
}
case AppLanguage.gd: {
i18n.loadAndActivate({locale, messages: messagesGd})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/gd'),
import('@formatjs/intl-pluralrules/locale-data/gd'),
import('@formatjs/intl-numberformat/locale-data/gd'),
import('@formatjs/intl-displaynames/locale-data/gd'),
])
break
return dateLocale
}
case AppLanguage.gl: {
i18n.loadAndActivate({locale, messages: messagesGl})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/gl'),
import('@formatjs/intl-pluralrules/locale-data/gl'),
import('@formatjs/intl-numberformat/locale-data/gl'),
import('@formatjs/intl-displaynames/locale-data/gl'),
])
break
return dateLocale
}
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/hi'),
import('@formatjs/intl-pluralrules/locale-data/hi'),
import('@formatjs/intl-numberformat/locale-data/hi'),
import('@formatjs/intl-displaynames/locale-data/hi'),
])
break
return dateLocale
}
case AppLanguage.hu: {
i18n.loadAndActivate({locale, messages: messagesHu})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/hu'),
import('@formatjs/intl-pluralrules/locale-data/hu'),
import('@formatjs/intl-numberformat/locale-data/hu'),
import('@formatjs/intl-displaynames/locale-data/hu'),
])
break
return dateLocale
}
case AppLanguage.ia: {
i18n.loadAndActivate({locale, messages: messagesIa})
@@ -240,52 +259,57 @@ export async function dynamicActivate(locale: AppLanguage) {
import('@formatjs/intl-numberformat/locale-data/ia'),
import('@formatjs/intl-displaynames/locale-data/ia'),
])
break
return undefined
}
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/id'),
import('@formatjs/intl-pluralrules/locale-data/id'),
import('@formatjs/intl-numberformat/locale-data/id'),
import('@formatjs/intl-displaynames/locale-data/id'),
])
break
return dateLocale
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/it'),
import('@formatjs/intl-pluralrules/locale-data/it'),
import('@formatjs/intl-numberformat/locale-data/it'),
import('@formatjs/intl-displaynames/locale-data/it'),
])
break
return dateLocale
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ja'),
import('@formatjs/intl-pluralrules/locale-data/ja'),
import('@formatjs/intl-numberformat/locale-data/ja'),
import('@formatjs/intl-displaynames/locale-data/ja'),
])
break
return dateLocale
}
case AppLanguage.km: {
i18n.loadAndActivate({locale, messages: messagesKm})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/km'),
import('@formatjs/intl-pluralrules/locale-data/km'),
import('@formatjs/intl-numberformat/locale-data/km'),
import('@formatjs/intl-displaynames/locale-data/km'),
])
break
return dateLocale
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ko'),
import('@formatjs/intl-pluralrules/locale-data/ko'),
import('@formatjs/intl-numberformat/locale-data/ko'),
import('@formatjs/intl-displaynames/locale-data/ko'),
])
break
return dateLocale
}
case AppLanguage.ne: {
i18n.loadAndActivate({locale, messages: messagesNe})
@@ -294,144 +318,164 @@ export async function dynamicActivate(locale: AppLanguage) {
import('@formatjs/intl-numberformat/locale-data/ne'),
import('@formatjs/intl-displaynames/locale-data/ne'),
])
break
return undefined
}
case AppLanguage.nl: {
i18n.loadAndActivate({locale, messages: messagesNl})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/nl'),
import('@formatjs/intl-pluralrules/locale-data/nl'),
import('@formatjs/intl-numberformat/locale-data/nl'),
import('@formatjs/intl-displaynames/locale-data/nl'),
])
break
return dateLocale
}
case AppLanguage.pl: {
i18n.loadAndActivate({locale, messages: messagesPl})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pl'),
import('@formatjs/intl-pluralrules/locale-data/pl'),
import('@formatjs/intl-numberformat/locale-data/pl'),
import('@formatjs/intl-displaynames/locale-data/pl'),
])
break
return dateLocale
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pt-BR'),
import('@formatjs/intl-pluralrules/locale-data/pt'),
import('@formatjs/intl-numberformat/locale-data/pt'),
import('@formatjs/intl-displaynames/locale-data/pt'),
])
break
return dateLocale
}
case AppLanguage.pt_PT: {
i18n.loadAndActivate({locale, messages: messagesPt_PT})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pt'),
import('@formatjs/intl-pluralrules/locale-data/pt-PT'),
import('@formatjs/intl-numberformat/locale-data/pt-PT'),
import('@formatjs/intl-displaynames/locale-data/pt-PT'),
])
break
return dateLocale
}
case AppLanguage.ro: {
i18n.loadAndActivate({locale, messages: messagesRo})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ro'),
import('@formatjs/intl-pluralrules/locale-data/ro'),
import('@formatjs/intl-numberformat/locale-data/ro'),
import('@formatjs/intl-displaynames/locale-data/ro'),
])
break
return dateLocale
}
case AppLanguage.ru: {
i18n.loadAndActivate({locale, messages: messagesRu})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ru'),
import('@formatjs/intl-pluralrules/locale-data/ru'),
import('@formatjs/intl-numberformat/locale-data/ru'),
import('@formatjs/intl-displaynames/locale-data/ru'),
])
break
return dateLocale
}
case AppLanguage.sv: {
i18n.loadAndActivate({locale, messages: messagesSv})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/sv'),
import('@formatjs/intl-pluralrules/locale-data/sv'),
import('@formatjs/intl-numberformat/locale-data/sv'),
import('@formatjs/intl-displaynames/locale-data/sv'),
])
break
return dateLocale
}
case AppLanguage.th: {
i18n.loadAndActivate({locale, messages: messagesTh})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/th'),
import('@formatjs/intl-pluralrules/locale-data/th'),
import('@formatjs/intl-numberformat/locale-data/th'),
import('@formatjs/intl-displaynames/locale-data/th'),
])
break
return dateLocale
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/tr'),
import('@formatjs/intl-pluralrules/locale-data/tr'),
import('@formatjs/intl-numberformat/locale-data/tr'),
import('@formatjs/intl-displaynames/locale-data/tr'),
])
break
return dateLocale
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/uk'),
import('@formatjs/intl-pluralrules/locale-data/uk'),
import('@formatjs/intl-numberformat/locale-data/uk'),
import('@formatjs/intl-displaynames/locale-data/uk'),
])
break
return dateLocale
}
case AppLanguage.vi: {
i18n.loadAndActivate({locale, messages: messagesVi})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/vi'),
import('@formatjs/intl-pluralrules/locale-data/vi'),
import('@formatjs/intl-numberformat/locale-data/vi'),
import('@formatjs/intl-displaynames/locale-data/vi'),
])
break
return dateLocale
}
case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-CN'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
])
break
return dateLocale
}
case AppLanguage.zh_HK: {
i18n.loadAndActivate({locale, messages: messagesZh_HK})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-HK'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
])
break
return dateLocale
}
case AppLanguage.zh_TW: {
i18n.loadAndActivate({locale, messages: messagesZh_TW})
await Promise.all([
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-TW'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
])
break
return dateLocale
}
default: {
i18n.loadAndActivate({locale, messages: messagesEn})
break
return defaultLocale
}
}
}
export function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
const [dateLocale, setDateLocale] = useState(defaultLocale)
useEffect(() => {
dynamicActivate(sanitizeAppLanguageSetting(appLanguage))
dynamicActivate(sanitizeAppLanguageSetting(appLanguage)).then(locale => {
setDateLocale(locale ?? defaultLocale)
})
}, [appLanguage])
return dateLocale
}
+160 -39
View File
@@ -1,5 +1,6 @@
import {useEffect} from 'react'
import {useEffect, useState} from 'react'
import {i18n} from '@lingui/core'
import defaultLocale from 'date-fns/locale/en-US'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -10,62 +11,105 @@ import {useLanguagePrefs} from '#/state/preferences'
*/
export async function dynamicActivate(locale: AppLanguage) {
let mod: any
let dateLocale: Locale = defaultLocale
switch (locale) {
case AppLanguage.an: {
mod = await import(`./locales/an/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/an/messages`),
import('date-fns/locale/es'),
])
break
}
case AppLanguage.ast: {
mod = await import(`./locales/ast/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ast/messages`),
import('date-fns/locale/es'),
])
break
}
case AppLanguage.ca: {
mod = await import(`./locales/ca/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ca/messages`),
import('date-fns/locale/ca'),
])
break
}
case AppLanguage.cy: {
mod = await import(`./locales/cy/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/cy/messages`),
import('date-fns/locale/cy'),
])
break
}
case AppLanguage.da: {
mod = await import(`./locales/da/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/da/messages`),
import('date-fns/locale/da'),
])
break
}
case AppLanguage.de: {
mod = await import(`./locales/de/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/de/messages`),
import('date-fns/locale/de'),
])
break
}
case AppLanguage.el: {
mod = await import(`./locales/el/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/el/messages`),
import('date-fns/locale/el'),
])
break
}
case AppLanguage.en_GB: {
mod = await import(`./locales/en-GB/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/en-GB/messages`),
import('date-fns/locale/en-GB'),
])
break
}
case AppLanguage.eo: {
mod = await import(`./locales/eo/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/eo/messages`),
import('date-fns/locale/eo'),
])
break
}
case AppLanguage.es: {
mod = await import(`./locales/es/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/es/messages`),
import('date-fns/locale/es'),
])
break
}
case AppLanguage.eu: {
mod = await import(`./locales/eu/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/eu/messages`),
import('date-fns/locale/eu'),
])
break
}
case AppLanguage.fi: {
mod = await import(`./locales/fi/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fi/messages`),
import('date-fns/locale/fi'),
])
break
}
case AppLanguage.fr: {
mod = await import(`./locales/fr/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fr/messages`),
import('date-fns/locale/fr'),
])
break
}
case AppLanguage.fy: {
mod = await import(`./locales/fy/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fy/messages`),
import('date-fns/locale/fy'),
])
break
}
case AppLanguage.ga: {
@@ -73,19 +117,31 @@ export async function dynamicActivate(locale: AppLanguage) {
break
}
case AppLanguage.gd: {
mod = await import(`./locales/gd/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/gd/messages`),
import('date-fns/locale/gd'),
])
break
}
case AppLanguage.gl: {
mod = await import(`./locales/gl/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/gl/messages`),
import('date-fns/locale/gl'),
])
break
}
case AppLanguage.hi: {
mod = await import(`./locales/hi/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/hi/messages`),
import('date-fns/locale/hi'),
])
break
}
case AppLanguage.hu: {
mod = await import(`./locales/hu/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/hu/messages`),
import('date-fns/locale/hu'),
])
break
}
case AppLanguage.ia: {
@@ -93,23 +149,38 @@ export async function dynamicActivate(locale: AppLanguage) {
break
}
case AppLanguage.id: {
mod = await import(`./locales/id/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/id/messages`),
import('date-fns/locale/id'),
])
break
}
case AppLanguage.it: {
mod = await import(`./locales/it/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/it/messages`),
import('date-fns/locale/it'),
])
break
}
case AppLanguage.ja: {
mod = await import(`./locales/ja/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ja/messages`),
import('date-fns/locale/ja'),
])
break
}
case AppLanguage.km: {
mod = await import(`./locales/km/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/km/messages`),
import('date-fns/locale/km'),
])
break
}
case AppLanguage.ko: {
mod = await import(`./locales/ko/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ko/messages`),
import('date-fns/locale/ko'),
])
break
}
case AppLanguage.ne: {
@@ -117,59 +188,101 @@ export async function dynamicActivate(locale: AppLanguage) {
break
}
case AppLanguage.nl: {
mod = await import(`./locales/nl/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/nl/messages`),
import('date-fns/locale/nl'),
])
break
}
case AppLanguage.pl: {
mod = await import(`./locales/pl/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pl/messages`),
import('date-fns/locale/pl'),
])
break
}
case AppLanguage.pt_BR: {
mod = await import(`./locales/pt-BR/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pt-BR/messages`),
import('date-fns/locale/pt-BR'),
])
break
}
case AppLanguage.pt_PT: {
mod = await import(`./locales/pt-PT/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pt-PT/messages`),
import('date-fns/locale/pt'),
])
break
}
case AppLanguage.ro: {
mod = await import(`./locales/ro/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ro/messages`),
import('date-fns/locale/ro'),
])
break
}
case AppLanguage.ru: {
mod = await import(`./locales/ru/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ru/messages`),
import('date-fns/locale/ru'),
])
break
}
case AppLanguage.sv: {
mod = await import(`./locales/sv/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/sv/messages`),
import('date-fns/locale/sv'),
])
break
}
case AppLanguage.th: {
mod = await import(`./locales/th/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/th/messages`),
import('date-fns/locale/th'),
])
break
}
case AppLanguage.tr: {
mod = await import(`./locales/tr/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/tr/messages`),
import('date-fns/locale/tr'),
])
break
}
case AppLanguage.uk: {
mod = await import(`./locales/uk/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/uk/messages`),
import('date-fns/locale/uk'),
])
break
}
case AppLanguage.vi: {
mod = await import(`./locales/vi/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/vi/messages`),
import('date-fns/locale/vi'),
])
break
}
case AppLanguage.zh_CN: {
mod = await import(`./locales/zh-CN/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-CN/messages`),
import('date-fns/locale/zh-CN'),
])
break
}
case AppLanguage.zh_HK: {
mod = await import(`./locales/zh-HK/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-HK/messages`),
import('date-fns/locale/zh-HK'),
])
break
}
case AppLanguage.zh_TW: {
mod = await import(`./locales/zh-TW/messages`)
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-TW/messages`),
import('date-fns/locale/zh-TW'),
])
break
}
default: {
@@ -180,14 +293,22 @@ export async function dynamicActivate(locale: AppLanguage) {
i18n.load(locale, mod.messages)
i18n.activate(locale)
return dateLocale
}
export function useLocaleLanguage() {
const {appLanguage} = useLanguagePrefs()
const [dateLocale, setDateLocale] = useState(defaultLocale)
useEffect(() => {
const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)
document.documentElement.lang = sanitizedLanguage
dynamicActivate(sanitizedLanguage)
dynamicActivate(sanitizedLanguage).then(locale => {
setDateLocale(locale)
})
}, [appLanguage])
return dateLocale
}
+24 -2
View File
@@ -1,10 +1,32 @@
import {createContext, useContext} from 'react'
import {i18n} from '@lingui/core'
import {I18nProvider as DefaultI18nProvider} from '@lingui/react'
import {type Locale} from 'date-fns'
import type React from 'react'
import {useLocaleLanguage} from './i18n'
const DateLocaleContext = createContext<Locale | undefined>(undefined)
DateLocaleContext.displayName = 'DateLocaleContext'
export default function I18nProvider({children}: {children: React.ReactNode}) {
useLocaleLanguage()
return <DefaultI18nProvider i18n={i18n}>{children}</DefaultI18nProvider>
const dateLocale = useLocaleLanguage()
return (
<DateLocaleContext value={dateLocale}>
<DefaultI18nProvider i18n={i18n}>{children}</DefaultI18nProvider>
</DateLocaleContext>
)
}
/**
* Returns a `date-fns` locale corresponding to the current app language
*/
export function useDateLocale() {
const ctx = useContext(DateLocaleContext)
if (!ctx) {
throw new Error('useDateLocale must be used within an I18nProvider')
}
return ctx
}
File diff suppressed because it is too large Load Diff
+23 -18
View File
@@ -1,4 +1,4 @@
import React from 'react'
import {useCallback, useState} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -12,6 +12,7 @@ import {AccountList} from '#/components/AccountList'
import {Button, ButtonText} from '#/components/Button'
import * as TextField from '#/components/forms/TextField'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer'
export const ChooseAccountForm = ({
@@ -21,14 +22,14 @@ export const ChooseAccountForm = ({
onSelectAccount: (account?: SessionAccount) => void
onPressBack: () => void
}) => {
const [pendingDid, setPendingDid] = React.useState<string | null>(null)
const [pendingDid, setPendingDid] = useState<string | null>(null)
const {_} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const {setShowLoggedOut} = useLoggedOutViewControls()
const onSelect = React.useCallback(
const onSelect = useCallback(
async (account: SessionAccount) => {
if (pendingDid) {
// The session API isn't resilient to race conditions so let's just ignore this.
@@ -54,7 +55,7 @@ export const ChooseAccountForm = ({
Toast.show(_(msg`Signed in as @${account.handle}`))
} catch (e: any) {
logger.error('choose account: initSession failed', {
message: e.message,
message: e instanceof Error ? e.message : 'Unknown error',
})
// Move to login form.
onSelectAccount(account)
@@ -69,6 +70,7 @@ export const ChooseAccountForm = ({
onSelectAccount,
setShowLoggedOut,
_,
ax,
],
)
@@ -78,26 +80,29 @@ export const ChooseAccountForm = ({
titleText={<Trans>Select account</Trans>}
style={web([a.py_2xl])}>
<View>
<TextField.LabelText>
<Trans>Sign in as...</Trans>
</TextField.LabelText>
{IS_WEB && (
<TextField.LabelText>
<Trans>Sign in as...</Trans>
</TextField.LabelText>
)}
<AccountList
onSelectAccount={onSelect}
onSelectOther={() => onSelectAccount()}
pendingDid={pendingDid}
/>
</View>
<View style={[a.flex_row]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>{_(msg`Back`)}</ButtonText>
</Button>
<View style={[a.flex_1]} />
</View>
{IS_WEB && (
<View style={[a.flex_row]}>
<Button
label={_(msg`Back`)}
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>{_(msg`Back`)}</ButtonText>
</Button>
<View style={[a.flex_1]} />
</View>
)}
</FormContainer>
)
}
+36 -28
View File
@@ -1,21 +1,22 @@
import React, {useState} from 'react'
import {ActivityIndicator, Keyboard, View} from 'react-native'
import {Keyboard, View} from 'react-native'
import {type ComAtprotoServerDescribeServer} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as EmailValidator from 'email-validator'
import {isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {Agent} from '#/state/session/agent'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
import * as TextField from '#/components/forms/TextField'
import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -118,43 +119,50 @@ export const ForgotPasswordForm = ({
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{!serviceDescription || isProcessing ? (
<ActivityIndicator />
<View style={[web([a.flex_row, a.align_center]), a.pt_md]}>
{IS_WEB && (
<>
<Button
label={_(msg`Back`)}
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
</>
)}
{!serviceDescription ? (
<Button
label={_(msg`Connecting to service...`)}
size="large"
color="secondary"
disabled>
<ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
</Button>
) : (
<Button
label={_(msg`Next`)}
variant="solid"
color={'primary'}
accessibilityHint={_(msg`Navigates to the next screen`)}
color="primary"
size="large"
onPress={onPressNext}>
onPress={onPressNext}
disabled={isProcessing}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
)}
{!serviceDescription || isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Processing...</Trans>
</Text>
) : undefined}
</View>
<View
style={[
t.atoms.border_contrast_medium,
a.border_t,
a.pt_2xl,
a.pt_xl,
a.mt_md,
a.flex_row,
a.justify_center,
+5 -6
View File
@@ -1,7 +1,7 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import type React from 'react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useBreakpoints, useGutters} from '#/alf'
import {Text} from '#/components/Typography'
export function FormContainer({
@@ -16,15 +16,14 @@ export function FormContainer({
style?: StyleProp<ViewStyle>
}) {
const {gtMobile} = useBreakpoints()
const t = useTheme()
const gutter = useGutters([0, 'wide'])
return (
<View
testID={testID}
style={[a.gap_md, a.flex_1, !gtMobile && [a.px_lg, a.py_md], style]}>
style={[a.gap_md, a.flex_1, !gtMobile && gutter, style]}>
{titleText && !gtMobile && (
<Text style={[a.text_xl, a.font_semi_bold, t.atoms.text_contrast_high]}>
{titleText}
</Text>
<Text style={[a.text_3xl, a.font_bold]}>{titleText}</Text>
)}
{children}
</View>
+47 -45
View File
@@ -1,11 +1,5 @@
import React, {useRef, useState} from 'react'
import {
ActivityIndicator,
Keyboard,
LayoutAnimation,
type TextInput,
View,
} from 'react-native'
import {useCallback, useRef, useState} from 'react'
import {Keyboard, type TextInput, View} from 'react-native'
import {
ComAtprotoServerCreateSession,
type ComAtprotoServerDescribeServer,
@@ -14,14 +8,13 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {isNetworkError} from '#/lib/strings/errors'
import {cleanError} from '#/lib/strings/errors'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {atoms as a, ios, useTheme} from '#/alf'
import {atoms as a, ios, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import {HostingProvider} from '#/components/forms/HostingProvider'
@@ -31,7 +24,7 @@ import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {IS_IOS} from '#/env'
import {IS_IOS, IS_WEB} from '#/env'
import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -62,9 +55,11 @@ export const LoginForm = ({
onAttemptFailed: () => void
}) => {
const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
useState<boolean>(false)
const [isProcessing, setIsProcessing] = useState(false)
const [errorField, setErrorField] = useState<
'none' | 'identifier' | 'password' | '2fa'
>('none')
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = useState(false)
const identifierValueRef = useRef<string>(initialHandle || '')
const passwordValueRef = useRef<string>('')
const [authFactorToken, setAuthFactorToken] = useState('')
@@ -77,26 +72,28 @@ export const LoginForm = ({
const {setShowLoggedOut} = useLoggedOutViewControls()
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
const onPressSelectService = React.useCallback(() => {
const onPressSelectService = useCallback(() => {
Keyboard.dismiss()
}, [])
const onPressNext = async () => {
if (isProcessing) return
Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('')
setErrorField('none')
const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current
if (!identifier) {
setError(_(msg`Please enter your username`))
setErrorField('identifier')
return
}
if (!password) {
setError(_(msg`Please enter your password`))
setErrorField('password')
return
}
@@ -141,7 +138,6 @@ export const LoginForm = ({
requestNotificationsPermission('Login')
} catch (e: any) {
const errMsg = e.toString()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setIsProcessing(false)
if (
e instanceof ComAtprotoServerCreateSession.AuthFactorTokenRequiredError
@@ -154,6 +150,7 @@ export const LoginForm = ({
error: errMsg,
})
setError(_(msg`Invalid 2FA confirmation code.`))
setErrorField('2fa')
} else if (
errMsg.includes('Authentication Required') ||
errMsg.includes('Invalid identifier or password')
@@ -194,7 +191,7 @@ export const LoginForm = ({
<Trans>Account</Trans>
</TextField.LabelText>
<View style={[a.gap_sm]}>
<TextField.Root>
<TextField.Root isInvalid={errorField === 'identifier'}>
<TextField.Icon icon={At} />
<TextField.Input
testID="loginUsernameInput"
@@ -209,6 +206,7 @@ export const LoginForm = ({
defaultValue={initialHandle || ''}
onChangeText={v => {
identifierValueRef.current = v
if (errorField) setErrorField('none')
}}
onSubmitEditing={() => {
passwordRef.current?.focus()
@@ -221,7 +219,7 @@ export const LoginForm = ({
/>
</TextField.Root>
<TextField.Root>
<TextField.Root isInvalid={errorField === 'password'}>
<TextField.Icon icon={Lock} />
<TextField.Input
testID="loginPasswordInput"
@@ -236,6 +234,7 @@ export const LoginForm = ({
clearButtonMode="while-editing"
onChangeText={v => {
passwordValueRef.current = v
if (errorField) setErrorField('none')
}}
onSubmitEditing={onPressNext}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
@@ -277,7 +276,7 @@ export const LoginForm = ({
<TextField.LabelText>
<Trans>2FA Confirmation</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Root isInvalid={errorField === '2fa'}>
<TextField.Icon icon={Ticket} />
<TextField.Input
testID="loginAuthFactorTokenInput"
@@ -288,8 +287,11 @@ export const LoginForm = ({
autoComplete="one-time-code"
returnKeyType="done"
blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
onChangeText={setAuthFactorToken}
value={authFactorToken} // controlled input due to uncontrolled input not receiving pasted values properly
onChangeText={text => {
setAuthFactorToken(text)
if (errorField) setErrorField('none')
}}
onSubmitEditing={onPressNext}
editable={!isProcessing}
accessibilityHint={_(
@@ -308,25 +310,24 @@ export const LoginForm = ({
</View>
)}
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_md]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
<View style={[a.pt_md, web([a.justify_between, a.flex_row])]}>
{IS_WEB && (
<Button
label={_(msg`Back`)}
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
)}
{!serviceDescription && error ? (
<Button
testID="loginRetryButton"
label={_(msg`Retry`)}
accessibilityHint={_(msg`Retries signing in`)}
variant="solid"
color="secondary"
color="primary_subtle"
size="large"
onPress={onPressRetryConnect}>
<ButtonText>
@@ -334,23 +335,24 @@ export const LoginForm = ({
</ButtonText>
</Button>
) : !serviceDescription ? (
<>
<ActivityIndicator />
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Connecting...</Trans>
</Text>
</>
<Button
label={_(msg`Connecting to service...`)}
size="large"
color="secondary"
disabled>
<ButtonIcon icon={Loader} />
<ButtonText>Connecting...</ButtonText>
</Button>
) : (
<Button
testID="loginNextButton"
label={_(msg`Next`)}
label={_(msg`Sign in`)}
accessibilityHint={_(msg`Navigates to the next screen`)}
variant="solid"
color="primary"
size="large"
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
<Trans>Sign in</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
+3 -4
View File
@@ -2,7 +2,7 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints} from '#/alf'
import {atoms as a, useBreakpoints, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography'
import {FormContainer} from './FormContainer'
@@ -19,18 +19,17 @@ export const PasswordUpdatedForm = ({
<FormContainer
testID="passwordUpdatedForm"
style={[a.gap_2xl, !gtMobile && a.mt_5xl]}>
<Text style={[a.text_3xl, a.font_semi_bold, a.text_center]}>
<Text style={[a.text_3xl, a.font_bold, a.text_center]}>
<Trans>Password updated!</Trans>
</Text>
<Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}>
<Trans>You can now sign in with your new password.</Trans>
</Text>
<View style={[a.flex_row, a.justify_center]}>
<View style={web([a.flex_row, a.justify_center])}>
<Button
onPress={onPressNext}
label={_(msg`Close alert`)}
accessibilityHint={_(msg`Closes password update alert`)}
variant="solid"
color="primary"
size="large">
<ButtonText>
+32 -34
View File
@@ -1,5 +1,5 @@
import {useState} from 'react'
import {ActivityIndicator, View} from 'react-native'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -7,14 +7,16 @@ import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {checkAndFormatResetCode} from '#/lib/strings/password'
import {logger} from '#/logger'
import {Agent} from '#/state/session/agent'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {atoms as a, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {FormError} from '#/components/forms/FormError'
import * as TextField from '#/components/forms/TextField'
import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
import {FormContainer} from './FormContainer'
export const SetNewPasswordForm = ({
@@ -31,7 +33,6 @@ export const SetNewPasswordForm = ({
onPasswordSet: () => void
}) => {
const {_} = useLingui()
const t = useTheme()
const ax = useAnalytics()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
@@ -163,37 +164,34 @@ export const SetNewPasswordForm = ({
<FormError error={error} />
<View style={[a.flex_row, a.align_center, a.pt_lg]}>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
{isProcessing ? (
<ActivityIndicator />
) : (
<Button
label={_(msg`Next`)}
variant="solid"
color="primary"
size="large"
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
</Button>
<View style={[web([a.flex_row, a.align_center]), a.pt_lg]}>
{IS_WEB && (
<>
<Button
label={_(msg`Back`)}
variant="solid"
color="secondary"
size="large"
onPress={onPressBack}>
<ButtonText>
<Trans>Back</Trans>
</ButtonText>
</Button>
<View style={a.flex_1} />
</>
)}
{isProcessing ? (
<Text style={[t.atoms.text_contrast_high, a.pl_md]}>
<Trans>Updating...</Trans>
</Text>
) : undefined}
<Button
label={_(msg`Next`)}
color="primary"
size="large"
onPress={onPressNext}
disabled={isProcessing}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
</View>
</FormContainer>
)
@@ -0,0 +1,92 @@
import {useContext} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {Logomark} from '#/view/icons/Logomark'
import {atoms as a, platform, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {
BUTTON_VISUAL_ALIGNMENT_OFFSET,
Header,
HEADER_SLOT_SIZE,
} from '#/components/Layout'
import {IS_WEB} from '#/env'
import {AuthLayoutNavigationContext} from '../context'
/**
* This is a simplified version of `Layout.Header` for the auth screens.
*/
export const Slot = Header.Slot
export function Outer({children}: {children: React.ReactNode}) {
const t = useTheme()
const gutters = useGutters([0, 'wide'])
return (
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_sm,
gutters,
platform({
native: [a.pb_xs, {minHeight: 48}],
web: [a.py_xs, {minHeight: 52}],
}),
t.atoms.border_contrast_low,
]}>
{children}
</View>
)
}
export function Content({children}: {children?: React.ReactNode}) {
return (
<View style={[a.flex_1, a.justify_center, {minHeight: HEADER_SLOT_SIZE}]}>
{IS_WEB ? children : <Logo />}
</View>
)
}
export function Logo() {
const t = useTheme()
return <Logomark fill={t.palette.primary_500} style={[a.mx_auto]} />
}
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const navigation = useContext(AuthLayoutNavigationContext)
const onPressBack = (evt: GestureResponderEvent) => {
onPress?.(evt)
if (evt.defaultPrevented) return
navigation?.goBack()
}
return (
<Slot>
<Button
label={_(msg`Go back`)}
onPress={onPressBack}
size="small"
variant="ghost"
color="secondary"
shape="round"
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ButtonIcon icon={ArrowLeft} size="lg" />
</Button>
</Slot>
)
}
@@ -0,0 +1,23 @@
import {type ButtonProps} from '#/components/Button'
import {Header} from '#/components/Layout'
// TEMP: Web needs a much more comprehensive layout change to support the new design
// so to do this incrementally, we'll do native only and migrate to web later
export const Slot = Header.Slot
export function Outer({}: {children: React.ReactNode}) {
return null
}
export function Content({}: {children?: React.ReactNode}) {
return null
}
export function Logo() {
return null
}
export function BackButton({}: Partial<ButtonProps>) {
return null
}
@@ -0,0 +1,5 @@
import {createContext} from 'react'
export const AuthLayoutNavigationContext = createContext<{
goBack: () => void
} | null>(null)
@@ -0,0 +1 @@
export * as Header from './Header'
+41 -23
View File
@@ -18,6 +18,8 @@ import {atoms as a, native} from '#/alf'
import {ScreenTransition} from '#/components/ScreenTransition'
import {useAnalytics} from '#/analytics'
import {ChooseAccountForm} from './ChooseAccountForm'
import * as AuthLayout from './components/AuthLayout'
import {AuthLayoutNavigationContext} from './components/AuthLayout/context'
enum Forms {
Login,
@@ -131,11 +133,14 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
let content = null
let title = ''
let description = ''
let goBack = null
switch (currentForm) {
case Forms.Login:
title = _(msg`Sign in`)
description = _(msg`Enter your username and password`)
goBack = () =>
accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
content = (
<LoginForm
error={error}
@@ -146,9 +151,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
onAttemptFailed={onAttemptFailed}
onAttemptSuccess={onAttemptSuccess}
setServiceUrl={setServiceUrl}
onPressBack={() =>
accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
}
onPressBack={goBack}
onPressForgotPassword={onPressForgotPassword}
onPressRetryConnect={refetchService}
/>
@@ -157,16 +160,18 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
case Forms.ChooseAccount:
title = _(msg`Sign in`)
description = _(msg`Select from an existing account`)
goBack = handlePressBack
content = (
<ChooseAccountForm
onSelectAccount={onSelectAccount}
onPressBack={handlePressBack}
onPressBack={goBack}
/>
)
break
case Forms.ForgotPassword:
title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`)
goBack = () => gotoForm(Forms.Login)
content = (
<ForgotPasswordForm
error={error}
@@ -174,7 +179,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
serviceDescription={serviceDescription}
setError={setError}
setServiceUrl={setServiceUrl}
onPressBack={() => gotoForm(Forms.Login)}
onPressBack={goBack}
onEmailSent={() => gotoForm(Forms.SetNewPassword)}
/>
)
@@ -182,12 +187,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
case Forms.SetNewPassword:
title = _(msg`Forgot Password`)
description = _(msg`Let's get your password reset!`)
goBack = () => gotoForm(Forms.ForgotPassword)
content = (
<SetNewPasswordForm
error={error}
serviceUrl={serviceUrl}
setError={setError}
onPressBack={() => gotoForm(Forms.ForgotPassword)}
onPressBack={goBack}
onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
/>
)
@@ -201,23 +207,35 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
break
}
const navigation = goBack ? {goBack} : null
return (
<Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}>
<KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}>
<LoggedOutLayout
leadin=""
title={title}
description={description}
scrollable>
<LayoutAnimationConfig skipEntering>
<ScreenTransition
key={currentForm}
direction={screenTransitionDirection}>
{content}
</ScreenTransition>
</LayoutAnimationConfig>
</LoggedOutLayout>
</KeyboardAvoidingView>
</Animated.View>
<AuthLayoutNavigationContext value={navigation}>
<Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}>
<KeyboardAvoidingView
testID="signIn"
behavior="padding"
style={a.flex_1}>
<AuthLayout.Header.Outer>
<AuthLayout.Header.BackButton />
<AuthLayout.Header.Content />
<AuthLayout.Header.Slot />
</AuthLayout.Header.Outer>
<LoggedOutLayout
leadin=""
title={title}
description={description}
scrollable>
<LayoutAnimationConfig skipEntering>
<ScreenTransition
key={currentForm}
direction={screenTransitionDirection}>
{content}
</ScreenTransition>
</LayoutAnimationConfig>
</LoggedOutLayout>
</KeyboardAvoidingView>
</Animated.View>
</AuthLayoutNavigationContext>
)
}
+56 -5
View File
@@ -5,13 +5,14 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {getLabelingServiceTitle, isAppLabeler} from '#/lib/moderation'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useRemoveLabelersMutation} from '#/state/queries/labeler'
import {
useMyLabelersQuery,
usePreferencesQuery,
@@ -21,10 +22,10 @@ import {
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
import {useSetMinimalShellMode} from '#/state/shell'
import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Admonition from '#/components/Admonition'
import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button} from '#/components/Button'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
@@ -42,6 +43,7 @@ import {InlineLinkText, Link} from '#/components/Link'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_IOS} from '#/env'
@@ -166,10 +168,34 @@ export function ModerationScreenInner({
data: labelers,
error: labelersError,
} = useMyLabelersQuery()
const {mutateAsync: removeLabelers, isPending: isRemovingLabelers} =
useRemoveLabelersMutation()
const aa = useAgeAssurance()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const aaCopy = useAgeAssuranceCopy()
const subscribedDids = preferences.moderationPrefs.labelers.map(l => l.did)
const returnedDids = new Set(labelers?.map(l => l.creator.did))
const unavailableDids = subscribedDids.filter(
did =>
!returnedDids.has(did) &&
!isAppLabeler(did) &&
!isNonConfigurableModerationAuthority(did),
)
const handleCleanup = async () => {
try {
await removeLabelers({dids: unavailableDids})
Toast.show(_(msg`Removed unavailable services`), {
type: 'success',
})
} catch (e: any) {
logger.error('Failed to remove unavailable labelers', {
safeMessage: e.message,
})
}
}
useFocusEffect(
useCallback(() => {
setMinimalShellMode(false)
@@ -209,7 +235,7 @@ export function ModerationScreenInner({
<View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}>
{aa.flags.adultContentDisabled && isBirthdateUpdateAllowed && (
<View style={[a.pb_2xl]}>
<Admonition type="tip" style={[a.pb_md]}>
<Admonition.Admonition type="tip" style={[a.pb_md]}>
<Trans>
Your declared age is under 18. Some settings below may be
disabled. If this was a mistake, you may edit your birthdate in
@@ -221,7 +247,7 @@ export function ModerationScreenInner({
</InlineLinkText>
.
</Trans>
</Admonition>
</Admonition.Admonition>
</View>
)}
@@ -439,6 +465,31 @@ export function ModerationScreenInner({
<Trans>Advanced</Trans>
</Text>
{unavailableDids.length > 0 && (
<Admonition.Outer type="tip" style={[a.mb_md]}>
<Admonition.Row>
<Admonition.Icon />
<Admonition.Content>
<Admonition.Text>
<Trans>
Some moderation services in your list are no longer available.
</Trans>
</Admonition.Text>
</Admonition.Content>
<Admonition.Button
color="primary_subtle"
label={_(msg`Remove unavailable moderation services`)}
onPress={handleCleanup}
disabled={isRemovingLabelers}>
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
{isRemovingLabelers && <ButtonIcon icon={Loader} />}
</Admonition.Button>
</Admonition.Row>
</Admonition.Outer>
)}
{isLabelersLoading ? (
<View style={[a.w_full, a.align_center, a.p_lg]}>
<Loader size="xl" />
@@ -9,6 +9,7 @@ import * as bcp47Match from 'bcp-47-match'
import {wait} from '#/lib/async/wait'
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
import {logger} from '#/logger'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {useLanguagePrefs} from '#/state/preferences'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -19,7 +20,7 @@ import {
OnboardingTitleText,
} from '#/screens/Onboarding/Layout'
import {useOnboardingInternalState} from '#/screens/Onboarding/state'
import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
import {useSuggestedOnboardingUsers} from '#/screens/Search/util/useSuggestedOnboardingUsers'
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -63,13 +64,14 @@ export function StepSuggestedAccounts() {
const interests = Object.keys(interestsDisplayNames)
.sort(boostInterests(popularInterests))
.sort(boostInterests(state.interestsStepResults.selectedInterests))
const {
data: suggestedUsers,
isLoading,
error,
isRefetching,
refetch,
} = useSuggestedUsers({
} = useSuggestedOnboardingUsers({
category: selectedInterest || (useFullExperience ? null : interests[0]),
search: !useFullExperience,
overrideInterests: state.interestsStepResults.selectedInterests,
@@ -128,7 +130,13 @@ export function StepSuggestedAccounts() {
toast.show(_(msg`Followed all accounts!`), {type: 'success'})
setFollowedUsers(followed => [...followed, ...newlyFollowed])
},
onError: () => {
onError: e => {
logger.error(
'Failed to follow all suggested accounts during onboarding',
{
safeMessage: e,
},
)
toast.show(
_(msg`Failed to follow all suggested accounts, please try again`),
{type: 'error'},
@@ -156,6 +164,14 @@ export function StepSuggestedAccounts() {
[ax, selectedInterest, suggestedUsers?.recId],
)
useEffect(() => {
if (error) {
logger.error('Failed to fetch suggested accounts during onboarding', {
safeMessage: error,
})
}
}, [error])
return (
<View style={[a.align_start, a.gap_sm]} testID="onboardingInterests">
<OnboardingPosition />
@@ -10,7 +10,6 @@ import {
import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {useTranslate} from '#/lib/hooks/useTranslate'
import {makeProfileLink} from '#/lib/routes/links'
@@ -39,7 +38,6 @@ import {
REPLY_LINE_WIDTH,
} from '#/screens/PostThread/const'
import {atoms as a, useTheme} from '#/alf'
import {colors} from '#/components/Admonition'
import {Button} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
@@ -60,6 +58,7 @@ import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {WhoCanReply} from '#/components/WhoCanReply'
import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow'
import * as bsky from '#/types/bsky'
export function ThreadItemAnchor({
@@ -452,14 +451,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text
testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
<Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
</Trans>
</Text>
</Link>
) : null}
@@ -470,14 +471,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text
testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
<Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Trans>
</Text>
</Link>
) : null}
@@ -486,10 +489,12 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text
testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
</Trans>
</Text>
</Link>
) : null}
@@ -497,10 +502,16 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<Text
testID="bookmarkCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.bookmarkCount)}
</Text>{' '}
<Plural value={post.bookmarkCount} one="save" other="saves" />
<Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.bookmarkCount)}
</Text>{' '}
<Plural
value={post.bookmarkCount}
one="save"
other="saves"
/>
</Trans>
</Text>
) : null}
</View>
@@ -635,8 +646,6 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
if (!isBackdated) return null
const orange = colors.warning
return (
<>
<Button
@@ -663,7 +672,7 @@ function BackdatedPostIndicator({post}: {post: AppBskyFeedDefs.PostView}) {
paddingVertical: 3,
},
]}>
<CalendarClockIcon fill={orange} size="sm" aria-hidden />
<CalendarClockIcon fill={t.palette.yellow} size="sm" aria-hidden />
<Text
style={[
a.text_xs,
@@ -8,7 +8,6 @@ import {
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {useActorStatus} from '#/lib/actor-status'
import {MAX_POST_LINES} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {makeProfileLink} from '#/lib/routes/links'
@@ -44,6 +43,7 @@ import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton'
import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography'
import {useActorStatus} from '#/features/liveNow'
export type ThreadItemPostProps = {
item: Extract<ThreadItem, {type: 'threadPost'}>
@@ -10,7 +10,6 @@ import {
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {useHaptics} from '#/lib/haptics'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -39,6 +38,8 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {VerificationCheckButton} from '#/components/verification/VerificationCheckButton'
import {IS_IOS} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {GermButton} from '../components/GermButton'
import {EditProfileDialog} from './EditProfileDialog'
import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics'
@@ -156,6 +157,7 @@ let ProfileHeaderStandard = ({
testID="profileHeaderDescription"
style={[a.text_md]}
numberOfLines={15}
selectable
value={descriptionRT}
enableTags
authorHandle={profile.handle}
@@ -163,6 +165,10 @@ let ProfileHeaderStandard = ({
</View>
) : undefined}
{profile.associated?.germ && (
<GermButton germ={profile.associated.germ} profile={profile} />
)}
{!isMe &&
!isBlockedUser &&
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
+4 -4
View File
@@ -14,7 +14,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useActorStatus} from '#/lib/actor-status'
import {BACK_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {type NavigationProp} from '#/lib/routes/types'
@@ -28,13 +27,14 @@ import {atoms as a, platform, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
import {EditLiveDialog} from '#/components/live/EditLiveDialog'
import {LiveIndicator} from '#/components/live/LiveIndicator'
import {LiveStatusDialog} from '#/components/live/LiveStatusDialog'
import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import {useActorStatus} from '#/features/liveNow'
import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog'
import {LiveIndicator} from '#/features/liveNow/components/LiveIndicator'
import {LiveStatusDialog} from '#/features/liveNow/components/LiveStatusDialog'
import {GrowableAvatar} from './GrowableAvatar'
import {GrowableBanner} from './GrowableBanner'
import {StatusBarShadow} from './StatusBarShadow'
+25 -78
View File
@@ -1,24 +1,19 @@
import React, {useCallback, useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {useAnimatedRef} from 'react-native-reanimated'
import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIsFocused, useNavigation} from '@react-navigation/native'
import {useIsFocused} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URIS} from '#/lib/constants'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {usePalette} from '#/lib/hooks/usePalette'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {ComposeIcon2} from '#/lib/icons'
import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {makeRecordUri} from '#/lib/strings/url-helpers'
import {s} from '#/lib/styles'
import {listenSoftReset} from '#/state/events'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {
@@ -36,12 +31,11 @@ import {truncateAndInvalidate} from '#/state/queries/util'
import {useSession} from '#/state/session'
import {PostFeed} from '#/view/com/posts/PostFeed'
import {EmptyState} from '#/view/com/util/EmptyState'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {FAB} from '#/view/com/util/fab/FAB'
import {Button} from '#/view/com/util/forms/Button'
import {type ListRef} from '#/view/com/util/List'
import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn'
import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {Text} from '#/view/com/util/text/Text'
import {
ProfileFeedHeader,
ProfileFeedHeaderSkeleton,
@@ -55,54 +49,30 @@ export function ProfileFeedScreen(props: Props) {
const {rkey, name: handleOrDid} = props.route.params
const feedParams: FeedParams | undefined = props.route.params.feedCacheKey
? {
feedCacheKey: props.route.params.feedCacheKey,
}
? {feedCacheKey: props.route.params.feedCacheKey}
: undefined
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const uri = useMemo(
() => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey),
[rkey, handleOrDid],
)
const {error, data: resolvedUri} = useResolveUriQuery(uri)
let {
error,
data: resolvedUri,
refetch,
isRefetching,
} = useResolveUriQuery(uri)
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
}, [navigation])
if (error) {
if (error && !isRefetching) {
return (
<Layout.Screen testID="profileFeedScreenError">
<Layout.Content>
<View style={[pal.view, pal.border, styles.notFoundContainer]}>
<Text type="title-lg" style={[pal.text, s.mb10]}>
<Trans>Could not load feed</Trans>
</Text>
<Text type="md" style={[pal.text, s.mb20]}>
{error.toString()}
</Text>
<View style={{flexDirection: 'row'}}>
<Button
type="default"
accessibilityLabel={_(msg`Go back`)}
accessibilityHint={_(msg`Returns to previous page`)}
onPress={onPressBack}
style={{flexShrink: 1}}>
<Text type="button" style={pal.text}>
<Trans>Go Back</Trans>
</Text>
</Button>
</View>
</View>
</Layout.Content>
<ErrorScreen
showHeader
title={_(msg`Could not load feed`)}
message={cleanError(error)}
onPressTryAgain={() => void refetch()}
/>
</Layout.Screen>
)
}
@@ -169,8 +139,8 @@ export function ProfileFeedScreenInner({
const feed = `feedgen|${feedInfo.uri}` as FeedDescriptor
const [hasNew, setHasNew] = React.useState(false)
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const [hasNew, setHasNew] = useState(false)
const [isScrolledDown, setIsScrolledDown] = useState(false)
const queryClient = useQueryClient()
const feedFeedback = useFeedFeedback(feedInfo, hasSession)
const scrollElRef = useAnimatedRef() as ListRef
@@ -180,11 +150,11 @@ export function ProfileFeedScreenInner({
animated: IS_NATIVE,
offset: 0, // -headerHeight,
})
truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
void truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
setHasNew(false)
}, [scrollElRef, queryClient, feed, setHasNew])
React.useEffect(() => {
useEffect(() => {
if (!isScreenFocused) {
return
}
@@ -201,7 +171,7 @@ export function ProfileFeedScreenInner({
)
}, [_])
const isVideoFeed = React.useMemo(() => {
const isVideoFeed = useMemo(() => {
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
const feedIsVideoMode =
feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO
@@ -254,26 +224,3 @@ export function ProfileFeedScreenInner({
</>
)
}
const styles = StyleSheet.create({
btn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 50,
marginLeft: 6,
},
notFoundContainer: {
margin: 10,
paddingHorizontal: 18,
paddingVertical: 14,
borderRadius: 6,
},
aboutSectionContainer: {
paddingVertical: 4,
paddingHorizontal: 16,
gap: 12,
},
})
@@ -0,0 +1,334 @@
import {Platform, View} from 'react-native'
import {Image} from 'expo-image'
import {
type AppBskyActorDefs,
type AppBskyActorGetProfile,
type AtpAgent,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {until} from '#/lib/async/until'
import {isNetworkError} from '#/lib/strings/errors'
import {RQKEY} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {CustomLinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
import {Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky'
export function GermButton({
germ,
profile,
}: {
germ: AppBskyActorDefs.ProfileAssociatedGerm
profile: bsky.profile.AnyProfileView
}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const {currentAccount} = useSession()
const linkWarningControl = Dialog.useDialogControl()
// exclude `none` and all unknown values
if (
!(germ.showButtonTo === 'everyone' || germ.showButtonTo === 'usersIFollow')
) {
return null
}
if (currentAccount?.did === profile.did) {
return <GermSelfButton did={currentAccount.did} />
}
if (germ.showButtonTo === 'usersIFollow' && !profile.viewer?.followedBy) {
return null
}
const url = constructGermUrl(germ, profile, currentAccount?.did)
if (!url) {
return null
}
return (
<>
<Link
to={url}
onPress={evt => {
ax.metric('profile:associated:germ:click-to-chat', {})
if (isCustomGermDomain(url)) {
evt.preventDefault()
linkWarningControl.open()
return false
}
}}
label={_(msg`Open Germ DM`)}
overridePresentation={false}
shouldProxy={false}
style={[
t.atoms.bg_contrast_50,
a.rounded_full,
a.self_start,
{padding: 6},
]}>
<GermLogo size="small" />
<Text style={[a.text_sm, a.font_medium, a.ml_xs]}>
<Trans>Germ DM</Trans>
</Text>
<ArrowTopRightIcon style={[t.atoms.text, a.mx_2xs]} width={14} />
</Link>
<CustomLinkWarningDialog
control={linkWarningControl}
link={{
href: url,
displayText: '',
share: false,
}}
/>
</>
)
}
function GermLogo({size}: {size: 'small' | 'large'}) {
return (
<Image
source={require('../../../../assets/images/germ_logo.webp')}
accessibilityIgnoresInvertColors={false}
contentFit="cover"
style={[
a.rounded_full,
size === 'large' ? {width: 32, height: 32} : {width: 16, height: 16},
]}
/>
)
}
function GermSelfButton({did}: {did: string}) {
const t = useTheme()
const ax = useAnalytics()
const {_} = useLingui()
const selfExplanationDialogControl = Dialog.useDialogControl()
const agent = useAgent()
const queryClient = useQueryClient()
const {mutate: deleteDeclaration, isPending} = useMutation({
mutationFn: async () => {
const previousRecord = await agent.com.germnetwork.declaration
.get({
repo: did,
rkey: 'self',
})
.then(res => res.value)
.catch(() => null)
await agent.com.germnetwork.declaration.delete({
repo: did,
rkey: 'self',
})
await whenAppViewReady(agent, did, res => !res.data.associated?.germ)
return previousRecord
},
onSuccess: previousRecord => {
ax.metric('profile:associated:germ:self-disconnect', {})
async function undo() {
if (!previousRecord) return
try {
await agent.com.germnetwork.declaration.put(
{
repo: did,
rkey: 'self',
},
previousRecord,
)
await whenAppViewReady(agent, did, res => !!res.data.associated?.germ)
await queryClient.refetchQueries({queryKey: RQKEY(did)})
Toast.show(_(msg`Germ DM reconnected`))
ax.metric('profile:associated:germ:self-reconnect', {})
} catch (e: any) {
Toast.show(
_(msg`Failed to reconnect Germ DM. Error: ${e?.message}`),
{
type: 'error',
},
)
if (!isNetworkError(e)) {
ax.logger.error('Failed to reconnect Germ DM link', {
safeMessage: e,
})
}
}
}
selfExplanationDialogControl.close(() => {
void queryClient.refetchQueries({queryKey: RQKEY(did)})
Toast.show(
<Toast.Outer>
<Toast.Icon />
<Toast.Text>
<Trans>Germ DM disconnected</Trans>
</Toast.Text>
{previousRecord && (
<Toast.Action label={_(msg`Undo`)} onPress={() => void undo()}>
<Trans>Undo</Trans>
</Toast.Action>
)}
</Toast.Outer>,
)
})
},
onError: error => {
Toast.show(
_(msg`Failed to disconnect Germ DM. Error: ${error?.message}`),
{
type: 'error',
},
)
if (!isNetworkError(error)) {
ax.logger.error('Failed to disconnect Germ DM link', {
safeMessage: error,
})
}
},
})
return (
<>
<Button
label={_(msg`Learn more about your Germ DM link`)}
onPress={() => {
ax.metric('profile:associated:germ:click-self-info', {})
selfExplanationDialogControl.open()
}}
style={[
t.atoms.bg_contrast_50,
a.rounded_full,
a.self_start,
{padding: 6, paddingRight: 10},
]}>
<GermLogo size="small" />
<Text style={[a.text_sm, a.font_medium, a.ml_xs]}>
<Trans>Germ DM</Trans>
</Text>
</Button>
<Dialog.Outer
control={selfExplanationDialogControl}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Germ DM Link`)}
style={web([{maxWidth: 400, borderRadius: 36}])}>
<View style={[a.flex_row, a.align_center, {gap: 6}]}>
<GermLogo size="large" />
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Germ DM Link</Trans>
</Text>
</View>
<Text style={[a.text_md, a.leading_snug, a.mt_sm]}>
<Trans>
This button lets others open the Germ DM app to send you a
message. You can manage its visibility from the Germ DM app, or
you can disconnect your Bluesky account from Germ DM altogether by
clicking the button below.
</Trans>
</Text>
<View style={[a.mt_2xl, a.gap_md]}>
<Button
label={_(msg`Got it`)}
size="large"
color="primary"
onPress={() => selfExplanationDialogControl.close()}>
<ButtonText>
<Trans>Got it</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Disconnect Germ DM`)}
size="large"
color="secondary"
onPress={() => deleteDeclaration()}
disabled={isPending}>
{isPending && <ButtonIcon icon={Loader} />}
<ButtonText>
<Trans>Disconnect Germ DM</Trans>
</ButtonText>
</Button>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
</>
)
}
function constructGermUrl(
declaration: AppBskyActorDefs.ProfileAssociatedGerm,
profile: bsky.profile.AnyProfileView,
viewerDid?: string,
) {
try {
const urlp = new URL(declaration.messageMeUrl)
if (urlp.pathname.endsWith('/')) {
urlp.pathname = urlp.pathname.slice(0, -1)
}
urlp.pathname += `/${platform()}`
if (viewerDid) {
urlp.hash = `#${profile.did}+${viewerDid}`
} else {
urlp.hash = `#${profile.did}`
}
return urlp.toString()
} catch {
return null
}
}
function isCustomGermDomain(url: string) {
try {
const urlp = new URL(url)
return urlp.hostname !== 'landing.ger.mx'
} catch {
return false
}
}
function platform() {
switch (Platform.OS) {
case 'ios':
return 'iOS'
case 'android':
return 'android'
default:
return 'web'
}
}
async function whenAppViewReady(
agent: AtpAgent,
actor: string,
fn: (res: AppBskyActorGetProfile.Response) => boolean,
) {
await until(
5, // 5 tries
1e3, // 1s delay between tries
fn,
() => agent.app.bsky.actor.getProfile({actor}),
)
}
@@ -259,7 +259,7 @@ export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) {
size="xs"
fill={
likeUri
? t.palette.like
? t.palette.pink
: t.atoms.text_contrast_low.color
}
/>
@@ -503,14 +503,13 @@ function DialogInner({
disabled={isLikePending || isUnlikePending}
label={_(msg`Like this feed`)}
size="small"
variant="solid"
color="secondary"
onPress={onToggleLiked}
style={[a.flex_1]}>
{isLiked ? (
<HeartFilled size="sm" fill={t.palette.like} />
<HeartFilled size="sm" fill={t.palette.pink} />
) : (
<ButtonIcon icon={Heart} position="left" />
<ButtonIcon icon={Heart} />
)}
<ButtonText>
@@ -521,7 +520,6 @@ function DialogInner({
disabled={isFeedStateChangePending}
label={isPinned ? _(msg`Unpin feed`) : _(msg`Pin feed`)}
size="small"
variant="solid"
color={isPinned ? 'secondary' : 'primary'}
onPress={onTogglePinned}
style={[a.flex_1]}>
+24 -9
View File
@@ -1,4 +1,4 @@
import {useCallback, useMemo, useRef} from 'react'
import {useCallback, useMemo, useRef, useState} from 'react'
import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
import {
@@ -35,12 +35,13 @@ import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {FAB} from '#/view/com/util/fab/FAB'
import {type ListRef} from '#/view/com/util/List'
import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen'
import {atoms as a, platform} from '#/alf'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ListAddRemoveUsersDialog} from '#/components/dialogs/lists/ListAddRemoveUsersDialog'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Hider from '#/components/moderation/Hider'
import {IS_WEB} from '#/env'
import {AboutSection} from './AboutSection'
import {ErrorScreen} from './components/ErrorScreen'
import {Header} from './components/Header'
@@ -149,6 +150,7 @@ function ProfileListScreenLoaded({
moderationOpts: ModerationOpts
preferences: UsePreferencesQueryResponse
}) {
const t = useTheme()
const {_} = useLingui()
const queryClient = useQueryClient()
const {openComposer} = useOpenComposer()
@@ -164,6 +166,8 @@ function ProfileListScreenLoaded({
const scrollElRef = useAnimatedRef()
const addUserDialogControl = useDialogControl()
const sectionTitlesCurate = [_(msg`Posts`), _(msg`People`)]
// modlist only
const [headerHeight, setHeaderHeight] = useState<number | null>(null)
const moderation = useMemo(() => {
return moderateUserList(list, moderationOpts)
@@ -263,13 +267,24 @@ function ProfileListScreenLoaded({
</Hider.Mask>
<Hider.Content>
<View style={[a.util_screen_outer]}>
<Layout.Center>{renderHeader()}</Layout.Center>
<AboutSection
list={list}
scrollElRef={scrollElRef as ListRef}
onPressAddUser={addUserDialogControl.open}
headerHeight={0}
/>
<Layout.Center
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}
style={[
native([a.absolute, a.z_10, t.atoms.bg]),
a.border_b,
t.atoms.border_contrast_low,
]}>
{renderHeader()}
</Layout.Center>
{headerHeight !== null && (
<AboutSection
list={list}
scrollElRef={scrollElRef as ListRef}
onPressAddUser={addUserDialogControl.open}
headerHeight={IS_WEB ? 0 : headerHeight}
/>
)}
<FAB
testID="composeFAB"
onPress={() => openComposer({logContext: 'Fab'})}
+48 -12
View File
@@ -78,10 +78,14 @@ function LoadMore({item}: {item: ExploreScreenItems & {type: 'loadMore'}}) {
const t = useTheme()
const {_} = useLingui()
const handleOnPress = () => {
void item.onLoadMore()
}
return (
<Button
label={_(msg`Load more`)}
onPress={item.onLoadMore}
onPress={handleOnPress}
style={[a.relative, a.w_full]}>
{({hovered, pressed}) => (
<>
@@ -171,7 +175,7 @@ type ExploreScreenItems =
key: string
message: string
isLoadingMore: boolean
onLoadMore: () => void
onLoadMore: () => void | Promise<void>
}
| {
type: 'profilePlaceholder'
@@ -358,7 +362,7 @@ export function Explore({
i.push({
type: 'tabbedHeader',
key: 'suggested-accounts-header',
title: _(msg`Suggested Accounts`),
title: _(msg`Suggested accounts`),
icon: Person,
searchButton: {
label: _(msg`Search for more accounts`),
@@ -435,7 +439,7 @@ export function Explore({
i.push({
type: 'header',
key: 'suggested-feeds-header',
title: _(msg`Discover New Feeds`),
title: _(msg`Discover new feeds`),
icon: ListSparkle,
searchButton: {
label: _(msg`Search for more feeds`),
@@ -463,9 +467,9 @@ export function Explore({
if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'feedsError',
key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`),
error: cleanError(feedsError),
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
@@ -512,9 +516,16 @@ export function Explore({
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
message: _(msg`Failed to load feeds`),
error: cleanError(feedsError),
})
} else if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`),
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
@@ -550,9 +561,16 @@ export function Explore({
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
message: _(msg`Failed to load feeds`),
error: cleanError(feedsError),
})
} else if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'suggestedFeedsError',
message: _(msg`Failed to load suggested feeds`),
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
@@ -589,9 +607,16 @@ export function Explore({
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
message: _(msg`Failed to load feeds`),
error: cleanError(feedsError),
})
} else if (suggestedFeedsError) {
i.push({
type: 'error',
key: 'feedsError',
message: _(msg`Failed to load suggested feeds`),
error: cleanError(suggestedFeedsError),
})
} else if (preferencesError) {
i.push({
type: 'error',
@@ -714,6 +739,9 @@ export function Explore({
const renderItem = useCallback(
({item, index}: {item: ExploreScreenItems; index: number}) => {
const handleOnPressRetry = () => {
void fetchNextPageFeedPreviews()
}
switch (item.type) {
case 'topBorder':
return (
@@ -987,7 +1015,7 @@ export function Explore({
label={_(
msg`There was an issue fetching posts. Tap here to try again.`,
)}
onPress={fetchNextPageFeedPreviews}
onPress={handleOnPressRetry}
/>
)
}
@@ -1069,6 +1097,14 @@ export function Explore({
[ax, suggestedFollowsModule],
)
const handleOnEndReached = () => {
void onLoadMoreFeedPreviews()
}
const handleOnRefresh = () => {
void onPTR()
}
return (
<List
data={items}
@@ -1081,7 +1117,7 @@ export function Explore({
stickyHeaderIndices={native(stickyHeaderIndices)}
viewabilityConfig={viewabilityConfig}
onItemSeen={onItemSeen}
onEndReached={onLoadMoreFeedPreviews}
onEndReached={handleOnEndReached}
/**
* Default: 2
*/
@@ -1117,7 +1153,7 @@ export function Explore({
*/
updateCellsBatchingPeriod={50}
refreshing={isPTR}
onRefresh={onPTR}
onRefresh={handleOnRefresh}
/>
)
}
@@ -1,7 +1,7 @@
import {useMemo} from 'react'
import {Pressable, View} from 'react-native'
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
import {msg, plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -9,7 +9,6 @@ import {useTrendingSettings} from '#/state/preferences/trending'
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
import {useTrendingConfig} from '#/state/service-config'
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {formatCount} from '#/view/com/util/numeric/format'
import {atoms as a, useGutters, useTheme, type ViewStyleProp, web} from '#/alf'
import {AvatarStack} from '#/components/AvatarStack'
import {type Props as SVGIconProps} from '#/components/icons/common'
@@ -65,7 +64,7 @@ export function TrendRow({
onPress?: () => void
}) {
const t = useTheme()
const {_, i18n} = useLingui()
const {_} = useLingui()
const gutters = useGutters([0, 'base'])
const category = useCategoryDisplayName(trend?.category || 'other')
@@ -74,13 +73,6 @@ export function TrendRow({
(1000 * 60 * 60),
)
const badgeType = trend.status === 'hot' ? 'hot' : age < 2 ? 'new' : age
const postCount = trend.postCount
? _(
plural(trend.postCount, {
other: `${formatCount(i18n, trend.postCount)} posts`,
}),
)
: null
const actors = useModerateTrendingActors(trend.actors)
@@ -132,8 +124,6 @@ export function TrendRow({
web(a.leading_snug),
]}
numberOfLines={1}>
{postCount}
{postCount && category && <> &middot; </>}
{category}
</Text>
</View>
@@ -0,0 +1,66 @@
import {useMemo} from 'react'
import {useInterestsDisplayNames} from '#/lib/interests'
import {useActorSearch} from '#/state/queries/actor-search'
import {useGetSuggestedOnboardingUsersQuery} from '#/state/queries/trending/useGetSuggestedOnboardingUsersQuery'
/**
* Conditional hook, used in case a user is a non-english speaker, in which
* case we fall back to searching for users instead of our more curated set.
*/
export function useSuggestedOnboardingUsers({
category = null,
search = false,
overrideInterests,
}: {
category?: string | null
/**
* If true, we'll search for users using the translated value of `category`,
* based on the user's app language setting
*/
search?: boolean
/**
* In onboarding, interests haven't been saved to prefs yet, so we need to
* pass them down through here
*/
overrideInterests: string[]
}) {
const interestsDisplayNames = useInterestsDisplayNames()
const curated = useGetSuggestedOnboardingUsersQuery({
enabled: !search,
category,
overrideInterests,
})
const searched = useActorSearch({
enabled: !!search,
// use user's app language translation for this value
query: category ? interestsDisplayNames[category] : '',
limit: 10,
})
return useMemo(() => {
if (search) {
return {
// we're not paginating right now
data: searched?.data
? {
actors: searched.data.pages.flatMap(p => p.actors) ?? [],
recId: undefined,
}
: undefined,
isLoading: searched.isLoading,
error: searched.error,
isRefetching: searched.isRefetching,
refetch: searched.refetch,
}
} else {
return {
data: curated.data,
isLoading: curated.isLoading,
error: curated.error,
isRefetching: curated.isRefetching,
refetch: curated.refetch,
}
}
}, [curated, searched, search])
}
+1 -8
View File
@@ -11,25 +11,18 @@ import {useGetSuggestedUsersQuery} from '#/state/queries/trending/useGetSuggeste
export function useSuggestedUsers({
category = null,
search = false,
overrideInterests,
}: {
category?: string | null
/**
* If true, we'll search for users using the translated value of `category`,
* based on the user's "app language setting
* based on the user's app language setting
*/
search?: boolean
/**
* In onboarding, interests haven't been saved to prefs yet, so we need to
* pass them down through here
*/
overrideInterests?: string[]
}) {
const interestsDisplayNames = useInterestsDisplayNames()
const curated = useGetSuggestedUsersQuery({
enabled: !search,
category,
overrideInterests,
})
const searched = useActorSearch({
enabled: !!search,
+7 -3
View File
@@ -3,7 +3,6 @@ import {useLingui} from '@lingui/react'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, useTheme} from '#/alf'
@@ -27,6 +26,7 @@ import * as Layout from '#/components/Layout'
import {ChangeHandleDialog} from './components/ChangeHandleDialog'
import {ChangePasswordDialog} from './components/ChangePasswordDialog'
import {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
import {DeleteAccountDialog} from './components/DeleteAccountDialog'
import {ExportCarDialog} from './components/ExportCarDialog'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
@@ -34,13 +34,13 @@ export function AccountSettingsScreen({}: Props) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const {openModal} = useModalControls()
const emailDialogControl = useEmailDialogControl()
const birthdayControl = useDialogControl()
const changeHandleControl = useDialogControl()
const changePasswordControl = useDialogControl()
const exportCarControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
return (
<Layout.Screen>
@@ -169,7 +169,7 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={_(msg`Delete account`)}
onPress={() => openModal({name: 'delete-account'})}
onPress={() => deleteAccountControl.open()}
destructive>
<SettingsList.ItemIcon icon={Trash_Stroke2_Corner2_Rounded} />
<SettingsList.ItemText>
@@ -185,6 +185,10 @@ export function AccountSettingsScreen({}: Props) {
<ChangePasswordDialog control={changePasswordControl} />
<ExportCarDialog control={exportCarControl} />
<DeactivateAccountDialog control={deactivateAccountControl} />
<DeleteAccountDialog
control={deleteAccountControl}
deactivateDialogControl={deactivateAccountControl}
/>
</Layout.Screen>
)
}
+2 -2
View File
@@ -21,7 +21,7 @@ import {EmptyState} from '#/view/com/util/EmptyState'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Admonition, colors} from '#/components/Admonition'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
@@ -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}]} />
<WarningIcon style={[{color: t.palette.yellow}]} />
<Text style={t.atoms.text_contrast_high}>
<Trans>Allows access to direct messages</Trans>
</Text>

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