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