diff --git a/theatre/studio/src/Studio.ts b/theatre/studio/src/Studio.ts index 639b434..ee9e164 100644 --- a/theatre/studio/src/Studio.ts +++ b/theatre/studio/src/Studio.ts @@ -21,6 +21,7 @@ import type {OnDiskState} from '@theatre/core/projects/store/storeTypes' import type {Deferred} from '@theatre/shared/utils/defer' import {defer} from '@theatre/shared/utils/defer' import type {ProjectId} from '@theatre/shared/utils/ids' +import checkForUpdates from './checkForUpdates' export type CoreExports = typeof _coreExports @@ -96,6 +97,7 @@ export class Studio { if (process.env.NODE_ENV !== 'test') { this.ui.render() + checkForUpdates() } } diff --git a/theatre/studio/src/checkForUpdates.ts b/theatre/studio/src/checkForUpdates.ts new file mode 100644 index 0000000..1e488dd --- /dev/null +++ b/theatre/studio/src/checkForUpdates.ts @@ -0,0 +1,71 @@ +import {val} from '@theatre/dataverse' +import type {$IntentionalAny} from '@theatre/shared/utils/types' +import getStudio from './getStudio' +import type {UpdateCheckerResponse} from './store/types' + +const UPDATE_CHECK_INTERVAL = 30 * 60 * 1000 // check for updates every 30 minutes +const TIME_TO_WAIT_ON_ERROR = 1000 * 60 * 60 // an hour + +export default async function checkForUpdates() { + while (true) { + const state = val(getStudio().atomP.ahistoric.updateChecker) + if (state) { + if (state.result !== 'error') { + const lastChecked = state.lastChecked + const now = Date.now() + const timeElapsedSinceLastCheckedForUpdate = Math.abs(now - lastChecked) + + // doing Math.max in case the clock has shifted + if (timeElapsedSinceLastCheckedForUpdate < UPDATE_CHECK_INTERVAL) { + await wait( + UPDATE_CHECK_INTERVAL - timeElapsedSinceLastCheckedForUpdate, + ) + } + } + } + try { + const response = await fetch( + new Request( + `https://updates.theatrejs.com/updates/${process.env.version}`, + ), + ) + if (response.ok) { + const json = await response.json() + if (!isValidUpdateCheckerResponse(json)) { + throw new Error(`Bad response`) + } + getStudio().transaction(({drafts}) => { + drafts.ahistoric.updateChecker = { + lastChecked: Date.now(), + result: {...json}, + } + }) + + await wait(1000) + } else { + throw new Error(`HTTP Error ${response.statusText}`) + } + } catch (error) { + // TODO log an error here + + await wait(TIME_TO_WAIT_ON_ERROR) + } + } +} + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +function isValidUpdateCheckerResponse( + json: unknown, +): json is UpdateCheckerResponse { + if (typeof json !== 'object') return false + const obj = json as $IntentionalAny + if (typeof obj['hasUpdates'] !== 'boolean') return false + // could use a runtime type checker but not important yet + return ( + (obj.hasUpdates === true && + typeof obj.newVersion === 'string' && + typeof obj.releasePage === 'string') || + obj.hasUpdates === false + ) +} diff --git a/theatre/studio/src/store/types/ahistoric.ts b/theatre/studio/src/store/types/ahistoric.ts index bd2a010..4f8b6f7 100644 --- a/theatre/studio/src/store/types/ahistoric.ts +++ b/theatre/studio/src/store/types/ahistoric.ts @@ -5,6 +5,10 @@ import type {IRange, StrictRecord} from '@theatre/shared/utils/types' import type {PointableSet} from '@theatre/shared/utils/PointableSet' import type {StudioSheetItemKey} from '@theatre/shared/utils/ids' +export type UpdateCheckerResponse = + | {hasUpdates: true; newVersion: string; releasePage: string} + | {hasUpdates: false} + export type StudioAhistoricState = { /** * undefined means the outline menu is pinned @@ -26,6 +30,11 @@ export type StudioAhistoricState = { distanceFromVerticalEdge: number } } + updateChecker?: { + // timestamp of the last time we checked for updates + lastChecked: number + result: UpdateCheckerResponse | 'error' + } projects: { stateByProjectId: StrictRecord< ProjectId, diff --git a/theatre/studio/src/toolbars/GlobalToolbar.tsx b/theatre/studio/src/toolbars/GlobalToolbar.tsx index 2185bbb..ead95f3 100644 --- a/theatre/studio/src/toolbars/GlobalToolbar.tsx +++ b/theatre/studio/src/toolbars/GlobalToolbar.tsx @@ -1,6 +1,6 @@ import {usePrism, useVal} from '@theatre/react' import getStudio from '@theatre/studio/getStudio' -import React from 'react' +import React, {useRef} from 'react' import styled from 'styled-components' import type {$IntentionalAny} from '@theatre/dataverse/dist/types' import useTooltip from '@theatre/studio/uiComponents/Popover/useTooltip' @@ -13,9 +13,13 @@ import { ChevronLeft, ChevronRight, Details, + Ellipsis, Outline, } from '@theatre/studio/uiComponents/icons' import {shouldShowDetailD} from '@theatre/studio/panels/DetailPanel/DetailPanel' +import ToolbarIconButton from '@theatre/studio/uiComponents/toolbar/ToolbarIconButton' +import usePopover from '@theatre/studio/uiComponents/Popover/usePopover' +import MoreMenu from './MoreMenu/MoreMenu' const Container = styled.div` height: 36px; @@ -48,6 +52,16 @@ const SubContainer = styled.div` gap: 8px; ` +const HasUpdatesBadge = styled.div` + position: absolute; + background: #40aaa4; + width: 6px; + height: 6px; + border-radius: 50%; + right: -2px; + top: -2px; +` + const GlobalToolbar: React.FC = () => { const conflicts = usePrism(() => { const ephemeralStateOfAllProjects = val( @@ -78,6 +92,27 @@ const GlobalToolbar: React.FC = () => { const detailsPinned = useVal(getStudio().atomP.ahistoric.pinDetails) const showOutline = useVal(getStudio().atomP.ephemeral.showOutline) const showDetails = useVal(shouldShowDetailD) + const hasUpdates = + useVal(getStudio().atomP.ahistoric.updateChecker.result.hasUpdates) === true + + const [moreMenu, openMoreMenu] = usePopover( + () => { + const triggerBounds = moreMenuTriggerRef.current!.getBoundingClientRect() + return { + debugName: 'More Menu', + + constraints: { + maxX: triggerBounds.right, + maxY: 8, + }, + verticalGap: 2, + } + }, + () => { + return + }, + ) + const moreMenuTriggerRef = useRef(null) return ( @@ -107,6 +142,17 @@ const GlobalToolbar: React.FC = () => { + {moreMenu} + { + openMoreMenu(e, moreMenuTriggerRef.current!) + }} + > + + {hasUpdates && } + + { diff --git a/theatre/studio/src/toolbars/MoreMenu/MoreMenu.tsx b/theatre/studio/src/toolbars/MoreMenu/MoreMenu.tsx new file mode 100644 index 0000000..7dd5633 --- /dev/null +++ b/theatre/studio/src/toolbars/MoreMenu/MoreMenu.tsx @@ -0,0 +1,203 @@ +import {useVal} from '@theatre/react' +import type {$IntentionalAny} from '@theatre/shared/utils/types' +import getStudio from '@theatre/studio/getStudio' +import React from 'react' +import styled from 'styled-components' + +const Container = styled.div` + width: 138px; + border-radius: 2px; + background-color: rgba(42, 45, 50, 0.9); + position: absolute; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.25), 0px 2px 6px rgba(0, 0, 0, 0.15); + backdrop-filter: blur(14px); + pointer-events: auto; + // makes the edges of the item highlights match the rounded corners + overflow: hidden; + + @supports not (backdrop-filter: blur()) { + background-color: rgba(42, 45, 50, 0.98); + } +` + +const Item = styled.div` + position: relative; + padding: 0px 12px; + font-weight: 400; + font-size: 11px; + height: 32px; + text-decoration: none; + user-select: none; + display: flex; + flex-direction: row; + align-items: center; + cursor: default; +` + +const Link = styled(Item)` + &:before { + position: absolute; + display: block; + content: ' '; + inset: 3px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.1); + opacity: 0; + } + + &.secondary { + color: rgba(255, 255, 255, 0.5); + } + + &:hover { + /* background-color: #398995; */ + color: white !important; + &:before { + opacity: 1; + } + } +` + +const VersionContainer = styled(Item)` + padding-top: 12px; + padding-bottom: 10px; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 8px; + color: rgba(255, 255, 255, 0.5); +` + +const VersionLabel = styled.div` + font-weight: 600; +` + +const VersionValueRow = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; +` + +const VersionValue = styled.div` + margin-left: 2px; +` + +const Divider = styled.div` + height: 1px; + margin: 0 2px; + background: rgba(255, 255, 255, 0.02); +` + +const UpdateDot = styled.div` + position: absolute; + width: 8px; + height: 8px; + background: #40aaa4; + right: 14px; + top: 12px; + border-radius: 50%; +` + +const version: string = process.env.version ?? '0.4.0' + +const untaggedVersion: string = version.match(/^[^\-]+/)![0] + +const MoreMenu = React.forwardRef((props: {}, ref) => { + const hasUpdates = useVal( + getStudio().atomP.ahistoric.updateChecker.result.hasUpdates, + ) + + return ( + + + Docs + + + + Changelog + + + + + Github + + + Twitter + + + Discord + + + + Version + + + {version}{' '} + {hasUpdates === true + ? '(outdated)' + : hasUpdates === false + ? '(latest)' + : ''} + + + + {hasUpdates === true && ( + <> + + + Update + + + + What's new? + + + )} + + ) +}) + +export default MoreMenu diff --git a/theatre/studio/src/toolbars/PinButton.tsx b/theatre/studio/src/toolbars/PinButton.tsx index a73fc02..373cb21 100644 --- a/theatre/studio/src/toolbars/PinButton.tsx +++ b/theatre/studio/src/toolbars/PinButton.tsx @@ -1,7 +1,7 @@ import styled from 'styled-components' import {pointerEventsAutoInNormalMode} from '@theatre/studio/css' -import type {ComponentPropsWithRef, ReactNode} from 'react'; -import React, { forwardRef} from 'react' +import type {ComponentPropsWithRef, ReactNode} from 'react' +import React, {forwardRef} from 'react' const Container = styled.button<{pinned?: boolean}>` ${pointerEventsAutoInNormalMode}; diff --git a/theatre/studio/src/uiComponents/Popover/usePopover.tsx b/theatre/studio/src/uiComponents/Popover/usePopover.tsx index b016082..d400a0d 100644 --- a/theatre/studio/src/uiComponents/Popover/usePopover.tsx +++ b/theatre/studio/src/uiComponents/Popover/usePopover.tsx @@ -44,6 +44,7 @@ type Opts = { pointerDistanceThreshold?: number closeOnClickOutside?: boolean constraints?: AbsolutePlacementBoxConstraints + verticalGap?: number } export default function usePopover( @@ -123,6 +124,7 @@ export default function usePopover( onClickOutside={state.onClickOutside} onPointerOutside={state.onPointerOutside} constraints={state.opts.constraints} + verticalGap={state.opts.verticalGap} /> , portalLayer!, diff --git a/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx b/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx index eb8ad56..ee6d747 100644 --- a/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx +++ b/theatre/studio/src/uiComponents/toolbar/ToolbarIconButton.tsx @@ -7,7 +7,7 @@ import mergeRefs from 'react-merge-refs' import MinimalTooltip from '@theatre/studio/uiComponents/Popover/MinimalTooltip' import ToolbarSwitchSelectContainer from './ToolbarSwitchSelectContainer' -const Container = styled.button` +export const Container = styled.button` ${pointerEventsAutoInNormalMode}; position: relative; display: flex; diff --git a/theatre/studio/src/refreshEvery.tsx b/theatre/studio/src/uiComponents/useDebugRefreshEvery.tsx similarity index 100% rename from theatre/studio/src/refreshEvery.tsx rename to theatre/studio/src/uiComponents/useDebugRefreshEvery.tsx