Merge remote-tracking branch 'origin/main' into mod-auth
* origin/main: (113 commits) Bump 1.91.2 (#5387) [Video] Additional android fixes (#5373) Milly tweaks (#5365) [Video] Fix safari showing spinner (#5364) [Video] Volume controls on web (#5363) Updates to Irish translation, back to 100% (#5345) Update catalan messages.po (#5223) Update Japanese translation (#5217) Update Korean localization (#5254) Update Chinese localization (#5291) Update French localization (#5227) Update Portuguese localization (#5256) [Video] Fix scrubber tap target (#5360) loosen checks on vtt file (#5359) Remove overflow hidden from external link outer el (#5356) Disallow some font scaling Nux after onboarding (#5357) Fix wrong empty state for liked by (#5343) [UITextView] Add background color support to iOS selectable text (#5335) Add context to `Unmute` and `Mute` strings (#5340) ...
This commit is contained in:
@@ -74,8 +74,7 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn: "Delete List"
|
- tapOn: "Delete List"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "confirmBtn"
|
id: "confirmBtn"
|
||||||
- assertVisible:
|
- assertVisible: "This list is empty!"
|
||||||
id: "listsEmpty"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Create a new curatelist"
|
label: "Create a new curatelist"
|
||||||
@@ -161,17 +160,6 @@ appId: xyz.blueskyweb.app
|
|||||||
- assertNotVisible:
|
- assertNotVisible:
|
||||||
id: "userAddRemoveListsModal"
|
id: "userAddRemoveListsModal"
|
||||||
|
|
||||||
- tapOn:
|
|
||||||
label: "Shows the curatelist on my profile"
|
|
||||||
id: "bottomBarProfileBtn"
|
|
||||||
- swipe:
|
|
||||||
from:
|
|
||||||
id: "profilePager-selector"
|
|
||||||
direction: LEFT
|
|
||||||
- tapOn:
|
|
||||||
id: "profilePager-selector-6"
|
|
||||||
- tapOn: "Good Ppl"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Adds and removes users on curatelists from the profile"
|
label: "Adds and removes users on curatelists from the profile"
|
||||||
id: "bottomBarSearchBtn"
|
id: "bottomBarSearchBtn"
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
# Pin alice's feed
|
# Pin alice's feed
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderDrawerBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarProfileBtn"
|
id: "viewHeaderDrawerBtn"
|
||||||
|
- tapOn:
|
||||||
|
id: "profileCardButton"
|
||||||
- swipe:
|
- swipe:
|
||||||
from:
|
from:
|
||||||
id: "profilePager-selector"
|
id: "profilePager-selector"
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Feeds ✨"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can go to feeds page using feeds button in tab bar"
|
label: "Can go to feeds page using feeds button in tab bar"
|
||||||
text: "Feeds ✨"
|
text: "Feeds ✨"
|
||||||
@@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can like posts"
|
label: "Can like posts"
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
- assertVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
text: "1"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
- assertNotVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can repost posts"
|
label: "Can repost posts"
|
||||||
id: "repostBtn"
|
id: "repostBtn"
|
||||||
- tapOn: "Repost"
|
- tapOn: "Repost"
|
||||||
- assertVisible:
|
|
||||||
id: "repostCount"
|
|
||||||
text: "1"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "repostBtn"
|
id: "repostBtn"
|
||||||
- tapOn: "Remove repost"
|
- tapOn: "Remove repost"
|
||||||
- assertNotVisible:
|
|
||||||
id: "repostCount"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can delete posts"
|
label: "Can delete posts"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
|
|||||||
|
|
||||||
|
|
||||||
# Navigate to my profile
|
# Navigate to my profile
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarProfileBtn"
|
id: "bottomBarProfileBtn"
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
# Navigate to another user profile
|
# Navigate to another user profile
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarSearchBtn"
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
|
|||||||
|
|
||||||
|
|
||||||
# Navigate to thread
|
# Navigate to thread
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Thread root"
|
||||||
- tapOn: "Thread root"
|
- tapOn: "Thread root"
|
||||||
- assertVisible: "Thread reply"
|
- assertVisible: "Thread reply"
|
||||||
|
|
||||||
@@ -19,32 +21,22 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-bob.test"
|
id: "postThreadItem-by-bob.test"
|
||||||
- assertVisible:
|
- assertVisible: "1 like"
|
||||||
id: "likeCount-expanded"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-bob.test"
|
id: "postThreadItem-by-bob.test"
|
||||||
- assertNotVisible:
|
- assertNotVisible: "1 like"
|
||||||
id: "likeCount-expanded"
|
|
||||||
|
|
||||||
# Can like a reply post
|
# Can like a reply post
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-carla.test"
|
id: "postThreadItem-by-carla.test"
|
||||||
- assertVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
childOf:
|
|
||||||
id: "postThreadItem-by-carla.test"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-carla.test"
|
id: "postThreadItem-by-carla.test"
|
||||||
- assertNotVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
childOf:
|
|
||||||
id: "postThreadItem-by-carla.test"
|
|
||||||
|
|
||||||
# Can repost the root post
|
# Can repost the root post
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|||||||
@@ -211,7 +211,6 @@ module.exports = function (config) {
|
|||||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'expo-video',
|
|
||||||
'react-native-compressor',
|
'react-native-compressor',
|
||||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||||
'./plugins/withAndroidManifestPlugin.js',
|
'./plugins/withAndroidManifestPlugin.js',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 3a1 1 0 0 1 1 1v8.086l1.793-1.793a1 1 0 1 1 1.414 1.414l-3.5 3.5a1 1 0 0 1-1.414 0l-3.5-3.5a1 1 0 1 1 1.414-1.414L11 12.086V4a1 1 0 0 1 1-1ZM4 14a1 1 0 0 1 1 1v4h14v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 378 B |
@@ -372,7 +372,7 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-full overflow-hidden rounded-lg aspect-square"
|
className="w-full overflow-hidden rounded-lg aspect-square relative"
|
||||||
style={{aspectRatio: `${aspectRatio} / 1`}}>
|
style={{aspectRatio: `${aspectRatio} / 1`}}>
|
||||||
<img
|
<img
|
||||||
src={content.thumbnail}
|
src={content.thumbnail}
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ func run(args []string) {
|
|||||||
Value: "",
|
Value: "",
|
||||||
EnvVars: []string{"BASIC_AUTH_PASSWORD"},
|
EnvVars: []string{"BASIC_AUTH_PASSWORD"},
|
||||||
},
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "cors-allowed-origins",
|
||||||
|
Usage: "list of allowed origins for CORS requests",
|
||||||
|
Required: false,
|
||||||
|
Value: cli.NewStringSlice("https://bsky.app", "https://main.bsky.dev", "https://app.staging.bsky.dev"),
|
||||||
|
EnvVars: []string{"CORS_ALLOWED_ORIGINS"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
linkHost := cctx.String("link-host")
|
linkHost := cctx.String("link-host")
|
||||||
ipccHost := cctx.String("ipcc-host")
|
ipccHost := cctx.String("ipcc-host")
|
||||||
basicAuthPassword := cctx.String("basic-auth-password")
|
basicAuthPassword := cctx.String("basic-auth-password")
|
||||||
|
corsOrigins := cctx.StringSlice("cors-allowed-origins")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
@@ -168,6 +169,12 @@ func serve(cctx *cli.Context) error {
|
|||||||
RedirectCode: http.StatusFound,
|
RedirectCode: http.StatusFound,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// CORS middleware
|
||||||
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowOrigins: corsOrigins,
|
||||||
|
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
|
||||||
|
}))
|
||||||
|
|
||||||
//
|
//
|
||||||
// configure routes
|
// configure routes
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -258,7 +258,51 @@
|
|||||||
.force-no-clicks * {
|
.force-no-clicks * {
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
input[type=range][orient=vertical] {
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
direction: rtl;
|
||||||
|
appearance: slider-vertical;
|
||||||
|
width: 16px;
|
||||||
|
vertical-align: bottom;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"][orient=vertical]::-webkit-slider-runnable-track {
|
||||||
|
background: white;
|
||||||
|
height: 100%;
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"][orient=vertical]::-moz-range-track {
|
||||||
|
background: white;
|
||||||
|
height: 100%;
|
||||||
|
width: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: white;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"][orient=vertical]::-moz-range-thumb {
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: white;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% include "scripts.html" %}
|
{% include "scripts.html" %}
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ module.exports = {
|
|||||||
'ja',
|
'ja',
|
||||||
'ko',
|
'ko',
|
||||||
'pt-BR',
|
'pt-BR',
|
||||||
|
'ru',
|
||||||
'tr',
|
'tr',
|
||||||
'uk',
|
'uk',
|
||||||
'zh-CN',
|
'zh-CN',
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.91.0",
|
"version": "1.91.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.13.5",
|
"@atproto/api": "^0.13.7",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||||
"@fortawesome/react-native-fontawesome": "^0.3.2",
|
"@fortawesome/react-native-fontawesome": "^0.3.2",
|
||||||
|
"@haileyok/bluesky-video": "0.1.10",
|
||||||
"@lingui/react": "^4.5.0",
|
"@lingui/react": "^4.5.0",
|
||||||
"@mattermost/react-native-paste-input": "^0.7.1",
|
"@mattermost/react-native-paste-input": "^0.7.1",
|
||||||
"@miblanchard/react-native-slider": "^2.3.1",
|
"@miblanchard/react-native-slider": "^2.3.1",
|
||||||
@@ -139,7 +140,6 @@
|
|||||||
"expo-system-ui": "~3.0.4",
|
"expo-system-ui": "~3.0.4",
|
||||||
"expo-task-manager": "~11.8.1",
|
"expo-task-manager": "~11.8.1",
|
||||||
"expo-updates": "~0.25.14",
|
"expo-updates": "~0.25.14",
|
||||||
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz",
|
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
@@ -190,7 +190,7 @@
|
|||||||
"react-native-safe-area-context": "4.10.1",
|
"react-native-safe-area-context": "4.10.1",
|
||||||
"react-native-screens": "~3.31.1",
|
"react-native-screens": "~3.31.1",
|
||||||
"react-native-svg": "^15.3.0",
|
"react-native-svg": "^15.3.0",
|
||||||
"react-native-uitextview": "^1.1.7",
|
"react-native-uitextview": "^1.3.0",
|
||||||
"react-native-url-polyfill": "^1.3.0",
|
"react-native-url-polyfill": "^1.3.0",
|
||||||
"react-native-uuid": "^2.0.2",
|
"react-native-uuid": "^2.0.2",
|
||||||
"react-native-view-shot": "^3.8.0",
|
"react-native-view-shot": "^3.8.0",
|
||||||
|
|||||||
+54
-51
@@ -57,16 +57,17 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
|||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||||
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
|
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {Shell} from '#/view/shell'
|
import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
|
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync()
|
SplashScreen.preventAutoHideAsync()
|
||||||
|
|
||||||
@@ -115,52 +116,51 @@ function InnerApp() {
|
|||||||
}, [_])
|
}, [_])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<Alf theme={theme}>
|
||||||
<Alf theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
<RootSiblingParent>
|
||||||
<ActiveVideoProvider>
|
<VideoVolumeProvider>
|
||||||
<RootSiblingParent>
|
<React.Fragment
|
||||||
<React.Fragment
|
// Resets the entire tree below when it changes:
|
||||||
// Resets the entire tree below when it changes:
|
key={currentAccount?.did}>
|
||||||
key={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<StatsigProvider>
|
||||||
<StatsigProvider>
|
<MessagesProvider>
|
||||||
<MessagesProvider>
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
<LabelDefsProvider>
|
||||||
<LabelDefsProvider>
|
<ModerationOptsProvider>
|
||||||
<ModerationOptsProvider>
|
<LoggedOutViewProvider>
|
||||||
<LoggedOutViewProvider>
|
<SelectedFeedProvider>
|
||||||
<SelectedFeedProvider>
|
<HiddenRepliesProvider>
|
||||||
<HiddenRepliesProvider>
|
<UnreadNotifsProvider>
|
||||||
<UnreadNotifsProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<MutedThreadsProvider>
|
||||||
<MutedThreadsProvider>
|
<ProgressGuideProvider>
|
||||||
<ProgressGuideProvider>
|
<GestureHandlerRootView
|
||||||
<GestureHandlerRootView
|
style={s.h100pct}>
|
||||||
style={s.h100pct}>
|
<TestCtrls />
|
||||||
<TestCtrls />
|
<Shell />
|
||||||
<Shell />
|
<NuxDialogs />
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</ProgressGuideProvider>
|
</ProgressGuideProvider>
|
||||||
</MutedThreadsProvider>
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
</HiddenRepliesProvider>
|
</HiddenRepliesProvider>
|
||||||
</SelectedFeedProvider>
|
</SelectedFeedProvider>
|
||||||
</LoggedOutViewProvider>
|
</LoggedOutViewProvider>
|
||||||
</ModerationOptsProvider>
|
</ModerationOptsProvider>
|
||||||
</LabelDefsProvider>
|
</LabelDefsProvider>
|
||||||
</MessagesProvider>
|
</MessagesProvider>
|
||||||
</StatsigProvider>
|
</StatsigProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</RootSiblingParent>
|
</VideoVolumeProvider>
|
||||||
</ActiveVideoProvider>
|
</RootSiblingParent>
|
||||||
</Splash>
|
</Splash>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Alf>
|
</Alf>
|
||||||
</SafeAreaProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +168,6 @@ function App() {
|
|||||||
const [isReady, setReady] = useState(false)
|
const [isReady, setReady] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
|
||||||
PlatformInfo.setAudioActive(false)
|
|
||||||
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
|
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
|
||||||
setReady(true),
|
setReady(true),
|
||||||
)
|
)
|
||||||
@@ -197,7 +195,12 @@ function App() {
|
|||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<StarterPackProvider>
|
<StarterPackProvider>
|
||||||
<InnerApp />
|
<SafeAreaProvider
|
||||||
|
initialMetrics={initialWindowMetrics}>
|
||||||
|
<IntentDialogProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</IntentDialogProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
</StarterPackProvider>
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|||||||
+47
-36
@@ -40,18 +40,22 @@ import {
|
|||||||
} from '#/state/session'
|
} from '#/state/session'
|
||||||
import {readLastActiveAccount} from '#/state/session/util'
|
import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
|
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
||||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||||
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
|
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
|
||||||
|
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||||
import {Shell} from '#/view/shell/index'
|
import {Shell} from '#/view/shell/index'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
|
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
|
|
||||||
@@ -69,6 +73,8 @@ function InnerApp() {
|
|||||||
useIntentHandler()
|
useIntentHandler()
|
||||||
const hasCheckedReferrer = useStarterPackEntry()
|
const hasCheckedReferrer = useStarterPackEntry()
|
||||||
|
|
||||||
|
useComposerKeyboardShortcut()
|
||||||
|
|
||||||
// init
|
// init
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function onLaunch(account?: SessionAccount) {
|
async function onLaunch(account?: SessionAccount) {
|
||||||
@@ -103,41 +109,44 @@ function InnerApp() {
|
|||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
<ActiveVideoProvider>
|
<VideoVolumeProvider>
|
||||||
<React.Fragment
|
<ActiveVideoProvider>
|
||||||
// Resets the entire tree below when it changes:
|
<React.Fragment
|
||||||
key={currentAccount?.did}>
|
// Resets the entire tree below when it changes:
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
<StatsigProvider>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<MessagesProvider>
|
<StatsigProvider>
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
<MessagesProvider>
|
||||||
<LabelDefsProvider>
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
<ModerationOptsProvider>
|
<LabelDefsProvider>
|
||||||
<LoggedOutViewProvider>
|
<ModerationOptsProvider>
|
||||||
<SelectedFeedProvider>
|
<LoggedOutViewProvider>
|
||||||
<HiddenRepliesProvider>
|
<SelectedFeedProvider>
|
||||||
<UnreadNotifsProvider>
|
<HiddenRepliesProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<UnreadNotifsProvider>
|
||||||
<MutedThreadsProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<SafeAreaProvider>
|
<MutedThreadsProvider>
|
||||||
<ProgressGuideProvider>
|
<SafeAreaProvider>
|
||||||
<Shell />
|
<ProgressGuideProvider>
|
||||||
</ProgressGuideProvider>
|
<Shell />
|
||||||
</SafeAreaProvider>
|
<NuxDialogs />
|
||||||
</MutedThreadsProvider>
|
</ProgressGuideProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</SafeAreaProvider>
|
||||||
</UnreadNotifsProvider>
|
</MutedThreadsProvider>
|
||||||
</HiddenRepliesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</SelectedFeedProvider>
|
</UnreadNotifsProvider>
|
||||||
</LoggedOutViewProvider>
|
</HiddenRepliesProvider>
|
||||||
</ModerationOptsProvider>
|
</SelectedFeedProvider>
|
||||||
</LabelDefsProvider>
|
</LoggedOutViewProvider>
|
||||||
</MessagesProvider>
|
</ModerationOptsProvider>
|
||||||
</StatsigProvider>
|
</LabelDefsProvider>
|
||||||
</QueryProvider>
|
</MessagesProvider>
|
||||||
</React.Fragment>
|
</StatsigProvider>
|
||||||
<ToastContainer />
|
</QueryProvider>
|
||||||
</ActiveVideoProvider>
|
</React.Fragment>
|
||||||
|
<ToastContainer />
|
||||||
|
</ActiveVideoProvider>
|
||||||
|
</VideoVolumeProvider>
|
||||||
</RootSiblingParent>
|
</RootSiblingParent>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Alf>
|
</Alf>
|
||||||
@@ -175,7 +184,9 @@ function App() {
|
|||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<StarterPackProvider>
|
<StarterPackProvider>
|
||||||
<InnerApp />
|
<IntentDialogProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</IntentDialogProvider>
|
||||||
</StarterPackProvider>
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|||||||
+15
-3
@@ -18,9 +18,17 @@ export * from '#/alf/util/themeSelector'
|
|||||||
export const Context = React.createContext<{
|
export const Context = React.createContext<{
|
||||||
themeName: ThemeName
|
themeName: ThemeName
|
||||||
theme: Theme
|
theme: Theme
|
||||||
|
themes: ReturnType<typeof createThemes>
|
||||||
}>({
|
}>({
|
||||||
themeName: 'light',
|
themeName: 'light',
|
||||||
theme: defaultTheme,
|
theme: defaultTheme,
|
||||||
|
themes: createThemes({
|
||||||
|
hues: {
|
||||||
|
primary: BLUE_HUE,
|
||||||
|
negative: RED_HUE,
|
||||||
|
positive: GREEN_HUE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
@@ -42,18 +50,22 @@ export function ThemeProvider({
|
|||||||
<Context.Provider
|
<Context.Provider
|
||||||
value={React.useMemo(
|
value={React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
themes,
|
||||||
themeName: themeName,
|
themeName: themeName,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
}),
|
}),
|
||||||
[theme, themeName],
|
[theme, themeName, themes],
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme(theme?: ThemeName) {
|
||||||
return React.useContext(Context).theme
|
const ctx = React.useContext(Context)
|
||||||
|
return React.useMemo(() => {
|
||||||
|
return theme ? ctx.themes[theme] : ctx.theme
|
||||||
|
}, [theme, ctx])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBreakpoints() {
|
export function useBreakpoints() {
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ export const ScrollableInner = React.forwardRef<
|
|||||||
borderTopLeftRadius: 40,
|
borderTopLeftRadius: 40,
|
||||||
borderTopRightRadius: 40,
|
borderTopRightRadius: 40,
|
||||||
},
|
},
|
||||||
flatten(style),
|
style,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={a.pb_4xl}
|
contentContainerStyle={a.pb_4xl}
|
||||||
ref={ref}>
|
ref={ref}>
|
||||||
|
|||||||
@@ -60,16 +60,13 @@ function CardOuter({
|
|||||||
export function SuggestedFollowPlaceholder() {
|
export function SuggestedFollowPlaceholder() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
<CardOuter style={[a.gap_md, t.atoms.border_contrast_low]}>
|
||||||
<ProfileCard.Header>
|
<ProfileCard.Header>
|
||||||
<ProfileCard.AvatarPlaceholder />
|
<ProfileCard.AvatarPlaceholder />
|
||||||
|
<ProfileCard.NameAndHandlePlaceholder />
|
||||||
</ProfileCard.Header>
|
</ProfileCard.Header>
|
||||||
|
|
||||||
<View style={[a.py_xs]}>
|
<ProfileCard.DescriptionPlaceholder numberOfLines={2} />
|
||||||
<ProfileCard.NameAndHandlePlaceholder />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ProfileCard.DescriptionPlaceholder />
|
|
||||||
</CardOuter>
|
</CardOuter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -176,9 +173,14 @@ function useExperimentalSuggestedUsersQuery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||||
const [feedType, feedUri] = feed.split('|')
|
const {currentAccount} = useSession()
|
||||||
|
const [feedType, feedUriOrDid] = feed.split('|')
|
||||||
if (feedType === 'author') {
|
if (feedType === 'author') {
|
||||||
return <SuggestedFollowsProfile did={feedUri} />
|
if (currentAccount?.did === feedUriOrDid) {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
return <SuggestedFollowsProfile did={feedUriOrDid} />
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return <SuggestedFollowsHome />
|
return <SuggestedFollowsHome />
|
||||||
}
|
}
|
||||||
@@ -197,6 +199,7 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
|||||||
isSuggestionsLoading={isSuggestionsLoading}
|
isSuggestionsLoading={isSuggestionsLoading}
|
||||||
profiles={data?.suggestions ?? []}
|
profiles={data?.suggestions ?? []}
|
||||||
error={error}
|
error={error}
|
||||||
|
viewContext="profile"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -212,6 +215,7 @@ export function SuggestedFollowsHome() {
|
|||||||
isSuggestionsLoading={isSuggestionsLoading}
|
isSuggestionsLoading={isSuggestionsLoading}
|
||||||
profiles={profiles}
|
profiles={profiles}
|
||||||
error={error}
|
error={error}
|
||||||
|
viewContext="feed"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -220,10 +224,12 @@ export function ProfileGrid({
|
|||||||
isSuggestionsLoading,
|
isSuggestionsLoading,
|
||||||
error,
|
error,
|
||||||
profiles,
|
profiles,
|
||||||
|
viewContext = 'feed',
|
||||||
}: {
|
}: {
|
||||||
isSuggestionsLoading: boolean
|
isSuggestionsLoading: boolean
|
||||||
profiles: AppBskyActorDefs.ProfileViewDetailed[]
|
profiles: AppBskyActorDefs.ProfileViewDetailed[]
|
||||||
error: Error | null
|
error: Error | null
|
||||||
|
viewContext: 'profile' | 'feed'
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -280,7 +286,7 @@ export function ProfileGrid({
|
|||||||
shape="round"
|
shape="round"
|
||||||
/>
|
/>
|
||||||
</ProfileCard.Header>
|
</ProfileCard.Header>
|
||||||
<ProfileCard.Description profile={profile} />
|
<ProfileCard.Description profile={profile} numberOfLines={2} />
|
||||||
</ProfileCard.Outer>
|
</ProfileCard.Outer>
|
||||||
</CardOuter>
|
</CardOuter>
|
||||||
)}
|
)}
|
||||||
@@ -297,33 +303,31 @@ export function ProfileGrid({
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
||||||
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
|
<View
|
||||||
<Text
|
style={[
|
||||||
style={[
|
a.p_lg,
|
||||||
a.flex_1,
|
a.pb_xs,
|
||||||
a.text_lg,
|
a.flex_row,
|
||||||
a.font_bold,
|
a.align_center,
|
||||||
t.atoms.text_contrast_medium,
|
a.justify_between,
|
||||||
]}>
|
]}>
|
||||||
<Trans>Suggested for you</Trans>
|
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
{viewContext === 'profile' ? (
|
||||||
|
<Trans>Similar accounts</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Suggested for you</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Person fill={t.atoms.text_contrast_low.color} />
|
<Person fill={t.atoms.text_contrast_low.color} size="sm" />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{gtMobile ? (
|
{gtMobile ? (
|
||||||
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
|
<View style={[a.flex_1, a.px_lg, a.pt_sm, a.pb_lg, a.gap_md]}>
|
||||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_sm]}>
|
||||||
{content}
|
{content}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View
|
<View style={[a.flex_row, a.justify_end, a.align_center, a.gap_md]}>
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.justify_end,
|
|
||||||
a.align_center,
|
|
||||||
a.pt_xs,
|
|
||||||
a.gap_md,
|
|
||||||
]}>
|
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
label={_(msg`Browse more suggestions`)}
|
label={_(msg`Browse more suggestions`)}
|
||||||
to="/search"
|
to="/search"
|
||||||
@@ -339,7 +343,7 @@ export function ProfileGrid({
|
|||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||||
decelerationRate="fast">
|
decelerationRate="fast">
|
||||||
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
|
<View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
|
||||||
{content}
|
{content}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a, ViewStyleProp} from '#/alf'
|
||||||
|
|
||||||
|
export function Fill({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {children?: React.ReactNode} & ViewStyleProp) {
|
||||||
|
return <View style={[a.absolute, a.inset_0, style]}>{children}</View>
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ export function LikedByList({uri}: {uri: string}) {
|
|||||||
isLoading={isUriLoading || isLikedByLoading}
|
isLoading={isUriLoading || isLikedByLoading}
|
||||||
isError={isError}
|
isError={isError}
|
||||||
emptyType="results"
|
emptyType="results"
|
||||||
|
emptyTitle={_(msg`No likes yet`)}
|
||||||
emptyMessage={_(
|
emptyMessage={_(
|
||||||
msg`Nobody has liked this yet. Maybe you should be the first!`,
|
msg`Nobody has liked this yet. Maybe you should be the first!`,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||||
|
import {Fill} from '#/components/Fill'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies and thin border within a bounding box. Used to contrast media from
|
||||||
|
* bg of the container.
|
||||||
|
*/
|
||||||
|
export function MediaInsetBorder({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
opaque,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
/**
|
||||||
|
* Used where this border needs to match adjacent borders, such as in
|
||||||
|
* external link previews
|
||||||
|
*/
|
||||||
|
opaque?: boolean
|
||||||
|
} & ViewStyleProp) {
|
||||||
|
const t = useTheme()
|
||||||
|
const isLight = t.name === 'light'
|
||||||
|
return (
|
||||||
|
<Fill
|
||||||
|
style={[
|
||||||
|
a.rounded_sm,
|
||||||
|
a.border,
|
||||||
|
opaque
|
||||||
|
? [t.atoms.border_contrast_low]
|
||||||
|
: [
|
||||||
|
isLight
|
||||||
|
? t.atoms.border_contrast_low
|
||||||
|
: t.atoms.border_contrast_high,
|
||||||
|
{opacity: 0.6},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</Fill>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
import {Trans} from '@lingui/macro'
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
@@ -43,10 +44,10 @@ export function Embed({
|
|||||||
))}
|
))}
|
||||||
</Outer>
|
</Outer>
|
||||||
)
|
)
|
||||||
} else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
|
} else if (AppBskyEmbedExternal.isView(media) && media.external.thumb) {
|
||||||
let url: URL | undefined
|
let url: URL | undefined
|
||||||
try {
|
try {
|
||||||
url = new URL(embed.external.uri)
|
url = new URL(media.external.uri)
|
||||||
} catch {}
|
} catch {}
|
||||||
if (url) {
|
if (url) {
|
||||||
const {success} = parseTenorGif(url)
|
const {success} = parseTenorGif(url)
|
||||||
@@ -54,17 +55,17 @@ export function Embed({
|
|||||||
return (
|
return (
|
||||||
<Outer style={style}>
|
<Outer style={style}>
|
||||||
<GifItem
|
<GifItem
|
||||||
thumbnail={embed.external.thumb}
|
thumbnail={media.external.thumb}
|
||||||
alt={embed.external.title}
|
alt={media.external.title}
|
||||||
/>
|
/>
|
||||||
</Outer>
|
</Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (AppBskyEmbedVideo.isView(embed)) {
|
} else if (AppBskyEmbedVideo.isView(media)) {
|
||||||
return (
|
return (
|
||||||
<Outer style={style}>
|
<Outer style={style}>
|
||||||
<VideoItem thumbnail={embed.thumbnail} alt={embed.alt} />
|
<VideoItem thumbnail={media.thumbnail} alt={media.alt} />
|
||||||
</Outer>
|
</Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,18 +92,20 @@ export function ImageItem({
|
|||||||
alt?: string
|
alt?: string
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
<View style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
|
<View style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
|
||||||
<Image
|
<Image
|
||||||
key={thumbnail}
|
key={thumbnail}
|
||||||
source={{uri: thumbnail}}
|
source={{uri: thumbnail}}
|
||||||
style={[a.flex_1, a.rounded_xs]}
|
style={[a.flex_1, a.rounded_xs, t.atoms.bg_contrast_25]}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
accessible={true}
|
accessible={true}
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
accessibilityHint={alt}
|
accessibilityHint={alt}
|
||||||
accessibilityLabel=""
|
accessibilityLabel=""
|
||||||
/>
|
/>
|
||||||
|
<MediaInsetBorder style={[a.rounded_xs]} />
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -220,8 +220,10 @@ export function NameAndHandlePlaceholder() {
|
|||||||
|
|
||||||
export function Description({
|
export function Description({
|
||||||
profile: profileUnshadowed,
|
profile: profileUnshadowed,
|
||||||
|
numberOfLines = 3,
|
||||||
}: {
|
}: {
|
||||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
numberOfLines?: number
|
||||||
}) {
|
}) {
|
||||||
const profile = useProfileShadow(profileUnshadowed)
|
const profile = useProfileShadow(profileUnshadowed)
|
||||||
const {description} = profile
|
const {description} = profile
|
||||||
@@ -244,31 +246,34 @@ export function Description({
|
|||||||
<RichText
|
<RichText
|
||||||
value={rt}
|
value={rt}
|
||||||
style={[a.leading_snug]}
|
style={[a.leading_snug]}
|
||||||
numberOfLines={3}
|
numberOfLines={numberOfLines}
|
||||||
disableLinks
|
disableLinks
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DescriptionPlaceholder() {
|
export function DescriptionPlaceholder({
|
||||||
|
numberOfLines = 3,
|
||||||
|
}: {
|
||||||
|
numberOfLines?: number
|
||||||
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
<View style={[a.gap_xs]}>
|
<View style={[{gap: 8}]}>
|
||||||
<View
|
{Array(numberOfLines)
|
||||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
.fill(0)
|
||||||
/>
|
.map((_, i) => (
|
||||||
<View
|
<View
|
||||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
key={i}
|
||||||
/>
|
style={[
|
||||||
<View
|
a.rounded_xs,
|
||||||
style={[
|
a.w_full,
|
||||||
a.rounded_xs,
|
t.atoms.bg_contrast_50,
|
||||||
a.w_full,
|
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
|
||||||
t.atoms.bg_contrast_50,
|
]}
|
||||||
{height: 12, width: 100},
|
/>
|
||||||
]}
|
))}
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ export function Outer({
|
|||||||
export function TitleText({children}: React.PropsWithChildren<{}>) {
|
export function TitleText({children}: React.PropsWithChildren<{}>) {
|
||||||
const {titleId} = React.useContext(Context)
|
const {titleId} = React.useContext(Context)
|
||||||
return (
|
return (
|
||||||
<Text nativeID={titleId} style={[a.text_2xl, a.font_bold, a.pb_sm]}>
|
<Text
|
||||||
|
nativeID={titleId}
|
||||||
|
style={[a.text_2xl, a.font_bold, a.pb_sm, a.leading_snug]}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface ProfilesListProps {
|
|||||||
|
|
||||||
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
|
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||||
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
|
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
|
||||||
const feed: FeedDescriptor = `list|${listUri}|as_following`
|
const feed: FeedDescriptor = `list|${listUri}`
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
const onScrollToTop = useCallback(() => {
|
const onScrollToTop = useCallback(() => {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import Svg, {Circle, Path} from 'react-native-svg'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {Nux, useUpsertNuxMutation} from '#/state/queries/nuxs'
|
||||||
|
import {atoms as a, ViewStyleProp} from '#/alf'
|
||||||
|
import {Button, ButtonProps} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
import {TenMillion} from './'
|
||||||
|
|
||||||
|
export function Trigger({children}: {children: ButtonProps['children']}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {mutate: upsertNux} = useUpsertNuxMutation()
|
||||||
|
const [show, setShow] = React.useState(false)
|
||||||
|
const [fallback, setFallback] = React.useState(false)
|
||||||
|
const control = Prompt.usePromptControl()
|
||||||
|
|
||||||
|
const handleOnPress = () => {
|
||||||
|
if (!fallback) {
|
||||||
|
setShow(true)
|
||||||
|
upsertNux({
|
||||||
|
id: Nux.TenMillionDialog,
|
||||||
|
completed: true,
|
||||||
|
data: undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
control.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onHandleFallback = () => {
|
||||||
|
setFallback(true)
|
||||||
|
control.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Bluesky is celebrating 10 million users!`)}
|
||||||
|
onPress={handleOnPress}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{show && !fallback && (
|
||||||
|
<TenMillion
|
||||||
|
showTimeout={0}
|
||||||
|
onClose={() => setShow(false)}
|
||||||
|
onFallback={onHandleFallback}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Prompt.Outer control={control}>
|
||||||
|
<View style={{maxWidth: 300}}>
|
||||||
|
<Prompt.TitleText>
|
||||||
|
<Trans>Bluesky is celebrating 10 million users!</Trans>
|
||||||
|
</Prompt.TitleText>
|
||||||
|
</View>
|
||||||
|
<Prompt.DescriptionText>
|
||||||
|
<Trans>
|
||||||
|
Together, we're rebuilding the social internet. We're glad you're
|
||||||
|
here.
|
||||||
|
</Trans>
|
||||||
|
</Prompt.DescriptionText>
|
||||||
|
<Prompt.DescriptionText>
|
||||||
|
<Trans>
|
||||||
|
To learn more,{' '}
|
||||||
|
<InlineLinkText
|
||||||
|
label={_(msg`View our post`)}
|
||||||
|
to="/profile/bsky.app/post/3l47prg3wgy23"
|
||||||
|
onPress={() => {
|
||||||
|
control.close()
|
||||||
|
}}
|
||||||
|
style={[a.text_md, a.leading_snug]}>
|
||||||
|
<Trans>check out our post.</Trans>
|
||||||
|
</InlineLinkText>
|
||||||
|
</Trans>
|
||||||
|
</Prompt.DescriptionText>
|
||||||
|
<Dialog.Close />
|
||||||
|
</Prompt.Outer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({width, style}: {width: number} & ViewStyleProp) {
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={width} viewBox="0 0 36 36" style={style}>
|
||||||
|
<Path
|
||||||
|
fill="#dd2e44"
|
||||||
|
d="M11.626 7.488a1.4 1.4 0 0 0-.268.395l-.008-.008L.134 33.141l.011.011c-.208.403.14 1.223.853 1.937c.713.713 1.533 1.061 1.936.853l.01.01L28.21 24.735l-.008-.009c.147-.07.282-.155.395-.269c1.562-1.562-.971-6.627-5.656-11.313c-4.687-4.686-9.752-7.218-11.315-5.656"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill="#ea596e"
|
||||||
|
d="M13 12L.416 32.506l-.282.635l.011.011c-.208.403.14 1.223.853 1.937c.232.232.473.408.709.557L17 17z"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill="#a0041e"
|
||||||
|
d="M23.012 13.066c4.67 4.672 7.263 9.652 5.789 11.124c-1.473 1.474-6.453-1.118-11.126-5.788c-4.671-4.672-7.263-9.654-5.79-11.127c1.474-1.473 6.454 1.119 11.127 5.791"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill="#aa8dd8"
|
||||||
|
d="M18.59 13.609a1 1 0 0 1-.734.215c-.868-.094-1.598-.396-2.109-.873c-.541-.505-.808-1.183-.735-1.862c.128-1.192 1.324-2.286 3.363-2.066c.793.085 1.147-.17 1.159-.292c.014-.121-.277-.446-1.07-.532c-.868-.094-1.598-.396-2.11-.873c-.541-.505-.809-1.183-.735-1.862c.13-1.192 1.325-2.286 3.362-2.065c.578.062.883-.057 1.012-.134c.103-.063.144-.123.148-.158c.012-.121-.275-.446-1.07-.532a1 1 0 0 1-.886-1.102a.997.997 0 0 1 1.101-.886c2.037.219 2.973 1.542 2.844 2.735c-.13 1.194-1.325 2.286-3.364 2.067c-.578-.063-.88.057-1.01.134c-.103.062-.145.123-.149.157c-.013.122.276.446 1.071.532c2.037.22 2.973 1.542 2.844 2.735s-1.324 2.286-3.362 2.065c-.578-.062-.882.058-1.012.134c-.104.064-.144.124-.148.158c-.013.121.276.446 1.07.532a1 1 0 0 1 .52 1.773"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill="#77b255"
|
||||||
|
d="M30.661 22.857c1.973-.557 3.334.323 3.658 1.478c.324 1.154-.378 2.615-2.35 3.17c-.77.216-1.001.584-.97.701c.034.118.425.312 1.193.095c1.972-.555 3.333.325 3.657 1.479c.326 1.155-.378 2.614-2.351 3.17c-.769.216-1.001.585-.967.702s.423.311 1.192.095a1 1 0 1 1 .54 1.925c-1.971.555-3.333-.323-3.659-1.479c-.324-1.154.379-2.613 2.353-3.169c.77-.217 1.001-.584.967-.702c-.032-.117-.422-.312-1.19-.096c-1.974.556-3.334-.322-3.659-1.479c-.325-1.154.378-2.613 2.351-3.17c.768-.215.999-.585.967-.701c-.034-.118-.423-.312-1.192-.096a1 1 0 1 1-.54-1.923"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill="#aa8dd8"
|
||||||
|
d="M23.001 20.16a1.001 1.001 0 0 1-.626-1.781c.218-.175 5.418-4.259 12.767-3.208a1 1 0 1 1-.283 1.979c-6.493-.922-11.187 2.754-11.233 2.791a1 1 0 0 1-.625.219"
|
||||||
|
/>
|
||||||
|
<Path
|
||||||
|
fill="#77b255"
|
||||||
|
d="M5.754 16a1 1 0 0 1-.958-1.287c1.133-3.773 2.16-9.794.898-11.364c-.141-.178-.354-.353-.842-.316c-.938.072-.849 2.051-.848 2.071a1 1 0 1 1-1.994.149c-.103-1.379.326-4.035 2.692-4.214c1.056-.08 1.933.287 2.552 1.057c2.371 2.951-.036 11.506-.542 13.192a1 1 0 0 1-.958.712"
|
||||||
|
/>
|
||||||
|
<Circle cx="25.5" cy="9.5" r="1.5" fill="#5c913b" />
|
||||||
|
<Circle cx="2" cy="18" r="2" fill="#9266cc" />
|
||||||
|
<Circle cx="32.5" cy="19.5" r="1.5" fill="#5c913b" />
|
||||||
|
<Circle cx="23.5" cy="31.5" r="1.5" fill="#5c913b" />
|
||||||
|
<Circle cx="28" cy="4" r="2" fill="#ffcc4d" />
|
||||||
|
<Circle cx="32.5" cy="8.5" r="1.5" fill="#ffcc4d" />
|
||||||
|
<Circle cx="29.5" cy="12.5" r="1.5" fill="#ffcc4d" />
|
||||||
|
<Circle cx="7.5" cy="23.5" r="1.5" fill="#ffcc4d" />
|
||||||
|
</Svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,694 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import Animated, {FadeIn} from 'react-native-reanimated'
|
||||||
|
import ViewShot from 'react-native-view-shot'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||||
|
import * as MediaLibrary from 'expo-media-library'
|
||||||
|
import {moderateProfile} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {networkRetry} from '#/lib/async/retry'
|
||||||
|
import {getCanvas} from '#/lib/canvas'
|
||||||
|
import {shareUrl} from '#/lib/sharing'
|
||||||
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
import {isIOS, isNative} from '#/platform/detection'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {useComposerControls} from 'state/shell'
|
||||||
|
import {formatCount} from '#/view/com/util/numeric/format'
|
||||||
|
import {Logomark} from '#/view/icons/Logomark'
|
||||||
|
import * as Toast from 'view/com/util/Toast'
|
||||||
|
import {
|
||||||
|
atoms as a,
|
||||||
|
ThemeProvider,
|
||||||
|
tokens,
|
||||||
|
useBreakpoints,
|
||||||
|
useTheme,
|
||||||
|
} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||||
|
import {OnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/OnePercent'
|
||||||
|
import {PointOnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/PointOnePercent'
|
||||||
|
import {TenPercent} from '#/components/dialogs/nuxs/TenMillion/icons/TenPercent'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {GradientFill} from '#/components/GradientFill'
|
||||||
|
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||||
|
import {Download_Stroke2_Corner0_Rounded as Download} from '#/components/icons/Download'
|
||||||
|
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const DEBUG = false
|
||||||
|
const RATIO = 8 / 10
|
||||||
|
const WIDTH = 2000
|
||||||
|
const HEIGHT = WIDTH * RATIO
|
||||||
|
|
||||||
|
function getFontSize(count: number) {
|
||||||
|
const length = count.toString().length
|
||||||
|
if (length < 7) {
|
||||||
|
return 80
|
||||||
|
} else if (length < 5) {
|
||||||
|
return 100
|
||||||
|
} else {
|
||||||
|
return 70
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPercentBadge(percent: number) {
|
||||||
|
if (percent <= 0.001) {
|
||||||
|
return PointOnePercent
|
||||||
|
} else if (percent <= 0.01) {
|
||||||
|
return OnePercent
|
||||||
|
} else if (percent <= 0.1) {
|
||||||
|
return TenPercent
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Frame({children}: {children: React.ReactNode}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.w_full,
|
||||||
|
a.overflow_hidden,
|
||||||
|
{
|
||||||
|
paddingTop: '80%',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenMillion({
|
||||||
|
showTimeout,
|
||||||
|
onClose,
|
||||||
|
onFallback,
|
||||||
|
}: {
|
||||||
|
showTimeout?: number
|
||||||
|
onClose?: () => void
|
||||||
|
onFallback?: () => void
|
||||||
|
}) {
|
||||||
|
const agent = useAgent()
|
||||||
|
const nuxDialogs = useNuxDialogContext()
|
||||||
|
const [userNumber, setUserNumber] = React.useState<number>(0)
|
||||||
|
const fetching = React.useRef(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
async function fetchUserNumber() {
|
||||||
|
const isBlueskyHosted = agent.sessionManager.pdsUrl
|
||||||
|
?.toString()
|
||||||
|
.includes('bsky.network')
|
||||||
|
|
||||||
|
if (isBlueskyHosted && agent.session?.accessJwt) {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://bsky.social/xrpc/com.atproto.temp.getSignupNumber`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${agent.session.accessJwt}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Network request failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.number && data.number <= 10_000_000) {
|
||||||
|
setUserNumber(data.number)
|
||||||
|
} else {
|
||||||
|
// should be rare
|
||||||
|
nuxDialogs.dismissActiveNux()
|
||||||
|
onFallback?.()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nuxDialogs.dismissActiveNux()
|
||||||
|
onFallback?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fetching.current) {
|
||||||
|
fetching.current = true
|
||||||
|
networkRetry(3, fetchUserNumber).catch(() => {
|
||||||
|
nuxDialogs.dismissActiveNux()
|
||||||
|
onFallback?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
agent.sessionManager.pdsUrl,
|
||||||
|
agent.session?.accessJwt,
|
||||||
|
setUserNumber,
|
||||||
|
nuxDialogs.dismissActiveNux,
|
||||||
|
nuxDialogs,
|
||||||
|
onFallback,
|
||||||
|
])
|
||||||
|
|
||||||
|
return userNumber ? (
|
||||||
|
<TenMillionInner
|
||||||
|
userNumber={userNumber}
|
||||||
|
showTimeout={showTimeout ?? 3e3}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenMillionInner({
|
||||||
|
userNumber,
|
||||||
|
showTimeout,
|
||||||
|
onClose: onCloseOuter,
|
||||||
|
}: {
|
||||||
|
userNumber: number
|
||||||
|
showTimeout: number
|
||||||
|
onClose?: () => void
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const lightTheme = useTheme('light')
|
||||||
|
const {_, i18n} = useLingui()
|
||||||
|
const control = Dialog.useDialogControl()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
|
const {openComposer} = useComposerControls()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
const {
|
||||||
|
isLoading: isProfileLoading,
|
||||||
|
data: profile,
|
||||||
|
error: profileError,
|
||||||
|
} = useProfileQuery({
|
||||||
|
did: currentAccount!.did,
|
||||||
|
})
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const nuxDialogs = useNuxDialogContext()
|
||||||
|
const moderation = React.useMemo(() => {
|
||||||
|
return profile && moderationOpts
|
||||||
|
? moderateProfile(profile, moderationOpts)
|
||||||
|
: undefined
|
||||||
|
}, [profile, moderationOpts])
|
||||||
|
const [uri, setUri] = React.useState<string | null>(null)
|
||||||
|
const percent = userNumber / 10_000_000
|
||||||
|
const Badge = getPercentBadge(percent)
|
||||||
|
const isLoadingData = isProfileLoading || !moderation || !profile
|
||||||
|
const isLoadingImage = !uri
|
||||||
|
|
||||||
|
const error: string = React.useMemo(() => {
|
||||||
|
if (profileError) {
|
||||||
|
return _(
|
||||||
|
msg`Oh no! We weren't able to generate an image for you to share. Rest assured, we're glad you're here 🦋`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}, [_, profileError])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Opening and closing
|
||||||
|
*/
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
control.open()
|
||||||
|
}, showTimeout)
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [control, showTimeout])
|
||||||
|
const onClose = React.useCallback(() => {
|
||||||
|
nuxDialogs.dismissActiveNux()
|
||||||
|
onCloseOuter?.()
|
||||||
|
}, [nuxDialogs, onCloseOuter])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Actions
|
||||||
|
*/
|
||||||
|
const sharePost = React.useCallback(() => {
|
||||||
|
if (uri) {
|
||||||
|
control.close(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
logEvent('tmd:post', {})
|
||||||
|
openComposer({
|
||||||
|
text: _(
|
||||||
|
msg`Bluesky now has over 10 million users, and I was #${i18n.number(
|
||||||
|
userNumber,
|
||||||
|
)}!`,
|
||||||
|
), // TODO
|
||||||
|
imageUris: [
|
||||||
|
{
|
||||||
|
uri,
|
||||||
|
width: WIDTH,
|
||||||
|
height: HEIGHT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}, 1e3)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [_, i18n, control, openComposer, uri, userNumber])
|
||||||
|
const onNativeShare = React.useCallback(() => {
|
||||||
|
if (uri) {
|
||||||
|
control.close(() => {
|
||||||
|
logEvent('tmd:share', {})
|
||||||
|
shareUrl(uri)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [uri, control])
|
||||||
|
const onNativeDownload = React.useCallback(async () => {
|
||||||
|
if (uri) {
|
||||||
|
const res = await requestMediaLibraryPermissionsAsync()
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
Toast.show(
|
||||||
|
_(
|
||||||
|
msg`You must grant access to your photo library to save the image.`,
|
||||||
|
),
|
||||||
|
'xmark',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await MediaLibrary.createAssetAsync(uri)
|
||||||
|
logEvent('tmd:download', {})
|
||||||
|
Toast.show(_(msg`Image saved to your camera roll!`))
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.log(e)
|
||||||
|
Toast.show(_(msg`An error occurred while saving the image!`), 'xmark')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [_, uri])
|
||||||
|
const onWebDownload = React.useCallback(async () => {
|
||||||
|
if (uri) {
|
||||||
|
const canvas = await getCanvas(uri)
|
||||||
|
const imgHref = canvas
|
||||||
|
.toDataURL('image/png')
|
||||||
|
.replace('image/png', 'image/octet-stream')
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.setAttribute('download', `Bluesky 10M Users.png`)
|
||||||
|
link.setAttribute('href', imgHref)
|
||||||
|
link.click()
|
||||||
|
logEvent('tmd:download', {})
|
||||||
|
}
|
||||||
|
}, [uri])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Canvas stuff
|
||||||
|
*/
|
||||||
|
const imageRef = React.useRef<ViewShot>(null)
|
||||||
|
const captureInProgress = React.useRef(false)
|
||||||
|
const onCanvasReady = React.useCallback(async () => {
|
||||||
|
if (
|
||||||
|
imageRef.current &&
|
||||||
|
imageRef.current.capture &&
|
||||||
|
!captureInProgress.current
|
||||||
|
) {
|
||||||
|
captureInProgress.current = true
|
||||||
|
const uri = await imageRef.current.capture()
|
||||||
|
setUri(uri)
|
||||||
|
}
|
||||||
|
}, [setUri])
|
||||||
|
const canvas = isLoadingData ? null : (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.overflow_hidden,
|
||||||
|
DEBUG
|
||||||
|
? {
|
||||||
|
width: 600,
|
||||||
|
height: 600 * RATIO,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View style={{width: 600}}>
|
||||||
|
<ThemeProvider theme="light">
|
||||||
|
<Frame>
|
||||||
|
<ViewShot
|
||||||
|
ref={imageRef}
|
||||||
|
options={{width: WIDTH, height: HEIGHT}}
|
||||||
|
style={[a.absolute, a.inset_0]}>
|
||||||
|
<View
|
||||||
|
onLayout={onCanvasReady}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
top: -1,
|
||||||
|
bottom: -1,
|
||||||
|
left: -1,
|
||||||
|
right: -1,
|
||||||
|
paddingVertical: 48,
|
||||||
|
paddingHorizontal: 48,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<GradientFill gradient={tokens.gradients.bonfire} />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.w_full,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_md,
|
||||||
|
{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
shadowRadius: 32,
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
elevation: 24,
|
||||||
|
shadowColor: tokens.gradients.bonfire.values[0][1],
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.px_xl,
|
||||||
|
a.py_xl,
|
||||||
|
{
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Logomark fill={t.palette.primary_500} width={36} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Centered content */}
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
paddingBottom: isNative ? 0 : 24,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Text
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.font_bold,
|
||||||
|
a.text_center,
|
||||||
|
a.pb_sm,
|
||||||
|
lightTheme.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Celebrating {formatCount(i18n, 10000000)} users
|
||||||
|
</Trans>{' '}
|
||||||
|
🎉
|
||||||
|
</Text>
|
||||||
|
<View style={[a.flex_row, a.align_start]}>
|
||||||
|
<Text
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
color: t.palette.primary_500,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '900',
|
||||||
|
width: 32,
|
||||||
|
top: isNative ? -10 : 0,
|
||||||
|
left: 0,
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateX: -16,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
#
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.text_center,
|
||||||
|
{
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontSize: getFontSize(userNumber),
|
||||||
|
lineHeight: getFontSize(userNumber),
|
||||||
|
fontWeight: '900',
|
||||||
|
letterSpacing: -2,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{i18n.number(userNumber)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{Badge && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
top: isNative ? 75 : 85,
|
||||||
|
right: '5%',
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
rotate: '8deg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Badge fill={t.palette.primary_500} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
{/* End centered content */}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.px_xl,
|
||||||
|
a.py_xl,
|
||||||
|
{
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
{/*
|
||||||
|
<UserAvatar
|
||||||
|
size={36}
|
||||||
|
avatar={profile.avatar}
|
||||||
|
moderation={moderation.ui('avatar')}
|
||||||
|
onLoad={onCanvasReady}
|
||||||
|
/>
|
||||||
|
*/}
|
||||||
|
<View style={[a.gap_2xs, a.flex_1]}>
|
||||||
|
<Text
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_tight,
|
||||||
|
{maxWidth: '60%'},
|
||||||
|
]}>
|
||||||
|
{sanitizeDisplayName(
|
||||||
|
profile.displayName ||
|
||||||
|
sanitizeHandle(profile.handle),
|
||||||
|
moderation.ui('displayName'),
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[a.flex_row, a.justify_between, a.gap_4xl]}>
|
||||||
|
<Text
|
||||||
|
allowFontScaling={false}
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_semibold,
|
||||||
|
a.leading_snug,
|
||||||
|
lightTheme.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{sanitizeHandle(profile.handle, '@')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{profile.createdAt && (
|
||||||
|
<Text
|
||||||
|
allowFontScaling={false}
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="head"
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_semibold,
|
||||||
|
a.leading_snug,
|
||||||
|
a.text_right,
|
||||||
|
lightTheme.atoms.text_contrast_low,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Joined{' '}
|
||||||
|
{i18n.date(profile.createdAt, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ViewShot>
|
||||||
|
</Frame>
|
||||||
|
</ThemeProvider>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control} onClose={onClose}>
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`Ten Million`)}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
padding: 0,
|
||||||
|
paddingTop: 0,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_md,
|
||||||
|
a.overflow_hidden,
|
||||||
|
isNative && {
|
||||||
|
borderTopLeftRadius: 40,
|
||||||
|
borderTopRightRadius: 40,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Frame>
|
||||||
|
<View
|
||||||
|
style={[a.absolute, a.inset_0, a.align_center, a.justify_center]}>
|
||||||
|
<GradientFill gradient={tokens.gradients.bonfire} />
|
||||||
|
{error ? (
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_md,
|
||||||
|
a.leading_snug,
|
||||||
|
a.text_center,
|
||||||
|
a.pb_md,
|
||||||
|
{
|
||||||
|
maxWidth: 300,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
(╯°□°)╯︵ ┻━┻
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_xl,
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_snug,
|
||||||
|
a.text_center,
|
||||||
|
{
|
||||||
|
maxWidth: 300,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : isLoadingData || isLoadingImage ? (
|
||||||
|
<Loader size="xl" fill="white" />
|
||||||
|
) : (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(150)}
|
||||||
|
style={[a.w_full, a.h_full]}>
|
||||||
|
<Image
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
source={{uri}}
|
||||||
|
style={[a.w_full, a.h_full]}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
{canvas}
|
||||||
|
|
||||||
|
<View style={[gtMobile ? a.p_2xl : a.p_xl]}>
|
||||||
|
<Text
|
||||||
|
allowFontScaling={false}
|
||||||
|
style={[
|
||||||
|
a.text_5xl,
|
||||||
|
a.leading_tight,
|
||||||
|
a.pb_lg,
|
||||||
|
{
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<Trans>Thanks for being one of our first 10 million users.</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={[a.leading_snug, a.text_lg, a.pb_xl]}>
|
||||||
|
<Trans>
|
||||||
|
Together, we're rebuilding the social internet. We're glad
|
||||||
|
you're here.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_end,
|
||||||
|
a.gap_md,
|
||||||
|
a.pt_xl,
|
||||||
|
]}>
|
||||||
|
{gtMobile && (
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>Brag a little!</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={isLoadingImage}
|
||||||
|
label={
|
||||||
|
isNative && isIOS
|
||||||
|
? _(msg`Share image externally`)
|
||||||
|
: _(msg`Download image`)
|
||||||
|
}
|
||||||
|
size="large"
|
||||||
|
variant="solid"
|
||||||
|
color="secondary"
|
||||||
|
shape="square"
|
||||||
|
onPress={
|
||||||
|
isNative
|
||||||
|
? isIOS
|
||||||
|
? onNativeShare
|
||||||
|
: onNativeDownload
|
||||||
|
: onWebDownload
|
||||||
|
}>
|
||||||
|
<ButtonIcon icon={isNative && isIOS ? Share : Download} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={isLoadingImage}
|
||||||
|
label={_(msg`Share image in post`)}
|
||||||
|
size="large"
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
onPress={sharePost}>
|
||||||
|
<ButtonText>{_(msg`Share`)}</ButtonText>
|
||||||
|
<ButtonIcon position="right" icon={ImageIcon} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Dialog.Close />
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {
|
||||||
|
Nux,
|
||||||
|
useNuxs,
|
||||||
|
useRemoveNuxsMutation,
|
||||||
|
useUpsertNuxMutation,
|
||||||
|
} from '#/state/queries/nuxs'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
|
import {useOnboardingState} from '#/state/shell'
|
||||||
|
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||||
|
import {TenMillion} from '#/components/dialogs/nuxs/TenMillion'
|
||||||
|
import {IS_DEV} from '#/env'
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
activeNux: Nux | undefined
|
||||||
|
dismissActiveNux: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const queuedNuxs: {
|
||||||
|
id: Nux
|
||||||
|
enabled?: (props: {gate: ReturnType<typeof useGate>}) => boolean
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: Nux.TenMillionDialog,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const Context = React.createContext<Context>({
|
||||||
|
activeNux: undefined,
|
||||||
|
dismissActiveNux: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useNuxDialogContext() {
|
||||||
|
return React.useContext(Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NuxDialogs() {
|
||||||
|
const {hasSession} = useSession()
|
||||||
|
const onboardingState = useOnboardingState()
|
||||||
|
return hasSession && !onboardingState.isActive ? <Inner /> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner() {
|
||||||
|
const gate = useGate()
|
||||||
|
const {nuxs} = useNuxs()
|
||||||
|
const [snoozed, setSnoozed] = React.useState(() => {
|
||||||
|
return isSnoozed()
|
||||||
|
})
|
||||||
|
const [activeNux, setActiveNux] = React.useState<Nux | undefined>()
|
||||||
|
const {mutateAsync: upsertNux} = useUpsertNuxMutation()
|
||||||
|
const {mutate: removeNuxs} = useRemoveNuxsMutation()
|
||||||
|
|
||||||
|
const snoozeNuxDialog = React.useCallback(() => {
|
||||||
|
snooze()
|
||||||
|
setSnoozed(true)
|
||||||
|
}, [setSnoozed])
|
||||||
|
|
||||||
|
const dismissActiveNux = React.useCallback(() => {
|
||||||
|
if (!activeNux) return
|
||||||
|
setActiveNux(undefined)
|
||||||
|
}, [activeNux, setActiveNux])
|
||||||
|
|
||||||
|
if (IS_DEV && typeof window !== 'undefined') {
|
||||||
|
// @ts-ignore
|
||||||
|
window.clearNuxDialog = (id: Nux) => {
|
||||||
|
if (!IS_DEV || !id) return
|
||||||
|
removeNuxs([id])
|
||||||
|
unsnooze()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (snoozed) return
|
||||||
|
if (!nuxs) return
|
||||||
|
|
||||||
|
for (const {id, enabled} of queuedNuxs) {
|
||||||
|
const nux = nuxs.find(nux => nux.id === id)
|
||||||
|
|
||||||
|
// check if completed first
|
||||||
|
if (nux && nux.completed) continue
|
||||||
|
|
||||||
|
// then check gate (track exposure)
|
||||||
|
if (enabled && !enabled({gate})) continue
|
||||||
|
|
||||||
|
// we have a winner
|
||||||
|
setActiveNux(id)
|
||||||
|
|
||||||
|
// immediately snooze for a day
|
||||||
|
snoozeNuxDialog()
|
||||||
|
|
||||||
|
// immediately update remote data (affects next reload)
|
||||||
|
upsertNux({
|
||||||
|
id,
|
||||||
|
completed: true,
|
||||||
|
data: undefined,
|
||||||
|
}).catch(e => {
|
||||||
|
logger.error(`NUX dialogs: failed to upsert '${id}' NUX`, {
|
||||||
|
safeMessage: e.message,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}, [nuxs, snoozed, snoozeNuxDialog, upsertNux, gate])
|
||||||
|
|
||||||
|
const ctx = React.useMemo(() => {
|
||||||
|
return {
|
||||||
|
activeNux,
|
||||||
|
dismissActiveNux,
|
||||||
|
}
|
||||||
|
}, [activeNux, dismissActiveNux])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={ctx}>
|
||||||
|
{activeNux === Nux.TenMillionDialog && <TenMillion />}
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {simpleAreDatesEqual} from '#/lib/strings/time'
|
||||||
|
import {device} from '#/storage'
|
||||||
|
|
||||||
|
export function snooze() {
|
||||||
|
device.set(['lastNuxDialog'], new Date().toISOString())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsnooze() {
|
||||||
|
device.set(['lastNuxDialog'], undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSnoozed() {
|
||||||
|
const lastNuxDialog = device.get(['lastNuxDialog'])
|
||||||
|
if (!lastNuxDialog) return false
|
||||||
|
const last = new Date(lastNuxDialog)
|
||||||
|
const now = new Date()
|
||||||
|
// already snoozed today
|
||||||
|
if (simpleAreDatesEqual(last, now)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const MessageContext = React.createContext(false)
|
||||||
|
|
||||||
|
export function MessageContextProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MessageContext.Provider value={true}>{children}</MessageContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsWithinMessage() {
|
||||||
|
return React.useContext(MessageContext)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {AppBskyEmbedRecord} from '@atproto/api'
|
|||||||
|
|
||||||
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import {MessageContextProvider} from './MessageContext'
|
||||||
|
|
||||||
let MessageItemEmbed = ({
|
let MessageItemEmbed = ({
|
||||||
embed,
|
embed,
|
||||||
@@ -13,13 +14,15 @@ let MessageItemEmbed = ({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
<MessageContextProvider>
|
||||||
<PostEmbeds
|
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||||
embed={embed}
|
<PostEmbeds
|
||||||
allowNestedQuotes
|
embed={embed}
|
||||||
viewContext={PostEmbedViewContext.Feed}
|
allowNestedQuotes
|
||||||
/>
|
viewContext={PostEmbedViewContext.Feed}
|
||||||
</View>
|
/>
|
||||||
|
</View>
|
||||||
|
</MessageContextProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageItemEmbed = React.memo(MessageItemEmbed)
|
MessageItemEmbed = React.memo(MessageItemEmbed)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ja,
|
ja,
|
||||||
ko,
|
ko,
|
||||||
ptBR,
|
ptBR,
|
||||||
|
ru,
|
||||||
tr,
|
tr,
|
||||||
uk,
|
uk,
|
||||||
zhCN,
|
zhCN,
|
||||||
@@ -47,6 +48,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
|
|||||||
ja,
|
ja,
|
||||||
ko,
|
ko,
|
||||||
['pt-BR']: ptBR,
|
['pt-BR']: ptBR,
|
||||||
|
ru,
|
||||||
tr,
|
tr,
|
||||||
uk,
|
uk,
|
||||||
['zh-CN']: zhCN,
|
['zh-CN']: zhCN,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
import {isFirefox, isSafari} from '#/lib/browser'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
|
||||||
|
function fullscreenSubscribe(onChange: () => void) {
|
||||||
|
document.addEventListener('fullscreenchange', onChange)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFullscreen(ref?: React.RefObject<HTMLElement>) {
|
||||||
|
if (!isWeb) throw new Error("'useFullscreen' is a web-only hook")
|
||||||
|
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
|
||||||
|
Boolean(document.fullscreenElement),
|
||||||
|
)
|
||||||
|
const scrollYRef = useRef<null | number>(null)
|
||||||
|
const [prevIsFullscreen, setPrevIsFullscreen] = useState(isFullscreen)
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (isFullscreen) {
|
||||||
|
document.exitFullscreen()
|
||||||
|
} else {
|
||||||
|
if (!ref) throw new Error('No ref provided')
|
||||||
|
if (!ref.current) return
|
||||||
|
scrollYRef.current = window.scrollY
|
||||||
|
ref.current.requestFullscreen()
|
||||||
|
}
|
||||||
|
}, [isFullscreen, ref])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevIsFullscreen === isFullscreen) return
|
||||||
|
setPrevIsFullscreen(isFullscreen)
|
||||||
|
|
||||||
|
// Chrome has an issue where it doesn't scroll back to the top after exiting fullscreen
|
||||||
|
// Let's play it safe and do it if not FF or Safari, since anything else will probably be chromium
|
||||||
|
if (prevIsFullscreen && !isFirefox && !isSafari) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (scrollYRef.current !== null) {
|
||||||
|
window.scrollTo(0, scrollYRef.current)
|
||||||
|
scrollYRef.current = null
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}, [isFullscreen, prevIsFullscreen])
|
||||||
|
|
||||||
|
return [isFullscreen, toggleFullscreen] as const
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Download_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M12 3a1 1 0 0 1 1 1v8.086l1.793-1.793a1 1 0 1 1 1.414 1.414l-3.5 3.5a1 1 0 0 1-1.414 0l-3.5-3.5a1 1 0 1 1 1.414-1.414L11 12.086V4a1 1 0 0 1 1-1ZM4 14a1 1 0 0 1 1 1v4h14v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
verifyEmailDialogControl: DialogControlProps
|
||||||
|
verifyEmailState: {code: string} | undefined
|
||||||
|
setVerifyEmailState: (state: {code: string} | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = React.createContext({} as Context)
|
||||||
|
export const useIntentDialogs = () => React.useContext(Context)
|
||||||
|
|
||||||
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
const verifyEmailDialogControl = Dialog.useDialogControl()
|
||||||
|
const [verifyEmailState, setVerifyEmailState] = React.useState<
|
||||||
|
{code: string} | undefined
|
||||||
|
>()
|
||||||
|
|
||||||
|
const value = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
verifyEmailDialogControl,
|
||||||
|
verifyEmailState,
|
||||||
|
setVerifyEmailState,
|
||||||
|
}),
|
||||||
|
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<VerifyEmailIntentDialog />
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useAgent, useSession} from 'state/session'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function VerifyEmailIntentDialog() {
|
||||||
|
const {verifyEmailDialogControl: control} = useIntentDialogs()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Inner control={control} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner({control}: {control: DialogControlProps}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {verifyEmailState: state} = useIntentDialogs()
|
||||||
|
const [status, setStatus] = React.useState<
|
||||||
|
'loading' | 'success' | 'failure' | 'resent'
|
||||||
|
>('loading')
|
||||||
|
const [sending, setSending] = React.useState(false)
|
||||||
|
const agent = useAgent()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
if (!state?.code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await agent.com.atproto.server.confirmEmail({
|
||||||
|
email: (currentAccount?.email || '').trim(),
|
||||||
|
token: state.code.trim(),
|
||||||
|
})
|
||||||
|
setStatus('success')
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('failure')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [agent.com.atproto.server, currentAccount?.email, state?.code])
|
||||||
|
|
||||||
|
const onPressResendEmail = async () => {
|
||||||
|
setSending(true)
|
||||||
|
await agent.com.atproto.server.requestEmailConfirmation()
|
||||||
|
setSending(false)
|
||||||
|
setStatus('resent')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}>
|
||||||
|
<Dialog.Close />
|
||||||
|
<View style={[a.gap_xl]}>
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<View style={[a.py_2xl, a.align_center, a.justify_center]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : status === 'success' ? (
|
||||||
|
<>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Email Verified</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>
|
||||||
|
<Trans>
|
||||||
|
Thanks, you have successfully verified your email address.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : status === 'failure' ? (
|
||||||
|
<>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Invalid Verification Code</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>
|
||||||
|
<Trans>
|
||||||
|
The verification code you have provided is invalid. Please make
|
||||||
|
sure that you have used the correct verification link or request
|
||||||
|
a new one.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Email Resent</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>
|
||||||
|
<Trans>
|
||||||
|
We have sent another verification email to{' '}
|
||||||
|
<Text style={[a.text_md, a.font_bold]}>
|
||||||
|
{currentAccount?.email}
|
||||||
|
</Text>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status !== 'loading' ? (
|
||||||
|
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
variant="solid"
|
||||||
|
color={status === 'failure' ? 'secondary' : 'primary'}
|
||||||
|
size="medium"
|
||||||
|
style={{marginLeft: 'auto'}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
{status === 'failure' ? (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Resend Verification Email`)}
|
||||||
|
onPress={onPressResendEmail}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
disabled={sending}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Resend Email</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import {View} from 'react-native'
|
|||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||||
|
|
||||||
export function PlayButtonIcon({size = 36}: {size?: number}) {
|
export function PlayButtonIcon({size = 32}: {size?: number}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
|
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
|
||||||
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
|
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
|
||||||
|
|||||||
@@ -379,7 +379,11 @@ export class FeedTuner {
|
|||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
for (let i = 0; i < slices.length; i++) {
|
for (let i = 0; i < slices.length; i++) {
|
||||||
const slice = slices[i]
|
const slice = slices[i]
|
||||||
if (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) {
|
if (
|
||||||
|
slice.isReply &&
|
||||||
|
!slice.isRepost &&
|
||||||
|
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
||||||
|
) {
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
i--
|
i--
|
||||||
}
|
}
|
||||||
@@ -443,13 +447,9 @@ function areSameAuthor(authors: AuthorContext): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldDisplayReplyInFollowing(
|
function shouldDisplayReplyInFollowing(
|
||||||
slice: FeedViewPostsSlice,
|
authors: AuthorContext,
|
||||||
userDid: string,
|
userDid: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (slice.isRepost) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const authors = slice.getAuthors()
|
|
||||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||||
if (!isSelfOrFollowing(author, userDid)) {
|
if (!isSelfOrFollowing(author, userDid)) {
|
||||||
// Only show replies from self or people you follow.
|
// Only show replies from self or people you follow.
|
||||||
@@ -463,21 +463,6 @@ function shouldDisplayReplyInFollowing(
|
|||||||
// Always show self-threads.
|
// Always show self-threads.
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
parentAuthor &&
|
|
||||||
parentAuthor.did !== author.did &&
|
|
||||||
rootAuthor &&
|
|
||||||
rootAuthor.did === author.did &&
|
|
||||||
slice.items.length > 2
|
|
||||||
) {
|
|
||||||
// If you follow A, show A -> someone[>0 likes] -> A chains too.
|
|
||||||
// This is different from cases below because you only know one person.
|
|
||||||
const parentPost = slice.items[1].post
|
|
||||||
const parentLikeCount = parentPost.likeCount ?? 0
|
|
||||||
if (parentLikeCount > 0) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// From this point on we need at least one more reason to show it.
|
// From this point on we need at least one more reason to show it.
|
||||||
if (
|
if (
|
||||||
parentAuthor &&
|
parentAuthor &&
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedGetFeed as GetCustomFeed,
|
AppBskyFeedGetFeed as GetCustomFeed,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
|
jsonStringToLex,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {getContentLanguages} from '#/state/preferences/languages'
|
import {getContentLanguages} from '#/state/preferences/languages'
|
||||||
@@ -111,7 +112,7 @@ async function loggedOutFetch({
|
|||||||
}&limit=${limit}&lang=${contentLangs}`,
|
}&limit=${limit}&lang=${contentLangs}`,
|
||||||
{method: 'GET', headers: {'Accept-Language': contentLangs}},
|
{method: 'GET', headers: {'Accept-Language': contentLangs}},
|
||||||
)
|
)
|
||||||
let data = res.ok ? await res.json() : null
|
let data = res.ok ? jsonStringToLex(await res.text()) : null
|
||||||
if (data?.feed?.length) {
|
if (data?.feed?.length) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -126,7 +127,7 @@ async function loggedOutFetch({
|
|||||||
}&limit=${limit}`,
|
}&limit=${limit}`,
|
||||||
{method: 'GET', headers: {'Accept-Language': ''}},
|
{method: 'GET', headers: {'Accept-Language': ''}},
|
||||||
)
|
)
|
||||||
data = res.ok ? await res.json() : null
|
data = res.ok ? jsonStringToLex(await res.text()) : null
|
||||||
if (data?.feed?.length) {
|
if (data?.feed?.length) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const getCanvas = (base64: string): Promise<HTMLCanvasElement> => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const image = new Image()
|
||||||
|
image.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = image.width
|
||||||
|
canvas.height = image.height
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx?.drawImage(image, 0, 0)
|
||||||
|
resolve(canvas)
|
||||||
|
}
|
||||||
|
image.src = base64
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
|
|||||||
|
|
||||||
export const MAX_LABELERS = 20
|
export const MAX_LABELERS = 20
|
||||||
|
|
||||||
|
export const VIDEO_SERVICE = 'https://video.bsky.app'
|
||||||
|
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
|
||||||
|
|
||||||
export const SUPPORTED_MIME_TYPES = [
|
export const SUPPORTED_MIME_TYPES = [
|
||||||
'video/mp4',
|
'video/mp4',
|
||||||
'video/mpeg',
|
'video/mpeg',
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ import {isNative} from 'platform/detection'
|
|||||||
import {useSession} from 'state/session'
|
import {useSession} from 'state/session'
|
||||||
import {useComposerControls} from 'state/shell'
|
import {useComposerControls} from 'state/shell'
|
||||||
import {useCloseAllActiveElements} from 'state/util'
|
import {useCloseAllActiveElements} from 'state/util'
|
||||||
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
type IntentType = 'compose'
|
type IntentType = 'compose' | 'verify-email'
|
||||||
|
|
||||||
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
||||||
|
|
||||||
export function useIntentHandler() {
|
export function useIntentHandler() {
|
||||||
const incomingUrl = Linking.useURL()
|
const incomingUrl = Linking.useURL()
|
||||||
const composeIntent = useComposeIntent()
|
const composeIntent = useComposeIntent()
|
||||||
|
const verifyEmailIntent = useVerifyEmailIntent()
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleIncomingURL = (url: string) => {
|
const handleIncomingURL = (url: string) => {
|
||||||
@@ -51,15 +53,25 @@ export function useIntentHandler() {
|
|||||||
text: params.get('text'),
|
text: params.get('text'),
|
||||||
imageUrisStr: params.get('imageUris'),
|
imageUrisStr: params.get('imageUris'),
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'verify-email': {
|
||||||
|
const code = params.get('code')
|
||||||
|
if (!code) return
|
||||||
|
verifyEmailIntent(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incomingUrl) handleIncomingURL(incomingUrl)
|
if (incomingUrl) handleIncomingURL(incomingUrl)
|
||||||
}, [incomingUrl, composeIntent])
|
}, [incomingUrl, composeIntent, verifyEmailIntent])
|
||||||
}
|
}
|
||||||
|
|
||||||
function useComposeIntent() {
|
export function useComposeIntent() {
|
||||||
const closeAllActiveElements = useCloseAllActiveElements()
|
const closeAllActiveElements = useCloseAllActiveElements()
|
||||||
const {openComposer} = useComposerControls()
|
const {openComposer} = useComposerControls()
|
||||||
const {hasSession} = useSession()
|
const {hasSession} = useSession()
|
||||||
@@ -103,3 +115,21 @@ function useComposeIntent() {
|
|||||||
[hasSession, closeAllActiveElements, openComposer],
|
[hasSession, closeAllActiveElements, openComposer],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useVerifyEmailIntent() {
|
||||||
|
const closeAllActiveElements = useCloseAllActiveElements()
|
||||||
|
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
|
||||||
|
useIntentDialogs()
|
||||||
|
return React.useCallback(
|
||||||
|
(code: string) => {
|
||||||
|
closeAllActiveElements()
|
||||||
|
setState({
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
control.open()
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
[closeAllActiveElements, control, setState],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import {getVideoMetaData, Video} from 'react-native-compressor'
|
import {getVideoMetaData, Video} from 'react-native-compressor'
|
||||||
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
|
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||||
|
import {extToMime} from '#/state/queries/video/util'
|
||||||
import {CompressedVideo} from './types'
|
import {CompressedVideo} from './types'
|
||||||
|
|
||||||
|
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
|
||||||
|
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: string,
|
file: ImagePickerAsset,
|
||||||
opts?: {
|
opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
@@ -11,12 +16,22 @@ export async function compressVideo(
|
|||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {onProgress, signal} = opts || {}
|
const {onProgress, signal} = opts || {}
|
||||||
|
|
||||||
|
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
||||||
|
file.mimeType as SupportedMimeTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
const minimumFileSizeForCompress = isAcceptableFormat
|
||||||
|
? MIN_SIZE_FOR_COMPRESSION
|
||||||
|
: 0
|
||||||
|
|
||||||
const compressed = await Video.compress(
|
const compressed = await Video.compress(
|
||||||
file,
|
file.uri,
|
||||||
{
|
{
|
||||||
compressionMethod: 'manual',
|
compressionMethod: 'manual',
|
||||||
bitrate: 3_000_000, // 3mbps
|
bitrate: 3_000_000, // 3mbps
|
||||||
maxSize: 1920,
|
maxSize: 1920,
|
||||||
|
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
|
||||||
|
minimumFileSizeForCompress,
|
||||||
getCancellationId: id => {
|
getCancellationId: id => {
|
||||||
if (signal) {
|
if (signal) {
|
||||||
signal.addEventListener('abort', () => {
|
signal.addEventListener('abort', () => {
|
||||||
@@ -30,5 +45,5 @@ export async function compressVideo(
|
|||||||
|
|
||||||
const info = await getVideoMetaData(compressed)
|
const info = await getVideoMetaData(compressed)
|
||||||
|
|
||||||
return {uri: compressed, size: info.size, mimeType: `video/mp4`}
|
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||||
import {CompressedVideo} from './types'
|
import {CompressedVideo} from './types'
|
||||||
|
|
||||||
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
|
const MAX_VIDEO_SIZE = 1024 * 1024 * 50 // 50mb
|
||||||
|
|
||||||
// doesn't actually compress, but throws if >100MB
|
// doesn't actually compress, converts to ArrayBuffer
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: string,
|
asset: ImagePickerAsset,
|
||||||
_opts?: {
|
_opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {mimeType, base64} = parseDataUrl(file)
|
const {mimeType, base64} = parseDataUrl(asset.uri)
|
||||||
const blob = base64ToBlob(base64, mimeType)
|
const blob = base64ToBlob(base64, mimeType)
|
||||||
const uri = URL.createObjectURL(blob)
|
const uri = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export class VideoTooLargeError extends Error {
|
export class VideoTooLargeError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('Videos cannot be larger than 100MB')
|
super('Videos cannot be larger than 50mb')
|
||||||
this.name = 'VideoTooLargeError'
|
this.name = 'VideoTooLargeError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,3 +11,10 @@ export class ServerError extends Error {
|
|||||||
this.name = 'ServerError'
|
this.name = 'ServerError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UploadLimitError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'UploadLimitError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,4 +225,8 @@ export type LogEvents = {
|
|||||||
'test:gate1:sometimes': {}
|
'test:gate1:sometimes': {}
|
||||||
'test:gate2:always': {}
|
'test:gate2:always': {}
|
||||||
'test:gate2:sometimes': {}
|
'test:gate2:sometimes': {}
|
||||||
|
|
||||||
|
'tmd:share': {}
|
||||||
|
'tmd:download': {}
|
||||||
|
'tmd:post': {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
export type Gate =
|
export type Gate =
|
||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
| 'debug_show_feedcontext'
|
'debug_show_feedcontext' | 'suggested_feeds_interstitial'
|
||||||
| 'onboarding_minimum_interests'
|
|
||||||
| 'suggested_feeds_interstitial'
|
|
||||||
| 'video_debug' // not recommended
|
|
||||||
| 'video_upload' // upload videos
|
|
||||||
| 'video_view_on_posts' // see posted videos
|
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
|||||||
return AppLanguage.ko
|
return AppLanguage.ko
|
||||||
case 'pt-BR':
|
case 'pt-BR':
|
||||||
return AppLanguage.pt_BR
|
return AppLanguage.pt_BR
|
||||||
|
case 'ru':
|
||||||
|
return AppLanguage.ru
|
||||||
case 'tr':
|
case 'tr':
|
||||||
return AppLanguage.tr
|
return AppLanguage.tr
|
||||||
case 'uk':
|
case 'uk':
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {messages as messagesIt} from '#/locale/locales/it/messages'
|
|||||||
import {messages as messagesJa} from '#/locale/locales/ja/messages'
|
import {messages as messagesJa} from '#/locale/locales/ja/messages'
|
||||||
import {messages as messagesKo} from '#/locale/locales/ko/messages'
|
import {messages as messagesKo} from '#/locale/locales/ko/messages'
|
||||||
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
|
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
|
||||||
|
import {messages as messagesRu} from '#/locale/locales/ru/messages'
|
||||||
import {messages as messagesTr} from '#/locale/locales/tr/messages'
|
import {messages as messagesTr} from '#/locale/locales/tr/messages'
|
||||||
import {messages as messagesUk} from '#/locale/locales/uk/messages'
|
import {messages as messagesUk} from '#/locale/locales/uk/messages'
|
||||||
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
|
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
|
||||||
@@ -131,6 +132,14 @@ export async function dynamicActivate(locale: AppLanguage) {
|
|||||||
])
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case AppLanguage.ru: {
|
||||||
|
i18n.loadAndActivate({locale, messages: messagesRu})
|
||||||
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ru'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ru'),
|
||||||
|
])
|
||||||
|
break
|
||||||
|
}
|
||||||
case AppLanguage.tr: {
|
case AppLanguage.tr: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesTr})
|
i18n.loadAndActivate({locale, messages: messagesTr})
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
|||||||
mod = await import(`./locales/pt-BR/messages`)
|
mod = await import(`./locales/pt-BR/messages`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case AppLanguage.ru: {
|
||||||
|
mod = await import(`./locales/ru/messages`)
|
||||||
|
break
|
||||||
|
}
|
||||||
case AppLanguage.tr: {
|
case AppLanguage.tr: {
|
||||||
mod = await import(`./locales/tr/messages`)
|
mod = await import(`./locales/tr/messages`)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export enum AppLanguage {
|
|||||||
ja = 'ja',
|
ja = 'ja',
|
||||||
ko = 'ko',
|
ko = 'ko',
|
||||||
pt_BR = 'pt-BR',
|
pt_BR = 'pt-BR',
|
||||||
|
ru = 'ru',
|
||||||
tr = 'tr',
|
tr = 'tr',
|
||||||
uk = 'uk',
|
uk = 'uk',
|
||||||
zh_CN = 'zh-CN',
|
zh_CN = 'zh-CN',
|
||||||
@@ -43,6 +44,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
|
|||||||
{code2: AppLanguage.ja, name: '日本語 – Japanese'},
|
{code2: AppLanguage.ja, name: '日本語 – Japanese'},
|
||||||
{code2: AppLanguage.ko, name: '한국어 – Korean'},
|
{code2: AppLanguage.ko, name: '한국어 – Korean'},
|
||||||
{code2: AppLanguage.pt_BR, name: 'Português (BR) – Portuguese (BR)'},
|
{code2: AppLanguage.pt_BR, name: 'Português (BR) – Portuguese (BR)'},
|
||||||
|
{code2: AppLanguage.ru, name: 'Русский – Russian'},
|
||||||
{code2: AppLanguage.tr, name: 'Türkçe – Turkish'},
|
{code2: AppLanguage.tr, name: 'Türkçe – Turkish'},
|
||||||
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
|
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
|
||||||
{code2: AppLanguage.zh_CN, name: '简体中文(中国)– Chinese (Simplified)'},
|
{code2: AppLanguage.zh_CN, name: '简体中文(中国)– Chinese (Simplified)'},
|
||||||
|
|||||||
+926
-591
File diff suppressed because it is too large
Load Diff
+793
-458
File diff suppressed because it is too large
Load Diff
+791
-456
File diff suppressed because it is too large
Load Diff
+792
-457
File diff suppressed because it is too large
Load Diff
+792
-457
File diff suppressed because it is too large
Load Diff
+888
-813
File diff suppressed because it is too large
Load Diff
+2211
-2745
File diff suppressed because it is too large
Load Diff
+791
-456
File diff suppressed because it is too large
Load Diff
+796
-461
File diff suppressed because it is too large
Load Diff
+1235
-903
File diff suppressed because it is too large
Load Diff
+745
-526
File diff suppressed because it is too large
Load Diff
+761
-520
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+791
-456
File diff suppressed because it is too large
Load Diff
+791
-456
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -144,8 +144,7 @@ export const ForgotPasswordForm = ({
|
|||||||
variant="solid"
|
variant="solid"
|
||||||
color={'primary'}
|
color={'primary'}
|
||||||
size="medium"
|
size="medium"
|
||||||
onPress={onPressNext}
|
onPress={onPressNext}>
|
||||||
disabled={!email}>
|
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Next</Trans>
|
<Trans>Next</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export const LoginForm = ({
|
|||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||||
const [isReady, setIsReady] = useState<boolean>(false)
|
|
||||||
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
|
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
|
||||||
useState<boolean>(false)
|
useState<boolean>(false)
|
||||||
const identifierValueRef = useRef<string>(initialHandle || '')
|
const identifierValueRef = useRef<string>(initialHandle || '')
|
||||||
@@ -83,12 +82,18 @@ export const LoginForm = ({
|
|||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
setError('')
|
setError('')
|
||||||
setIsProcessing(true)
|
|
||||||
|
|
||||||
const identifier = identifierValueRef.current.toLowerCase().trim()
|
const identifier = identifierValueRef.current.toLowerCase().trim()
|
||||||
const password = passwordValueRef.current
|
const password = passwordValueRef.current
|
||||||
const authFactorToken = authFactorTokenValueRef.current
|
const authFactorToken = authFactorTokenValueRef.current
|
||||||
|
|
||||||
|
if (!identifier || !password) {
|
||||||
|
setError(_(msg`Invalid username or password`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProcessing(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// try to guess the handle if the user just gave their own username
|
// try to guess the handle if the user just gave their own username
|
||||||
let fullIdent = identifier
|
let fullIdent = identifier
|
||||||
@@ -157,22 +162,6 @@ export const LoginForm = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkIsReady = () => {
|
|
||||||
if (
|
|
||||||
!!serviceDescription &&
|
|
||||||
!!identifierValueRef.current &&
|
|
||||||
!!passwordValueRef.current
|
|
||||||
) {
|
|
||||||
if (!isReady) {
|
|
||||||
setIsReady(true)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isReady) {
|
|
||||||
setIsReady(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
|
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
|
||||||
<View>
|
<View>
|
||||||
@@ -204,7 +193,6 @@ export const LoginForm = ({
|
|||||||
defaultValue={initialHandle || ''}
|
defaultValue={initialHandle || ''}
|
||||||
onChangeText={v => {
|
onChangeText={v => {
|
||||||
identifierValueRef.current = v
|
identifierValueRef.current = v
|
||||||
checkIsReady()
|
|
||||||
}}
|
}}
|
||||||
onSubmitEditing={() => {
|
onSubmitEditing={() => {
|
||||||
passwordRef.current?.focus()
|
passwordRef.current?.focus()
|
||||||
@@ -233,7 +221,6 @@ export const LoginForm = ({
|
|||||||
clearButtonMode="while-editing"
|
clearButtonMode="while-editing"
|
||||||
onChangeText={v => {
|
onChangeText={v => {
|
||||||
passwordValueRef.current = v
|
passwordValueRef.current = v
|
||||||
checkIsReady()
|
|
||||||
}}
|
}}
|
||||||
onSubmitEditing={onPressNext}
|
onSubmitEditing={onPressNext}
|
||||||
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
||||||
@@ -325,7 +312,7 @@ export const LoginForm = ({
|
|||||||
<Trans>Connecting...</Trans>
|
<Trans>Connecting...</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : isReady ? (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
testID="loginNextButton"
|
testID="loginNextButton"
|
||||||
label={_(msg`Next`)}
|
label={_(msg`Next`)}
|
||||||
@@ -339,7 +326,7 @@ export const LoginForm = ({
|
|||||||
</ButtonText>
|
</ButtonText>
|
||||||
{isProcessing && <ButtonIcon icon={Loader} />}
|
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</FormContainer>
|
</FormContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import {useQuery} from '@tanstack/react-query'
|
|||||||
|
|
||||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {capitalize} from '#/lib/strings/capitalize'
|
import {capitalize} from '#/lib/strings/capitalize'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {useOnboardingDispatch} from '#/state/shell'
|
import {useOnboardingDispatch} from '#/state/shell'
|
||||||
import {
|
import {
|
||||||
@@ -29,23 +27,16 @@ import * as Toggle from '#/components/forms/Toggle'
|
|||||||
import {IconCircle} from '#/components/IconCircle'
|
import {IconCircle} from '#/components/IconCircle'
|
||||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
|
||||||
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
|
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
const PROMPT_HEIGHT = isWeb ? 42 : 36
|
|
||||||
// matches the padding of the OnboardingControls.Portal
|
|
||||||
const PROMPT_OFFSET = isWeb ? a.pb_2xl.paddingBottom : a.pb_lg.paddingBottom
|
|
||||||
const MIN_INTERESTS = 3
|
|
||||||
|
|
||||||
export function StepInterests() {
|
export function StepInterests() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const gate = useGate()
|
|
||||||
const interestsDisplayNames = useInterestsDisplayNames()
|
const interestsDisplayNames = useInterestsDisplayNames()
|
||||||
|
|
||||||
const {state, dispatch} = React.useContext(Context)
|
const {state, dispatch} = React.useContext(Context)
|
||||||
@@ -143,12 +134,6 @@ export function StepInterests() {
|
|||||||
track('OnboardingV2:StepInterests:Start')
|
track('OnboardingV2:StepInterests:Start')
|
||||||
}, [track])
|
}, [track])
|
||||||
|
|
||||||
const isMinimumInterestsEnabled =
|
|
||||||
gate('onboarding_minimum_interests') && data?.interests.length !== 0
|
|
||||||
const meetsMinimumRequirement = isMinimumInterestsEnabled
|
|
||||||
? interests.length >= MIN_INTERESTS
|
|
||||||
: true
|
|
||||||
|
|
||||||
const title = isError ? (
|
const title = isError ? (
|
||||||
<Trans>Oh no! Something went wrong.</Trans>
|
<Trans>Oh no! Something went wrong.</Trans>
|
||||||
) : (
|
) : (
|
||||||
@@ -186,13 +171,8 @@ export function StepInterests() {
|
|||||||
|
|
||||||
<TitleText>{title}</TitleText>
|
<TitleText>{title}</TitleText>
|
||||||
<DescriptionText>{description}</DescriptionText>
|
<DescriptionText>{description}</DescriptionText>
|
||||||
{isMinimumInterestsEnabled && (
|
|
||||||
<DescriptionText style={[a.pt_sm]}>
|
|
||||||
<Trans>Choose 3 or more:</Trans>
|
|
||||||
</DescriptionText>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={[a.w_full, isMinimumInterestsEnabled ? a.pt_md : a.pt_2xl]}>
|
<View style={[a.w_full, a.pt_2xl]}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
) : isError || !data ? (
|
) : isError || !data ? (
|
||||||
@@ -268,7 +248,7 @@ export function StepInterests() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
disabled={saving || !data || !meetsMinimumRequirement}
|
disabled={saving || !data}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
color="gradient_sky"
|
color="gradient_sky"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -283,53 +263,6 @@ export function StepInterests() {
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!meetsMinimumRequirement && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.align_center,
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
margin: 'auto',
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY:
|
|
||||||
-1 *
|
|
||||||
(PROMPT_OFFSET + PROMPT_HEIGHT + a.pb_lg.paddingBottom),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.gap_sm,
|
|
||||||
a.rounded_full,
|
|
||||||
a.border,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
t.atoms.border_contrast_medium,
|
|
||||||
{
|
|
||||||
height: PROMPT_HEIGHT,
|
|
||||||
...t.atoms.shadow_sm,
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
},
|
|
||||||
isWeb
|
|
||||||
? [a.py_md, a.px_lg, a.pr_xl]
|
|
||||||
: [a.py_sm, a.px_md, a.pr_lg],
|
|
||||||
]}>
|
|
||||||
<CircleInfo />
|
|
||||||
<Text>
|
|
||||||
<Trans>
|
|
||||||
Choose at least {MIN_INTERESTS - interests.length} more
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</OnboardingControls.Portal>
|
</OnboardingControls.Portal>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ let ProfileHeaderStandard = ({
|
|||||||
<ButtonText>
|
<ButtonText>
|
||||||
{profile.viewer?.following ? (
|
{profile.viewer?.following ? (
|
||||||
<Trans>Following</Trans>
|
<Trans>Following</Trans>
|
||||||
|
) : profile.viewer?.followedBy ? (
|
||||||
|
<Trans>Follow Back</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>Follow</Trans>
|
<Trans>Follow</Trans>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export function StepInfo({
|
|||||||
defaultValue={state.password}
|
defaultValue={state.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -21,31 +21,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
|||||||
if (feedDesc.startsWith('feedgen')) {
|
if (feedDesc.startsWith('feedgen')) {
|
||||||
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
|
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
|
||||||
}
|
}
|
||||||
if (feedDesc.startsWith('list')) {
|
if (feedDesc === 'following' || feedDesc.startsWith('list')) {
|
||||||
let feedTuners = []
|
|
||||||
if (feedDesc.endsWith('|as_following')) {
|
|
||||||
// Same as Following tuners below, copypaste for now.
|
|
||||||
feedTuners.push(FeedTuner.removeOrphans)
|
|
||||||
if (preferences?.feedViewPrefs.hideReposts) {
|
|
||||||
feedTuners.push(FeedTuner.removeReposts)
|
|
||||||
}
|
|
||||||
if (preferences?.feedViewPrefs.hideReplies) {
|
|
||||||
feedTuners.push(FeedTuner.removeReplies)
|
|
||||||
} else {
|
|
||||||
feedTuners.push(
|
|
||||||
FeedTuner.followedRepliesOnly({
|
|
||||||
userDid: currentAccount?.did || '',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
|
||||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
|
||||||
}
|
|
||||||
feedTuners.push(FeedTuner.dedupThreads)
|
|
||||||
}
|
|
||||||
return feedTuners
|
|
||||||
}
|
|
||||||
if (feedDesc === 'following') {
|
|
||||||
const feedTuners = [FeedTuner.removeOrphans]
|
const feedTuners = [FeedTuner.removeOrphans]
|
||||||
|
|
||||||
if (preferences?.feedViewPrefs.hideReposts) {
|
if (preferences?.feedViewPrefs.hideReposts) {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import zod from 'zod'
|
||||||
|
|
||||||
|
import {BaseNux} from '#/state/queries/nuxs/types'
|
||||||
|
|
||||||
|
export enum Nux {
|
||||||
|
TenMillionDialog = 'TenMillionDialog',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nuxNames = new Set(Object.values(Nux))
|
||||||
|
|
||||||
|
export type AppNux = BaseNux<{
|
||||||
|
id: Nux.TenMillionDialog
|
||||||
|
data: undefined
|
||||||
|
}>
|
||||||
|
|
||||||
|
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
||||||
|
[Nux.TenMillionDialog]: undefined,
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {AppNux, Nux} from '#/state/queries/nuxs/definitions'
|
||||||
|
import {parseAppNux, serializeAppNux} from '#/state/queries/nuxs/util'
|
||||||
|
import {
|
||||||
|
preferencesQueryKey,
|
||||||
|
usePreferencesQuery,
|
||||||
|
} from '#/state/queries/preferences'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
|
||||||
|
export {Nux} from '#/state/queries/nuxs/definitions'
|
||||||
|
|
||||||
|
export function useNuxs() {
|
||||||
|
const {data, ...rest} = usePreferencesQuery()
|
||||||
|
|
||||||
|
if (data && rest.isSuccess) {
|
||||||
|
const nuxs = data.bskyAppState.nuxs
|
||||||
|
?.map(parseAppNux)
|
||||||
|
?.filter(Boolean) as AppNux[]
|
||||||
|
|
||||||
|
if (nuxs) {
|
||||||
|
return {
|
||||||
|
nuxs,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nuxs: undefined,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNux<T extends Nux>(id: T) {
|
||||||
|
const {nuxs, ...rest} = useNuxs()
|
||||||
|
|
||||||
|
if (nuxs && rest.isSuccess) {
|
||||||
|
const nux = nuxs.find(nux => nux.id === id)
|
||||||
|
|
||||||
|
if (nux) {
|
||||||
|
return {
|
||||||
|
nux: nux as Extract<AppNux, {id: T}>,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nux: undefined,
|
||||||
|
...rest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertNuxMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
retry: 3,
|
||||||
|
mutationFn: async (nux: AppNux) => {
|
||||||
|
await agent.bskyAppUpsertNux(serializeAppNux(nux))
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveNuxsMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
retry: 3,
|
||||||
|
mutationFn: async (ids: string[]) => {
|
||||||
|
await agent.bskyAppRemoveNuxs(ids)
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {AppBskyActorDefs} from '@atproto/api'
|
||||||
|
|
||||||
|
export type Data = Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
export type BaseNux<
|
||||||
|
T extends Pick<AppBskyActorDefs.Nux, 'id' | 'expiresAt'> & {data: Data},
|
||||||
|
> = Pick<AppBskyActorDefs.Nux, 'id' | 'completed' | 'expiresAt'> & T
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {AppBskyActorDefs, nuxSchema} from '@atproto/api'
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppNux,
|
||||||
|
Nux,
|
||||||
|
nuxNames,
|
||||||
|
NuxSchemas,
|
||||||
|
} from '#/state/queries/nuxs/definitions'
|
||||||
|
|
||||||
|
export function parseAppNux(nux: AppBskyActorDefs.Nux): AppNux | undefined {
|
||||||
|
if (!nuxNames.has(nux.id as Nux)) return
|
||||||
|
if (!nuxSchema.safeParse(nux).success) return
|
||||||
|
|
||||||
|
const {data, ...rest} = nux
|
||||||
|
|
||||||
|
const schema = NuxSchemas[nux.id as Nux]
|
||||||
|
|
||||||
|
if (schema && data) {
|
||||||
|
const parsedData = JSON.parse(data)
|
||||||
|
|
||||||
|
if (!schema.safeParse(parsedData).success) return
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: parsedData,
|
||||||
|
} as AppNux
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: undefined,
|
||||||
|
} as AppNux
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux {
|
||||||
|
const {data, ...rest} = nux
|
||||||
|
const schema = NuxSchemas[nux.id as Nux]
|
||||||
|
|
||||||
|
const result: AppBskyActorDefs.Nux = {
|
||||||
|
...rest,
|
||||||
|
data: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema) {
|
||||||
|
schema.parse(data)
|
||||||
|
result.data = JSON.stringify(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
nuxSchema.parse(result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -51,7 +51,6 @@ type AuthorFilter =
|
|||||||
| 'posts_with_media'
|
| 'posts_with_media'
|
||||||
type FeedUri = string
|
type FeedUri = string
|
||||||
type ListUri = string
|
type ListUri = string
|
||||||
type ListFilter = 'as_following' // Applies current Following settings. Currently client-side.
|
|
||||||
|
|
||||||
export type FeedDescriptor =
|
export type FeedDescriptor =
|
||||||
| 'following'
|
| 'following'
|
||||||
@@ -59,7 +58,6 @@ export type FeedDescriptor =
|
|||||||
| `feedgen|${FeedUri}`
|
| `feedgen|${FeedUri}`
|
||||||
| `likes|${ActorDid}`
|
| `likes|${ActorDid}`
|
||||||
| `list|${ListUri}`
|
| `list|${ListUri}`
|
||||||
| `list|${ListUri}|${ListFilter}`
|
|
||||||
export interface FeedParams {
|
export interface FeedParams {
|
||||||
mergeFeedEnabled?: boolean
|
mergeFeedEnabled?: boolean
|
||||||
mergeFeedSources?: string[]
|
mergeFeedSources?: string[]
|
||||||
|
|||||||
@@ -408,10 +408,14 @@ export function* findAllPostsInQueryData(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||||
|
// Check notifications first. If you have a post in notifications,
|
||||||
|
// it's often due to a like or a repost, and we want to prioritize
|
||||||
|
// a post object with >0 likes/reposts over a stale version with no
|
||||||
|
// metrics in order to avoid a notification->post scroll jump.
|
||||||
yield postViewToPlaceholderThread(post)
|
yield postViewToPlaceholderThread(post)
|
||||||
}
|
}
|
||||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
||||||
yield postViewToPlaceholderThread(post)
|
yield postViewToPlaceholderThread(post)
|
||||||
}
|
}
|
||||||
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
||||||
|
|||||||
@@ -37,5 +37,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
|||||||
bskyAppState: {
|
bskyAppState: {
|
||||||
queuedNudges: [],
|
queuedNudges: [],
|
||||||
activeProgressGuide: undefined,
|
activeProgressGuide: undefined,
|
||||||
|
nuxs: [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,13 +106,16 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
|||||||
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
|
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
|
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
|
||||||
gcTime: 0,
|
|
||||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||||
actor: did,
|
actor: did,
|
||||||
})
|
})
|
||||||
return res.data
|
const data = res.data.isFallback ? {suggestions: []} : res.data
|
||||||
|
data.suggestions = data.suggestions.filter(profile => {
|
||||||
|
return !profile.viewer?.following
|
||||||
|
})
|
||||||
|
return data
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useCompressVideoMutation({
|
|||||||
mutationKey: ['video', 'compress'],
|
mutationKey: ['video', 'compress'],
|
||||||
mutationFn: cancelable(
|
mutationFn: cancelable(
|
||||||
(asset: ImagePickerAsset) =>
|
(asset: ImagePickerAsset) =>
|
||||||
compressVideo(asset.uri, {
|
compressVideo(asset, {
|
||||||
onProgress: num => onProgress(trunc2dp(num)),
|
onProgress: num => onProgress(trunc2dp(num)),
|
||||||
signal,
|
signal,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {AtpAgent} from '@atproto/api'
|
import {AtpAgent} from '@atproto/api'
|
||||||
|
|
||||||
import {SupportedMimeTypes} from '#/lib/constants'
|
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
|
||||||
|
|
||||||
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
|
|
||||||
|
|
||||||
export const createVideoEndpointUrl = (
|
export const createVideoEndpointUrl = (
|
||||||
route: string,
|
route: string,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string>,
|
||||||
) => {
|
) => {
|
||||||
const url = new URL(`${UPLOAD_ENDPOINT}`)
|
const url = new URL(VIDEO_SERVICE)
|
||||||
url.pathname = route
|
url.pathname = route
|
||||||
if (params) {
|
if (params) {
|
||||||
for (const key in params) {
|
for (const key in params) {
|
||||||
@@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
|
|||||||
export function useVideoAgent() {
|
export function useVideoAgent() {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return new AtpAgent({
|
return new AtpAgent({
|
||||||
service: UPLOAD_ENDPOINT,
|
service: VIDEO_SERVICE,
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
@@ -41,3 +39,18 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
|
|||||||
throw new Error(`Unsupported mime type: ${mimeType}`)
|
throw new Error(`Unsupported mime type: ${mimeType}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extToMime(ext: string) {
|
||||||
|
switch (ext) {
|
||||||
|
case 'mp4':
|
||||||
|
return 'video/mp4'
|
||||||
|
case 'webm':
|
||||||
|
return 'video/webm'
|
||||||
|
case 'mpeg':
|
||||||
|
return 'video/mpeg'
|
||||||
|
case 'mov':
|
||||||
|
return 'video/quicktime'
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported file extension: ${ext}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {VIDEO_SERVICE_DID} from '#/lib/constants'
|
||||||
|
import {UploadLimitError} from '#/lib/media/video/errors'
|
||||||
|
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useVideoAgent} from './util'
|
||||||
|
|
||||||
|
export function useServiceAuthToken({
|
||||||
|
aud,
|
||||||
|
lxm,
|
||||||
|
exp,
|
||||||
|
}: {
|
||||||
|
aud?: string
|
||||||
|
lxm: string
|
||||||
|
exp?: number
|
||||||
|
}) {
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useCallback(async () => {
|
||||||
|
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||||
|
|
||||||
|
if (!pdsAud) {
|
||||||
|
throw new Error('Agent does not have a PDS URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
|
||||||
|
aud: aud ?? pdsAud,
|
||||||
|
lxm,
|
||||||
|
exp,
|
||||||
|
})
|
||||||
|
|
||||||
|
return serviceAuth.token
|
||||||
|
}, [agent, aud, lxm, exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoUploadLimits() {
|
||||||
|
const agent = useVideoAgent()
|
||||||
|
const getToken = useServiceAuthToken({
|
||||||
|
lxm: 'app.bsky.video.getUploadLimits',
|
||||||
|
aud: VIDEO_SERVICE_DID,
|
||||||
|
})
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return useCallback(async () => {
|
||||||
|
const {data: limits} = await agent.app.bsky.video
|
||||||
|
.getUploadLimits(
|
||||||
|
{},
|
||||||
|
{headers: {Authorization: `Bearer ${await getToken()}`}},
|
||||||
|
)
|
||||||
|
.catch(err => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
throw new UploadLimitError(err.message)
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!limits.canUpload) {
|
||||||
|
if (limits.message) {
|
||||||
|
throw new UploadLimitError(limits.message)
|
||||||
|
} else {
|
||||||
|
throw new UploadLimitError(
|
||||||
|
_(
|
||||||
|
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [agent, _, getToken])
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
|
|||||||
import {ServerError} from '#/lib/media/video/errors'
|
import {ServerError} from '#/lib/media/video/errors'
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||||
|
|
||||||
export const useUploadVideoMutation = ({
|
export const useUploadVideoMutation = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
@@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
|
|||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const getToken = useServiceAuthToken({
|
||||||
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
|
})
|
||||||
|
const checkLimits = useVideoUploadLimits()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ['video', 'upload'],
|
mutationKey: ['video', 'upload'],
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
|
await checkLimits()
|
||||||
|
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
|
||||||
|
|
||||||
if (!serviceAuthAud) {
|
|
||||||
throw new Error('Agent does not have a PDS URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
|
||||||
{
|
|
||||||
aud: serviceAuthAud,
|
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const uploadTask = createUploadTask(
|
const uploadTask = createUploadTask(
|
||||||
uri,
|
uri,
|
||||||
video.uri,
|
video.uri,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': video.mimeType,
|
'content-type': video.mimeType,
|
||||||
Authorization: `Bearer ${serviceAuth.token}`,
|
Authorization: `Bearer ${await getToken()}`,
|
||||||
},
|
},
|
||||||
httpMethod: 'POST',
|
httpMethod: 'POST',
|
||||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
|
|||||||
import {ServerError} from '#/lib/media/video/errors'
|
import {ServerError} from '#/lib/media/video/errors'
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||||
|
|
||||||
export const useUploadVideoMutation = ({
|
export const useUploadVideoMutation = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
@@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
|
|||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const getToken = useServiceAuthToken({
|
||||||
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
|
})
|
||||||
|
const checkLimits = useVideoUploadLimits()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ['video', 'upload'],
|
mutationKey: ['video', 'upload'],
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
|
await checkLimits()
|
||||||
|
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
|
||||||
|
|
||||||
if (!serviceAuthAud) {
|
|
||||||
throw new Error('Agent does not have a PDS URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
|
||||||
{
|
|
||||||
aud: serviceAuthAud,
|
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
let bytes = video.bytes
|
let bytes = video.bytes
|
||||||
|
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = await getToken()
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
@@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
|
|||||||
}
|
}
|
||||||
xhr.open('POST', uri)
|
xhr.open('POST', uri)
|
||||||
xhr.setRequestHeader('Content-Type', video.mimeType)
|
xhr.setRequestHeader('Content-Type', video.mimeType)
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||||
xhr.send(bytes)
|
xhr.send(bytes)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import {AbortError} from '#/lib/async/cancelable'
|
|||||||
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {ServerError, VideoTooLargeError} from 'lib/media/video/errors'
|
import {
|
||||||
|
ServerError,
|
||||||
|
UploadLimitError,
|
||||||
|
VideoTooLargeError,
|
||||||
|
} from 'lib/media/video/errors'
|
||||||
import {CompressedVideo} from 'lib/media/video/types'
|
import {CompressedVideo} from 'lib/media/video/types'
|
||||||
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||||
import {useVideoAgent} from 'state/queries/video/util'
|
import {useVideoAgent} from 'state/queries/video/util'
|
||||||
@@ -149,10 +153,45 @@ export function useUploadVideo({
|
|||||||
onError: e => {
|
onError: e => {
|
||||||
if (e instanceof AbortError) {
|
if (e instanceof AbortError) {
|
||||||
return
|
return
|
||||||
} else if (e instanceof ServerError) {
|
} else if (e instanceof ServerError || e instanceof UploadLimitError) {
|
||||||
|
let message
|
||||||
|
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
|
||||||
|
switch (e.message) {
|
||||||
|
case 'User is not allowed to upload videos':
|
||||||
|
message = _(msg`You are not allowed to upload videos.`)
|
||||||
|
break
|
||||||
|
case 'Uploading is disabled at the moment':
|
||||||
|
message = _(
|
||||||
|
msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "Failed to get user's upload stats":
|
||||||
|
message = _(
|
||||||
|
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'User has exceeded daily upload bytes limit':
|
||||||
|
message = _(
|
||||||
|
msg`You've reached your daily limit for video uploads (too many bytes)`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'User has exceeded daily upload videos limit':
|
||||||
|
message = _(
|
||||||
|
msg`You've reached your daily limit for video uploads (too many videos)`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'Account is not old enough to upload videos':
|
||||||
|
message = _(
|
||||||
|
msg`Your account is not yet old enough to upload videos. Please try again later.`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
message = e.message
|
||||||
|
break
|
||||||
|
}
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
error: e.message,
|
error: message,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -185,7 +224,7 @@ export function useUploadVideo({
|
|||||||
} else if (e instanceof VideoTooLargeError) {
|
} else if (e instanceof VideoTooLargeError) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
error: _(msg`The selected video is larger than 100MB.`),
|
error: _(msg`The selected video is larger than 50MB.`),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {useDialogStateContext} from '#/state/dialogs'
|
||||||
|
import {useLightbox} from '#/state/lightbox'
|
||||||
|
import {useModals} from '#/state/modals'
|
||||||
|
import {useIsDrawerOpen} from '#/state/shell/drawer-open'
|
||||||
|
import {useComposerControls} from './'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Based on {@link https://github.com/jaywcjlove/hotkeys-js/blob/b0038773f3b902574f22af747f3bb003a850f1da/src/index.js#L51C1-L64C2}
|
||||||
|
*/
|
||||||
|
function shouldIgnore(event: KeyboardEvent) {
|
||||||
|
const target: any = event.target || event.srcElement
|
||||||
|
if (!target) return false
|
||||||
|
const {tagName} = target
|
||||||
|
if (!tagName) return false
|
||||||
|
const isInput =
|
||||||
|
tagName === 'INPUT' &&
|
||||||
|
![
|
||||||
|
'checkbox',
|
||||||
|
'radio',
|
||||||
|
'range',
|
||||||
|
'button',
|
||||||
|
'file',
|
||||||
|
'reset',
|
||||||
|
'submit',
|
||||||
|
'color',
|
||||||
|
].includes(target.type)
|
||||||
|
// ignore: isContentEditable === 'true', <input> and <textarea> when readOnly state is false, <select>
|
||||||
|
if (
|
||||||
|
target.isContentEditable ||
|
||||||
|
((isInput || tagName === 'TEXTAREA' || tagName === 'SELECT') &&
|
||||||
|
!target.readOnly)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComposerKeyboardShortcut() {
|
||||||
|
const {openComposer} = useComposerControls()
|
||||||
|
const {openDialogs} = useDialogStateContext()
|
||||||
|
const {isModalActive} = useModals()
|
||||||
|
const {activeLightbox} = useLightbox()
|
||||||
|
const isDrawerOpen = useIsDrawerOpen()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
function handler(event: KeyboardEvent) {
|
||||||
|
if (shouldIgnore(event)) return
|
||||||
|
if (
|
||||||
|
openDialogs.current.size > 0 ||
|
||||||
|
isModalActive ||
|
||||||
|
activeLightbox ||
|
||||||
|
isDrawerOpen
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (event.key === 'n' || event.key === 'N') {
|
||||||
|
openComposer({})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [openComposer, isModalActive, openDialogs, activeLightbox, isDrawerOpen])
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
* Device data that's specific to the device and does not vary based account
|
* Device data that's specific to the device and does not vary based account
|
||||||
*/
|
*/
|
||||||
export type Device = {
|
export type Device = {
|
||||||
|
lastNuxDialog: string | undefined
|
||||||
geolocation?: {
|
geolocation?: {
|
||||||
countryCode: string | undefined
|
countryCode: string | undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
|||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {LikelyType} from '#/lib/link-meta/link-meta'
|
import {LikelyType} from '#/lib/link-meta/link-meta'
|
||||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {insertMentionAt} from '#/lib/strings/mention-manip'
|
import {insertMentionAt} from '#/lib/strings/mention-manip'
|
||||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||||
@@ -140,7 +140,6 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
}: Props & {
|
}: Props & {
|
||||||
cancelRef?: React.RefObject<CancelRef>
|
cancelRef?: React.RefObject<CancelRef>
|
||||||
}) {
|
}) {
|
||||||
const gate = useGate()
|
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||||
@@ -200,6 +199,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
|
||||||
|
|
||||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
|
|
||||||
@@ -302,9 +302,13 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const onPhotoPasted = useCallback(
|
const onPhotoPasted = useCallback(
|
||||||
async (uri: string) => {
|
async (uri: string) => {
|
||||||
track('Composer:PastedPhotos')
|
track('Composer:PastedPhotos')
|
||||||
await gallery.paste(uri)
|
if (uri.startsWith('data:video/')) {
|
||||||
|
selectVideo({uri, type: 'video', height: 0, width: 0})
|
||||||
|
} else {
|
||||||
|
await gallery.paste(uri)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[gallery, track],
|
[gallery, track, selectVideo],
|
||||||
)
|
)
|
||||||
|
|
||||||
const isAltTextRequiredAndMissing = useMemo(() => {
|
const isAltTextRequiredAndMissing = useMemo(() => {
|
||||||
@@ -730,8 +734,37 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<LayoutAnimationConfig skipExiting>
|
||||||
<View style={[a.mt_md]}>
|
{hasVideo && (
|
||||||
|
<Animated.View
|
||||||
|
style={[a.w_full, a.mt_lg]}
|
||||||
|
entering={native(ZoomIn)}
|
||||||
|
exiting={native(ZoomOut)}>
|
||||||
|
{videoUploadState.asset &&
|
||||||
|
(videoUploadState.status === 'compressing' ? (
|
||||||
|
<VideoTranscodeProgress
|
||||||
|
asset={videoUploadState.asset}
|
||||||
|
progress={videoUploadState.progress}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
|
) : videoUploadState.video ? (
|
||||||
|
<VideoPreview
|
||||||
|
asset={videoUploadState.asset}
|
||||||
|
video={videoUploadState.video}
|
||||||
|
setDimensions={updateVideoDimensions}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
|
) : null)}
|
||||||
|
<SubtitleDialogBtn
|
||||||
|
defaultAltText={videoAltText}
|
||||||
|
saveAltText={setVideoAltText}
|
||||||
|
captions={captions}
|
||||||
|
setCaptions={setCaptions}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</LayoutAnimationConfig>
|
||||||
|
<View style={!hasVideo ? [a.mt_md] : []}>
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||||
<View style={{pointerEvents: 'none'}}>
|
<View style={{pointerEvents: 'none'}}>
|
||||||
@@ -742,36 +775,6 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<LayoutAnimationConfig skipExiting>
|
|
||||||
{(videoUploadState.asset || videoUploadState.video) && (
|
|
||||||
<Animated.View
|
|
||||||
style={[a.w_full, a.mt_xs]}
|
|
||||||
entering={native(ZoomIn)}
|
|
||||||
exiting={native(ZoomOut)}>
|
|
||||||
{videoUploadState.asset &&
|
|
||||||
(videoUploadState.status === 'compressing' ? (
|
|
||||||
<VideoTranscodeProgress
|
|
||||||
asset={videoUploadState.asset}
|
|
||||||
progress={videoUploadState.progress}
|
|
||||||
clear={clearVideo}
|
|
||||||
/>
|
|
||||||
) : videoUploadState.video ? (
|
|
||||||
<VideoPreview
|
|
||||||
asset={videoUploadState.asset}
|
|
||||||
video={videoUploadState.video}
|
|
||||||
setDimensions={updateVideoDimensions}
|
|
||||||
clear={clearVideo}
|
|
||||||
/>
|
|
||||||
) : null)}
|
|
||||||
<SubtitleDialogBtn
|
|
||||||
defaultAltText={videoAltText}
|
|
||||||
saveAltText={setVideoAltText}
|
|
||||||
captions={captions}
|
|
||||||
setCaptions={setCaptions}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
</LayoutAnimationConfig>
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
@@ -799,13 +802,11 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
) : (
|
) : (
|
||||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
{gate('video_upload') && (
|
<SelectVideoBtn
|
||||||
<SelectVideoBtn
|
onSelectVideo={selectVideo}
|
||||||
onSelectVideo={selectVideo}
|
disabled={!canSelectImages}
|
||||||
disabled={!canSelectImages}
|
setError={setError}
|
||||||
setError={setError}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
<SelectGifBtn
|
<SelectGifBtn
|
||||||
onClose={focusTextInput}
|
onClose={focusTextInput}
|
||||||
@@ -1154,10 +1155,12 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
|||||||
const progress = state.jobStatus?.progress
|
const progress = state.jobStatus?.progress
|
||||||
? state.jobStatus.progress / 100
|
? state.jobStatus.progress / 100
|
||||||
: state.progress
|
: state.progress
|
||||||
let wheelProgress = progress === 0 || progress === 1 ? 0.33 : progress
|
const shouldRotate =
|
||||||
|
state.status === 'processing' && (progress === 0 || progress === 1)
|
||||||
|
let wheelProgress = shouldRotate ? 0.33 : progress
|
||||||
|
|
||||||
const rotate = useDerivedValue(() => {
|
const rotate = useDerivedValue(() => {
|
||||||
if (progress === 0 || progress >= 0.99) {
|
if (shouldRotate) {
|
||||||
return withRepeat(
|
return withRepeat(
|
||||||
withTiming(360, {
|
withTiming(360, {
|
||||||
duration: 2500,
|
duration: 2500,
|
||||||
|
|||||||
@@ -93,9 +93,9 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
}
|
}
|
||||||
}, [onPressPublish])
|
}, [onPressPublish])
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
textInputWebEmitter.addListener('photo-pasted', onPhotoPasted)
|
textInputWebEmitter.addListener('media-pasted', onPhotoPasted)
|
||||||
return () => {
|
return () => {
|
||||||
textInputWebEmitter.removeListener('photo-pasted', onPhotoPasted)
|
textInputWebEmitter.removeListener('media-pasted', onPhotoPasted)
|
||||||
}
|
}
|
||||||
}, [onPhotoPasted])
|
}, [onPhotoPasted])
|
||||||
|
|
||||||
@@ -105,8 +105,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
if (transfer) {
|
if (transfer) {
|
||||||
const items = transfer.items
|
const items = transfer.items
|
||||||
|
|
||||||
getImageFromUri(items, (uri: string) => {
|
getImageOrVideoFromUri(items, (uri: string) => {
|
||||||
textInputWebEmitter.emit('photo-pasted', uri)
|
textInputWebEmitter.emit('media-pasted', uri)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +160,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
|
|||||||
view.pasteText(text)
|
view.pasteText(text)
|
||||||
preventDefault = true
|
preventDefault = true
|
||||||
}
|
}
|
||||||
getImageFromUri(clipboardData.items, (uri: string) => {
|
getImageOrVideoFromUri(clipboardData.items, (uri: string) => {
|
||||||
textInputWebEmitter.emit('photo-pasted', uri)
|
textInputWebEmitter.emit('media-pasted', uri)
|
||||||
})
|
})
|
||||||
if (preventDefault) {
|
if (preventDefault) {
|
||||||
// Return `true` to prevent ProseMirror's default paste behavior.
|
// Return `true` to prevent ProseMirror's default paste behavior.
|
||||||
@@ -346,7 +346,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function getImageFromUri(
|
function getImageOrVideoFromUri(
|
||||||
items: DataTransferItemList,
|
items: DataTransferItemList,
|
||||||
callback: (uri: string) => void,
|
callback: (uri: string) => void,
|
||||||
) {
|
) {
|
||||||
@@ -363,11 +363,21 @@ function getImageFromUri(
|
|||||||
if (blob.type.startsWith('image/')) {
|
if (blob.type.startsWith('image/')) {
|
||||||
blobToDataUri(blob).then(callback, err => console.error(err))
|
blobToDataUri(blob).then(callback, err => console.error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (blob.type.startsWith('video/')) {
|
||||||
|
blobToDataUri(blob).then(callback, err => console.error(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (type.startsWith('image/')) {
|
} else if (type.startsWith('image/')) {
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile()
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
blobToDataUri(file).then(callback, err => console.error(err))
|
||||||
|
}
|
||||||
|
} else if (type.startsWith('video/')) {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
blobToDataUri(file).then(callback, err => console.error(err))
|
blobToDataUri(file).then(callback, err => console.error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
|
|||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
|
import {BSKY_SERVICE} from 'lib/constants'
|
||||||
|
import {getHostnameFromUrl} from 'lib/strings/url-helpers'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
|
||||||
const VIDEO_MAX_DURATION = 60
|
const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSelectVideo: (video: ImagePickerAsset) => void
|
onSelectVideo: (video: ImagePickerAsset) => void
|
||||||
@@ -38,20 +40,32 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentAccount?.emailConfirmed) {
|
if (
|
||||||
|
currentAccount &&
|
||||||
|
!currentAccount.emailConfirmed &&
|
||||||
|
getHostnameFromUrl(currentAccount.service) ===
|
||||||
|
getHostnameFromUrl(BSKY_SERVICE)
|
||||||
|
) {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
control.open()
|
control.open()
|
||||||
} else {
|
} else {
|
||||||
const response = await launchImageLibraryAsync({
|
const response = await launchImageLibraryAsync({
|
||||||
exif: false,
|
exif: false,
|
||||||
mediaTypes: MediaTypeOptions.Videos,
|
mediaTypes: MediaTypeOptions.Videos,
|
||||||
videoMaxDuration: VIDEO_MAX_DURATION,
|
|
||||||
quality: 1,
|
quality: 1,
|
||||||
legacy: true,
|
legacy: true,
|
||||||
preferredAssetRepresentationMode:
|
preferredAssetRepresentationMode:
|
||||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||||
})
|
})
|
||||||
if (response.assets && response.assets.length > 0) {
|
if (response.assets && response.assets.length > 0) {
|
||||||
|
if (isNative) {
|
||||||
|
if (typeof response.assets[0].duration !== 'number')
|
||||||
|
throw Error('Asset is not a video')
|
||||||
|
if (response.assets[0].duration > VIDEO_MAX_DURATION) {
|
||||||
|
setError(_(msg`Videos must be less than 60 seconds long`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
onSelectVideo(response.assets[0])
|
onSelectVideo(response.assets[0])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -64,12 +78,12 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
onSelectVideo,
|
|
||||||
requestVideoAccessIfNeeded,
|
requestVideoAccessIfNeeded,
|
||||||
|
currentAccount,
|
||||||
|
control,
|
||||||
setError,
|
setError,
|
||||||
_,
|
_,
|
||||||
control,
|
onSelectVideo,
|
||||||
currentAccount?.emailConfirmed,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user