From 30a24c8ae6f720c46fcfa6b8871d48ddc2457c4e Mon Sep 17 00:00:00 2001 From: Frank Noirot Date: Tue, 8 Oct 2024 12:32:47 -0400 Subject: [PATCH] Add menu item to share link to file --- src/components/ProjectSidebarMenu.tsx | 28 +++++++++++++++++-- src/lib/base64.test.ts | 40 +++++++++++++++++++++++++++ src/lib/base64.ts | 29 +++++++++++++++++++ src/lib/constants.ts | 4 +++ src/lib/createFileLink.test.ts | 17 ++++++++++++ src/lib/createFileLink.ts | 24 ++++++++++++++++ 6 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 src/lib/base64.test.ts create mode 100644 src/lib/base64.ts create mode 100644 src/lib/createFileLink.test.ts create mode 100644 src/lib/createFileLink.ts diff --git a/src/components/ProjectSidebarMenu.tsx b/src/components/ProjectSidebarMenu.tsx index 986da7890..5735743a5 100644 --- a/src/components/ProjectSidebarMenu.tsx +++ b/src/components/ProjectSidebarMenu.tsx @@ -10,11 +10,14 @@ import { APP_NAME } from 'lib/constants' import { useCommandsContext } from 'hooks/useCommandsContext' import { CustomIcon } from './CustomIcon' import { useLspContext } from './LspProvider' -import { engineCommandManager } from 'lib/singletons' +import { codeManager, engineCommandManager } from 'lib/singletons' import { machineManager } from 'lib/machineManager' import usePlatform from 'hooks/usePlatform' import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath' import Tooltip from './Tooltip' +import { createFileLink } from 'lib/createFileLink' +import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' +import toast from 'react-hot-toast' const ProjectSidebarMenu = ({ project, @@ -96,6 +99,7 @@ function ProjectMenuPopover({ const location = useLocation() const navigate = useNavigate() const filePath = useAbsoluteFilePath() + const { settings } = useSettingsAuthContext() const { commandBarState, commandBarSend } = useCommandsContext() const { onProjectClose } = useLspContext() const exportCommandInfo = { name: 'Export', groupId: 'modeling' } @@ -154,7 +158,6 @@ function ProjectMenuPopover({ data: exportCommandInfo, }), }, - 'break', { id: 'make', Element: 'button', @@ -180,6 +183,27 @@ function ProjectMenuPopover({ }) }, }, + { + id: 'share-link', + Element: 'button', + className: !isDesktop() ? 'hidden' : '', + children: 'Share link to file', + onClick: async () => { + const shareUrl = createFileLink({ + code: codeManager.code, + name: file?.name || '', + units: settings.context.modeling.defaultUnit.current, + }) + + await globalThis.navigator.clipboard.writeText(shareUrl) + toast.success( + 'Link copied to clipboard. Anyone who clicks this link will get a copy of this file. Share carefully!', + { + duration: 5000, + } + ) + }, + }, 'break', { id: 'go-home', diff --git a/src/lib/base64.test.ts b/src/lib/base64.test.ts new file mode 100644 index 000000000..edb7db9d2 --- /dev/null +++ b/src/lib/base64.test.ts @@ -0,0 +1,40 @@ +import { expect } from 'vitest' +import { base64ToString, stringToBase64 } from './base64' + +describe('base64 encoding', () => { + test('to base64, simple code', async () => { + const code = `extrusionDistance = 12` + // Generated by online tool + const expectedBase64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==` + + const base64 = stringToBase64(code) + expect(base64).toBe(expectedBase64) + }) + + test(`to base64, code with UTF-8 characters`, async () => { + // example adapted from MDN docs: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + const code = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;` + // Generated by online tool + const expectedBase64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7` + + const base64 = stringToBase64(code) + expect(base64).toBe(expectedBase64) + }) + + // The following are simply the reverse of the above tests + test('from base64, simple code', async () => { + const base64 = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg==` + const expectedCode = `extrusionDistance = 12` + + const code = base64ToString(base64) + expect(code).toBe(expectedCode) + }) + + test(`from base64, code with UTF-8 characters`, async () => { + const base64 = `YSA9IDU7IMSAID0gMTIuaW4oKTsg8JCAgCA9IMSAOyDmlocgPSAxMi4wOyDwn6aEID0gLTU7` + const expectedCode = `a = 5; Ā = 12.in(); 𐀀 = Ā; 文 = 12.0; 🦄 = -5;` + + const code = base64ToString(base64) + expect(code).toBe(expectedCode) + }) +}) diff --git a/src/lib/base64.ts b/src/lib/base64.ts new file mode 100644 index 000000000..11b1962be --- /dev/null +++ b/src/lib/base64.ts @@ -0,0 +1,29 @@ +/** + * Converts a string to a base64 string, preserving the UTF-8 encoding + */ +export function stringToBase64(str: string) { + return bytesToBase64(new TextEncoder().encode(str)) +} + +/** + * Converts a base64 string to a string, preserving the UTF-8 encoding + */ +export function base64ToString(base64: string) { + return new TextDecoder().decode(base64ToBytes(base64)) +} + +/** + * From the MDN Web Docs + * https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + */ +function base64ToBytes(base64: string) { + const binString = atob(base64) + return Uint8Array.from(binString, (m) => m.codePointAt(0)!) +} + +function bytesToBase64(bytes: Uint8Array) { + const binString = Array.from(bytes, (byte) => + String.fromCodePoint(byte) + ).join('') + return btoa(binString) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 4c906dc9d..39134b6ec 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -65,6 +65,7 @@ export const KCL_DEFAULT_DEGREE = `360` export const TEST_SETTINGS_FILE_KEY = 'playwright-test-settings' export const DEFAULT_HOST = 'https://api.zoo.dev' +export const PROD_APP_URL = 'https://app.zoo.dev' export const SETTINGS_FILE_NAME = 'settings.toml' export const TOKEN_FILE_NAME = 'token.txt' export const PROJECT_SETTINGS_FILE_NAME = 'project.toml' @@ -102,3 +103,6 @@ export const KCL_SAMPLES_MANIFEST_URLS = { 'https://raw.githubusercontent.com/KittyCAD/kcl-samples/main/manifest.json', localFallback: '/kcl-samples-manifest-fallback.json', } as const + +/** URL parameter to create a file */ +export const CREATE_FILE_URL_PARAM = 'create-file' diff --git a/src/lib/createFileLink.test.ts b/src/lib/createFileLink.test.ts new file mode 100644 index 000000000..18a740291 --- /dev/null +++ b/src/lib/createFileLink.test.ts @@ -0,0 +1,17 @@ +import { CREATE_FILE_URL_PARAM, PROD_APP_URL } from './constants' +import { createFileLink } from './createFileLink' + +describe(`createFileLink`, () => { + test(`with simple code`, async () => { + const code = `extrusionDistance = 12` + const name = `test` + const units = `mm` + + // Converted with external online tools + const expectedEncodedCode = `ZXh0cnVzaW9uRGlzdGFuY2UgPSAxMg%3D%3D` + const expectedLink = `${PROD_APP_URL}/?${CREATE_FILE_URL_PARAM}&name=test&units=mm&code=${expectedEncodedCode}` + + const result = createFileLink({ code, name, units }) + expect(result).toBe(expectedLink) + }) +}) diff --git a/src/lib/createFileLink.ts b/src/lib/createFileLink.ts new file mode 100644 index 000000000..4d5668917 --- /dev/null +++ b/src/lib/createFileLink.ts @@ -0,0 +1,24 @@ +import { UnitLength_type } from '@kittycad/lib/dist/types/src/models' +import { CREATE_FILE_URL_PARAM, PROD_APP_URL } from './constants' +import { stringToBase64 } from './base64' + +/** + * Given a file's code, name, and units, creates shareable link + * TODO: make the app respect this link + */ +export function createFileLink({ + code, + name, + units, +}: { + code: string + name: string + units: UnitLength_type +}) { + return new URL( + `/?${CREATE_FILE_URL_PARAM}&name=${encodeURIComponent( + name + )}&units=${units}&code=${encodeURIComponent(stringToBase64(code))}`, + PROD_APP_URL + ).href +}