Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e087f6190 | |||
| f3cc850b62 | |||
| 35f9ed82b9 | |||
| 8c4fc087f8 | |||
| f3ddc074a6 | |||
| 5e0ffc0917 | |||
| 3fae42c077 | |||
| 80c0aa35e2 | |||
| 4e3c3e9905 | |||
| 1565e071c1 | |||
| c906fea77a | |||
| 775a041b71 | |||
| 98d17fd950 | |||
| 41b3742365 | |||
| 512ce3d5d0 | |||
| bdf143ce79 | |||
| adc079e095 | |||
| 74330500de | |||
| ac939dc84e | |||
| b12d703353 | |||
| c188fd4f04 | |||
| a1cda126e6 | |||
| ae115ca17e | |||
| 197c069416 | |||
| e67d81f269 | |||
| 7c08bba6cf | |||
| 29bc0f1372 | |||
| 82f42e734c | |||
| 3b9f0b0a68 | |||
| a6ba0810b7 | |||
| 49253dbd88 | |||
| cc2255cc49 | |||
| 8ee9709fbb | |||
| 90d650def2 | |||
| 79e27e303b | |||
| 6be7751769 | |||
| 9b830fd425 | |||
| 4ebeb94b41 | |||
| b6b364700e | |||
| 95f9cc1ed5 | |||
| 06fcea988e | |||
| b166d395d9 | |||
| 1228306309 | |||
| bc001168f3 | |||
| b4cb4c4d13 | |||
| 2e34f965fc | |||
| 9cdfb40ce7 | |||
| c83dddce57 | |||
| 795428971d | |||
| 82fea6da7d | |||
| 2f156bb906 | |||
| c46219bc4d | |||
| 6d68a5bd21 | |||
| cbac380c96 | |||
| 8c4820b1f1 | |||
| 93d2f3a8db | |||
| 3a9526d55e | |||
| 830f305d2d | |||
| 8a088e8710 | |||
| 8cbe3985fa | |||
| 1ec70420e6 | |||
| 63fbf577d9 | |||
| 355868e01e | |||
| 37e5a0f7a4 | |||
| 4356ff0040 | |||
| 4eca13941a | |||
| 8580e4a00e | |||
| 8581285d34 | |||
| 57997d6d16 | |||
| 5459857112 | |||
| 3a28a07081 | |||
| a57faedbae | |||
| a148b2477b |
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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': {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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.',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
+41
-34
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
Vendored
+10
@@ -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
|
||||
|
||||
+4
-3
@@ -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,
|
||||
+8
-7
@@ -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,
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+883
-758
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -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'})}
|
||||
|
||||
@@ -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 && <> · </>}
|
||||
{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])
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user