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:
Eric Bailey
2024-09-17 12:29:44 -05:00
141 changed files with 27305 additions and 13119 deletions
+1 -13
View File
@@ -74,8 +74,7 @@ appId: xyz.blueskyweb.app
- tapOn: "Delete List"
- tapOn:
id: "confirmBtn"
- assertVisible:
id: "listsEmpty"
- assertVisible: "This list is empty!"
- tapOn:
label: "Create a new curatelist"
@@ -161,17 +160,6 @@ appId: xyz.blueskyweb.app
- assertNotVisible:
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:
label: "Adds and removes users on curatelists from the profile"
id: "bottomBarSearchBtn"
+6 -1
View File
@@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Pin alice's feed
- extendedWaitUntil:
visible:
id: "viewHeaderDrawerBtn"
- tapOn:
id: "bottomBarProfileBtn"
id: "viewHeaderDrawerBtn"
- tapOn:
id: "profileCardButton"
- swipe:
from:
id: "profilePager-selector"
+3 -10
View File
@@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eSignInAlice"
- extendedWaitUntil:
visible:
text: "Feeds ✨"
- tapOn:
label: "Can go to feeds page using feeds button in tab bar"
text: "Feeds ✨"
@@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
- tapOn:
label: "Can like posts"
id: "likeBtn"
- assertVisible:
id: "likeCount"
text: "1"
- tapOn:
id: "likeBtn"
- assertNotVisible:
id: "likeCount"
- tapOn:
label: "Can repost posts"
id: "repostBtn"
- tapOn: "Repost"
- assertVisible:
id: "repostCount"
text: "1"
- tapOn:
id: "repostBtn"
- tapOn: "Remove repost"
- assertNotVisible:
id: "repostCount"
- tapOn:
label: "Can delete posts"
+3
View File
@@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
# Navigate to my profile
- extendedWaitUntil:
visible:
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarProfileBtn"
+3
View File
@@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Navigate to another user profile
- extendedWaitUntil:
visible:
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- tapOn:
+4 -12
View File
@@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
# Navigate to thread
- extendedWaitUntil:
visible: "Thread root"
- tapOn: "Thread root"
- assertVisible: "Thread reply"
@@ -19,32 +21,22 @@ appId: xyz.blueskyweb.app
id: "likeBtn"
childOf:
id: "postThreadItem-by-bob.test"
- assertVisible:
id: "likeCount-expanded"
- assertVisible: "1 like"
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-bob.test"
- assertNotVisible:
id: "likeCount-expanded"
- assertNotVisible: "1 like"
# Can like a reply post
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
- assertVisible:
id: "likeCount"
childOf:
id: "postThreadItem-by-carla.test"
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
- assertNotVisible:
id: "likeCount"
childOf:
id: "postThreadItem-by-carla.test"
# Can repost the root post
- tapOn:
-1
View File
@@ -211,7 +211,6 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
'expo-video',
'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.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

+1 -1
View File
@@ -372,7 +372,7 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
return (
<div
className="w-full overflow-hidden rounded-lg aspect-square"
className="w-full overflow-hidden rounded-lg aspect-square relative"
style={{aspectRatio: `${aspectRatio} / 1`}}>
<img
src={content.thumbnail}
+7
View File
@@ -80,6 +80,13 @@ func run(args []string) {
Value: "",
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"},
},
},
},
}
+7
View File
@@ -57,6 +57,7 @@ func serve(cctx *cli.Context) error {
linkHost := cctx.String("link-host")
ipccHost := cctx.String("ipcc-host")
basicAuthPassword := cctx.String("basic-auth-password")
corsOrigins := cctx.StringSlice("cors-allowed-origins")
// Echo
e := echo.New()
@@ -168,6 +169,12 @@ func serve(cctx *cli.Context) error {
RedirectCode: http.StatusFound,
}))
// CORS middleware
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: corsOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
}))
//
// configure routes
//
+45 -1
View File
@@ -258,7 +258,51 @@
.force-no-clicks * {
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>
{% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
+1
View File
@@ -14,6 +14,7 @@ module.exports = {
'ja',
'ko',
'pt-BR',
'ru',
'tr',
'uk',
'zh-CN',
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.91.0",
"version": "1.91.2",
"private": true,
"engines": {
"node": ">=18"
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "0.13.5",
"@atproto/api": "^0.13.7",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -68,6 +68,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.1.10",
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1",
@@ -139,7 +140,6 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"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",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@@ -190,7 +190,7 @@
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "~3.31.1",
"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-uuid": "^2.0.2",
"react-native-view-shot": "^3.8.0",
+54 -51
View File
@@ -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 HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
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 {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
SplashScreen.preventAutoHideAsync()
@@ -115,52 +116,51 @@ function InnerApp() {
}, [_])
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<ActiveVideoProvider>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</ActiveVideoProvider>
</Splash>
</ThemeProvider>
</Alf>
</SafeAreaProvider>
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<RootSiblingParent>
<VideoVolumeProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
<NuxDialogs />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</VideoVolumeProvider>
</RootSiblingParent>
</Splash>
</ThemeProvider>
</Alf>
)
}
@@ -168,8 +168,6 @@ function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(false)
Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() =>
setReady(true),
)
@@ -197,7 +195,12 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
+47 -36
View File
@@ -40,18 +40,22 @@ import {
} from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util'
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 ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
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 {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -69,6 +73,8 @@ function InnerApp() {
useIntentHandler()
const hasCheckedReferrer = useStarterPackEntry()
useComposerKeyboardShortcut()
// init
useEffect(() => {
async function onLaunch(account?: SessionAccount) {
@@ -103,41 +109,44 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<RootSiblingParent>
<ActiveVideoProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</ActiveVideoProvider>
<VideoVolumeProvider>
<ActiveVideoProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<Shell />
<NuxDialogs />
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</ActiveVideoProvider>
</VideoVolumeProvider>
</RootSiblingParent>
</ThemeProvider>
</Alf>
@@ -175,7 +184,9 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
+15 -3
View File
@@ -18,9 +18,17 @@ export * from '#/alf/util/themeSelector'
export const Context = React.createContext<{
themeName: ThemeName
theme: Theme
themes: ReturnType<typeof createThemes>
}>({
themeName: 'light',
theme: defaultTheme,
themes: createThemes({
hues: {
primary: BLUE_HUE,
negative: RED_HUE,
positive: GREEN_HUE,
},
}),
})
export function ThemeProvider({
@@ -42,18 +50,22 @@ export function ThemeProvider({
<Context.Provider
value={React.useMemo(
() => ({
themes,
themeName: themeName,
theme: theme,
}),
[theme, themeName],
[theme, themeName, themes],
)}>
{children}
</Context.Provider>
)
}
export function useTheme() {
return React.useContext(Context).theme
export function useTheme(theme?: ThemeName) {
const ctx = React.useContext(Context)
return React.useMemo(() => {
return theme ? ctx.themes[theme] : ctx.theme
}, [theme, ctx])
}
export function useBreakpoints() {
+1 -1
View File
@@ -256,7 +256,7 @@ export const ScrollableInner = React.forwardRef<
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
flatten(style),
style,
]}
contentContainerStyle={a.pb_4xl}
ref={ref}>
+34 -30
View File
@@ -60,16 +60,13 @@ function CardOuter({
export function SuggestedFollowPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<CardOuter style={[a.gap_md, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
<View style={[a.py_xs]}>
<ProfileCard.NameAndHandlePlaceholder />
</View>
<ProfileCard.DescriptionPlaceholder />
<ProfileCard.DescriptionPlaceholder numberOfLines={2} />
</CardOuter>
)
}
@@ -176,9 +173,14 @@ function useExperimentalSuggestedUsersQuery() {
}
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const [feedType, feedUri] = feed.split('|')
const {currentAccount} = useSession()
const [feedType, feedUriOrDid] = feed.split('|')
if (feedType === 'author') {
return <SuggestedFollowsProfile did={feedUri} />
if (currentAccount?.did === feedUriOrDid) {
return null
} else {
return <SuggestedFollowsProfile did={feedUriOrDid} />
}
} else {
return <SuggestedFollowsHome />
}
@@ -197,6 +199,7 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isSuggestionsLoading={isSuggestionsLoading}
profiles={data?.suggestions ?? []}
error={error}
viewContext="profile"
/>
)
}
@@ -212,6 +215,7 @@ export function SuggestedFollowsHome() {
isSuggestionsLoading={isSuggestionsLoading}
profiles={profiles}
error={error}
viewContext="feed"
/>
)
}
@@ -220,10 +224,12 @@ export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
viewContext = 'feed',
}: {
isSuggestionsLoading: boolean
profiles: AppBskyActorDefs.ProfileViewDetailed[]
error: Error | null
viewContext: 'profile' | 'feed'
}) {
const t = useTheme()
const {_} = useLingui()
@@ -280,7 +286,7 @@ export function ProfileGrid({
shape="round"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
<ProfileCard.Description profile={profile} numberOfLines={2} />
</ProfileCard.Outer>
</CardOuter>
)}
@@ -297,33 +303,31 @@ export function ProfileGrid({
return (
<View
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]}>
<Text
style={[
a.flex_1,
a.text_lg,
a.font_bold,
t.atoms.text_contrast_medium,
]}>
<Trans>Suggested for you</Trans>
<View
style={[
a.p_lg,
a.pb_xs,
a.flex_row,
a.align_center,
a.justify_between,
]}>
<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>
<Person fill={t.atoms.text_contrast_low.color} />
<Person fill={t.atoms.text_contrast_low.color} size="sm" />
</View>
{gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, 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_sm]}>
{content}
</View>
<View
style={[
a.flex_row,
a.justify_end,
a.align_center,
a.pt_xs,
a.gap_md,
]}>
<View style={[a.flex_row, a.justify_end, a.align_center, a.gap_md]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
to="/search"
@@ -339,7 +343,7 @@ export function ProfileGrid({
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
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}
<Button
+11
View File
@@ -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>
}
+1
View File
@@ -75,6 +75,7 @@ export function LikedByList({uri}: {uri: string}) {
isLoading={isUriLoading || isLikedByLoading}
isError={isError}
emptyType="results"
emptyTitle={_(msg`No likes yet`)}
emptyMessage={_(
msg`Nobody has liked this yet. Maybe you should be the first!`,
)}
+45
View File
@@ -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>
)
}
+11 -8
View File
@@ -10,7 +10,8 @@ import {
import {Trans} from '@lingui/macro'
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 {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -43,10 +44,10 @@ export function Embed({
))}
</Outer>
)
} else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
} else if (AppBskyEmbedExternal.isView(media) && media.external.thumb) {
let url: URL | undefined
try {
url = new URL(embed.external.uri)
url = new URL(media.external.uri)
} catch {}
if (url) {
const {success} = parseTenorGif(url)
@@ -54,17 +55,17 @@ export function Embed({
return (
<Outer style={style}>
<GifItem
thumbnail={embed.external.thumb}
alt={embed.external.title}
thumbnail={media.external.thumb}
alt={media.external.title}
/>
</Outer>
)
}
}
} else if (AppBskyEmbedVideo.isView(embed)) {
} else if (AppBskyEmbedVideo.isView(media)) {
return (
<Outer style={style}>
<VideoItem thumbnail={embed.thumbnail} alt={embed.alt} />
<VideoItem thumbnail={media.thumbnail} alt={media.alt} />
</Outer>
)
}
@@ -91,18 +92,20 @@ export function ImageItem({
alt?: string
children?: React.ReactNode
}) {
const t = useTheme()
return (
<View style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
<Image
key={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"
accessible={true}
accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/>
<MediaInsetBorder style={[a.rounded_xs]} />
{children}
</View>
)
+22 -17
View File
@@ -220,8 +220,10 @@ export function NameAndHandlePlaceholder() {
export function Description({
profile: profileUnshadowed,
numberOfLines = 3,
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
numberOfLines?: number
}) {
const profile = useProfileShadow(profileUnshadowed)
const {description} = profile
@@ -244,31 +246,34 @@ export function Description({
<RichText
value={rt}
style={[a.leading_snug]}
numberOfLines={3}
numberOfLines={numberOfLines}
disableLinks
/>
</View>
)
}
export function DescriptionPlaceholder() {
export function DescriptionPlaceholder({
numberOfLines = 3,
}: {
numberOfLines?: number
}) {
const t = useTheme()
return (
<View style={[a.gap_xs]}>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: 100},
]}
/>
<View style={[{gap: 8}]}>
{Array(numberOfLines)
.fill(0)
.map((_, i) => (
<View
key={i}
style={[
a.rounded_xs,
a.w_full,
t.atoms.bg_contrast_50,
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
]}
/>
))}
</View>
)
}
+3 -1
View File
@@ -59,7 +59,9 @@ export function Outer({
export function TitleText({children}: React.PropsWithChildren<{}>) {
const {titleId} = React.useContext(Context)
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}
</Text>
)
@@ -18,7 +18,7 @@ interface ProfilesListProps {
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
const feed: FeedDescriptor = `list|${listUri}|as_following`
const feed: FeedDescriptor = `list|${listUri}`
const {_} = useLingui()
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>
)
}
+121
View File
@@ -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>
)
}
+22
View File
@@ -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
}
+17
View File
@@ -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)
}
+10 -7
View File
@@ -4,6 +4,7 @@ import {AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
import {MessageContextProvider} from './MessageContext'
let MessageItemEmbed = ({
embed,
@@ -13,13 +14,15 @@ let MessageItemEmbed = ({
const t = useTheme()
return (
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
<MessageContextProvider>
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
</MessageContextProvider>
)
}
MessageItemEmbed = React.memo(MessageItemEmbed)
+2
View File
@@ -21,6 +21,7 @@ import {
ja,
ko,
ptBR,
ru,
tr,
uk,
zhCN,
@@ -47,6 +48,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
ja,
ko,
['pt-BR']: ptBR,
ru,
tr,
uk,
['zh-CN']: zhCN,
+53
View File
@@ -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
}
+5
View File
@@ -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',
})
+37
View File
@@ -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>
)
}
+1 -1
View File
@@ -4,7 +4,7 @@ import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
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 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
+6 -21
View File
@@ -379,7 +379,11 @@ export class FeedTuner {
): FeedViewPostsSlice[] => {
for (let i = 0; i < slices.length; i++) {
const slice = slices[i]
if (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) {
if (
slice.isReply &&
!slice.isRepost &&
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
) {
slices.splice(i, 1)
i--
}
@@ -443,13 +447,9 @@ function areSameAuthor(authors: AuthorContext): boolean {
}
function shouldDisplayReplyInFollowing(
slice: FeedViewPostsSlice,
authors: AuthorContext,
userDid: string,
): boolean {
if (slice.isRepost) {
return true
}
const authors = slice.getAuthors()
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
if (!isSelfOrFollowing(author, userDid)) {
// Only show replies from self or people you follow.
@@ -463,21 +463,6 @@ function shouldDisplayReplyInFollowing(
// Always show self-threads.
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.
if (
parentAuthor &&
+3 -2
View File
@@ -2,6 +2,7 @@ import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent,
jsonStringToLex,
} from '@atproto/api'
import {getContentLanguages} from '#/state/preferences/languages'
@@ -111,7 +112,7 @@ async function loggedOutFetch({
}&limit=${limit}&lang=${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) {
return {
success: true,
@@ -126,7 +127,7 @@ async function loggedOutFetch({
}&limit=${limit}`,
{method: 'GET', headers: {'Accept-Language': ''}},
)
data = res.ok ? await res.json() : null
data = res.ok ? jsonStringToLex(await res.text()) : null
if (data?.feed?.length) {
return {
success: true,
+15
View File
@@ -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
})
}
+3
View File
@@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
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 = [
'video/mp4',
'video/mpeg',
+33 -3
View File
@@ -6,15 +6,17 @@ import {isNative} from 'platform/detection'
import {useSession} from 'state/session'
import {useComposerControls} from 'state/shell'
import {useCloseAllActiveElements} from 'state/util'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
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+)?$/
export function useIntentHandler() {
const incomingUrl = Linking.useURL()
const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent()
React.useEffect(() => {
const handleIncomingURL = (url: string) => {
@@ -51,15 +53,25 @@ export function useIntentHandler() {
text: params.get('text'),
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)
}, [incomingUrl, composeIntent])
}, [incomingUrl, composeIntent, verifyEmailIntent])
}
function useComposeIntent() {
export function useComposeIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {openComposer} = useComposerControls()
const {hasSession} = useSession()
@@ -103,3 +115,21 @@ function useComposeIntent() {
[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],
)
}
+18 -3
View File
@@ -1,9 +1,14 @@
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'
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
export async function compressVideo(
file: string,
file: ImagePickerAsset,
opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
@@ -11,12 +16,22 @@ export async function compressVideo(
): Promise<CompressedVideo> {
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(
file,
file.uri,
{
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
minimumFileSizeForCompress,
getCancellationId: id => {
if (signal) {
signal.addEventListener('abort', () => {
@@ -30,5 +45,5 @@ export async function compressVideo(
const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size, mimeType: `video/mp4`}
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
}
+6 -4
View File
@@ -1,17 +1,19 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {VideoTooLargeError} from 'lib/media/video/errors'
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(
file: string,
asset: ImagePickerAsset,
_opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(file)
const {mimeType, base64} = parseDataUrl(asset.uri)
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
+8 -1
View File
@@ -1,6 +1,6 @@
export class VideoTooLargeError extends Error {
constructor() {
super('Videos cannot be larger than 100MB')
super('Videos cannot be larger than 50mb')
this.name = 'VideoTooLargeError'
}
}
@@ -11,3 +11,10 @@ export class ServerError extends Error {
this.name = 'ServerError'
}
}
export class UploadLimitError extends Error {
constructor(message: string) {
super(message)
this.name = 'UploadLimitError'
}
}
+4
View File
@@ -225,4 +225,8 @@ export type LogEvents = {
'test:gate1:sometimes': {}
'test:gate2:always': {}
'test:gate2:sometimes': {}
'tmd:share': {}
'tmd:download': {}
'tmd:post': {}
}
+1 -6
View File
@@ -1,8 +1,3 @@
export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'onboarding_minimum_interests'
| 'suggested_feeds_interstitial'
| 'video_debug' // not recommended
| 'video_upload' // upload videos
| 'video_view_on_posts' // see posted videos
'debug_show_feedcontext' | 'suggested_feeds_interstitial'
+2
View File
@@ -143,6 +143,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ko
case 'pt-BR':
return AppLanguage.pt_BR
case 'ru':
return AppLanguage.ru
case 'tr':
return AppLanguage.tr
case 'uk':
+9
View File
@@ -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 messagesKo} from '#/locale/locales/ko/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 messagesUk} from '#/locale/locales/uk/messages'
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
@@ -131,6 +132,14 @@ export async function dynamicActivate(locale: AppLanguage) {
])
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: {
i18n.loadAndActivate({locale, messages: messagesTr})
await Promise.all([
+4
View File
@@ -60,6 +60,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/pt-BR/messages`)
break
}
case AppLanguage.ru: {
mod = await import(`./locales/ru/messages`)
break
}
case AppLanguage.tr: {
mod = await import(`./locales/tr/messages`)
break
+2
View File
@@ -18,6 +18,7 @@ export enum AppLanguage {
ja = 'ja',
ko = 'ko',
pt_BR = 'pt-BR',
ru = 'ru',
tr = 'tr',
uk = 'uk',
zh_CN = 'zh-CN',
@@ -43,6 +44,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.ja, name: '日本語 Japanese'},
{code2: AppLanguage.ko, name: '한국어 Korean'},
{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.uk, name: 'Українська Ukrainian'},
{code2: AppLanguage.zh_CN, name: '简体中文(中国)– Chinese (Simplified)'},
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
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
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
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
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
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
+1 -2
View File
@@ -144,8 +144,7 @@ export const ForgotPasswordForm = ({
variant="solid"
color={'primary'}
size="medium"
onPress={onPressNext}
disabled={!email}>
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
+9 -22
View File
@@ -60,7 +60,6 @@ export const LoginForm = ({
const {track} = useAnalytics()
const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [isReady, setIsReady] = useState<boolean>(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
useState<boolean>(false)
const identifierValueRef = useRef<string>(initialHandle || '')
@@ -83,12 +82,18 @@ export const LoginForm = ({
Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('')
setIsProcessing(true)
const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current
const authFactorToken = authFactorTokenValueRef.current
if (!identifier || !password) {
setError(_(msg`Invalid username or password`))
return
}
setIsProcessing(true)
try {
// try to guess the handle if the user just gave their own username
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 (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
<View>
@@ -204,7 +193,6 @@ export const LoginForm = ({
defaultValue={initialHandle || ''}
onChangeText={v => {
identifierValueRef.current = v
checkIsReady()
}}
onSubmitEditing={() => {
passwordRef.current?.focus()
@@ -233,7 +221,6 @@ export const LoginForm = ({
clearButtonMode="while-editing"
onChangeText={v => {
passwordValueRef.current = v
checkIsReady()
}}
onSubmitEditing={onPressNext}
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>
</Text>
</>
) : isReady ? (
) : (
<Button
testID="loginNextButton"
label={_(msg`Next`)}
@@ -339,7 +326,7 @@ export const LoginForm = ({
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
) : undefined}
)}
</View>
</FormContainer>
)
+2 -69
View File
@@ -6,10 +6,8 @@ import {useQuery} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {useGate} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
@@ -29,23 +27,16 @@ import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
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 {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {Loader} from '#/components/Loader'
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() {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const gate = useGate()
const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = React.useContext(Context)
@@ -143,12 +134,6 @@ export function StepInterests() {
track('OnboardingV2:StepInterests:Start')
}, [track])
const isMinimumInterestsEnabled =
gate('onboarding_minimum_interests') && data?.interests.length !== 0
const meetsMinimumRequirement = isMinimumInterestsEnabled
? interests.length >= MIN_INTERESTS
: true
const title = isError ? (
<Trans>Oh no! Something went wrong.</Trans>
) : (
@@ -186,13 +171,8 @@ export function StepInterests() {
<TitleText>{title}</TitleText>
<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 ? (
<Loader size="xl" />
) : isError || !data ? (
@@ -268,7 +248,7 @@ export function StepInterests() {
</View>
) : (
<Button
disabled={saving || !data || !meetsMinimumRequirement}
disabled={saving || !data}
variant="gradient"
color="gradient_sky"
size="large"
@@ -283,53 +263,6 @@ export function StepInterests() {
/>
</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>
</View>
)
@@ -219,6 +219,8 @@ let ProfileHeaderStandard = ({
<ButtonText>
{profile.viewer?.following ? (
<Trans>Following</Trans>
) : profile.viewer?.followedBy ? (
<Trans>Follow Back</Trans>
) : (
<Trans>Follow</Trans>
)}
+1
View File
@@ -172,6 +172,7 @@ export function StepInfo({
defaultValue={state.password}
secureTextEntry
autoComplete="new-password"
autoCapitalize="none"
/>
</TextField.Root>
</View>
+1 -25
View File
@@ -21,31 +21,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
if (feedDesc.startsWith('feedgen')) {
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
}
if (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') {
if (feedDesc === 'following' || feedDesc.startsWith('list')) {
const feedTuners = [FeedTuner.removeOrphans]
if (preferences?.feedViewPrefs.hideReposts) {
+18
View File
@@ -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,
}
+85
View File
@@ -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,
})
},
})
}
+7
View File
@@ -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
+52
View File
@@ -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
}
-2
View File
@@ -51,7 +51,6 @@ type AuthorFilter =
| 'posts_with_media'
type FeedUri = string
type ListUri = string
type ListFilter = 'as_following' // Applies current Following settings. Currently client-side.
export type FeedDescriptor =
| 'following'
@@ -59,7 +58,6 @@ export type FeedDescriptor =
| `feedgen|${FeedUri}`
| `likes|${ActorDid}`
| `list|${ListUri}`
| `list|${ListUri}|${ListFilter}`
export interface FeedParams {
mergeFeedEnabled?: boolean
mergeFeedSources?: string[]
+6 -2
View File
@@ -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)
}
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
yield postViewToPlaceholderThread(post)
}
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
+1
View File
@@ -37,5 +37,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
bskyAppState: {
queuedNudges: [],
activeProgressGuide: undefined,
nuxs: [],
},
}
+5 -2
View File
@@ -106,13 +106,16 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const agent = useAgent()
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
gcTime: 0,
queryKey: suggestedFollowsByActorQueryKey(did),
queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
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
},
})
}
+1 -1
View File
@@ -20,7 +20,7 @@ export function useCompressVideoMutation({
mutationKey: ['video', 'compress'],
mutationFn: cancelable(
(asset: ImagePickerAsset) =>
compressVideo(asset.uri, {
compressVideo(asset, {
onProgress: num => onProgress(trunc2dp(num)),
signal,
}),
+18 -5
View File
@@ -1,15 +1,13 @@
import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api'
import {SupportedMimeTypes} from '#/lib/constants'
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
export const createVideoEndpointUrl = (
route: string,
params?: Record<string, string>,
) => {
const url = new URL(`${UPLOAD_ENDPOINT}`)
const url = new URL(VIDEO_SERVICE)
url.pathname = route
if (params) {
for (const key in params) {
@@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
export function useVideoAgent() {
return useMemo(() => {
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}`)
}
}
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])
}
+10 -18
View File
@@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
@@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal
}) => {
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()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
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(
uri,
video.uri,
{
headers: {
'content-type': video.mimeType,
Authorization: `Bearer ${serviceAuth.token}`,
Authorization: `Bearer ${await getToken()}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
+12 -19
View File
@@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
@@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal
}) => {
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()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
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
if (!bytes) {
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
const token = await getToken()
const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
(resolve, reject) => {
@@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(bytes)
},
)
+43 -4
View File
@@ -9,7 +9,11 @@ import {AbortError} from '#/lib/async/cancelable'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {logger} from '#/logger'
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 {useCompressVideoMutation} from 'state/queries/video/compress-video'
import {useVideoAgent} from 'state/queries/video/util'
@@ -149,10 +153,45 @@ export function useUploadVideo({
onError: e => {
if (e instanceof AbortError) {
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! Were gradually giving access to video, and youre 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({
type: 'SetError',
error: e.message,
error: message,
})
} else {
dispatch({
@@ -185,7 +224,7 @@ export function useUploadVideo({
} else if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 100MB.`),
error: _(msg`The selected video is larger than 50MB.`),
})
} else {
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])
}
+1
View File
@@ -2,6 +2,7 @@
* Device data that's specific to the device and does not vary based account
*/
export type Device = {
lastNuxDialog: string | undefined
geolocation?: {
countryCode: string | undefined
}
+48 -45
View File
@@ -59,7 +59,7 @@ import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
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 {insertMentionAt} from '#/lib/strings/mention-manip'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
@@ -140,7 +140,6 @@ export const ComposePost = observer(function ComposePost({
}: Props & {
cancelRef?: React.RefObject<CancelRef>
}) {
const gate = useGate()
const {currentAccount} = useSession()
const agent = useAgent()
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)
@@ -302,9 +302,13 @@ export const ComposePost = observer(function ComposePost({
const onPhotoPasted = useCallback(
async (uri: string) => {
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(() => {
@@ -730,8 +734,37 @@ export const ComposePost = observer(function ComposePost({
/>
</View>
)}
<View style={[a.mt_md]}>
<LayoutAnimationConfig skipExiting>
{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 ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
@@ -742,36 +775,6 @@ export const ComposePost = observer(function ComposePost({
)}
</View>
) : 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>
</Animated.ScrollView>
<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]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
{gate('video_upload') && (
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages}
setError={setError}
/>
)}
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages}
setError={setError}
/>
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn
onClose={focusTextInput}
@@ -1154,10 +1155,12 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const progress = state.jobStatus?.progress
? state.jobStatus.progress / 100
: 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(() => {
if (progress === 0 || progress >= 0.99) {
if (shouldRotate) {
return withRepeat(
withTiming(360, {
duration: 2500,
@@ -93,9 +93,9 @@ export const TextInput = React.forwardRef(function TextInputImpl(
}
}, [onPressPublish])
React.useEffect(() => {
textInputWebEmitter.addListener('photo-pasted', onPhotoPasted)
textInputWebEmitter.addListener('media-pasted', onPhotoPasted)
return () => {
textInputWebEmitter.removeListener('photo-pasted', onPhotoPasted)
textInputWebEmitter.removeListener('media-pasted', onPhotoPasted)
}
}, [onPhotoPasted])
@@ -105,8 +105,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
if (transfer) {
const items = transfer.items
getImageFromUri(items, (uri: string) => {
textInputWebEmitter.emit('photo-pasted', uri)
getImageOrVideoFromUri(items, (uri: string) => {
textInputWebEmitter.emit('media-pasted', uri)
})
}
@@ -160,8 +160,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
view.pasteText(text)
preventDefault = true
}
getImageFromUri(clipboardData.items, (uri: string) => {
textInputWebEmitter.emit('photo-pasted', uri)
getImageOrVideoFromUri(clipboardData.items, (uri: string) => {
textInputWebEmitter.emit('media-pasted', uri)
})
if (preventDefault) {
// Return `true` to prevent ProseMirror's default paste behavior.
@@ -346,7 +346,7 @@ const styles = StyleSheet.create({
},
})
function getImageFromUri(
function getImageOrVideoFromUri(
items: DataTransferItemList,
callback: (uri: string) => void,
) {
@@ -363,11 +363,21 @@ function getImageFromUri(
if (blob.type.startsWith('image/')) {
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/')) {
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) {
blobToDataUri(file).then(callback, err => console.error(err))
}
@@ -13,12 +13,14 @@ import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals'
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 {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
import * as Prompt from '#/components/Prompt'
const VIDEO_MAX_DURATION = 60
const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds
type Props = {
onSelectVideo: (video: ImagePickerAsset) => void
@@ -38,20 +40,32 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
return
}
if (!currentAccount?.emailConfirmed) {
if (
currentAccount &&
!currentAccount.emailConfirmed &&
getHostnameFromUrl(currentAccount.service) ===
getHostnameFromUrl(BSKY_SERVICE)
) {
Keyboard.dismiss()
control.open()
} else {
const response = await launchImageLibraryAsync({
exif: false,
mediaTypes: MediaTypeOptions.Videos,
videoMaxDuration: VIDEO_MAX_DURATION,
quality: 1,
legacy: true,
preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Current,
})
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 {
onSelectVideo(response.assets[0])
} catch (err) {
@@ -64,12 +78,12 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
}
}
}, [
onSelectVideo,
requestVideoAccessIfNeeded,
currentAccount,
control,
setError,
_,
control,
currentAccount?.emailConfirmed,
onSelectVideo,
])
return (

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