Organization and Pro tier link sharing features exposure (#6986)

* Improve url sharing for orgs and pros

* Remove sharing via menu item

* fmt

* Not sure what's different but it is

* fmt & lint

* whoops

* Update snapshots

* Typos from codespell

* Fix alignment

* Update snapshots

* Prune

---------

Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com>
Co-authored-by: Pierre Jacquier <pierre@zoo.dev>
This commit is contained in:
Zookeeper Lee
2025-05-16 05:51:08 -04:00
committed by GitHub
parent 17eb84325f
commit 48a4fd8373
36 changed files with 259 additions and 117 deletions

View File

@ -15,15 +15,9 @@ import { useAbsoluteFilePath } from '@src/hooks/useAbsoluteFilePath'
import usePlatform from '@src/hooks/usePlatform'
import { APP_NAME } from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
import { copyFileShareLink } from '@src/lib/links'
import { PATHS } from '@src/lib/paths'
import {
codeManager,
engineCommandManager,
kclManager,
} from '@src/lib/singletons'
import { engineCommandManager, kclManager } from '@src/lib/singletons'
import { type IndexLoaderData } from '@src/lib/types'
import { useToken } from '@src/lib/singletons'
import { commandBarActor } from '@src/lib/singletons'
const ProjectSidebarMenu = ({
@ -108,7 +102,6 @@ function ProjectMenuPopover({
const location = useLocation()
const navigate = useNavigate()
const filePath = useAbsoluteFilePath()
const token = useToken()
const machineManager = useContext(MachineManagerContext)
const commands = useSelector(commandBarActor, commandsSelector)
@ -116,7 +109,6 @@ function ProjectMenuPopover({
const insertCommandInfo = { name: 'Insert', groupId: 'code' }
const exportCommandInfo = { name: 'Export', groupId: 'modeling' }
const makeCommandInfo = { name: 'Make', groupId: 'modeling' }
const shareCommandInfo = { name: 'share-file-link', groupId: 'code' }
const findCommand = (obj: { name: string; groupId: string }) =>
Boolean(
commands.find((c) => c.name === obj.name && c.groupId === obj.groupId)
@ -217,19 +209,6 @@ function ProjectMenuPopover({
})
},
},
{
id: 'share-link',
Element: 'button',
children: 'Share part via Zoo link',
disabled: !findCommand(shareCommandInfo),
onClick: async () => {
await copyFileShareLink({
token: token ?? '',
code: codeManager.code,
name: project?.name || '',
})
},
},
'break',
{
id: 'go-home',

View File

@ -1,53 +1,168 @@
import { err } from '@src/lib/trap'
import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip'
import usePlatform from '@src/hooks/usePlatform'
import { hotkeyDisplay } from '@src/lib/hotkeyWrapper'
import { commandBarActor } from '@src/lib/singletons'
import { billingActor, commandBarActor } from '@src/lib/singletons'
import { useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { Popover } from '@headlessui/react'
import { useSelector } from '@xstate/react'
import { useKclContext } from '@src/lang/KclProvider'
import { Tier } from '@src/machines/billingMachine'
import type { SubscriptionsOrError } from '@src/machines/billingMachine'
const shareHotkey = 'mod+alt+s'
const onShareClick = () =>
commandBarActor.send({
type: 'Find and select command',
data: { name: 'share-file-link', groupId: 'code' },
})
const canPasswordProtectShareLinks = (
subOrErr: undefined | SubscriptionsOrError
): boolean => {
if (subOrErr === undefined || typeof subOrErr === 'number' || err(subOrErr))
return false
return subOrErr.modeling_app.share_links[0] === 'password_protected'
}
/** Share Zoo link button shown in the upper-right of the modeling view */
export const ShareButton = () => {
const platform = usePlatform()
useHotkeys(shareHotkey, onShareClick, {
const [showOptions, setShowOptions] = useState(false)
const [isRestrictedToOrg, setIsRestrictedToOrg] = useState(false)
const [password, setPassword] = useState('')
const billingContext = useSelector(billingActor, ({ context }) => context)
const allowOrgRestrict = billingContext.tier === Tier.Organization
const allowPassword = canPasswordProtectShareLinks(
billingContext.subscriptionsOrError
)
const hasOptions = allowOrgRestrict || allowPassword
// Prevents Organization and Pro tier users from one-click sharing,
// and give them a chance to set a password and restrict to org.
const onShareClickFreeOrUnknownRestricted = () => {
if (hasOptions) {
setShowOptions(true)
return
}
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'share-file-link',
groupId: 'code',
isRestrictedToOrg: false,
},
})
}
const onShareClickProOrOrganization = () => {
setShowOptions(false)
commandBarActor.send({
type: 'Find and select command',
data: {
name: 'share-file-link',
groupId: 'code',
isRestrictedToOrg,
password,
},
})
}
useHotkeys(shareHotkey, onShareClickFreeOrUnknownRestricted, {
scopes: ['modeling'],
})
const kclContext = useKclContext()
const disabled = kclContext.ast.body.some((n) => n.type === 'ImportStatement')
// It doesn't make sense for the user to be able to click on this
// until we get what their subscription allows for.
const disabled =
kclContext.ast.body.some((n) => n.type === 'ImportStatement') ||
billingContext.tier === undefined
return (
<button
type="button"
onClick={onShareClick}
disabled={disabled}
className="flex gap-1 items-center py-0 pl-0.5 pr-1.5 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 border border-solid active:border-primary"
data-testid="share-button"
>
<CustomIcon name="link" className="w-5 h-5" />
<span className="flex-1">Share</span>
<Tooltip
position="bottom-right"
contentClassName="max-w-none flex items-center gap-4"
>
<span className="flex-1">
{disabled
? `Share links are not currently supported for multi-file assemblies`
: `Share part via Zoo link`}
</span>
{!disabled && (
<kbd className="hotkey text-xs capitalize">
{hotkeyDisplay(shareHotkey, platform)}
</kbd>
)}
</Tooltip>
</button>
<Popover className="relative flex">
<Popover.Button className="relative group border-0 w-fit min-w-max p-0 rounded-l-full focus-visible:outline-appForeground">
<button
type="button"
onClick={onShareClickFreeOrUnknownRestricted}
disabled={disabled}
className="flex gap-1 items-center py-0 pl-0.5 pr-1.5 m-0 bg-chalkboard-10/80 dark:bg-chalkboard-100/50 hover:bg-chalkboard-10 dark:hover:bg-chalkboard-100 border border-solid active:border-primary"
data-testid="share-button"
>
<CustomIcon name="link" className="w-5 h-5" />
<span className="flex-1">Share</span>
<Tooltip
position="bottom-right"
contentClassName="max-w-none flex items-center gap-4"
>
<span className="flex-1">
{disabled
? `Share links are not currently supported for multi-file assemblies`
: `Share part via Zoo link`}
</span>
{!disabled && (
<kbd className="hotkey text-xs capitalize">
{hotkeyDisplay(shareHotkey, platform)}
</kbd>
)}
</Tooltip>
</button>
</Popover.Button>
{showOptions && (
<Popover.Panel
focus={true}
className={`z-10 absolute top-full right-0 mt-1 pb-1 w-48 bg-chalkboard-10 dark:bg-chalkboard-90
border border-solid border-chalkboard-20 dark:border-chalkboard-90 rounded
shadow-lg`}
>
<div className="flex flex-col px-2">
<div className="flex flex-row gap-1 items-center">
<CustomIcon name="lockClosed" className="w-6 h-6" />
<input
disabled={!allowPassword}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
className={`${allowPassword ? 'cursor-pointer' : 'cursor-not-allowed'} text-xs w-full py-1 bg-transparent text-chalkboard-100 placeholder:text-chalkboard-70 dark:text-chalkboard-10 dark:placeholder:text-chalkboard-50 focus:outline-none focus:ring-0`}
type="text"
placeholder="Set a password"
/>
</div>
<div>
<label
htmlFor="org-only"
className="pl-1 inline-flex items-center"
>
<input
disabled={!allowOrgRestrict}
checked={isRestrictedToOrg}
onChange={(_) => setIsRestrictedToOrg(!isRestrictedToOrg)}
type="checkbox"
name="org-only"
className="form-checkbox"
/>
<span
className={`text-xs ml-2 ${allowOrgRestrict ? 'cursor-pointer' : 'cursor-not-allowed text-chalkboard-50'}`}
>
Org. only access
</span>
</label>
{!allowOrgRestrict && (
<Tooltip>Upgrade to Organization to use this feature.</Tooltip>
)}
</div>
<button disabled={disabled} onClick={onShareClickProOrOrganization}>
Generate
</button>
</div>
</Popover.Panel>
)}
</Popover>
)
}