Add hotkey dialog and rust-only analytics

This commit is contained in:
Gregory Schier
2024-01-10 16:18:08 -08:00
parent ac9d050d9e
commit 0776f6a2be
23 changed files with 152 additions and 144 deletions

View File

@@ -2,7 +2,6 @@ import { useQueryClient } from '@tanstack/react-query';
import { appWindow } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { keyValueQueryKey } from '../hooks/useKeyValue';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
@@ -13,7 +12,6 @@ import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
import { responsesQueryKey } from '../hooks/useResponses';
import { useSyncWindowTitle } from '../hooks/useSyncWindowTitle';
import { workspacesQueryKey } from '../hooks/useWorkspaces';
import { trackPage } from '../lib/analytics';
import { NAMESPACE_NO_SYNC } from '../lib/keyValueStore';
import type { HttpRequest, HttpResponse, Model, Workspace } from '../lib/models';
import { modelsEq } from '../lib/models';
@@ -39,10 +37,6 @@ export function GlobalHooks() {
setPathname(location.pathname).catch(console.error);
}, [location.pathname]);
useEffectOnce(() => {
trackPage('/');
});
useListenToTauriEvent<Model>('created_model', ({ payload, windowLabel }) => {
if (shouldIgnoreEvent(payload, windowLabel)) return;

View File

@@ -0,0 +1,10 @@
import { hotkeyActions } from '../hooks/useHotkey';
import { HotKeyList } from './core/HotKeyList';
export const KeyboardShortcutsDialog = () => {
return (
<div className="h-full w-full">
<HotKeyList hotkeys={hotkeyActions} />
</div>
);
};

View File

@@ -12,6 +12,7 @@ import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { VStack } from './core/Stacks';
import { useDialog } from './DialogContext';
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
export function SettingsDropdown() {
const importData = useImportData();
@@ -66,7 +67,18 @@ export function SettingsDropdown() {
onSelect: toggleAppearance,
leftSlot: <Icon icon={appearance === 'dark' ? 'sun' : 'moon'} />,
},
{ type: 'separator', label: `v${appVersion.data}` },
{
key: 'hotkeys',
label: 'Keyboard shortcuts',
onSelect: () =>
dialog.show({
title: 'Keyboard Shortcuts',
size: 'dynamic',
render: () => <KeyboardShortcutsDialog />,
}),
leftSlot: <Icon icon="keyboard" />,
},
{ type: 'separator', label: `Yaak v${appVersion.data}` },
{
key: 'update-mode',
label: updateMode === 'stable' ? 'Enable Beta' : 'Disable Beta',

View File

@@ -290,27 +290,13 @@ function getExtensions({
// Handle onChange
EditorView.updateListener.of((update) => {
if (onChange && update.docChanged && isViewUpdateFromUserInput(update)) {
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
];
}
function isViewUpdateFromUserInput(viewUpdate: ViewUpdate) {
// Make sure document has changed, ensuring user events like selections don't count.
if (viewUpdate.docChanged) {
// Check transactions for any that are direct user input, not changes from Y.js or another extension.
for (const transaction of viewUpdate.transactions) {
// Not using Transaction.isUserEvent because that only checks for a specific User event type ( "input", "delete", etc.). Checking the annotation directly allows for any type of user event.
const userEventType = transaction.annotation(Transaction.userEvent);
if (userEventType) return userEventType;
}
}
return false;
}
const syncGutterBg = ({
parent,
className = '',

View File

@@ -26,6 +26,7 @@ const icons = {
gear: I.GearIcon,
hamburger: I.HamburgerMenuIcon,
home: I.HomeIcon,
keyboard: I.KeyboardIcon,
listBullet: I.ListBulletIcon,
magicWand: I.MagicWandIcon,
magnifyingGlass: I.MagnifyingGlassIcon,

View File

@@ -13,8 +13,6 @@ export function useCreateEnvironment() {
const prompt = usePrompt();
const workspaceId = useActiveWorkspaceId();
const queryClient = useQueryClient();
const environments = useEnvironments();
const workspaces = useWorkspaces();
return useMutation<Environment, unknown, void>({
mutationFn: async () => {
@@ -26,7 +24,7 @@ export function useCreateEnvironment() {
});
return invoke('create_environment', { name, variables: [], workspaceId });
},
onSettled: () => trackEvent('environment', 'create'),
onSettled: () => trackEvent('Environment', 'Create'),
onSuccess: async (environment) => {
if (workspaceId == null) return;
routes.setEnvironment(environment);

View File

@@ -18,7 +18,7 @@ export function useCreateFolder() {
patch.sortPriority = patch.sortPriority || -Date.now();
return invoke('create_folder', { workspaceId, ...patch });
},
onSettled: () => trackEvent('folder', 'create'),
onSettled: () => trackEvent('Folder', 'Create'),
onSuccess: async (request) => {
await queryClient.invalidateQueries(foldersQueryKey({ workspaceId: request.workspaceId }));
},

View File

@@ -28,7 +28,7 @@ export function useCreateRequest() {
patch.folderId = patch.folderId || activeRequest?.folderId;
return invoke('create_request', { workspaceId, name: '', ...patch });
},
onSettled: () => trackEvent('http_request', 'create'),
onSettled: () => trackEvent('HttpRequest', 'Create'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -12,7 +12,7 @@ export function useCreateWorkspace({ navigateAfter }: { navigateAfter: boolean }
mutationFn: (patch) => {
return invoke('create_workspace', patch);
},
onSettled: () => trackEvent('workspace', 'create'),
onSettled: () => trackEvent('Workspace', 'Create'),
onSuccess: async (workspace) => {
queryClient.setQueryData<Workspace[]>(workspacesQueryKey({}), (workspaces) => [
...(workspaces ?? []),

View File

@@ -28,7 +28,7 @@ export function useDeleteAnyRequest() {
if (!confirmed) return null;
return invoke('delete_request', { requestId: id });
},
onSettled: () => trackEvent('http_request', 'delete'),
onSettled: () => trackEvent('HttpRequest', 'Delete'),
onSuccess: async (request) => {
// Was it cancelled?
if (request === null) return;

View File

@@ -24,7 +24,7 @@ export function useDeleteEnvironment(environment: Environment | null) {
if (!confirmed) return null;
return invoke('delete_environment', { environmentId: environment?.id });
},
onSettled: () => trackEvent('environment', 'delete'),
onSettled: () => trackEvent('Environment', 'Delete'),
onSuccess: async (environment) => {
if (environment === null) return;

View File

@@ -27,7 +27,7 @@ export function useDeleteFolder(id: string | null) {
if (!confirmed) return null;
return invoke('delete_folder', { folderId: id });
},
onSettled: () => trackEvent('folder', 'delete'),
onSettled: () => trackEvent('Folder', 'Delete'),
onSuccess: async (folder) => {
// Was it cancelled?
if (folder === null) return;

View File

@@ -10,7 +10,7 @@ export function useDeleteResponse(id: string | null) {
mutationFn: async () => {
return await invoke('delete_response', { id: id });
},
onSettled: () => trackEvent('http_response', 'delete'),
onSettled: () => trackEvent('HttpResponse', 'Delete'),
onSuccess: ({ requestId, id: responseId }) => {
queryClient.setQueryData<HttpResponse[]>(responsesQueryKey({ requestId }), (responses) =>
(responses ?? []).filter((response) => response.id !== responseId),

View File

@@ -10,7 +10,7 @@ export function useDeleteResponses(requestId?: string) {
if (requestId === undefined) return;
await invoke('delete_all_responses', { requestId });
},
onSettled: () => trackEvent('http_response', 'delete_many'),
onSettled: () => trackEvent('HttpResponse', 'DeleteMany'),
onSuccess: async () => {
if (requestId === undefined) return;
queryClient.setQueryData(responsesQueryKey({ requestId }), []);

View File

@@ -29,7 +29,7 @@ export function useDeleteWorkspace(workspace: Workspace | null) {
if (!confirmed) return null;
return invoke('delete_workspace', { workspaceId: workspace?.id });
},
onSettled: () => trackEvent('workspace', 'delete'),
onSettled: () => trackEvent('Workspace', 'Delete'),
onSuccess: async (workspace) => {
if (workspace === null) return;

View File

@@ -23,7 +23,7 @@ export function useDuplicateRequest({
if (id === null) throw new Error("Can't duplicate a null request");
return invoke('duplicate_request', { id });
},
onSettled: () => trackEvent('http_request', 'duplicate'),
onSettled: () => trackEvent('HttpRequest', 'Duplicate'),
onSuccess: async (request) => {
queryClient.setQueryData<HttpRequest[]>(
requestsQueryKey({ workspaceId: request.workspaceId }),

View File

@@ -22,6 +22,8 @@ const hotkeys: Record<HotkeyAction, string[]> = {
'environmentEditor.toggle': ['CmdCtrl+e'],
};
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
interface Options {
enable?: boolean;
}

View File

@@ -10,7 +10,7 @@ export function useSendAnyRequest() {
const alert = useAlert();
return useMutation<HttpResponse, string, string | null>({
mutationFn: (id) => invoke('send_request', { requestId: id, environmentId }),
onSettled: () => trackEvent('http_request', 'send'),
onSettled: () => trackEvent('HttpRequest', 'Send'),
onError: (err) => alert({ title: 'Export Failed', body: err }),
});
}

View File

@@ -10,6 +10,5 @@ export function useSendManyRequests() {
sendAnyRequest.mutate(id);
}
},
onSettled: () => trackEvent('http_request', 'send'),
});
}

View File

@@ -1,58 +1,20 @@
import { getVersion } from '@tauri-apps/api/app';
import type { Environment, Folder, HttpRequest, HttpResponse, KeyValue, Workspace } from './models';
const appVersion = await getVersion();
import { invoke } from '@tauri-apps/api';
export function trackEvent(
resource:
| Workspace['model']
| Environment['model']
| Folder['model']
| HttpRequest['model']
| HttpResponse['model']
| KeyValue['model'],
event: 'create' | 'update' | 'delete' | 'delete_many' | 'send' | 'duplicate',
| 'App'
| 'Workspace'
| 'Environment'
| 'Folder'
| 'HttpRequest'
| 'HttpResponse'
| 'KeyValue',
action: 'Launch' | 'Create' | 'Update' | 'Delete' | 'DeleteMany' | 'Send' | 'Duplicate',
attributes: Record<string, string | number> = {},
) {
send('/e', [
{ name: 'e', value: `${resource}.${event}` },
{ name: 'a', value: JSON.stringify({ ...attributes, version: appVersion }) },
]);
}
export function trackPage(pathname: string) {
if (pathname === sessionStorage.lastPathName) {
return;
}
sessionStorage.lastPathName = pathname;
send('/p', [
{
name: 'h',
value: 'desktop.yaak.app',
},
{ name: 'p', value: pathname },
]);
}
function send(path: string, params: { name: string; value: string | number }[]) {
if (localStorage.disableAnalytics === 'true') {
console.log('Analytics disabled', path, params);
}
params.push({ name: 'id', value: 'site_zOK0d7jeBy2TLxFCnZ' });
params.push({
name: 'tz',
value: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
params.push({ name: 'xy', value: screensize() });
const qs = params.map((v) => `${v.name}=${encodeURIComponent(v.value)}`).join('&');
const url = `https://t.yaak.app/t${path}?${qs}`;
fetch(url, { mode: 'no-cors' }).catch((err) => console.log('Error:', err));
}
function screensize() {
const w = window.screen.width;
const h = window.screen.height;
return `${Math.round(w / 100) * 100}x${Math.round(h / 100) * 100}`;
invoke('track_event', {
resource: resource,
action,
attributes,
}).catch(console.error);
}