upstream:main → zio/dev

zio/dev
Ducky 2024-09-19 06:18:51 +01:00
commit fa997fa7c1
142 changed files with 11153 additions and 8548 deletions

View File

@ -74,8 +74,7 @@ appId: xyz.blueskyweb.app
- tapOn: "Delete List" - tapOn: "Delete List"
- tapOn: - tapOn:
id: "confirmBtn" id: "confirmBtn"
- assertVisible: - assertVisible: "This list is empty!"
id: "listsEmpty"
- tapOn: - tapOn:
label: "Create a new curatelist" label: "Create a new curatelist"
@ -161,17 +160,6 @@ appId: xyz.blueskyweb.app
- assertNotVisible: - assertNotVisible:
id: "userAddRemoveListsModal" id: "userAddRemoveListsModal"
- tapOn:
label: "Shows the curatelist on my profile"
id: "bottomBarProfileBtn"
- swipe:
from:
id: "profilePager-selector"
direction: LEFT
- tapOn:
id: "profilePager-selector-6"
- tapOn: "Good Ppl"
- tapOn: - tapOn:
label: "Adds and removes users on curatelists from the profile" label: "Adds and removes users on curatelists from the profile"
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"

View File

@ -21,14 +21,12 @@ appId: xyz.blueskyweb.app
id: "likeBtn" id: "likeBtn"
childOf: childOf:
id: "postThreadItem-by-bob.test" id: "postThreadItem-by-bob.test"
- assertVisible: - assertVisible: "1 like"
id: "likeCount-expanded"
- tapOn: - tapOn:
id: "likeBtn" id: "likeBtn"
childOf: childOf:
id: "postThreadItem-by-bob.test" id: "postThreadItem-by-bob.test"
- assertNotVisible: - assertNotVisible: "1 like"
id: "likeCount-expanded"
# Can like a reply post # Can like a reply post
- tapOn: - tapOn:

View File

@ -55,6 +55,7 @@ module.exports = function (config) {
: undefined : undefined
const UPDATES_ENABLED = !!UPDATES_CHANNEL const UPDATES_ENABLED = !!UPDATES_CHANNEL
const USE_SENTRY = Boolean(process.env.SENTRY_AUTH_TOKEN)
const SENTRY_DIST = `${PLATFORM}.${VERSION}.${IS_TESTFLIGHT ? 'tf' : ''}${ const SENTRY_DIST = `${PLATFORM}.${VERSION}.${IS_TESTFLIGHT ? 'tf' : ''}${
IS_DEV ? 'dev' : '' IS_DEV ? 'dev' : ''
}` }`
@ -186,7 +187,15 @@ module.exports = function (config) {
}, },
plugins: [ plugins: [
'expo-localization', 'expo-localization',
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo', USE_SENTRY && [
'@sentry/react-native/expo',
{
organization: 'blueskyweb',
project: 'react-native',
release: VERSION,
dist: SENTRY_DIST,
},
],
[ [
'expo-build-properties', 'expo-build-properties',
{ {
@ -211,7 +220,6 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
}, },
], ],
'expo-video',
'react-native-compressor', 'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js', './plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestPlugin.js',
@ -222,6 +230,31 @@ module.exports = function (config) {
'./plugins/shareExtension/withShareExtensions.js', './plugins/shareExtension/withShareExtensions.js',
'./plugins/notificationsExtension/withNotificationsExtension.js', './plugins/notificationsExtension/withNotificationsExtension.js',
'./plugins/withAppDelegateReferrer.js', './plugins/withAppDelegateReferrer.js',
[
'expo-font',
{
fonts: [
// './assets/fonts/inter/Inter-Thin.otf',
// './assets/fonts/inter/Inter-ThinItalic.otf',
// './assets/fonts/inter/Inter-ExtraLight.otf',
// './assets/fonts/inter/Inter-ExtraLightItalic.otf',
// './assets/fonts/inter/Inter-Light.otf',
// './assets/fonts/inter/Inter-LightItalic.otf',
'./assets/fonts/inter/Inter-Regular.otf',
'./assets/fonts/inter/Inter-Italic.otf',
'./assets/fonts/inter/Inter-Medium.otf',
'./assets/fonts/inter/Inter-MediumItalic.otf',
'./assets/fonts/inter/Inter-SemiBold.otf',
'./assets/fonts/inter/Inter-SemiBoldItalic.otf',
'./assets/fonts/inter/Inter-Bold.otf',
'./assets/fonts/inter/Inter-BoldItalic.otf',
'./assets/fonts/inter/Inter-ExtraBold.otf',
'./assets/fonts/inter/Inter-ExtraBoldItalic.otf',
'./assets/fonts/inter/Inter-Black.otf',
'./assets/fonts/inter/Inter-BlackItalic.otf',
],
},
],
].filter(Boolean), ].filter(Boolean),
extra: { extra: {
eas: { eas: {
@ -264,7 +297,7 @@ module.exports = function (config) {
* @see https://docs.expo.dev/guides/using-sentry/#app-configuration * @see https://docs.expo.dev/guides/using-sentry/#app-configuration
*/ */
{ {
file: 'sentry-expo/upload-sourcemaps', file: './postHooks/uploadSentrySourcemapsPostHook',
config: { config: {
organization: 'blueskyweb', organization: 'blueskyweb',
project: 'react-native', project: 'react-native',

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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

View File

@ -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="M9 5a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2h-5v14a1 1 0 1 1-2 0V6h-5a1 1 0 0 1-1-1Zm-3.073 7v8a1 1 0 1 0 2 0v-8H12a1 1 0 1 0 0-2H6.971a1.015 1.015 0 0 0-.089 0H2a1 1 0 1 0 0 2h3.927Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 317 B

View File

@ -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="M3.65 17.247c-.242.832-.632 1.178-1.325 1.178-.814 0-1.325-.476-1.325-1.23 0-.216.06-.51.173-.831L4.586 7.07c.364-1.014.979-1.482 1.966-1.482 1.022 0 1.629.45 2.001 1.473l3.43 9.303c.121.337.165.571.165.831 0 .72-.546 1.23-1.308 1.23-.736 0-1.126-.338-1.36-1.152l-.658-1.975H4.309l-.658 1.95ZM6.5 8.152l-1.62 5.12h3.335l-1.654-5.12H6.5Zm13.005 8.688c-.52.988-1.68 1.568-2.84 1.568-1.768 0-3.11-1.144-3.11-2.815 0-1.69 1.299-2.668 3.62-2.807l2.34-.138v-.615c0-.867-.607-1.369-1.56-1.369-.771 0-1.239.251-1.802.979-.277.312-.597.468-1.004.468-.615 0-1.057-.399-1.057-.97 0-.2.043-.382.13-.572.433-1.109 1.923-1.793 3.845-1.793 2.383 0 3.933 1.23 3.933 3.1v5.293c0 .84-.511 1.273-1.23 1.273-.684 0-1.16-.38-1.213-1.126v-.476h-.052Zm-3.43-1.386c0 .693.572 1.126 1.42 1.126 1.11 0 2.02-.719 2.02-1.723v-.676l-1.959.121c-.944.07-1.48.494-1.48 1.152Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 986 B

View File

@ -258,6 +258,51 @@
.force-no-clicks * { .force-no-clicks * {
pointer-events: none !important; pointer-events: none !important;
} }
input[type=range][orient=vertical] {
writing-mode: vertical-lr;
direction: rtl;
appearance: slider-vertical;
width: 16px;
vertical-align: bottom;
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
}
input[type="range"][orient=vertical]::-webkit-slider-runnable-track {
background: white;
height: 100%;
width: 4px;
border-radius: 4px;
}
input[type="range"][orient=vertical]::-moz-range-track {
background: white;
height: 100%;
width: 4px;
border-radius: 4px;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
border-radius: 50%;
background-color: white;
height: 16px;
width: 16px;
margin-left: -6px;
}
input[type="range"][orient=vertical]::-moz-range-thumb {
border: none;
border-radius: 50%;
background-color: white;
height: 16px;
width: 16px;
margin-left: -6px;
}
</style> </style>
{% include "scripts.html" %} {% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">

View File

@ -0,0 +1,36 @@
diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
index 7e0b4cd..177454c 100644
--- a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
+++ b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
@@ -3,6 +3,8 @@ import { LogBox } from 'react-native';
* This is a workaround for using fetch on RN, this is a known issue in react-native and only generates a warning.
*/
export function ignoreRequireCycleLogs() {
- LogBox.ignoreLogs(['Require cycle:']);
+ try {
+ LogBox.ignoreLogs(['Require cycle:']);
+ } catch (e) {}
}
//# sourceMappingURL=ignorerequirecyclelogs.js.map
\ No newline at end of file
diff --git a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
index 0f244f2..ae7dfb3 100755
--- a/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
+++ b/node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps.js
@@ -174,6 +174,7 @@ if (!outputDir) {
process.exit(1);
}
+const otherArgs = process.argv.slice(3);
const files = getAssetPathsSync(outputDir);
const groupedAssets = groupAssets(files);
@@ -195,7 +196,7 @@ for (const [assetGroupName, assets] of Object.entries(groupedAssets)) {
const isHermes = assets.find(asset => asset.endsWith('.hbc'));
const windowsCallback = process.platform === "win32" ? 'node ' : '';
- execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')}`, {
+ execSync(`${windowsCallback}${sentryCliBin} sourcemaps upload ${isHermes ? '--debug-id-reference' : ''} ${assets.join(' ')} ${otherArgs.join(' ')}`, {
env: {
...process.env,
[SENTRY_PROJECT]: sentryProject,

View File

@ -1,15 +0,0 @@
diff --git a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
index 7e0b4cd..177454c 100644
--- a/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
+++ b/node_modules/@sentry/react-native/dist/js/utils/ignorerequirecyclelogs.js
@@ -3,6 +3,8 @@ import { LogBox } from 'react-native';
* This is a workaround for using fetch on RN, this is a known issue in react-native and only generates a warning.
*/
export function ignoreRequireCycleLogs() {
- LogBox.ignoreLogs(['Require cycle:']);
+ try {
+ LogBox.ignoreLogs(['Require cycle:']);
+ } catch (e) {}
}
//# sourceMappingURL=ignorerequirecyclelogs.js.map
\ No newline at end of file

View File

@ -12,28 +12,3 @@ index bb74e80..0aa0202 100644
Map<String, Object> constants = new HashMap<>(3); Map<String, Object> constants = new HashMap<>(3);
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>()); constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
index 109d3fe..c421931 100644
--- a/node_modules/expo-modules-core/build/uuid/uuid.js
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js
@@ -1,5 +1,7 @@
import bytesToUuid from './lib/bytesToUuid';
import { Uuidv5Namespace } from './uuid.types';
+import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled';
+ensureNativeModulesAreInstalled();
const nativeUuidv4 = globalThis?.expo?.uuidv4;
const nativeUuidv5 = globalThis?.expo?.uuidv5;
function uuidv4() {
diff --git a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
index ee2268a..4851b67 100644
--- a/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
+++ b/node_modules/expo-modules-core/ios/Core/SharedObjects/SharedObjectRegistry.swift
@@ -173,7 +173,7 @@ public final class SharedObjectRegistry {
}
internal func clear() {
- Self.lockQueue.async {
+ Self.lockQueue.sync {
self.pairs.removeAll()
}
}

View File

@ -0,0 +1,34 @@
const exec = require('child_process').execSync
const SENTRY_AUTH_TOKEN = process.env.SENTRY_AUTH_TOKEN
module.exports = ({config}) => {
if (!SENTRY_AUTH_TOKEN) {
console.log(
'SENTRY_AUTH_TOKEN environment variable must be set to upload sourcemaps. Skipping.',
)
return
}
const org = config.organization
const project = config.project
const release = config.release
const dist = config.dist
if (!org || !project || !release || !dist) {
console.log(
'"organization", "project", "release", and "dist" must be set in the hook config to upload sourcemaps. Skipping.',
)
return
}
try {
console.log('Uploading sourcemaps to Sentry...')
exec(
`node node_modules/@sentry/react-native/scripts/expo-upload-sourcemaps dist --url https://sentry.io/ -o ${org} -p ${project} -r ${release} -d ${dist}`,
)
console.log('Sourcemaps uploaded to Sentry.')
} catch (e) {
console.error('Error uploading sourcemaps to Sentry:', e)
}
}

View File

@ -52,17 +52,17 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls' import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell' import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf' import {ThemeProvider as Alf, useFonts} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
@ -106,63 +106,60 @@ function InnerApp() {
}, [_]) }, [_])
return ( return (
<Alf theme={theme}> <StatsigProvider
<ThemeProvider theme={theme}> // Resets the entire tree below when it changes:
<Splash isReady={isReady && hasCheckedReferrer}> key={currentAccount?.did}>
<ActiveVideoProvider> <Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<RootSiblingParent> <RootSiblingParent>
<React.Fragment <VideoVolumeProvider>
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider> <MessagesProvider>
<MessagesProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <LabelDefsProvider>
<LabelDefsProvider> <ModerationOptsProvider>
<ModerationOptsProvider> <LoggedOutViewProvider>
<LoggedOutViewProvider> <SelectedFeedProvider>
<SelectedFeedProvider> <HiddenRepliesProvider>
<HiddenRepliesProvider> <UnreadNotifsProvider>
<UnreadNotifsProvider> <BackgroundNotificationPreferencesProvider>
<BackgroundNotificationPreferencesProvider> <MutedThreadsProvider>
<MutedThreadsProvider> <ProgressGuideProvider>
<ProgressGuideProvider> <GestureHandlerRootView style={s.h100pct}>
<GestureHandlerRootView <TestCtrls />
style={s.h100pct}> <Shell />
<TestCtrls /> <NuxDialogs />
<Shell /> </GestureHandlerRootView>
</GestureHandlerRootView> </ProgressGuideProvider>
</ProgressGuideProvider> </MutedThreadsProvider>
</MutedThreadsProvider> </BackgroundNotificationPreferencesProvider>
</BackgroundNotificationPreferencesProvider> </UnreadNotifsProvider>
</UnreadNotifsProvider> </HiddenRepliesProvider>
</HiddenRepliesProvider> </SelectedFeedProvider>
</SelectedFeedProvider> </LoggedOutViewProvider>
</LoggedOutViewProvider> </ModerationOptsProvider>
</ModerationOptsProvider> </LabelDefsProvider>
</LabelDefsProvider> </MessagesProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider> </QueryProvider>
</React.Fragment> </VideoVolumeProvider>
</RootSiblingParent> </RootSiblingParent>
</ActiveVideoProvider> </Splash>
</Splash> </ThemeProvider>
</ThemeProvider> </Alf>
</Alf> </StatsigProvider>
) )
} }
function App() { function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)
const [loaded] = useFonts()
React.useEffect(() => { React.useEffect(() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(false)
initPersistedState().then(() => setReady(true)) initPersistedState().then(() => setReady(true))
}, []) }, [])
if (!isReady) { if (!isReady || !loaded) {
return null return null
} }

View File

@ -35,17 +35,20 @@ import {
} from '#/state/session' } from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web' import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index' import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf' import {ThemeProvider as Alf, useFonts} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs' import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
@ -60,6 +63,8 @@ function InnerApp() {
useIntentHandler() useIntentHandler()
const hasCheckedReferrer = useStarterPackEntry() const hasCheckedReferrer = useStarterPackEntry()
useComposerKeyboardShortcut()
// init // init
useEffect(() => { useEffect(() => {
async function onLaunch(account?: SessionAccount) { async function onLaunch(account?: SessionAccount) {
@ -91,15 +96,15 @@ function InnerApp() {
return ( return (
<KeyboardProvider enabled={false}> <KeyboardProvider enabled={false}>
<Alf theme={theme}> <StatsigProvider
<ThemeProvider theme={theme}> // Resets the entire tree below when it changes:
<RootSiblingParent> key={currentAccount?.did}>
<ActiveVideoProvider> <Alf theme={theme}>
<React.Fragment <ThemeProvider theme={theme}>
// Resets the entire tree below when it changes: <RootSiblingParent>
key={currentAccount?.did}> <VideoVolumeProvider>
<QueryProvider currentDid={currentAccount?.did}> <ActiveVideoProvider>
<StatsigProvider> <QueryProvider currentDid={currentAccount?.did}>
<MessagesProvider> <MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider> <LabelDefsProvider>
@ -113,6 +118,7 @@ function InnerApp() {
<SafeAreaProvider> <SafeAreaProvider>
<ProgressGuideProvider> <ProgressGuideProvider>
<Shell /> <Shell />
<NuxDialogs />
</ProgressGuideProvider> </ProgressGuideProvider>
</SafeAreaProvider> </SafeAreaProvider>
</MutedThreadsProvider> </MutedThreadsProvider>
@ -124,26 +130,27 @@ function InnerApp() {
</ModerationOptsProvider> </ModerationOptsProvider>
</LabelDefsProvider> </LabelDefsProvider>
</MessagesProvider> </MessagesProvider>
</StatsigProvider> </QueryProvider>
</QueryProvider> <ToastContainer />
</React.Fragment> </ActiveVideoProvider>
<ToastContainer /> </VideoVolumeProvider>
</ActiveVideoProvider> </RootSiblingParent>
</RootSiblingParent> </ThemeProvider>
</ThemeProvider> </Alf>
</Alf> </StatsigProvider>
</KeyboardProvider> </KeyboardProvider>
) )
} }
function App() { function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)
const [loaded, error] = useFonts()
React.useEffect(() => { React.useEffect(() => {
initPersistedState().then(() => setReady(true)) initPersistedState().then(() => setReady(true))
}, []) }, [])
if (!isReady) { if (!isReady || (!loaded && !error)) {
return null return null
} }

View File

@ -225,43 +225,43 @@ export const atoms = {
}, },
text_2xs: { text_2xs: {
fontSize: tokens.fontSize._2xs, fontSize: tokens.fontSize._2xs,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
text_xs: { text_xs: {
fontSize: tokens.fontSize.xs, fontSize: tokens.fontSize.xs,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
text_sm: { text_sm: {
fontSize: tokens.fontSize.sm, fontSize: tokens.fontSize.sm,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
text_md: { text_md: {
fontSize: tokens.fontSize.md, fontSize: tokens.fontSize.md,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
text_lg: { text_lg: {
fontSize: tokens.fontSize.lg, fontSize: tokens.fontSize.lg,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
text_xl: { text_xl: {
fontSize: tokens.fontSize.xl, fontSize: tokens.fontSize.xl,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
text_2xl: { text_2xl: {
fontSize: tokens.fontSize._2xl, fontSize: tokens.fontSize._2xl,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
text_3xl: { text_3xl: {
fontSize: tokens.fontSize._3xl, fontSize: tokens.fontSize._3xl,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
text_4xl: { text_4xl: {
fontSize: tokens.fontSize._4xl, fontSize: tokens.fontSize._4xl,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
text_5xl: { text_5xl: {
fontSize: tokens.fontSize._5xl, fontSize: tokens.fontSize._5xl,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
}, },
leading_tight: { leading_tight: {
lineHeight: 1.15, lineHeight: 1.15,
@ -273,10 +273,7 @@ export const atoms = {
lineHeight: 1.5, lineHeight: 1.5,
}, },
tracking_normal: { tracking_normal: {
letterSpacing: 0, letterSpacing: tokens.TRACKING,
},
tracking_wide: {
letterSpacing: 0.25,
}, },
font_normal: { font_normal: {
fontWeight: tokens.fontWeight.normal, fontWeight: tokens.fontWeight.normal,

111
src/alf/fonts.ts 100644
View File

@ -0,0 +1,111 @@
import {useFonts as defaultUseFonts} from 'expo-font'
import {isNative, isWeb} from '#/platform/detection'
import {Device, device} from '#/storage'
const FAMILIES = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif`
const factor = 0.0625 // 1 - (15/16)
const fontScaleMultipliers: Record<Device['fontScale'], number> = {
'-2': 1 - factor * 3,
'-1': 1 - factor * 2,
'0': 1 - factor * 1, // default
'1': 1,
'2': 1 + factor * 1,
}
export function computeFontScaleMultiplier(scale: Device['fontScale']) {
return fontScaleMultipliers[scale]
}
export function getFontScale() {
return device.get(['fontScale']) ?? '0'
}
export function setFontScale(fontScale: Device['fontScale']) {
device.set(['fontScale'], fontScale)
}
export function getFontFamily() {
return device.get(['fontFamily']) || 'theme'
}
export function setFontFamily(fontFamily: Device['fontFamily']) {
device.set(['fontFamily'], fontFamily)
}
/*
* Unused fonts are commented out, but the files are there if we need them.
*/
export function useFonts() {
/**
* For native, the `expo-font` config plugin embeds the fonts in the
* application binary. But `expo-font` isn't supported on web, so we fall
* back to async loading here.
*/
if (isNative) return [true, null]
return defaultUseFonts({
// 'Inter-Thin': require('../../assets/fonts/inter/Inter-Thin.otf'),
// 'Inter-ThinItalic': require('../../assets/fonts/inter/Inter-ThinItalic.otf'),
// 'Inter-ExtraLight': require('../../assets/fonts/inter/Inter-ExtraLight.otf'),
// 'Inter-ExtraLightItalic': require('../../assets/fonts/inter/Inter-ExtraLightItalic.otf'),
// 'Inter-Light': require('../../assets/fonts/inter/Inter-Light.otf'),
// 'Inter-LightItalic': require('../../assets/fonts/inter/Inter-LightItalic.otf'),
'Inter-Regular': require('../../assets/fonts/inter/Inter-Regular.otf'),
'Inter-Italic': require('../../assets/fonts/inter/Inter-Italic.otf'),
'Inter-Medium': require('../../assets/fonts/inter/Inter-Medium.otf'),
'Inter-MediumItalic': require('../../assets/fonts/inter/Inter-MediumItalic.otf'),
'Inter-SemiBold': require('../../assets/fonts/inter/Inter-SemiBold.otf'),
'Inter-SemiBoldItalic': require('../../assets/fonts/inter/Inter-SemiBoldItalic.otf'),
'Inter-Bold': require('../../assets/fonts/inter/Inter-Bold.otf'),
'Inter-BoldItalic': require('../../assets/fonts/inter/Inter-BoldItalic.otf'),
'Inter-ExtraBold': require('../../assets/fonts/inter/Inter-ExtraBold.otf'),
'Inter-ExtraBoldItalic': require('../../assets/fonts/inter/Inter-ExtraBoldItalic.otf'),
'Inter-Black': require('../../assets/fonts/inter/Inter-Black.otf'),
'Inter-BlackItalic': require('../../assets/fonts/inter/Inter-BlackItalic.otf'),
})
}
/*
* Unused fonts are commented out, but the files are there if we need them.
*/
export function applyFonts(
style: Record<string, any>,
fontFamily: 'system' | 'theme',
) {
if (fontFamily === 'theme') {
style.fontFamily =
{
// '100': 'Inter-Thin',
// '200': 'Inter-ExtraLight',
// '300': 'Inter-Light',
'100': 'Inter-Regular',
'200': 'Inter-Regular',
'300': 'Inter-Regular',
'400': 'Inter-Regular',
'500': 'Inter-Medium',
'600': 'Inter-SemiBold',
'700': 'Inter-Bold',
'800': 'Inter-ExtraBold',
'900': 'Inter-Black',
}[style.fontWeight as string] || 'Inter-Regular'
if (style.fontStyle === 'italic') {
if (style.fontFamily === 'Inter-Regular') {
style.fontFamily = 'Inter-Italic'
} else {
style.fontFamily += 'Italic'
}
}
// fallback families only supported on web
if (isWeb) {
style.fontFamily += `, ${FAMILIES}`
}
} else {
// fallback families only supported on web
if (isWeb) {
style.fontFamily = style.fontFamily || FAMILIES
}
}
}

View File

@ -1,32 +1,98 @@
import React from 'react' import React from 'react'
import {useMediaQuery} from 'react-responsive' import {useMediaQuery} from 'react-responsive'
import {
computeFontScaleMultiplier,
getFontFamily,
getFontScale,
setFontFamily as persistFontFamily,
setFontScale as persistFontScale,
} from '#/alf/fonts'
import {createThemes, defaultTheme} from '#/alf/themes' import {createThemes, defaultTheme} from '#/alf/themes'
import {Theme, ThemeName} from '#/alf/types' import {Theme, ThemeName} from '#/alf/types'
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration' import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
import {Device} from '#/storage'
export {atoms} from '#/alf/atoms' export {atoms} from '#/alf/atoms'
export * from '#/alf/fonts'
export * as tokens from '#/alf/tokens' export * as tokens from '#/alf/tokens'
export * from '#/alf/types' export * from '#/alf/types'
export * from '#/alf/util/flatten' export * from '#/alf/util/flatten'
export * from '#/alf/util/platform' export * from '#/alf/util/platform'
export * from '#/alf/util/themeSelector' export * from '#/alf/util/themeSelector'
export type Alf = {
themeName: ThemeName
theme: Theme
themes: ReturnType<typeof createThemes>
fonts: {
scale: Exclude<Device['fontScale'], undefined>
scaleMultiplier: number
family: Device['fontFamily']
setFontScale: (fontScale: Exclude<Device['fontScale'], undefined>) => void
setFontFamily: (fontFamily: Device['fontFamily']) => void
}
/**
* Feature flags or other gated options
*/
flags: {}
}
/* /*
* Context * Context
*/ */
export const Context = React.createContext<{ export const Context = React.createContext<Alf>({
themeName: ThemeName
theme: Theme
}>({
themeName: 'light', themeName: 'light',
theme: defaultTheme, theme: defaultTheme,
themes: createThemes({
hues: {
primary: BLUE_HUE,
negative: RED_HUE,
positive: GREEN_HUE,
},
}),
fonts: {
scale: getFontScale(),
scaleMultiplier: computeFontScaleMultiplier(getFontScale()),
family: getFontFamily(),
setFontScale: () => {},
setFontFamily: () => {},
},
flags: {},
}) })
export function ThemeProvider({ export function ThemeProvider({
children, children,
theme: themeName, theme: themeName,
}: React.PropsWithChildren<{theme: ThemeName}>) { }: React.PropsWithChildren<{theme: ThemeName}>) {
const [fontScale, setFontScale] = React.useState<Alf['fonts']['scale']>(() =>
getFontScale(),
)
const [fontScaleMultiplier, setFontScaleMultiplier] = React.useState(() =>
computeFontScaleMultiplier(fontScale),
)
const setFontScaleAndPersist = React.useCallback<
Alf['fonts']['setFontScale']
>(
fontScale => {
setFontScale(fontScale)
persistFontScale(fontScale)
setFontScaleMultiplier(computeFontScaleMultiplier(fontScale))
},
[setFontScale],
)
const [fontFamily, setFontFamily] = React.useState<Alf['fonts']['family']>(
() => getFontFamily(),
)
const setFontFamilyAndPersist = React.useCallback<
Alf['fonts']['setFontFamily']
>(
fontFamily => {
setFontFamily(fontFamily)
persistFontFamily(fontFamily)
},
[setFontFamily],
)
const themes = React.useMemo(() => { const themes = React.useMemo(() => {
return createThemes({ return createThemes({
hues: { hues: {
@ -36,24 +102,47 @@ export function ThemeProvider({
}, },
}) })
}, []) }, [])
const theme = themes[themeName]
return ( return (
<Context.Provider <Context.Provider
value={React.useMemo( value={React.useMemo<Alf>(
() => ({ () => ({
themes,
themeName: themeName, themeName: themeName,
theme: theme, theme: themes[themeName],
fonts: {
scale: fontScale,
scaleMultiplier: fontScaleMultiplier,
family: fontFamily,
setFontScale: setFontScaleAndPersist,
setFontFamily: setFontFamilyAndPersist,
},
flags: {},
}), }),
[theme, themeName], [
themeName,
themes,
fontScale,
setFontScaleAndPersist,
fontFamily,
setFontFamilyAndPersist,
fontScaleMultiplier,
],
)}> )}>
{children} {children}
</Context.Provider> </Context.Provider>
) )
} }
export function useTheme() { export function useAlf() {
return React.useContext(Context).theme return React.useContext(Context)
}
export function useTheme(theme?: ThemeName) {
const alf = useAlf()
return React.useMemo(() => {
return theme ? alf.themes[theme] : alf.theme
}, [theme, alf])
} }
export function useBreakpoints() { export function useBreakpoints() {

View File

@ -1,3 +1,7 @@
import {Platform} from 'react-native'
export const TRACKING = Platform.OS === 'android' ? 0.1 : 0
export const color = { export const color = {
temp_purple: 'rgb(105 0 255)', temp_purple: 'rgb(105 0 255)',
temp_purple_dark: 'rgb(83 0 202)', temp_purple_dark: 'rgb(83 0 202)',

View File

@ -7,7 +7,6 @@ import {
PressableProps, PressableProps,
StyleProp, StyleProp,
StyleSheet, StyleSheet,
Text,
TextProps, TextProps,
TextStyle, TextStyle,
View, View,
@ -17,7 +16,7 @@ import {LinearGradient} from 'expo-linear-gradient'
import {android, atoms as a, flatten, select, tokens, useTheme} from '#/alf' import {android, atoms as a, flatten, select, tokens, useTheme} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common' import {Props as SVGIconProps} from '#/components/icons/common'
import {normalizeTextStyles} from '#/components/Typography' import {Text} from '#/components/Typography'
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
export type ButtonColor = export type ButtonColor =
@ -635,14 +634,7 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
const textStyles = useSharedButtonTextStyles() const textStyles = useSharedButtonTextStyles()
return ( return (
<Text <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
{...rest}
style={normalizeTextStyles([
a.font_bold,
a.text_center,
textStyles,
style,
])}>
{children} {children}
</Text> </Text>
) )

View File

@ -37,6 +37,7 @@ import {Portal} from '#/components/Portal'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context' export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/types' export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils'
// @ts-ignore // @ts-ignore
export const Input = createInput(BottomSheetTextInput) export const Input = createInput(BottomSheetTextInput)
@ -256,7 +257,7 @@ export const ScrollableInner = React.forwardRef<
borderTopLeftRadius: 40, borderTopLeftRadius: 40,
borderTopRightRadius: 40, borderTopRightRadius: 40,
}, },
flatten(style), style,
]} ]}
contentContainerStyle={a.pb_4xl} contentContainerStyle={a.pb_4xl}
ref={ref}> ref={ref}>

View File

@ -27,6 +27,7 @@ import {Portal} from '#/components/Portal'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context' export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/types' export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils'
export {Input} from '#/components/forms/TextField' export {Input} from '#/components/forms/TextField'
const stopPropagation = (e: any) => e.stopPropagation() const stopPropagation = (e: any) => e.stopPropagation()

View File

@ -0,0 +1,18 @@
import React from 'react'
import {DialogControlProps} from '#/components/Dialog/types'
export function useAutoOpen(control: DialogControlProps, showTimeout?: number) {
React.useEffect(() => {
if (showTimeout) {
const timeout = setTimeout(() => {
control.open()
}, showTimeout)
return () => {
clearTimeout(timeout)
}
} else {
control.open()
}
}, [control, showTimeout])
}

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>
}

View File

@ -75,6 +75,7 @@ export function LikedByList({uri}: {uri: string}) {
isLoading={isUriLoading || isLikedByLoading} isLoading={isUriLoading || isLikedByLoading}
isError={isError} isError={isError}
emptyType="results" emptyType="results"
emptyTitle={_(msg`No likes yet`)}
emptyMessage={_( emptyMessage={_(
msg`Nobody has liked this yet. Maybe you should be the first!`, msg`Nobody has liked this yet. Maybe you should be the first!`,
)} )}

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>
)
}

View File

@ -11,6 +11,7 @@ import {Trans} from '@lingui/macro'
import {parseTenorGif} from '#/lib/strings/embed-player' import {parseTenorGif} from '#/lib/strings/embed-player'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@ -104,6 +105,7 @@ export function ImageItem({
accessibilityHint={alt} accessibilityHint={alt}
accessibilityLabel="" accessibilityLabel=""
/> />
<MediaInsetBorder style={[a.rounded_xs]} />
{children} {children}
</View> </View>
) )

View File

@ -59,7 +59,9 @@ export function Outer({
export function TitleText({children}: React.PropsWithChildren<{}>) { export function TitleText({children}: React.PropsWithChildren<{}>) {
const {titleId} = React.useContext(Context) const {titleId} = React.useContext(Context)
return ( return (
<Text nativeID={titleId} style={[a.text_2xl, a.font_bold, a.pb_sm]}> <Text
nativeID={titleId}
style={[a.text_2xl, a.font_bold, a.pb_sm, a.leading_snug]}>
{children} {children}
</Text> </Text>
) )

View File

@ -18,7 +18,7 @@ interface ProfilesListProps {
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>( export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) { function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
const feed: FeedDescriptor = `list|${listUri}|as_following` const feed: FeedDescriptor = `list|${listUri}`
const {_} = useLingui() const {_} = useLingui()
const onScrollToTop = useCallback(() => { const onScrollToTop = useCallback(() => {

View File

@ -3,7 +3,7 @@ import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native'
import {UITextView} from 'react-native-uitextview' import {UITextView} from 'react-native-uitextview'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {atoms, flatten, useTheme, web} from '#/alf' import {Alf, applyFonts, atoms, flatten, useAlf, useTheme, web} from '#/alf'
export type TextProps = RNTextProps & { export type TextProps = RNTextProps & {
/** /**
@ -34,19 +34,30 @@ export function leading<
* If the `lineHeight` value is > 2, we assume it's an absolute value and * If the `lineHeight` value is > 2, we assume it's an absolute value and
* returns it as-is. * returns it as-is.
*/ */
export function normalizeTextStyles(styles: StyleProp<TextStyle>) { export function normalizeTextStyles(
styles: StyleProp<TextStyle>,
{
fontScale,
fontFamily,
}: {
fontScale: number
fontFamily: Alf['fonts']['family']
} & Pick<Alf, 'flags'>,
) {
const s = flatten(styles) const s = flatten(styles)
// should always be defined on these components // should always be defined on these components
const fontSize = s.fontSize || atoms.text_md.fontSize s.fontSize = (s.fontSize || atoms.text_md.fontSize) * fontScale
if (s?.lineHeight) { if (s?.lineHeight) {
if (s.lineHeight !== 0 && s.lineHeight <= 2) { if (s.lineHeight !== 0 && s.lineHeight <= 2) {
s.lineHeight = Math.round(fontSize * s.lineHeight) s.lineHeight = Math.round(s.fontSize * s.lineHeight)
} }
} else if (!isNative) { } else if (!isNative) {
s.lineHeight = s.fontSize s.lineHeight = s.fontSize
} }
applyFonts(s, fontFamily)
return s return s
} }
@ -54,8 +65,13 @@ export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
* Our main text component. Use this most of the time. * Our main text component. Use this most of the time.
*/ */
export function Text({style, selectable, ...rest}: TextProps) { export function Text({style, selectable, ...rest}: TextProps) {
const {fonts, flags} = useAlf()
const t = useTheme() const t = useTheme()
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)]) const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)], {
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags,
})
return <UITextView selectable={selectable} uiTextView style={s} {...rest} /> return <UITextView selectable={selectable} uiTextView style={s} {...rest} />
} }

View File

@ -0,0 +1,119 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppearanceToggleButtonGroup} from '#/screens/Settings/AppearanceSettings'
import {atoms as a, useAlf, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {Divider} from '#/components/Divider'
import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/TextSize'
import {TitleCase_Stroke2_Corner0_Rounded as Aa} from '#/components/icons/TitleCase'
import {Text} from '#/components/Typography'
export function NeueTypography() {
const t = useTheme()
const {_} = useLingui()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
const {fonts} = useAlf()
Dialog.useAutoOpen(control, 3e3)
const onClose = React.useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
const onChangeFontFamily = React.useCallback(
(values: string[]) => {
const next = values[0] === 'system' ? 'system' : 'theme'
fonts.setFontFamily(next)
},
[fonts],
)
const onChangeFontScale = React.useCallback(
(values: string[]) => {
const next = values[0] || ('0' as any)
fonts.setFontScale(next)
},
[fonts],
)
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Introducing new font settings`)}>
<View style={[a.gap_xl]}>
<View style={[a.gap_md]}>
<Text style={[a.text_3xl, {fontWeight: '900'}]}>
<Trans>Introducing new font settings </Trans>
</Text>
<Text style={[a.text_lg, a.leading_snug]}>
<Trans>
To the ensure the best possible experience, we're introducing a
new theme font, along with adjustable font sizing settings.
</Trans>
</Text>
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Defaults are shown below. You can edit these in your Appearance
Settings later.
</Trans>
</Text>
</View>
<Divider />
<View style={[a.gap_lg]}>
<AppearanceToggleButtonGroup
title={_(msg`Font`)}
description={_(
msg`For the best experience, we recommend using the theme font.`,
)}
icon={Aa}
items={[
{
label: _(msg`System`),
name: 'system',
},
{
label: _(msg`Theme`),
name: 'theme',
},
]}
values={[fonts.family]}
onChange={onChangeFontFamily}
/>
<AppearanceToggleButtonGroup
title={_(msg`Font size`)}
icon={TextSize}
items={[
{
label: _(msg`Smaller`),
name: '-1',
},
{
label: _(msg`Default`),
name: '0',
},
{
label: _(msg`Larger`),
name: '1',
},
]}
values={[fonts.scale]}
onChange={onChangeFontScale}
/>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}

View File

@ -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

View File

@ -0,0 +1,719 @@
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 displayName = React.useMemo(() => {
if (!profile || !moderation) return ''
return sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
}, [profile, moderation])
const handle = React.useMemo(() => {
if (!profile) return ''
return sanitizeHandle(profile.handle, '@')
}, [profile])
const joinedDate = React.useMemo(() => {
if (!profile || !profile.createdAt) return ''
const date = i18n.date(profile.createdAt, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
return date
}, [i18n, profile])
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,
)}!`,
),
imageUris: [
{
uri,
width: WIDTH,
height: HEIGHT,
altText: _(
msg`A virtual certificate with text "Celebrating 10M users on Bluesky, #${i18n.number(
userNumber,
)}, ${displayName} ${handle}, joined on ${joinedDate}"`,
),
},
],
})
}, 1e3)
})
}
}, [
_,
i18n,
control,
openComposer,
uri,
userNumber,
displayName,
handle,
joinedDate,
])
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%'},
]}>
{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,
]}>
{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 on {joinedDate}</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>
)
}

View File

@ -0,0 +1,183 @@
import React from 'react'
import {AppBskyActorDefs} from '@atproto/api'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {
Nux,
useNuxs,
useRemoveNuxsMutation,
useUpsertNuxMutation,
} from '#/state/queries/nuxs'
import {
usePreferencesQuery,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile'
import {SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
import {NeueTypography} from '#/components/dialogs/nuxs/NeueTypography'
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
// NUXs
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>
currentAccount: SessionAccount
currentProfile: AppBskyActorDefs.ProfileViewDetailed
preferences: UsePreferencesQueryResponse
}) => boolean
}[] = [
{
id: Nux.TenMillionDialog,
},
{
id: Nux.NeueTypography,
enabled(props) {
if (props.currentProfile.createdAt) {
if (new Date(props.currentProfile.createdAt) < new Date('2024-09-25')) {
return true
}
}
return false
},
},
]
const Context = React.createContext<Context>({
activeNux: undefined,
dismissActiveNux: () => {},
})
export function useNuxDialogContext() {
return React.useContext(Context)
}
export function NuxDialogs() {
const {currentAccount} = useSession()
const {data: preferences} = usePreferencesQuery()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const onboardingActive = useOnboardingState().isActive
const isLoading =
!currentAccount || !preferences || !profile || onboardingActive
return !isLoading ? (
<Inner
currentAccount={currentAccount}
currentProfile={profile}
preferences={preferences}
/>
) : null
}
function Inner({
currentAccount,
currentProfile,
preferences,
}: {
currentAccount: SessionAccount
currentProfile: AppBskyActorDefs.ProfileViewDetailed
preferences: UsePreferencesQueryResponse
}) {
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, currentAccount, currentProfile, preferences})
) {
continue
}
logger.debug(`NUX dialogs: activating '${id}' NUX`)
// 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,
currentAccount,
currentProfile,
preferences,
])
const ctx = React.useMemo(() => {
return {
activeNux,
dismissActiveNux,
}
}, [activeNux, dismissActiveNux])
return (
<Context.Provider value={ctx}>
{activeNux === Nux.TenMillionDialog && <TenMillion />}
{activeNux === Nux.NeueTypography && <NeueTypography />}
</Context.Provider>
)
}

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
}

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',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const TextSize_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M9 5a1 1 0 0 1 1-1h12a1 1 0 1 1 0 2h-5v14a1 1 0 1 1-2 0V6h-5a1 1 0 0 1-1-1Zm-3.073 7v8a1 1 0 1 0 2 0v-8H12a1 1 0 1 0 0-2H6.971a1.015 1.015 0 0 0-.089 0H2a1 1 0 1 0 0 2h3.927Z',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const TitleCase_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3.65 17.247c-.242.832-.632 1.178-1.325 1.178-.814 0-1.325-.476-1.325-1.23 0-.216.06-.51.173-.831L4.586 7.07c.364-1.014.979-1.482 1.966-1.482 1.022 0 1.629.45 2.001 1.473l3.43 9.303c.121.337.165.571.165.831 0 .72-.546 1.23-1.308 1.23-.736 0-1.126-.338-1.36-1.152l-.658-1.975H4.309l-.658 1.95ZM6.5 8.152l-1.62 5.12h3.335l-1.654-5.12H6.5Zm13.005 8.688c-.52.988-1.68 1.568-2.84 1.568-1.768 0-3.11-1.144-3.11-2.815 0-1.69 1.299-2.668 3.62-2.807l2.34-.138v-.615c0-.867-.607-1.369-1.56-1.369-.771 0-1.239.251-1.802.979-.277.312-.597.468-1.004.468-.615 0-1.057-.399-1.057-.97 0-.2.043-.382.13-.572.433-1.109 1.923-1.793 3.845-1.793 2.383 0 3.933 1.23 3.933 3.1v5.293c0 .84-.511 1.273-1.23 1.273-.684 0-1.16-.38-1.213-1.126v-.476h-.052Zm-3.43-1.386c0 .693.572 1.126 1.42 1.126 1.11 0 2.02-.719 2.02-1.723v-.676l-1.959.121c-.944.07-1.48.494-1.48 1.152Z',
})

View File

@ -4,7 +4,7 @@ import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
export function PlayButtonIcon({size = 36}: {size?: number}) { export function PlayButtonIcon({size = 32}: {size?: number}) {
const t = useTheme() const t = useTheme()
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975 const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25 const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25

View File

@ -2,12 +2,12 @@ import React from 'react'
import {AppState, AppStateStatus} from 'react-native' import {AppState, AppStateStatus} from 'react-native'
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import {createClient, SegmentClient} from '@segment/analytics-react-native' import {createClient, SegmentClient} from '@segment/analytics-react-native'
import * as Sentry from '@sentry/react-native'
import {sha256} from 'js-sha256' import {sha256} from 'js-sha256'
import {Native} from 'sentry-expo'
import {useSession, SessionAccount} from '#/state/session'
import {ScreenPropertiesMap, TrackPropertiesMap} from './types'
import {logger} from '#/logger' import {logger} from '#/logger'
import {SessionAccount, useSession} from '#/state/session'
import {ScreenPropertiesMap, TrackPropertiesMap} from './types'
type AppInfo = { type AppInfo = {
build?: string | undefined build?: string | undefined
@ -72,7 +72,7 @@ export function init(account: SessionAccount | undefined) {
if (account.did) { if (account.did) {
const did_hashed = sha256(account.did) const did_hashed = sha256(account.did)
client.identify(did_hashed, {did_hashed}) client.identify(did_hashed, {did_hashed})
Native.setUser({id: did_hashed}) Sentry.setUser({id: did_hashed})
logger.debug('Ping w/hash') logger.debug('Ping w/hash')
} else { } else {
logger.debug('Ping w/o hash') logger.debug('Ping w/o hash')

View File

@ -1,11 +1,11 @@
import React from 'react' import React from 'react'
import {createClient} from '@segment/analytics-react' import {createClient} from '@segment/analytics-react'
import * as Sentry from '@sentry/react-native'
import {sha256} from 'js-sha256' import {sha256} from 'js-sha256'
import {Browser} from 'sentry-expo'
import {ScreenPropertiesMap, TrackPropertiesMap} from './types'
import {useSession, SessionAccount} from '#/state/session'
import {logger} from '#/logger' import {logger} from '#/logger'
import {SessionAccount, useSession} from '#/state/session'
import {ScreenPropertiesMap, TrackPropertiesMap} from './types'
type SegmentClient = ReturnType<typeof createClient> type SegmentClient = ReturnType<typeof createClient>
@ -70,7 +70,7 @@ export function init(account: SessionAccount | undefined) {
if (account.did) { if (account.did) {
const did_hashed = sha256(account.did) const did_hashed = sha256(account.did)
client.identify(did_hashed, {did_hashed}) client.identify(did_hashed, {did_hashed})
Browser.setUser({id: did_hashed}) Sentry.setUser({id: did_hashed})
logger.debug('Ping w/hash') logger.debug('Ping w/hash')
} else { } else {
logger.debug('Ping w/o hash') logger.debug('Ping w/o hash')

View File

@ -2,6 +2,7 @@ import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed, AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent, BskyAgent,
jsonStringToLex,
} from '@atproto/api' } from '@atproto/api'
import {getContentLanguages} from '#/state/preferences/languages' import {getContentLanguages} from '#/state/preferences/languages'
@ -111,7 +112,7 @@ async function loggedOutFetch({
}&limit=${limit}&lang=${contentLangs}`, }&limit=${limit}&lang=${contentLangs}`,
{method: 'GET', headers: {'Accept-Language': contentLangs}}, {method: 'GET', headers: {'Accept-Language': contentLangs}},
) )
let data = res.ok ? await res.json() : null let data = res.ok ? jsonStringToLex(await res.text()) : null
if (data?.feed?.length) { if (data?.feed?.length) {
return { return {
success: true, success: true,
@ -126,7 +127,7 @@ async function loggedOutFetch({
}&limit=${limit}`, }&limit=${limit}`,
{method: 'GET', headers: {'Accept-Language': ''}}, {method: 'GET', headers: {'Accept-Language': ''}},
) )
data = res.ok ? await res.json() : null data = res.ok ? jsonStringToLex(await res.text()) : null
if (data?.feed?.length) { if (data?.feed?.length) {
return { return {
success: true, success: true,

15
src/lib/canvas.ts 100644
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
})
}

View File

@ -10,7 +10,7 @@ import {
} from '#/components/icons/Heart2' } from '#/components/icons/Heart2'
const animationConfig = { const animationConfig = {
duration: 400, duration: 600,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)', easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode, fill: 'forwards' as FillMode,
} }

View File

@ -71,7 +71,7 @@ export function useIntentHandler() {
}, [incomingUrl, composeIntent, verifyEmailIntent]) }, [incomingUrl, composeIntent, verifyEmailIntent])
} }
function useComposeIntent() { export function useComposeIntent() {
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const {hasSession} = useSession() const {hasSession} = useSession()

View File

@ -5,7 +5,7 @@
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application' import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'
import {init} from 'sentry-expo' import {init} from '@sentry/react-native'
import {BUILD_ENV, IS_DEV, IS_TESTFLIGHT} from 'lib/app-info' import {BUILD_ENV, IS_DEV, IS_TESTFLIGHT} from 'lib/app-info'
@ -30,10 +30,10 @@ const dist = `${Platform.OS}.${nativeBuildVersion}.${
}${IS_DEV ? 'dev' : ''}` }${IS_DEV ? 'dev' : ''}`
init({ init({
enabled: !__DEV__,
autoSessionTracking: false, autoSessionTracking: false,
dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432', dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
enableInExpoDevelopment: false, // enable this to test in dev
environment: BUILD_ENV ?? 'development', environment: BUILD_ENV ?? 'development',
dist, dist,
release, release,

View File

@ -225,4 +225,8 @@ export type LogEvents = {
'test:gate1:sometimes': {} 'test:gate1:sometimes': {}
'test:gate2:always': {} 'test:gate2:always': {}
'test:gate2:sometimes': {} 'test:gate2:sometimes': {}
'tmd:share': {}
'tmd:download': {}
'tmd:post': {}
} }

View File

@ -79,13 +79,13 @@ export const s = StyleSheet.create({
// font weights // font weights
fw600: {fontWeight: '600'}, fw600: {fontWeight: '600'},
bold: {fontWeight: 'bold'}, bold: {fontWeight: '700'},
fw500: {fontWeight: '500'}, fw500: {fontWeight: '500'},
semiBold: {fontWeight: '500'}, semiBold: {fontWeight: '500'},
fw400: {fontWeight: '400'}, fw400: {fontWeight: '400'},
normal: {fontWeight: '400'}, normal: {fontWeight: '400'},
fw300: {fontWeight: '300'}, fw300: {fontWeight: '400'},
light: {fontWeight: '300'}, light: {fontWeight: '400'},
fw200: {fontWeight: '200'}, fw200: {fontWeight: '200'},
// text decoration // text decoration

View File

@ -1,5 +1,6 @@
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {tokens} from '#/alf'
import {darkPalette, dimPalette, lightPalette} from '#/alf/themes' import {darkPalette, dimPalette, lightPalette} from '#/alf/themes'
import {colors} from './styles' import {colors} from './styles'
import type {Theme} from './ThemeContext' import type {Theme} from './ThemeContext'
@ -88,163 +89,163 @@ export const defaultTheme: Theme = {
typography: { typography: {
'2xl-thin': { '2xl-thin': {
fontSize: 18, fontSize: 18,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '300', fontWeight: '400',
}, },
'2xl': { '2xl': {
fontSize: 18, fontSize: 18,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '400', fontWeight: '400',
}, },
'2xl-medium': { '2xl-medium': {
fontSize: 18, fontSize: 18,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '500', fontWeight: '500',
}, },
'2xl-bold': { '2xl-bold': {
fontSize: 18, fontSize: 18,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '700', fontWeight: '700',
}, },
'2xl-heavy': { '2xl-heavy': {
fontSize: 18, fontSize: 18,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '800', fontWeight: '800',
}, },
'xl-thin': { 'xl-thin': {
fontSize: 17, fontSize: 17,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '300', fontWeight: '400',
}, },
xl: { xl: {
fontSize: 17, fontSize: 17,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '400', fontWeight: '400',
}, },
'xl-medium': { 'xl-medium': {
fontSize: 17, fontSize: 17,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '500', fontWeight: '500',
}, },
'xl-bold': { 'xl-bold': {
fontSize: 17, fontSize: 17,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '700', fontWeight: '700',
}, },
'xl-heavy': { 'xl-heavy': {
fontSize: 17, fontSize: 17,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '800', fontWeight: '800',
}, },
'lg-thin': { 'lg-thin': {
fontSize: 16, fontSize: 16,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '300', fontWeight: '400',
}, },
lg: { lg: {
fontSize: 16, fontSize: 16,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '400', fontWeight: '400',
}, },
'lg-medium': { 'lg-medium': {
fontSize: 16, fontSize: 16,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '500', fontWeight: '500',
}, },
'lg-bold': { 'lg-bold': {
fontSize: 16, fontSize: 16,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '700', fontWeight: '700',
}, },
'lg-heavy': { 'lg-heavy': {
fontSize: 16, fontSize: 16,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '800', fontWeight: '800',
}, },
'md-thin': { 'md-thin': {
fontSize: 15, fontSize: 15,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '300', fontWeight: '400',
}, },
md: { md: {
fontSize: 15, fontSize: 15,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '400', fontWeight: '400',
}, },
'md-medium': { 'md-medium': {
fontSize: 15, fontSize: 15,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '500', fontWeight: '500',
}, },
'md-bold': { 'md-bold': {
fontSize: 15, fontSize: 15,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '700', fontWeight: '700',
}, },
'md-heavy': { 'md-heavy': {
fontSize: 15, fontSize: 15,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '800', fontWeight: '800',
}, },
'sm-thin': { 'sm-thin': {
fontSize: 14, fontSize: 14,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '300', fontWeight: '400',
}, },
sm: { sm: {
fontSize: 14, fontSize: 14,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '400', fontWeight: '400',
}, },
'sm-medium': { 'sm-medium': {
fontSize: 14, fontSize: 14,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '500', fontWeight: '500',
}, },
'sm-bold': { 'sm-bold': {
fontSize: 14, fontSize: 14,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '700', fontWeight: '700',
}, },
'sm-heavy': { 'sm-heavy': {
fontSize: 14, fontSize: 14,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '800', fontWeight: '800',
}, },
'xs-thin': { 'xs-thin': {
fontSize: 13, fontSize: 13,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '300', fontWeight: '400',
}, },
xs: { xs: {
fontSize: 13, fontSize: 13,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '400', fontWeight: '400',
}, },
'xs-medium': { 'xs-medium': {
fontSize: 13, fontSize: 13,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '500', fontWeight: '500',
}, },
'xs-bold': { 'xs-bold': {
fontSize: 13, fontSize: 13,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '700', fontWeight: '700',
}, },
'xs-heavy': { 'xs-heavy': {
fontSize: 13, fontSize: 13,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '800', fontWeight: '800',
}, },
'title-2xl': { 'title-2xl': {
fontSize: 34, fontSize: 34,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '500', fontWeight: '500',
}, },
'title-xl': { 'title-xl': {
fontSize: 28, fontSize: 28,
letterSpacing: 0.25, letterSpacing: tokens.TRACKING,
fontWeight: '500', fontWeight: '500',
}, },
'title-lg': { 'title-lg': {
@ -254,32 +255,32 @@ export const defaultTheme: Theme = {
title: { title: {
fontWeight: '500', fontWeight: '500',
fontSize: 20, fontSize: 20,
letterSpacing: 0.15, letterSpacing: tokens.TRACKING,
}, },
'title-sm': { 'title-sm': {
fontWeight: 'bold', fontWeight: 'bold',
fontSize: 17, fontSize: 17,
letterSpacing: 0.15, letterSpacing: tokens.TRACKING,
}, },
'post-text': { 'post-text': {
fontSize: 16, fontSize: 16,
letterSpacing: 0.2, letterSpacing: tokens.TRACKING,
fontWeight: '400', fontWeight: '400',
}, },
'post-text-lg': { 'post-text-lg': {
fontSize: 20, fontSize: 20,
letterSpacing: 0.2, letterSpacing: tokens.TRACKING,
fontWeight: '400', fontWeight: '400',
}, },
'button-lg': { 'button-lg': {
fontWeight: '500', fontWeight: '500',
fontSize: 18, fontSize: 18,
letterSpacing: 0.5, letterSpacing: tokens.TRACKING,
}, },
button: { button: {
fontWeight: '500', fontWeight: '500',
fontSize: 14, fontSize: 14,
letterSpacing: 0.5, letterSpacing: tokens.TRACKING,
}, },
mono: { mono: {
fontSize: 14, fontSize: 14,

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

View File

@ -1,6 +1,6 @@
import {beforeAll, describe, expect, jest, test} from '@jest/globals'
import * as Sentry from '@sentry/react-native'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {jest, describe, expect, test, beforeAll} from '@jest/globals'
import {Native as Sentry} from 'sentry-expo'
import {Logger, LogLevel, sentryTransport} from '#/logger' import {Logger, LogLevel, sentryTransport} from '#/logger'
@ -16,12 +16,10 @@ jest.mock('#/env', () => ({
LOG_DEBUG: '', LOG_DEBUG: '',
})) }))
jest.mock('sentry-expo', () => ({ jest.mock('@sentry/react-native', () => ({
Native: { addBreadcrumb: jest.fn(),
addBreadcrumb: jest.fn(), captureException: jest.fn(),
captureException: jest.fn(), captureMessage: jest.fn(),
captureMessage: jest.fn(),
},
})) }))
beforeAll(() => { beforeAll(() => {

View File

@ -1 +1 @@
export {Native as Sentry} from 'sentry-expo' export * as Sentry from '@sentry/react-native'

View File

@ -1 +1 @@
export {Browser as Sentry} from 'sentry-expo' export * as Sentry from '@sentry/react-native'

View File

@ -144,8 +144,7 @@ export const ForgotPasswordForm = ({
variant="solid" variant="solid"
color={'primary'} color={'primary'}
size="medium" size="medium"
onPress={onPressNext} onPress={onPressNext}>
disabled={!email}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Next</Trans>
</ButtonText> </ButtonText>

View File

@ -60,7 +60,6 @@ export const LoginForm = ({
const {track} = useAnalytics() const {track} = useAnalytics()
const t = useTheme() const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [isReady, setIsReady] = useState<boolean>(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
useState<boolean>(false) useState<boolean>(false)
const identifierValueRef = useRef<string>(initialHandle || '') const identifierValueRef = useRef<string>(initialHandle || '')
@ -83,12 +82,18 @@ export const LoginForm = ({
Keyboard.dismiss() Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('') setError('')
setIsProcessing(true)
const identifier = identifierValueRef.current.toLowerCase().trim() const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current const password = passwordValueRef.current
const authFactorToken = authFactorTokenValueRef.current const authFactorToken = authFactorTokenValueRef.current
if (!identifier || !password) {
setError(_(msg`Invalid username or password`))
return
}
setIsProcessing(true)
try { try {
// try to guess the handle if the user just gave their own username // try to guess the handle if the user just gave their own username
let fullIdent = identifier let fullIdent = identifier
@ -157,22 +162,6 @@ export const LoginForm = ({
} }
} }
const checkIsReady = () => {
if (
!!serviceDescription &&
!!identifierValueRef.current &&
!!passwordValueRef.current
) {
if (!isReady) {
setIsReady(true)
}
} else {
if (isReady) {
setIsReady(false)
}
}
}
return ( return (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
<View> <View>
@ -204,7 +193,6 @@ export const LoginForm = ({
defaultValue={initialHandle || ''} defaultValue={initialHandle || ''}
onChangeText={v => { onChangeText={v => {
identifierValueRef.current = v identifierValueRef.current = v
checkIsReady()
}} }}
onSubmitEditing={() => { onSubmitEditing={() => {
passwordRef.current?.focus() passwordRef.current?.focus()
@ -233,7 +221,6 @@ export const LoginForm = ({
clearButtonMode="while-editing" clearButtonMode="while-editing"
onChangeText={v => { onChangeText={v => {
passwordValueRef.current = v passwordValueRef.current = v
checkIsReady()
}} }}
onSubmitEditing={onPressNext} onSubmitEditing={onPressNext}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
@ -325,7 +312,7 @@ export const LoginForm = ({
<Trans>Connecting...</Trans> <Trans>Connecting...</Trans>
</Text> </Text>
</> </>
) : isReady ? ( ) : (
<Button <Button
testID="loginNextButton" testID="loginNextButton"
label={_(msg`Next`)} label={_(msg`Next`)}
@ -339,7 +326,7 @@ export const LoginForm = ({
</ButtonText> </ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />} {isProcessing && <ButtonIcon icon={Loader} />}
</Button> </Button>
) : undefined} )}
</View> </View>
</FormContainer> </FormContainer>
) )

View File

@ -14,17 +14,21 @@ import {s} from '#/lib/styles'
import {useSetThemePrefs, useThemePrefs} from '#/state/shell' import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
import {ScrollView} from '#/view/com/util/Views' import {ScrollView} from '#/view/com/util/Views'
import {atoms as a, native, useTheme} from '#/alf' import {atoms as a, native, useAlf, useTheme} from '#/alf'
import * as ToggleButton from '#/components/forms/ToggleButton' import * as ToggleButton from '#/components/forms/ToggleButton'
import {Props as SVGIconProps} from '#/components/icons/common'
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone' import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/TextSize'
import {TitleCase_Stroke2_Corner0_Rounded as Aa} from '#/components/icons/TitleCase'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
export function AppearanceSettingsScreen({}: Props) { export function AppearanceSettingsScreen({}: Props) {
const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {_} = useLingui()
const {isTabletOrMobile} = useWebMediaQueries() const {isTabletOrMobile} = useWebMediaQueries()
const {fonts} = useAlf()
const {colorMode, darkTheme} = useThemePrefs() const {colorMode, darkTheme} = useThemePrefs()
const {setColorMode, setDarkTheme} = useSetThemePrefs() const {setColorMode, setDarkTheme} = useSetThemePrefs()
@ -54,6 +58,22 @@ export function AppearanceSettingsScreen({}: Props) {
[setDarkTheme, darkTheme], [setDarkTheme, darkTheme],
) )
const onChangeFontFamily = useCallback(
(values: string[]) => {
const next = values[0] === 'system' ? 'system' : 'theme'
fonts.setFontFamily(next)
},
[fonts],
)
const onChangeFontScale = useCallback(
(values: string[]) => {
const next = values[0] || ('0' as any)
fonts.setFontScale(next)
},
[fonts],
)
return ( return (
<LayoutAnimationConfig skipExiting skipEntering> <LayoutAnimationConfig skipExiting skipEntering>
<View testID="preferencesThreadsScreen" style={s.hContentRegion}> <View testID="preferencesThreadsScreen" style={s.hContentRegion}>
@ -71,65 +91,143 @@ export function AppearanceSettingsScreen({}: Props) {
</View> </View>
</SimpleViewHeader> </SimpleViewHeader>
<View style={[a.p_xl, a.gap_lg]}> <View style={[a.gap_3xl, a.pt_xl, a.px_xl]}>
<View style={[a.flex_row, a.align_center, a.gap_md]}> <View style={[a.gap_lg]}>
<PhoneIcon style={t.atoms.text} /> <AppearanceToggleButtonGroup
<Text style={a.text_md}> title={_(msg`Color mode`)}
<Trans>Mode</Trans> icon={PhoneIcon}
</Text> items={[
</View> {
<ToggleButton.Group label: _(msg`System`),
label={_(msg`Dark mode`)} name: 'system',
values={[colorMode]} },
onChange={onChangeAppearance}> {
<ToggleButton.Button label={_(msg`System`)} name="system"> label: _(msg`Light`),
<ToggleButton.ButtonText> name: 'light',
<Trans>System</Trans> },
</ToggleButton.ButtonText> {
</ToggleButton.Button> label: _(msg`Dark`),
<ToggleButton.Button label={_(msg`Light`)} name="light"> name: 'dark',
<ToggleButton.ButtonText> },
<Trans>Light</Trans> ]}
</ToggleButton.ButtonText> values={[colorMode]}
</ToggleButton.Button> onChange={onChangeAppearance}
<ToggleButton.Button label={_(msg`Dark`)} name="dark"> />
<ToggleButton.ButtonText>
<Trans>Dark</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
{colorMode !== 'light' && (
<Animated.View
entering={native(FadeInDown)}
exiting={native(FadeOutDown)}
style={[a.mt_md, a.gap_lg]}>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<MoonIcon style={t.atoms.text} />
<Text style={a.text_md}>
<Trans>Dark theme</Trans>
</Text>
</View>
<ToggleButton.Group {colorMode !== 'light' && (
label={_(msg`Dark theme`)} <Animated.View
values={[darkTheme ?? 'dim']} entering={native(FadeInDown)}
onChange={onChangeDarkTheme}> exiting={native(FadeOutDown)}>
<ToggleButton.Button label={_(msg`Dim`)} name="dim"> <AppearanceToggleButtonGroup
<ToggleButton.ButtonText> title={_(msg`Dark theme`)}
<Trans>Dim</Trans> icon={MoonIcon}
</ToggleButton.ButtonText> items={[
</ToggleButton.Button> {
<ToggleButton.Button label={_(msg`Dark`)} name="dark"> label: _(msg`Dim`),
<ToggleButton.ButtonText> name: 'dim',
<Trans>Dark</Trans> },
</ToggleButton.ButtonText> {
</ToggleButton.Button> label: _(msg`Dark`),
</ToggleButton.Group> name: 'dark',
</Animated.View> },
)} ]}
values={[darkTheme ?? 'dim']}
onChange={onChangeDarkTheme}
/>
</Animated.View>
)}
<AppearanceToggleButtonGroup
title={_(msg`Font`)}
description={_(
msg`For the best experience, we recommend using the theme font.`,
)}
icon={Aa}
items={[
{
label: _(msg`System`),
name: 'system',
},
{
label: _(msg`Theme`),
name: 'theme',
},
]}
values={[fonts.family]}
onChange={onChangeFontFamily}
/>
<AppearanceToggleButtonGroup
title={_(msg`Font size`)}
icon={TextSize}
items={[
{
label: _(msg`Smaller`),
name: '-1',
},
{
label: _(msg`Default`),
name: '0',
},
{
label: _(msg`Larger`),
name: '1',
},
]}
values={[fonts.scale]}
onChange={onChangeFontScale}
/>
</View>
</View> </View>
</ScrollView> </ScrollView>
</View> </View>
</LayoutAnimationConfig> </LayoutAnimationConfig>
) )
} }
export function AppearanceToggleButtonGroup({
title,
description,
icon: Icon,
items,
values,
onChange,
}: {
title: string
description?: string
icon: React.ComponentType<SVGIconProps>
items: {
label: string
name: string
}[]
values: string[]
onChange: (values: string[]) => void
}) {
const t = useTheme()
return (
<View style={[a.gap_md]}>
<View style={[a.gap_xs]}>
<View style={[a.flex_row, a.align_center, a.gap_md]}>
<Icon style={t.atoms.text} />
<Text style={[a.text_md, a.font_bold]}>{title}</Text>
</View>
{description && (
<Text
style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
{description}
</Text>
)}
</View>
<ToggleButton.Group label={title} values={values} onChange={onChange}>
{items.map(item => (
<ToggleButton.Button
key={item.name}
label={item.label}
name={item.name}>
<ToggleButton.ButtonText>{item.label}</ToggleButton.ButtonText>
</ToggleButton.Button>
))}
</ToggleButton.Group>
</View>
)
}

View File

@ -1,19 +1,20 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {ImageModel} from './image'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {openPicker} from 'lib/media/picker'
import {getImageDim} from 'lib/media/manip' import {getImageDim} from 'lib/media/manip'
import {openPicker} from 'lib/media/picker'
import {ImageInitOptions, ImageModel} from './image'
interface InitialImageUri { interface InitialImageUri {
uri: string uri: string
width: number width: number
height: number height: number
altText?: string
} }
export class GalleryModel { export class GalleryModel {
images: ImageModel[] = [] images: ImageModel[] = []
constructor(uris?: {uri: string; width: number; height: number}[]) { constructor(uris?: InitialImageUri[]) {
makeAutoObservable(this) makeAutoObservable(this)
if (uris) { if (uris) {
@ -33,7 +34,7 @@ export class GalleryModel {
return this.images.some(image => image.altText.trim() === '') return this.images.some(image => image.altText.trim() === '')
} }
*add(image_: Omit<RNImage, 'size'>) { *add(image_: ImageInitOptions) {
if (this.size >= 4) { if (this.size >= 4) {
return return
} }
@ -59,7 +60,6 @@ export class GalleryModel {
path: uri, path: uri,
height, height,
width, width,
mime: 'image/jpeg',
} }
runInAction(() => { runInAction(() => {
@ -100,10 +100,10 @@ export class GalleryModel {
async addFromUris(uris: InitialImageUri[]) { async addFromUris(uris: InitialImageUri[]) {
for (const uriObj of uris) { for (const uriObj of uris) {
this.add({ this.add({
mime: 'image/jpeg',
height: uriObj.height, height: uriObj.height,
width: uriObj.width, width: uriObj.width,
path: uriObj.uri, path: uriObj.uri,
altText: uriObj.altText,
}) })
} }
} }

View File

@ -1,14 +1,15 @@
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {makeAutoObservable, runInAction} from 'mobx'
import {POST_IMG_MAX} from 'lib/constants'
import * as ImageManipulator from 'expo-image-manipulator' import * as ImageManipulator from 'expo-image-manipulator'
import {getDataUriSize} from 'lib/media/util'
import {openCropper} from 'lib/media/picker'
import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator' import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
import {makeAutoObservable, runInAction} from 'mobx'
import {Position} from 'react-avatar-editor' import {Position} from 'react-avatar-editor'
import {Dimensions} from 'lib/media/types'
import {isIOS} from 'platform/detection'
import {logger} from '#/logger' import {logger} from '#/logger'
import {POST_IMG_MAX} from 'lib/constants'
import {openCropper} from 'lib/media/picker'
import {Dimensions} from 'lib/media/types'
import {getDataUriSize} from 'lib/media/util'
import {isIOS} from 'platform/detection'
export interface ImageManipulationAttributes { export interface ImageManipulationAttributes {
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
@ -19,6 +20,13 @@ export interface ImageManipulationAttributes {
flipVertical?: boolean flipVertical?: boolean
} }
export interface ImageInitOptions {
path: string
width: number
height: number
altText?: string
}
const MAX_IMAGE_SIZE_IN_BYTES = 976560 const MAX_IMAGE_SIZE_IN_BYTES = 976560
export class ImageModel implements Omit<RNImage, 'size'> { export class ImageModel implements Omit<RNImage, 'size'> {
@ -41,12 +49,15 @@ export class ImageModel implements Omit<RNImage, 'size'> {
} }
prevAttributes: ImageManipulationAttributes = {} prevAttributes: ImageManipulationAttributes = {}
constructor(image: Omit<RNImage, 'size'>) { constructor(image: ImageInitOptions) {
makeAutoObservable(this) makeAutoObservable(this)
this.path = image.path this.path = image.path
this.width = image.width this.width = image.width
this.height = image.height this.height = image.height
if (image.altText !== undefined) {
this.setAltText(image.altText)
}
} }
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {

View File

@ -21,31 +21,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
if (feedDesc.startsWith('feedgen')) { if (feedDesc.startsWith('feedgen')) {
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)] return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
} }
if (feedDesc.startsWith('list')) { if (feedDesc === 'following' || feedDesc.startsWith('list')) {
let feedTuners = []
if (feedDesc.endsWith('|as_following')) {
// Same as Following tuners below, copypaste for now.
feedTuners.push(FeedTuner.removeOrphans)
if (preferences?.feedViewPrefs.hideReposts) {
feedTuners.push(FeedTuner.removeReposts)
}
if (preferences?.feedViewPrefs.hideReplies) {
feedTuners.push(FeedTuner.removeReplies)
} else {
feedTuners.push(
FeedTuner.followedRepliesOnly({
userDid: currentAccount?.did || '',
}),
)
}
if (preferences?.feedViewPrefs.hideQuotePosts) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
feedTuners.push(FeedTuner.dedupThreads)
}
return feedTuners
}
if (feedDesc === 'following') {
const feedTuners = [FeedTuner.removeOrphans] const feedTuners = [FeedTuner.removeOrphans]
if (preferences?.feedViewPrefs.hideReposts) { if (preferences?.feedViewPrefs.hideReposts) {

View File

@ -175,19 +175,9 @@ async function fetchSubjects(
}> { }> {
const postUris = new Set<string>() const postUris = new Set<string>()
const packUris = new Set<string>() const packUris = new Set<string>()
const postUrisWithLikes = new Set<string>()
const postUrisWithReposts = new Set<string>()
for (const notif of groupedNotifs) { for (const notif of groupedNotifs) {
if (notif.subjectUri?.includes('app.bsky.feed.post')) { if (notif.subjectUri?.includes('app.bsky.feed.post')) {
postUris.add(notif.subjectUri) postUris.add(notif.subjectUri)
if (notif.type === 'post-like') {
postUrisWithLikes.add(notif.subjectUri)
}
if (notif.type === 'repost') {
postUrisWithReposts.add(notif.subjectUri)
}
} else if ( } else if (
notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack') notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack')
) { ) {
@ -216,15 +206,6 @@ async function fetchSubjects(
AppBskyFeedPost.validateRecord(post.record).success AppBskyFeedPost.validateRecord(post.record).success
) { ) {
postsMap.set(post.uri, post) postsMap.set(post.uri, post)
// HACK. In some cases, the appview appears to lag behind and returns empty counters.
// To prevent scroll jump due to missing metrics, fill in 1 like/repost instead of 0.
if (post.likeCount === 0 && postUrisWithLikes.has(post.uri)) {
post.likeCount = 1
}
if (post.repostCount === 0 && postUrisWithReposts.has(post.uri)) {
post.repostCount = 1
}
} }
} }
for (const pack of packsChunks.flat()) { for (const pack of packsChunks.flat()) {

View File

@ -3,27 +3,23 @@ import zod from 'zod'
import {BaseNux} from '#/state/queries/nuxs/types' import {BaseNux} from '#/state/queries/nuxs/types'
export enum Nux { export enum Nux {
One = 'one', TenMillionDialog = 'TenMillionDialog',
Two = 'two', NeueTypography = 'NeueTypography',
} }
export const nuxNames = new Set(Object.values(Nux)) export const nuxNames = new Set(Object.values(Nux))
export type AppNux = export type AppNux =
| BaseNux<{ | BaseNux<{
id: Nux.One id: Nux.TenMillionDialog
data: { data: undefined
likes: number
}
}> }>
| BaseNux<{ | BaseNux<{
id: Nux.Two id: Nux.NeueTypography
data: undefined data: undefined
}> }>
export const NuxSchemas = { export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.One]: zod.object({ [Nux.TenMillionDialog]: undefined,
likes: zod.number(), [Nux.NeueTypography]: undefined,
}),
[Nux.Two]: undefined,
} }

View File

@ -57,6 +57,7 @@ export function useUpsertNuxMutation() {
const agent = useAgent() const agent = useAgent()
return useMutation({ return useMutation({
retry: 3,
mutationFn: async (nux: AppNux) => { mutationFn: async (nux: AppNux) => {
await agent.bskyAppUpsertNux(serializeAppNux(nux)) await agent.bskyAppUpsertNux(serializeAppNux(nux))
// triggers a refetch // triggers a refetch
@ -72,6 +73,7 @@ export function useRemoveNuxsMutation() {
const agent = useAgent() const agent = useAgent()
return useMutation({ return useMutation({
retry: 3,
mutationFn: async (ids: string[]) => { mutationFn: async (ids: string[]) => {
await agent.bskyAppRemoveNuxs(ids) await agent.bskyAppRemoveNuxs(ids)
// triggers a refetch // triggers a refetch

View File

@ -4,6 +4,4 @@ export type Data = Record<string, unknown> | undefined
export type BaseNux< export type BaseNux<
T extends Pick<AppBskyActorDefs.Nux, 'id' | 'expiresAt'> & {data: Data}, T extends Pick<AppBskyActorDefs.Nux, 'id' | 'expiresAt'> & {data: Data},
> = T & { > = Pick<AppBskyActorDefs.Nux, 'id' | 'completed' | 'expiresAt'> & T
completed: boolean
}

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