feat: add segment integration

closes #1748
This commit is contained in:
beguene
2021-10-07 12:29:21 +02:00
parent 7ce2f57ab0
commit 0999431bf0
47 changed files with 2253 additions and 1002 deletions

View File

@@ -3,6 +3,7 @@ on: [pull_request, workflow_dispatch]
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }}
jobs:
pre_run:

View File

@@ -7,6 +7,7 @@ on:
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }}
jobs:
pre-run:

View File

@@ -83,6 +83,7 @@
"@rehooks/document-title": "1.0.2",
"@sentry/react": "6.12.0",
"@sentry/tracing": "6.12.0",
"@segment/analytics-next": "1.31.1",
"@stacks/blockchain-api-client": "0.65.0",
"@stacks/common": "2.0.1",
"@stacks/connect-ui": "5.2.0",
@@ -240,6 +241,7 @@
"bn.js": "5.2.0",
"buffer": "6.0.3",
"css-what": "5.0.1",
"dot-prop": "6.0.1",
"hosted-git-info": "4.0.2",
"immer": "9.0.6",
"mixme": "0.5.2",

View File

@@ -17,6 +17,7 @@ import { AppErrorBoundary } from '@features/errors/app-error-boundary';
import { TransactionSettingsDrawer } from '@features/fee-nonce-drawers/transaction-settings-drawer';
import { SpeedUpTransactionDrawer } from '@features/fee-nonce-drawers/speed-up-transaction-drawer';
import { Devtools } from '@features/devtool/devtools';
import { initSegment } from '@common/segment-init';
const devToolsEnabled = false;
@@ -27,6 +28,7 @@ declare global {
}
window.__APP_VERSION__ = VERSION;
void initSegment();
export const App: React.FC = () => {
return (

View File

@@ -0,0 +1,113 @@
import { statusFromTx } from '@common/api/transactions';
import { isAddressTransactionWithTransfers } from '@common/transactions/transaction-utils';
import { useCurrentAccount } from '@store/accounts/account.hooks';
import {
AddressTransactionWithTransfers,
MempoolTransaction,
} from '@stacks/stacks-blockchain-api-types';
import dayjs from 'dayjs';
import { useAnalytics } from './use-analytics';
import { StacksTransaction } from '@stacks/transactions';
let previousAccountTransactions: Map<string, TxStatus>;
function changedTransactions(previousTxs: Map<string, TxStatus>, txs: Map<string, TxStatus>) {
if (!previousTxs) return;
const result = new Map(
[...txs]
.filter(
([key, value]) =>
(!previousTxs.get(key) && value.status === 'submitted') ||
(previousTxs.get(key) && previousTxs.get(key)?.status !== value.status)
)
.map(([key, value]) => {
const timeSinceLastState =
previousTxs.get(key) && dayjs(value.timeIso).diff(previousTxs.get(key)?.timeIso);
return [
key,
{ ...value, previous_state: previousTxs.get(key)?.status, timeSinceLastState },
];
})
);
return result;
}
interface TxStatus {
timeIso: string;
broadcastTimeIso: string;
status: string;
type: 'inbound' | 'outbound';
timeSinceBroadcast: number;
tx: AddressTransactionWithTransfers | MempoolTransaction | StacksTransaction;
}
type localTx = {
[key: string]: {
transaction: StacksTransaction;
timestamp: number;
};
};
export function useTrackChangedTransactions(
transactions: (AddressTransactionWithTransfers | MempoolTransaction)[],
localTransactions: localTx
) {
const currentAccount = useCurrentAccount();
const analytics = useAnalytics();
const now = new Date().toISOString();
const result = new Map(previousAccountTransactions);
Object.keys(localTransactions).forEach(key => {
const tx = localTransactions[key];
const status = 'submitted';
result.set(key, {
timeIso: now,
broadcastTimeIso: now,
timeSinceBroadcast: 0,
status,
type: 'outbound',
tx: tx.transaction,
});
});
transactions.forEach(atx => {
let time, tx;
let type: 'inbound' | 'outbound';
if (isAddressTransactionWithTransfers(atx)) {
tx = atx.tx;
type = tx.sender_address === currentAccount?.address ? 'outbound' : 'inbound';
time = ('burn_block_time_iso' in tx && tx.burn_block_time_iso) || now;
} else {
tx = atx;
type = 'outbound';
time = ('receipt_time_iso' in tx && tx.receipt_time_iso) || now;
}
const status = statusFromTx(tx);
if (status === 'success_microblock') {
time = now; // since there is no real timestamp for microblocks, we just use the current time
}
const broadcastTimeIso = `${
previousAccountTransactions?.get(tx.tx_id)?.broadcastTimeIso || now
}`;
const timeSinceBroadcast = dayjs(time).diff(broadcastTimeIso);
result.set(tx.tx_id, {
type,
timeIso: time.toString(),
status,
tx: atx,
broadcastTimeIso,
timeSinceBroadcast,
});
});
const changed = changedTransactions(previousAccountTransactions, result);
previousAccountTransactions = result;
if (!changed) return result;
[...changed].forEach(([_, value]) =>
analytics.track('change_transaction_state', { ...value, tx: undefined })
);
return result;
}

View File

@@ -0,0 +1,54 @@
import { useCurrentNetworkState } from '@store/network/networks.hooks';
import { useLocation } from 'react-router-dom';
import { IS_TEST_ENV } from '@common/constants';
import { EventParams, PageParams } from '@segment/analytics-next/dist/pkg/core/arguments-resolver';
import { analytics } from '@common/segment-init';
import { logger } from '@common/logger';
const IGNORED_PATH_REGEXPS = [/^\/$/];
function isIgnoredPath(path: string) {
return IGNORED_PATH_REGEXPS.find(regexp => regexp.test(path));
}
function isHiroApiUrl(url: string) {
return /^https:\/\/.*\.stacks.co/.test(url);
}
export function useAnalytics() {
const currentNetwork = useCurrentNetworkState();
const location = useLocation();
const defaultProperties = {
network: currentNetwork.name.toLowerCase(),
usingDefaultHiroApi: isHiroApiUrl(currentNetwork.url),
route: location.pathname,
version: VERSION,
};
const defaultOptions = {
context: { ip: '0.0.0.0' },
};
return {
page: async (...args: PageParams) => {
if (!analytics || IS_TEST_ENV) return;
const [category, name, properties, options, ...rest] = args;
if (typeof name === 'string' && isIgnoredPath(name)) return;
const prop = { ...defaultProperties, ...properties };
const opts = { ...defaultOptions, ...options };
return analytics.page(category, name, prop, opts, ...rest).catch(logger.error);
},
track: async (...args: EventParams) => {
if (!analytics || IS_TEST_ENV) return;
const [eventName, properties, options, ...rest] = args;
const prop = { ...defaultProperties, ...properties };
const opts = { ...defaultOptions, ...options };
return analytics.track(eventName, prop, opts, ...rest).catch(logger.error);
},
};
}

View File

@@ -13,6 +13,7 @@ import {
useSeedInputErrorState,
useSeedInputState,
} from '@store/onboarding/onboarding.hooks';
import { useAnalytics } from '../analytics/use-analytics';
export function useSignIn() {
const [, setMagicRecoveryCode] = useMagicRecoveryCodeState();
@@ -22,6 +23,7 @@ export function useSignIn() {
const { isLoading, setIsLoading, setIsIdle } = useLoading('useSignIn');
const doChangeScreen = useChangeScreen();
const { doStoreSeed } = useWallet();
const analytics = useAnalytics();
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -30,9 +32,10 @@ export function useSignIn() {
setError(message);
setIsIdle();
textAreaRef.current?.focus();
void analytics.track('submit_invalid_secret_key');
return;
},
[setError, setIsIdle, textAreaRef]
[analytics, setError, setIsIdle]
);
const handleSubmit = useCallback(
@@ -61,6 +64,7 @@ export function useSignIn() {
try {
await doStoreSeed({ secretKey: parsedKeyInput });
void analytics.track('submit_valid_secret_key');
doChangeScreen(ScreenPaths.SET_PASSWORD);
setIsIdle();
} catch (error) {
@@ -68,13 +72,14 @@ export function useSignIn() {
}
},
[
seed,
doStoreSeed,
doChangeScreen,
handleSetError,
setIsIdle,
setIsLoading,
seed,
handleSetError,
setMagicRecoveryCode,
doChangeScreen,
doStoreSeed,
analytics,
setIsIdle,
]
);

View File

@@ -1,17 +0,0 @@
import { useEffect } from 'react';
import { ScreenPaths } from '@common/types';
import { IS_TEST_ENV } from '@common/constants';
import { userHasAllowedDiagnosticsKey } from '@store/onboarding/onboarding.hooks';
import { useChangeScreen } from './use-change-screen';
export function usePromptUserToSetDiagnosticPermissions() {
const changeScreen = useChangeScreen();
useEffect(() => {
if (IS_TEST_ENV) return;
const persistedUserDiagnosticDecision = localStorage.getItem(userHasAllowedDiagnosticsKey);
if (persistedUserDiagnosticDecision === null) changeScreen(ScreenPaths.REQUEST_DIAGNOSTICS);
}, [changeScreen]);
}

View File

@@ -2,7 +2,6 @@ import { useTabState } from '@store/ui/ui.hooks';
export function useHomeTabs() {
const [activeTab, setActiveTab] = useTabState('HOME_TABS');
const setActiveTabBalances = () => setActiveTab(0);
const setActiveTabActivity = () => setActiveTab(1);
return {

View File

@@ -6,6 +6,7 @@ import { getTicker, initBigNumber } from '@common/utils';
import { ftDecimals, stacksValue } from '@common/stacks-utils';
import { useCurrentAccountAvailableStxBalance } from '@store/accounts/account.hooks';
import { useSelectedAssetState, useUpdateSelectedAsset } from '@store/assets/asset.hooks';
import { useAnalytics } from './analytics/use-analytics';
export function getFullyQualifiedAssetName(asset?: AssetWithMeta) {
return asset ? `${asset.contractAddress}.${asset.contractName}::${asset.name}` : undefined;
@@ -15,11 +16,13 @@ export function useSelectedAsset() {
const selectedAsset = useSelectedAssetState();
const setSelectedAsset = useUpdateSelectedAsset();
const availableStxBalance = useCurrentAccountAvailableStxBalance();
const analytics = useAnalytics();
const handleUpdateSelectedAsset = useCallback(
(asset: AssetWithMeta | undefined) => {
setSelectedAsset(getFullyQualifiedAssetName(asset) || undefined);
void analytics.track('select_asset_for_send');
},
[setSelectedAsset]
[analytics, setSelectedAsset]
);
const name = selectedAsset?.meta?.name || selectedAsset?.name;
const isStx = selectedAsset?.name === 'Stacks Token';

View File

@@ -3,9 +3,12 @@ import { useCallback } from 'react';
import { SetPassword, StoreSeed, UnlockWallet, SwitchAccount } from '@background/vault-types';
import { InternalMethods } from '@common/message-types';
import { useInnerMessageWrapper } from '@store/wallet/wallet.hooks';
import { clearSessionLocalData } from '@common/store-utils';
import { useAnalytics } from './analytics/use-analytics';
export function useVaultMessenger() {
const innerMessageWrapper = useInnerMessageWrapper();
const analytics = useAnalytics();
const doSetPassword = useCallback(
(payload: string) => {
@@ -64,7 +67,8 @@ export function useVaultMessenger() {
innerMessageWrapper({ method: InternalMethods.signOut, payload: undefined });
const doSignOut = async () => {
await handleSignOut();
localStorage.clear();
void analytics.track('sign_out');
clearSessionLocalData();
};
const doLockWallet = () =>
innerMessageWrapper({ method: InternalMethods.lockWallet, payload: undefined });

View File

@@ -0,0 +1,36 @@
import { Analytics, AnalyticsBrowser } from '@segment/analytics-next';
import { IS_TEST_ENV } from './constants';
import { logger } from './logger';
import { checkUserHasGrantedPermission } from './sentry-init';
export let analytics: Analytics;
export function initSegment() {
const writeKey = process.env.SEGMENT_WRITE_KEY;
const hasPermission = checkUserHasGrantedPermission();
if (!hasPermission) {
logger.info('segment init aborted: has no permission.');
return;
}
if (IS_TEST_ENV) return;
if (!writeKey) {
logger.info('segment init aborted: No WRITE_KEY setup.');
return;
}
return AnalyticsBrowser.standalone(writeKey, {
integrations: {
'Segment.io': {
deliveryStrategy: {
strategy: 'batching',
config: {
size: 10,
timeout: 5000,
},
},
},
},
})
.then(res => (analytics = res))
.catch(logger.error);
}

View File

@@ -3,7 +3,7 @@ import { Integrations } from '@sentry/tracing';
import { userHasAllowedDiagnosticsKey } from '@store/onboarding/onboarding.hooks';
function checkUserHasGrantedPermission() {
export function checkUserHasGrantedPermission() {
return localStorage.getItem(userHasAllowedDiagnosticsKey) === 'true';
}

View File

@@ -1,3 +1,4 @@
import { userHasAllowedDiagnosticsKey } from '@store/onboarding/onboarding.hooks';
import hash from 'object-hash';
import { hashQueryKey, QueryKey } from 'react-query';
@@ -25,3 +26,19 @@ export function setLocalData<Data>(params: string[], data: Data): Data {
localStorage.setItem(key, JSON.stringify(data));
return data;
}
// LocalStorage keys kept across sign-in/signout sessions
const PERSISTENT_LOCAL_DATA: string[] = [userHasAllowedDiagnosticsKey];
export function clearSessionLocalData() {
const backup = PERSISTENT_LOCAL_DATA.map((key: string) => [key, localStorage.getItem(key)]);
localStorage.clear();
// Store the backup in localStorage
backup.forEach(([key, value]) => {
if (key === null || value === null) return;
localStorage.setItem(key, value);
});
return localStorage;
}

View File

@@ -3,6 +3,7 @@ import { Box, Flex, Button, Stack } from '@stacks/ui';
import { useWallet } from '@common/hooks/use-wallet';
import { Body } from '@components/typography';
import { SettingsSelectors } from '@tests/integration/settings.selectors';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
interface CreateAccountProps {
close: () => void;
@@ -14,15 +15,17 @@ export const CreateAccount: React.FC<CreateAccountProps> = ({ close }) => {
const { doCreateNewAccount } = useWallet();
const [isSetting, setSetting] = useState(false);
const [hasFired, setHasFired] = useState(false);
const analytics = useAnalytics();
const createAccount = useCallback(async () => {
if (!isSetting) {
setSetting(true);
await doCreateNewAccount();
void analytics.track('create_new_account');
setSetting(false);
window.setTimeout(() => close(), TIMEOUT);
}
}, [doCreateNewAccount, isSetting, setSetting, close]);
}, [isSetting, doCreateNewAccount, analytics, close]);
useEffect(() => {
if (!hasFired) {

View File

@@ -30,6 +30,7 @@ import {
StxTransfer,
} from '@common/transactions/transaction-utils';
import { useAssetByIdentifier } from '@store/assets/asset.hooks';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
type Tx = MempoolTransaction | Transaction;
@@ -260,6 +261,11 @@ export const TxItem = ({ transaction, ...rest }: TxItemProps) => {
const [component, bind, { isHovered }] = usePressable(true);
const { handleOpenTxLink } = useExplorerLink();
const currentAccount = useCurrentAccount();
const analytics = useAnalytics();
const openTxLink = () => {
void analytics.track('view_transaction');
handleOpenTxLink(transaction.tx_id);
};
if (!transaction) return null;
@@ -277,7 +283,7 @@ export const TxItem = ({ transaction, ...rest }: TxItemProps) => {
isInline
position="relative"
zIndex={2}
onClick={() => handleOpenTxLink(transaction.tx_id)}
onClick={openTxLink}
>
<TxItemIcon transaction={transaction} />
<SpaceBetween flexGrow={1}>

View File

@@ -16,6 +16,7 @@ import { useAccounts } from '@store/accounts/account.hooks';
import { useUpdateAccountDrawerStep } from '@store/ui/ui.hooks';
import { AccountStep } from '@store/ui/ui.models';
import { AccountWithAddress } from '@store/accounts/account.models';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
interface SwitchAccountProps {
close: () => void;
@@ -148,11 +149,17 @@ const AccountList: React.FC<{ handleClose: () => void }> = memo(({ handleClose }
export const SwitchAccounts: React.FC<SwitchAccountProps> = memo(({ close }) => {
const setAccountDrawerStep = useUpdateAccountDrawerStep();
const analytics = useAnalytics();
const setCreateAccountStep = () => {
void analytics.track('choose_to_create_account');
setAccountDrawerStep(AccountStep.Create);
};
return (
<>
<AccountList handleClose={close} />
<Box pt="base" pb="loose" px="loose">
<Button onClick={() => setAccountDrawerStep(AccountStep.Create)}>Create an account</Button>
<Button onClick={setCreateAccountStep}>Create an account</Button>
</Box>
</>
);

View File

@@ -1,19 +1,25 @@
import React from 'react';
import { useCurrentAccountLocalTxids } from '@store/accounts/account-activity.hooks';
import {
useCurrentAccountLocallySubmittedStacksTransactions,
useCurrentAccountLocalTxids,
} from '@store/accounts/account-activity.hooks';
import { TransactionList } from '@components/popup/transaction-list';
import { LocalTxList } from '@features/local-transaction-activity/local-tx-list';
import { NoAccountActivity } from './components/no-account-activity';
import { useAccountTransactionsWithTransfers } from '@common/hooks/account/use-account-transactions-with-transfers.hooks';
import { useCurrentAccountFilteredMempoolTransactionsState } from '@query/mempool/mempool.hooks';
import { useTrackChangedTransactions } from '@common/hooks/analytics/transactions-analytics.hooks';
export const ActivityList = () => {
const transactions = useAccountTransactionsWithTransfers();
const pendingTransactions = useCurrentAccountFilteredMempoolTransactionsState();
const txids = useCurrentAccountLocalTxids();
const localTxs = useCurrentAccountLocallySubmittedStacksTransactions();
const allTransactions = [...pendingTransactions, ...transactions];
useTrackChangedTransactions(allTransactions, localTxs);
const hasTxs = txids.length > 0 || transactions.length > 0;
if (!hasTxs) return <NoAccountActivity />;

View File

@@ -8,6 +8,7 @@ import { Box, BoxProps, color, Flex, Stack } from '@stacks/ui';
import { useUpdateCurrentNetworkKey } from '@store/network/networks.hooks';
import { NetworkStatusIndicator } from './components/network-status-indicator';
import { useNetworkStatus } from 'query/network/network.hooks';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
export const NetworkListItem: React.FC<{ item: string } & BoxProps> = ({ item, ...props }) => {
const { setShowNetworks } = useDrawers();
@@ -16,11 +17,13 @@ export const NetworkListItem: React.FC<{ item: string } & BoxProps> = ({ item, .
const network = networks[item];
const isActive = item === currentNetworkKey;
const isOnline = useNetworkStatus(network.url);
const analytics = useAnalytics();
const handleItemClick = useCallback(() => {
void analytics.track('change_network');
setCurrentNetworkKey(item);
setTimeout(() => setShowNetworks(false), 25);
}, [setCurrentNetworkKey, item, setShowNetworks]);
}, [analytics, setCurrentNetworkKey, item, setShowNetworks]);
return (
<Box

View File

@@ -6,16 +6,19 @@ import { ScreenPaths } from '@common/types';
import { useDrawers } from '@common/hooks/use-drawers';
import { useShowNetworksStore } from '@store/ui/ui.hooks';
import { NetworkList } from '@features/network-drawer/network-list';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
export const NetworksDrawer: React.FC = () => {
const { setShowNetworks } = useDrawers();
const [isShowing] = useShowNetworksStore();
const doChangeScreen = useChangeScreen();
const analytics = useAnalytics();
const handleAddNetworkClick = useCallback(() => {
void analytics.track('add_network');
setShowNetworks(false);
doChangeScreen(ScreenPaths.ADD_NETWORK);
}, [setShowNetworks, doChangeScreen]);
}, [analytics, setShowNetworks, doChangeScreen]);
return (
<ControlledDrawer

View File

@@ -11,6 +11,7 @@ import { USERNAMES_ENABLED } from '@common/constants';
import { forwardRefWithAs } from '@stacks/ui-core';
import { SettingsSelectors } from '@tests/integration/settings.selectors';
import { AccountStep } from '@store/ui/ui.models';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
const MenuWrapper = forwardRefWithAs((props, ref) => (
<Box
@@ -71,6 +72,7 @@ export const SettingsPopover: React.FC = () => {
setShowSignOut,
} = useDrawers();
const changeScreen = useChangeScreen();
const analytics = useAnalytics();
const handleClose = useCallback(() => {
setShowSettings(false);
@@ -141,6 +143,7 @@ export const SettingsPopover: React.FC = () => {
<MenuItem
data-testid={SettingsSelectors.ChangeNetworkAction}
onClick={wrappedCloseCallback(() => {
void analytics.track('choose_to_change_network');
setShowNetworks(true);
})}
>
@@ -155,6 +158,7 @@ export const SettingsPopover: React.FC = () => {
{isSignedIn && (
<MenuItem
onClick={wrappedCloseCallback(() => {
void analytics.track('lock_session');
void doLockWallet();
changeScreen(ScreenPaths.POPUP_HOME);
})}

View File

@@ -1,9 +1,9 @@
import React, { FC } from 'react';
import { Body } from '@components/typography';
import { Body, Title } from '@components/typography';
import { Box, Button, Flex, color, Stack } from '@stacks/ui';
import { BaseDrawer } from '@components/drawer';
import { FiCheck } from 'react-icons/fi';
import { InitialPageSelectors } from '@tests/integration/initial-page.selectors';
interface ReasonToAllowDiagnosticsProps {
text: string;
@@ -23,43 +23,48 @@ interface AllowDiagnosticsLayoutProps {
onUserAllowDiagnostics(): void;
onUserDenyDiagnosticsPermissions(): void;
}
export const AllowDiagnosticsLayout: FC<AllowDiagnosticsLayoutProps> = props => {
export function AllowDiagnosticsFullLayout(props: AllowDiagnosticsLayoutProps) {
const { onUserAllowDiagnostics, onUserDenyDiagnosticsPermissions } = props;
const title = 'Help us improve';
return (
<BaseDrawer title={title} isShowing onClose={() => onUserDenyDiagnosticsPermissions()}>
<Stack spacing="extra-loose" flexGrow={1} justifyContent="center">
<Title as="h1" fontWeight={500} textAlign="center">
Help us improve
</Title>
<Box mx="loose" mb="extra-loose">
<Body>
Hiro would like to gather anonymous data to help improve the experience of using Stacks
apps and the wallet.
We would like to gather de-identified usage data to help improve your experience with Hiro
Wallet.
</Body>
<Stack mt="loose" spacing="base-tight">
<ReasonToAllowDiagnostics text="Send anonymous data about page views and clicks" />
<ReasonToAllowDiagnostics text="We'll never collect personal data such as your Stacks address, keys, balances, or IP address" />
<ReasonToAllowDiagnostics text="We'll never share or sell any user data" />
<ReasonToAllowDiagnostics text="This data is tied to randomly-generated IDs and not personal data, such as your Stacks addresses, keys, balance, or IP addresses" />
<ReasonToAllowDiagnostics text="This data is used to generate and send crash reports, help us fix errors, and analyze trends and statistics" />
</Stack>
<Flex mt="loose" fontSize="14px">
<Flex mt="loose" fontSize="14px" justifyContent="center">
<Button
type="button"
size="lg"
mode="primary"
data-testid={InitialPageSelectors.AnalyticsAllow}
onClick={() => onUserAllowDiagnostics()}
mr="base-tight"
>
Yes, I'll help
Allow
</Button>
<Button
type="button"
mode="tertiary"
size="lg"
ml="base"
variant="link"
onClick={() => onUserDenyDiagnosticsPermissions()}
>
No thanks
Deny
</Button>
</Flex>
</Box>
</BaseDrawer>
</Stack>
);
};
}

View File

@@ -4,26 +4,35 @@ import { ScreenPaths } from '@common/types';
import { useChangeScreen } from '@common/hooks/use-change-screen';
import { useHasAllowedDiagnostics } from '@store/onboarding/onboarding.hooks';
import { AllowDiagnosticsLayout } from './allow-diagnostics-layout';
import { initSentry } from '@common/sentry-init';
import { initSegment } from '@common/segment-init';
import { AllowDiagnosticsFullLayout } from './allow-diagnostics-layout';
import { PopupContainer } from '@components/popup/container';
import { Header } from '@components/header';
export const AllowDiagnosticsDrawer = () => {
export const AllowDiagnosticsFullPage = () => {
const changeScreen = useChangeScreen();
const [, setHasAllowedDiagnostics] = useHasAllowedDiagnostics();
const goHomeAndSetDiagnosticsPermissionTo = useCallback(
(areDiagnosticsAllowed: boolean) => {
changeScreen(ScreenPaths.HOME);
(areDiagnosticsAllowed: boolean | undefined) => {
if (typeof areDiagnosticsAllowed === undefined) return;
setHasAllowedDiagnostics(areDiagnosticsAllowed);
if (areDiagnosticsAllowed) initSentry();
if (areDiagnosticsAllowed) {
initSentry();
void initSegment();
}
changeScreen(ScreenPaths.HOME);
},
[changeScreen, setHasAllowedDiagnostics]
);
return (
<AllowDiagnosticsLayout
onUserDenyDiagnosticsPermissions={() => goHomeAndSetDiagnosticsPermissionTo(false)}
onUserAllowDiagnostics={() => goHomeAndSetDiagnosticsPermissionTo(true)}
/>
<PopupContainer header={<Header hideActions />} requestType="auth">
<AllowDiagnosticsFullLayout
onUserDenyDiagnosticsPermissions={() => goHomeAndSetDiagnosticsPermissionTo(false)}
onUserAllowDiagnostics={() => goHomeAndSetDiagnosticsPermissionTo(true)}
/>
</PopupContainer>
);
};

View File

@@ -5,16 +5,26 @@ import type { StackProps } from '@stacks/ui';
import { Tabs } from '@components/tabs';
import { LoadingSpinner } from '@components/loading-spinner';
import { useHomeTabs } from '@common/hooks/use-home-tabs';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
interface HomeTabsProps extends StackProps {
balances: JSX.Element;
activity: JSX.Element;
}
const ANALYTICS_PATH = ['/balances', '/activity'];
export function HomeTabs(props: HomeTabsProps) {
const { balances, activity, ...rest } = props;
const analytics = useAnalytics();
const { activeTab, setActiveTab } = useHomeTabs();
const setActiveTabTracked = (index: number) => {
void analytics.page('view', ANALYTICS_PATH[index]);
setActiveTab(index);
};
return (
<Stack flexGrow={1} spacing="extra-loose" {...rest}>
<Tabs
@@ -23,7 +33,7 @@ export function HomeTabs(props: HomeTabsProps) {
{ slug: 'activity', label: 'Activity' },
]}
activeTab={activeTab}
onTabClick={setActiveTab}
onTabClick={setActiveTabTracked}
/>
<Flex position="relative" flexGrow={1}>
{activeTab === 0 && (

View File

@@ -8,10 +8,17 @@ import { FiCopy } from 'react-icons/fi';
import { CurrentUserAvatar } from '@features/current-user/current-user-avatar';
import { CurrentUsername } from '@features/current-user/current-user-name';
import { UserAreaSelectors } from '@tests/integration/user-area.selectors';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
const UserAddress = memo((props: StackProps) => {
const currentAccount = useCurrentAccount();
const { onCopy, hasCopied } = useClipboard(currentAccount?.address || '');
const analytics = useAnalytics();
const copyToClipboard = () => {
void analytics.track('copy_address_to_clipboard');
onCopy();
};
return currentAccount ? (
<Stack isInline {...props}>
<Caption>{truncateMiddle(currentAccount.address, 4)}</Caption>
@@ -19,7 +26,7 @@ const UserAddress = memo((props: StackProps) => {
<Stack>
<Box
_hover={{ cursor: 'pointer' }}
onClick={onCopy}
onClick={copyToClipboard}
size="12px"
color={color('text-caption')}
data-testid={UserAreaSelectors.AccountCopyAddress}

View File

@@ -10,13 +10,9 @@ import { Header } from '@components/header';
import { HiroMessages } from '@features/hiro-messages/hiro-messages';
import { ActivityList } from '@features/activity-list/account-activity';
import { BalancesList } from '@features/balances-list/balances-list';
import { usePromptUserToSetDiagnosticPermissions } from '@common/hooks/use-diagnostic-permission-prompt';
import { HomeTabs } from './components/home-tabs';
export const Home = () => {
usePromptUserToSetDiagnosticPermissions();
return (
<>
<PopupContainer

View File

@@ -9,20 +9,23 @@ import { useOnboardingState } from '@common/hooks/auth/use-onboarding-state';
import { Title, Body } from '@components/typography';
import { Header } from '@components/header';
import { InitialPageSelectors } from '@tests/integration/initial-page.selectors';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
const Actions: React.FC<StackProps> = props => {
const { doMakeWallet } = useWallet();
const { decodedAuthRequest } = useOnboardingState();
const doChangeScreen = useChangeScreen();
const analytics = useAnalytics();
const [isCreatingWallet, setIsCreatingWallet] = useState(false);
const register = useCallback(async () => {
setIsCreatingWallet(true);
await doMakeWallet();
void analytics.track('generate_new_secret_key');
if (decodedAuthRequest) {
doChangeScreen(ScreenPaths.SET_PASSWORD);
}
}, [doMakeWallet, doChangeScreen, decodedAuthRequest]);
}, [doMakeWallet, analytics, decodedAuthRequest, doChangeScreen]);
return (
<Stack justifyContent="center" spacing="loose" textAlign="center" {...props}>

View File

@@ -11,12 +11,19 @@ import { truncateMiddle } from '@stacks/ui-utils';
import { Tooltip } from '@components/tooltip';
import { QrCode } from './components/address-qr-code';
import { ReceiveTokensHeader } from './components/recieve-tokens-header';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
export const PopupReceive: React.FC = () => {
const { currentAccount, currentAccountStxAddress } = useWallet();
const doChangeScreen = useChangeScreen();
const address = currentAccountStxAddress || '';
const analytics = useAnalytics();
const { onCopy, hasCopied } = useClipboard(address);
const copyToClipboard = () => {
void analytics.track('copy_address_to_clipboard');
onCopy();
};
return (
<PopupContainer
header={<ReceiveTokensHeader onClose={() => doChangeScreen(ScreenPaths.POPUP_HOME)} />}
@@ -38,7 +45,7 @@ export const PopupReceive: React.FC = () => {
</Box>
</Stack>
<Box mt="auto">
<Button width="100%" onClick={onCopy} borderRadius="10px">
<Button width="100%" onClick={copyToClipboard} borderRadius="10px">
Copy your address
</Button>
</Box>

View File

@@ -1,10 +1,11 @@
import React, { memo } from 'react';
import React, { memo, useEffect } from 'react';
import { useWallet } from '@common/hooks/use-wallet';
import { Button, color, Stack, BoxProps, useClipboard, StackProps } from '@stacks/ui';
import { Button, color, Stack, BoxProps, StackProps, useClipboard } from '@stacks/ui';
import { PopupContainer } from '@components/popup/container';
import { Body, Text } from '@components/typography';
import { Card } from '@components/card';
import { Header } from '@components/header';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
const SecretKeyMessage: React.FC<BoxProps> = props => {
const { secretKey } = useWallet();
@@ -37,6 +38,13 @@ const SecretKeyActions: React.FC<{ handleNext?: () => void } & StackProps> = ({
}) => {
const { secretKey } = useWallet();
const { onCopy, hasCopied } = useClipboard(secretKey || '');
const analytics = useAnalytics();
const copyToClipboard = () => {
void analytics.track('copy_secret_key_to_clipboard');
onCopy();
};
return (
<Stack spacing="base" {...rest}>
<Button
@@ -47,7 +55,7 @@ const SecretKeyActions: React.FC<{ handleNext?: () => void } & StackProps> = ({
color={color(hasCopied ? 'text-caption' : 'brand')}
mode="tertiary"
borderRadius="10px"
onClick={hasCopied ? undefined : onCopy}
onClick={hasCopied ? undefined : copyToClipboard}
>
{hasCopied ? 'Copied!' : 'Copy to clipboard'}
</Button>
@@ -70,16 +78,26 @@ export const SaveYourKeyView: React.FC<{
onClose?: () => void;
title?: string;
hideActions?: boolean;
}> = memo(({ title, handleNext, hideActions, onClose }) => (
<PopupContainer
header={
<Header onClose={onClose} hideActions={hideActions} title={title || 'Save your Secret Key'} />
}
>
<Stack spacing="loose">
<SecretKeyMessage />
<SecretKeyCard />
<SecretKeyActions handleNext={handleNext || onClose} />
</Stack>
</PopupContainer>
));
}> = memo(({ title, handleNext, hideActions, onClose }) => {
const analytics = useAnalytics();
useEffect(() => {
void analytics.page('view', '/save-your-secret-key');
}, [analytics]);
return (
<PopupContainer
header={
<Header
onClose={onClose}
hideActions={hideActions}
title={title || 'Save your Secret Key'}
/>
}
>
<Stack spacing="loose">
<SecretKeyMessage />
<SecretKeyCard />
<SecretKeyActions handleNext={handleNext || onClose} />
</Stack>
</PopupContainer>
);
});

View File

@@ -9,6 +9,7 @@ import { useSendAmountFieldActions } from '../hooks/use-send-form';
import { SendMaxWithSuspense } from './send-max-button';
import { SendFormSelectors } from '@tests/page-objects/send-form.selectors';
import { useCurrentAccountBalancesUnanchoredState } from '@store/accounts/account.hooks';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
interface AmountFieldProps extends StackProps {
value: number;
@@ -19,6 +20,7 @@ interface AmountFieldProps extends StackProps {
export const AmountField = memo((props: AmountFieldProps) => {
const { value, error, ...rest } = props;
const analytics = useAnalytics();
const assets = useAssets();
const balances = useCurrentAccountBalancesUnanchoredState();
const { selectedAsset, placeholder } = useSelectedAsset();
@@ -27,6 +29,11 @@ export const AmountField = memo((props: AmountFieldProps) => {
setFieldValue,
});
const handleSetSendMaxTracked = (feeRate: number) => {
void analytics.track('select_maximum_amount_for_send');
return handleSetSendMax(feeRate);
};
return (
<Stack {...rest}>
<InputGroup flexDirection="column">
@@ -52,7 +59,7 @@ export const AmountField = memo((props: AmountFieldProps) => {
{balances && selectedAsset ? (
<SendMaxWithSuspense
showButton={Boolean(balances && selectedAsset)}
onSetMax={feeRate => handleSetSendMax(feeRate)}
onSetMax={feeRate => handleSetSendMaxTracked(feeRate)}
/>
) : null}
</Box>

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { debounce } from 'ts-debounce';
import { Box, Button, Input, Stack } from '@stacks/ui';
import { Form, Formik } from 'formik';
@@ -14,6 +14,7 @@ import { USERNAMES_ENABLED } from '@common/constants';
import { validatePassword, blankPasswordValidation } from '@common/validation/validate-password';
import { Body, Caption } from '@components/typography';
import { Header } from '@components/header';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
const HUMAN_REACTION_DEBOUNCE_TIME = 250;
@@ -33,6 +34,10 @@ export const SetPasswordPage: React.FC<SetPasswordProps> = ({
const { doSetPassword, wallet, doFinishSignIn } = useWallet();
const doChangeScreen = useChangeScreen();
const { decodedAuthRequest } = useOnboardingState();
const analytics = useAnalytics();
useEffect(() => {
void analytics.page('view', '/set-password');
}, [analytics]);
const submit = useCallback(
async (password: string) => {
@@ -69,12 +74,13 @@ export const SetPasswordPage: React.FC<SetPasswordProps> = ({
if (!password) return;
setLoading(true);
if (strengthResult.meetsAllStrengthRequirements) {
void analytics.track('submit_valid_password');
await submit(password);
return;
}
setLoading(false);
},
[strengthResult, submit]
[strengthResult, submit, analytics]
);
const validationSchema = yup.object({
@@ -87,6 +93,9 @@ export const SetPasswordPage: React.FC<SetPasswordProps> = ({
if (typeof value !== 'string') return false;
const result = validatePassword(value);
setStrengthResult(result);
if (!result.meetsAllStrengthRequirements) {
void analytics.track('submit_invalid_password');
}
return result.meetsAllStrengthRequirements;
}, HUMAN_REACTION_DEBOUNCE_TIME),
}),

View File

@@ -17,6 +17,7 @@ import { useSetLocalTxsCallback } from '@store/accounts/account-activity.hooks';
import { todaysIsoDate } from '@common/date-utils';
import { logger } from '@common/logger';
import { useCurrentAccountTxIds } from '@query/transactions/transaction.hooks';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
function getErrorMessage(
reason: TxBroadcastResultRejected['reason'] | 'ConflictingNonceInMempool'
@@ -52,6 +53,7 @@ export function useSubmitTransactionCallback({
const { setActiveTabActivity } = useHomeTabs();
const setLocalTxs = useSetLocalTxsCallback();
const externalTxid = useCurrentAccountTxIds();
const analytics = useAnalytics();
return useCallback<(tx: StacksTransaction) => Promise<void>>(
async transaction => {
@@ -74,6 +76,7 @@ export function useSubmitTransactionCallback({
}
if (nonce) await doSetLatestNonce(nonce);
toast.success('Transaction submitted!');
void analytics.track('broadcast_transaction');
doChangeScreen(ScreenPaths.HOME);
onClose();
setIsIdle();
@@ -89,17 +92,18 @@ export function useSubmitTransactionCallback({
}
},
[
setLocalTxs,
doSetLatestNonce,
replaceByFee,
onClose,
refreshAccountData,
doChangeScreen,
setIsLoading,
setIsIdle,
replaceByFee,
stacksNetwork,
setActiveTabActivity,
onClose,
setIsIdle,
externalTxid,
doSetLatestNonce,
analytics,
doChangeScreen,
setActiveTabActivity,
refreshAccountData,
setLocalTxs,
]
);
}

View File

@@ -1,8 +1,13 @@
import { useCurrentAccountFilteredMempoolTransactionsState } from '@query/mempool/mempool.hooks';
import { useAccountConfirmedTransactions } from '@store/accounts/account.hooks';
export function useCurrentAccountTxIds() {
function useCurrentAccountTxs() {
const confirmedTxs = useAccountConfirmedTransactions();
const mempoolTxs = useCurrentAccountFilteredMempoolTransactionsState();
return [...new Set([...confirmedTxs, ...mempoolTxs].map(tx => tx.tx_id))];
return [...new Set([...confirmedTxs, ...mempoolTxs])];
}
export function useCurrentAccountTxIds() {
const currentAccountTxs = useCurrentAccountTxs();
return currentAccountTxs.map(tx => tx.tx_id);
}

View File

@@ -27,7 +27,9 @@ import { Unlock } from '@pages/unlock';
import { Home } from '@pages/home/home';
import { useUpdateLastSeenStore } from '@store/wallet/wallet.hooks';
import { SignOutConfirmDrawer } from '@pages/sign-out-confirm/sign-out-confirm';
import { AllowDiagnosticsDrawer } from '@pages/allow-diagnostics/allow-diagnostics';
import { useAnalytics } from '@common/hooks/analytics/use-analytics';
import { useHasAllowedDiagnostics } from '@store/onboarding/onboarding.hooks';
import { AllowDiagnosticsFullPage } from '@pages/allow-diagnostics/allow-diagnostics';
interface RouteProps {
path: ScreenPaths;
@@ -45,10 +47,12 @@ export const Routes: React.FC = () => {
const setLastSeen = useUpdateLastSeenStore();
const doChangeScreen = useChangeScreen();
const analytics = useAnalytics();
useSaveAuthRequest();
const isSignedIn = signedIn && !isOnboardingInProgress;
const isLocked = !signedIn && encryptedSecretKey;
const [hasAllowedDiagnostics, _] = useHasAllowedDiagnostics();
// Keep track of 'last seen' by updating it whenever a route is set.
useEffect(() => {
@@ -56,7 +60,12 @@ export const Routes: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
useEffect(() => {
void analytics.page('view', `${pathname}`);
}, [analytics, pathname]);
const getHomeComponent = useCallback(() => {
if (hasAllowedDiagnostics === undefined) return <AllowDiagnosticsFullPage />;
if (isSignedIn || encryptedSecretKey) {
return (
<AccountGate>
@@ -65,7 +74,7 @@ export const Routes: React.FC = () => {
);
}
return <Installed />;
}, [isSignedIn, encryptedSecretKey]);
}, [hasAllowedDiagnostics, isSignedIn, encryptedSecretKey]);
const getSignInComponent = () => {
if (isLocked) return <Unlock />;
@@ -91,7 +100,6 @@ export const Routes: React.FC = () => {
<RoutesDom>
<Route path={ScreenPaths.HOME} element={getHomeComponent()}>
<Route path={ScreenPaths.SIGN_OUT_CONFIRM} element={<SignOutConfirmDrawer />} />
<Route path={ScreenPaths.REQUEST_DIAGNOSTICS} element={<AllowDiagnosticsDrawer />} />
</Route>
{/* Installation */}
<Route path={ScreenPaths.SIGN_IN_INSTALLED} element={<InstalledSignIn />} />

View File

@@ -20,7 +20,7 @@ export function useCurrentAccountLocalTxids() {
: [];
}
function useCurrentAccountLocallySubmittedStacksTransactions() {
export function useCurrentAccountLocallySubmittedStacksTransactions() {
const localTxs = useAtomValue(currentAccountLocallySubmittedTxsState);
const txids = useCurrentAccountLocalTxids();
const result: any = {};

View File

@@ -31,7 +31,10 @@ export const authRequestState = atom<AuthRequestState>({
export const userHasAllowedDiagnosticsKey = 'stacks-wallet-has-allowed-diagnostics';
export const hasAllowedDiagnosticsState = atomWithStorage(userHasAllowedDiagnosticsKey, false);
export const hasAllowedDiagnosticsState = atomWithStorage<boolean | undefined>(
userHasAllowedDiagnosticsKey,
undefined
);
magicRecoveryCodePasswordState.debugLabel = 'magicRecoveryCodePasswordState';
seedInputState.debugLabel = 'seedInputState';

View File

@@ -22,6 +22,7 @@ describe(`Authentication integration tests`, () => {
});
it('should be able to sign up from authentication page', async () => {
await wallet.clickAllowAnalytics();
await wallet.clickSignUp();
await wallet.saveKey();
await wallet.waitForHomePage();
@@ -32,6 +33,7 @@ describe(`Authentication integration tests`, () => {
});
it('should be able to login from authentication page then logout', async () => {
await wallet.clickAllowAnalytics();
await wallet.clickSignIn();
await wallet.loginWithPreviousSecretKey(SECRET_KEY);
await wallet.waitForHomePage();

View File

@@ -19,6 +19,7 @@ describe(`Wallet Balance integration tests`, () => {
beforeAll(async () => {
browser = await setupBrowser();
wallet = await WalletPage.init(browser, ScreenPaths.INSTALLED);
await wallet.clickAllowAnalytics();
await wallet.signIn(SECRET_KEY_2);
await selectTestNet(wallet);
await wallet.waitForHomePage();

View File

@@ -1,4 +1,5 @@
export enum InitialPageSelectors {
SignUp = 'sign-up',
SignIn = 'sign-in',
AnalyticsAllow = 'analytics-allow',
}

View File

@@ -16,6 +16,7 @@ describe(`Send tokens flow`, () => {
beforeEach(async () => {
browser = await setupBrowser();
walletPage = await WalletPage.init(browser, ScreenPaths.INSTALLED);
await walletPage.clickAllowAnalytics();
await walletPage.signUp();
await walletPage.waitForHomePage();
await walletPage.goToSendForm();
@@ -100,6 +101,7 @@ describe('Preview for sending token', () => {
beforeEach(async () => {
browser = await setupBrowser();
walletPage = await WalletPage.init(browser, ScreenPaths.INSTALLED);
await walletPage.clickAllowAnalytics();
await walletPage.signIn(SECRET_KEY_2);
await walletPage.waitForHomePage();
await walletPage.goToSendForm();

View File

@@ -26,6 +26,7 @@ describe(`Copy Address`, () => {
});
it('should be able to copy address', async () => {
await wallet.clickAllowAnalytics();
await wallet.signUp();
await wallet.page.click(createTestSelector(UserAreaSelectors.AccountBalancesCopyAddress));
let copiedAddress = await readClipboard();

View File

@@ -25,6 +25,7 @@ describe(`Create and switch account`, () => {
});
it('should be able to create a new account then switch', async () => {
await wallet.clickAllowAnalytics();
await wallet.signUp();
await wallet.clickSettingsButton();
await wallet.page.click(wallet.$createAccountButton);
@@ -47,6 +48,7 @@ describe(`Create and switch account`, () => {
});
it(`should be able to create ${numOfAccountsToTest} new accounts then switch between them`, async () => {
await wallet.clickAllowAnalytics();
await wallet.signUp();
for (let i = 0; i < numOfAccountsToTest - 1; i++) {
await wallet.clickSettingsButton();

View File

@@ -15,6 +15,7 @@ describe(`Settings integration tests`, () => {
beforeAll(async () => {
browser = await setupBrowser();
wallet = await WalletPage.init(browser, ScreenPaths.INSTALLED);
await wallet.clickAllowAnalytics();
await wallet.signUp();
}, BEFORE_ALL_TIMEOUT);

View File

@@ -16,6 +16,7 @@ describe(`Transactions integration tests`, () => {
browser = await setupBrowser();
wallet = await WalletPage.init(browser, ScreenPaths.INSTALLED);
demo = browser.demo;
await wallet.clickAllowAnalytics();
await wallet.signUp();
await demo.page.reload();
// Pattern for opening popup in new page for testing

View File

@@ -17,6 +17,7 @@ export class WalletPage {
static url = 'http://localhost:8081/index.html#';
$signUpButton = createTestSelector(InitialPageSelectors.SignUp);
$signInButton = createTestSelector(InitialPageSelectors.SignIn);
$analyticsAllowButton = createTestSelector('analytics-allow');
homePage = createTestSelector('home-page');
$textareaReadOnlySeedPhrase = `${createTestSelector('textarea-seed-phrase')}[data-loaded="true"]`;
$buttonSignInKeyContinue = createTestSelector('sign-in-key-continue');
@@ -61,6 +62,9 @@ export class WalletPage {
return new this(page);
}
async clickAllowAnalytics() {
await this.page.click(this.$analyticsAllowButton);
}
async clickSignUp() {
await this.page.click(this.$signUpButton);
}

View File

@@ -202,6 +202,7 @@ const config = {
new webpack.EnvironmentPlugin({
SENTRY_DSN: process.env.SENTRY_DSN ?? '',
SEGMENT_WRITE_KEY: process.env.SEGMENT_WRITE_KEY ?? '',
}),
new webpack.ProvidePlugin({

2698
yarn.lock

File diff suppressed because it is too large Load Diff