diff --git a/console/src/cluster/external.ts b/console/src/cluster/external.ts index eff91cfbce..3f59f8ac44 100644 --- a/console/src/cluster/external.ts +++ b/console/src/cluster/external.ts @@ -10,7 +10,7 @@ import { Connect, connectWindowLayout } from "@/cluster/Connect"; import { versionOutdatedAdapter } from "@/cluster/notification"; import { Layout } from "@/layout"; -import { NotificationAdapter } from "@/notifications/Notifications"; +import { type NotificationAdapter } from "@/notifications/Notifications"; export * from "@/cluster/Badges"; export * from "@/cluster/Connect"; diff --git a/console/src/cluster/notification.tsx b/console/src/cluster/notification.tsx index 54dd147f6b..23311326a1 100644 --- a/console/src/cluster/notification.tsx +++ b/console/src/cluster/notification.tsx @@ -10,10 +10,10 @@ import { Button, Synnax } from "@synnaxlabs/pluto"; import { - NotificationAdapter, + type NotificationAdapter, SugaredNotification, } from "@/notifications/Notifications"; -import { OpenUpdateDialogAction } from "@/version/Updater"; +import { Version } from "@/version"; export const versionOutdatedAdapter: NotificationAdapter = (status) => { if (status.data == null) return null; @@ -32,6 +32,6 @@ export const versionOutdatedAdapter: NotificationAdapter = (status) => { Update Cluster , ]; - else nextStatus.actions = []; + else nextStatus.actions = []; return nextStatus; }; diff --git a/console/src/hardware/device/external.ts b/console/src/hardware/device/external.ts index 7beb69d2a3..0a041fa4ac 100644 --- a/console/src/hardware/device/external.ts +++ b/console/src/hardware/device/external.ts @@ -7,9 +7,9 @@ // License, use of this software will be governed by the Apache License, Version 2.0, // included in the file licenses/APL.txt. -import { Version } from "@/version"; +import { notificationAdapter } from "@/hardware/device/useListenForChanges"; export * from "@/hardware/device/ontology"; export * from "@/hardware/device/useListenForChanges"; -export const NOTIFICATION_ADAPTERS = [Version.notificationAdapter]; +export const NOTIFICATION_ADAPTERS = [notificationAdapter]; diff --git a/console/src/store.ts b/console/src/store.ts index 0f2ca96d42..dae8892d45 100644 --- a/console/src/store.ts +++ b/console/src/store.ts @@ -76,8 +76,8 @@ export type RootAction = | Cluster.Action | LinePlot.Action | Schematic.Action - | Range.Action | Permissions.Action + | Version.Action | Workspace.Action; export type RootStore = Store; diff --git a/console/src/version/Badge.tsx b/console/src/version/Badge.tsx index cd86bcbf51..d8021238b9 100644 --- a/console/src/version/Badge.tsx +++ b/console/src/version/Badge.tsx @@ -14,11 +14,11 @@ import { type ReactElement } from "react"; import { Layout } from "@/layout"; import { infoLayout } from "@/version/Info"; -import { useSelect } from "@/version/selectors"; +import { useSelectVersion } from "@/version/selectors"; import { useCheckForUpdates } from "@/version/Updater"; export const Badge = (): ReactElement => { - const v = useSelect(); + const version = useSelectVersion(); const placer = Layout.usePlacer(); const updateAvailable = useCheckForUpdates(); return ( @@ -28,7 +28,7 @@ export const Badge = (): ReactElement => { size="medium" color={updateAvailable ? "var(--pluto-secondary-z)" : "var(--pluto-text-color)"} > - {"v" + v} + {"v" + version} ); }; diff --git a/console/src/version/Info.tsx b/console/src/version/Info.tsx index 3939d36ab0..8037563e77 100644 --- a/console/src/version/Info.tsx +++ b/console/src/version/Info.tsx @@ -16,7 +16,7 @@ import { check, DownloadEvent } from "@tauri-apps/plugin-updater"; import { useState } from "react"; import { Layout } from "@/layout"; -import { useSelect } from "@/version/selectors"; +import { useSelectVersion } from "@/version/selectors"; export const infoLayout: Layout.State = { type: "versionInfo", @@ -32,7 +32,7 @@ export const infoLayout: Layout.State = { }; export const Info: Layout.Renderer = () => { - const version = useSelect(); + const version = useSelectVersion(); const updateQuery = useQuery({ queryKey: ["version.update"], queryFn: async () => { diff --git a/console/src/version/Updater.tsx b/console/src/version/Updater.tsx index 083dd08463..7711621705 100644 --- a/console/src/version/Updater.tsx +++ b/console/src/version/Updater.tsx @@ -7,36 +7,44 @@ // License, use of this software will be governed by the Apache License, Version 2.0, // included in the file licenses/APL.txt. +import { Icon } from "@synnaxlabs/media"; import { Button, Status, useAsyncEffect } from "@synnaxlabs/pluto"; import { id, TimeSpan } from "@synnaxlabs/x"; import { check } from "@tauri-apps/plugin-updater"; import { useState } from "react"; +import { useDispatch } from "react-redux"; import { Layout } from "@/layout"; -import { NotificationAdapter } from "@/notifications/Notifications"; +import { type NotificationAdapter } from "@/notifications/Notifications"; import { infoLayout } from "@/version/Info"; +import { useSelectUpdateNotificationsSilenced } from "@/version/selectors"; +import { silenceUpdateNotifications } from "@/version/slice"; export const useCheckForUpdates = (): boolean => { const addStatus = Status.useAggregator(); - + const isSilenced = useSelectUpdateNotificationsSilenced(); const [available, setAvailable] = useState(false); - const checkForUpdates = async () => { + const checkForUpdates = async (addNotifications: boolean) => { const update = await check(); if (update?.available !== true || available) return; setAvailable(true); - addStatus({ - key: `versionUpdate-${id.id()}`, - variant: "info", - message: `Update available`, - }); + if (addNotifications) + addStatus({ + key: `versionUpdate-${id.id()}`, + variant: "info", + message: `Update available`, + }); }; useAsyncEffect(async () => { - await checkForUpdates(); - const i = setInterval(checkForUpdates, TimeSpan.seconds(30).milliseconds); + await checkForUpdates(!isSilenced); + const i = setInterval( + () => checkForUpdates(!isSilenced), + TimeSpan.seconds(30).milliseconds, + ); return () => clearInterval(i); - }, []); + }, [isSilenced]); return available; }; @@ -45,15 +53,24 @@ export const notificationAdapter: NotificationAdapter = (status) => { if (!status.key.startsWith("versionUpdate")) return null; return { ...status, - actions: [], + actions: [, ], }; }; export const OpenUpdateDialogAction = () => { - const place = Layout.usePlacer(); + const placer = Layout.usePlacer(); return ( - place(infoLayout)}> + placer(infoLayout)}> Update ); }; + +const SilenceAction = () => { + const dispatch = useDispatch(); + return ( + dispatch(silenceUpdateNotifications())}> + + + ); +}; diff --git a/console/src/version/external.ts b/console/src/version/external.ts index 86646f041c..ad2ccfe22b 100644 --- a/console/src/version/external.ts +++ b/console/src/version/external.ts @@ -7,8 +7,8 @@ // License, use of this software will be governed by the Apache License, Version 2.0, // included in the file licenses/APL.txt. -import { Layout } from "@/layout"; -import { NotificationAdapter } from "@/notifications/Notifications"; +import { type Layout } from "@/layout"; +import { type NotificationAdapter } from "@/notifications/Notifications"; import { Info, infoLayout } from "@/version/Info"; import { notificationAdapter } from "@/version/Updater"; diff --git a/console/src/version/migrations/index.ts b/console/src/version/migrations/index.ts new file mode 100644 index 0000000000..ca0bfac2a1 --- /dev/null +++ b/console/src/version/migrations/index.ts @@ -0,0 +1,36 @@ +// Copyright 2024 Synnax Labs, Inc. +// +// Use of this software is governed by the Business Source License included in the file +// licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with the Business Source +// License, use of this software will be governed by the Apache License, Version 2.0, +// included in the file licenses/APL.txt. + +import { migrate } from "@synnaxlabs/x"; + +import * as v0 from "@/version/migrations/v0"; +import * as v1 from "@/version/migrations/v1"; + +export type SliceState = v1.SliceState; +export type AnySliceState = v0.SliceState | v1.SliceState; +export const ZERO_SLICE_STATE = v1.ZERO_SLICE_STATE; + +export const SLICE_MIGRATIONS: migrate.Migrations = {}; + +// Because the v0 state had a key called version, the usual migration pattern from X +// does not work. Instead, we need to check if the state is a v0 state, manually convert +// it to a v1 state, and then run the internal migrator. + +const internalMigrator = migrate.migrator({ + name: "version.slice", + migrations: SLICE_MIGRATIONS, + def: ZERO_SLICE_STATE, +}); + +const v0StrictSliceStateZ = v0.sliceStateZ.strict(); + +export const migrateSlice: (v: AnySliceState) => SliceState = (v) => { + const state = v0StrictSliceStateZ.safeParse(v).success ? v1.migrate(v) : v; + return internalMigrator(state); +}; diff --git a/console/src/version/migrations/migrations.spec.ts b/console/src/version/migrations/migrations.spec.ts new file mode 100644 index 0000000000..d105059a52 --- /dev/null +++ b/console/src/version/migrations/migrations.spec.ts @@ -0,0 +1,42 @@ +// Copyright 2024 Synnax Labs, Inc. +// +// Use of this software is governed by the Business Source License included in +// the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with the Business +// Source License, use of this software will be governed by the Apache License, +// Version 2.0, included in the file licenses/APL.txt. + +import { describe, expect, it } from "vitest"; + +import { migrateSlice, ZERO_SLICE_STATE } from "@/version/migrations"; +import * as v0 from "@/version/migrations/v0"; +import * as v1 from "@/version/migrations/v1"; + +describe("migrations", () => { + describe("slice", () => { + const STATES = [v0.ZERO_SLICE_STATE, v1.ZERO_SLICE_STATE]; + STATES.forEach((state) => + it(`should migrate slice from ${state.version} to latest`, () => { + const migrated = migrateSlice(state); + expect(migrated).toEqual(ZERO_SLICE_STATE); + }), + ); + }); + describe("slice with a version", () => { + const consoleVersion = "0.27.0"; + const V0_STATE: v0.SliceState = { + version: consoleVersion, + }; + const V1_STATE: v1.SliceState = { + version: "1.0.0", + consoleVersion, + updateNotificationsSilenced: false, + }; + it(`should migrate slice from ${V0_STATE.version} to latest`, () => { + const migrated = migrateSlice(V0_STATE); + console.log(migrated); + expect(migrated).toEqual(migrateSlice(V1_STATE)); + }); + }); +}); diff --git a/console/src/version/migrations/v0.ts b/console/src/version/migrations/v0.ts new file mode 100644 index 0000000000..a67f5e3a29 --- /dev/null +++ b/console/src/version/migrations/v0.ts @@ -0,0 +1,20 @@ +// Copyright 2024 Synnax Labs, Inc. +// +// Use of this software is governed by the Business Source License included in the file +// licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with the Business Source +// License, use of this software will be governed by the Apache License, Version 2.0, +// included in the file licenses/APL.txt. + +import { z } from "zod"; + +export const sliceStateZ = z.object({ + version: z.string(), +}); + +export type SliceState = z.infer; + +export const ZERO_SLICE_STATE: SliceState = { + version: "0.0.0", +}; diff --git a/console/src/version/migrations/v1.ts b/console/src/version/migrations/v1.ts new file mode 100644 index 0000000000..60d7829a9d --- /dev/null +++ b/console/src/version/migrations/v1.ts @@ -0,0 +1,31 @@ +// Copyright 2024 Synnax Labs, Inc. +// +// Use of this software is governed by the Business Source License included in the file +// licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with the Business Source +// License, use of this software will be governed by the Apache License, Version 2.0, +// included in the file licenses/APL.txt. + +import { z } from "zod"; + +import * as v0 from "@/version/migrations/v0"; + +export const sliceStateZ = z.object({ + version: z.literal("1.0.0"), + consoleVersion: z.string(), + updateNotificationsSilenced: z.boolean(), +}); +export type SliceState = z.infer; + +export const ZERO_SLICE_STATE: SliceState = { + version: "1.0.0", + consoleVersion: "0.0.0", + updateNotificationsSilenced: false, +}; + +export const migrate: (state: v0.SliceState) => SliceState = (state) => ({ + version: "1.0.0", + consoleVersion: state.version, + updateNotificationsSilenced: false, +}); diff --git a/console/src/version/selectors.ts b/console/src/version/selectors.ts index 30d69a8bd1..82331f239b 100644 --- a/console/src/version/selectors.ts +++ b/console/src/version/selectors.ts @@ -8,8 +8,20 @@ // included in the file licenses/APL.txt. import { useMemoSelect } from "@/hooks"; -import { type StoreState } from "@/version/slice"; +import { SLICE_NAME, type SliceState, type StoreState } from "@/version/slice"; -export const select = (state: StoreState): string => state.version.version; +export const selectSliceState = (state: StoreState): SliceState => state[SLICE_NAME]; -export const useSelect = (): string => useMemoSelect(select, []); +export const useSelectSliceState = (): SliceState => + useMemoSelect((state: StoreState) => selectSliceState(state), []); + +export const selectVersion = (state: StoreState): string => + selectSliceState(state).consoleVersion; + +export const useSelectVersion = (): string => useMemoSelect(selectVersion, []); + +export const selectUpdateNotificationsSilenced = (state: StoreState): boolean => + selectSliceState(state).updateNotificationsSilenced; + +export const useSelectUpdateNotificationsSilenced = (): boolean => + useMemoSelect(selectUpdateNotificationsSilenced, []); diff --git a/console/src/version/slice.ts b/console/src/version/slice.ts index 5eb7d06b97..548ea406ef 100644 --- a/console/src/version/slice.ts +++ b/console/src/version/slice.ts @@ -7,45 +7,36 @@ // License, use of this software will be governed by the Apache License, Version 2.0, // included in the file licenses/APL.txt. -import type { PayloadAction } from "@reduxjs/toolkit"; -import { createSlice } from "@reduxjs/toolkit"; -import { migrate } from "@synnaxlabs/x"; +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; -export const SLICE_NAME = "version"; +import * as latest from "@/version/migrations"; -export interface SliceState { - version: string; -} +export type SliceState = latest.SliceState; +export const ZERO_SLICE_STATE = latest.ZERO_SLICE_STATE; +export const migrateSlice = latest.migrateSlice; + +export const SLICE_NAME = "version"; export interface StoreState { [SLICE_NAME]: SliceState; } - -export const ZERO_SLICE_STATE: SliceState = { - version: "0.0.0", -}; - export type SetVersionAction = PayloadAction; -export const MIGRATIONS: migrate.Migrations = {}; - -export const migrateSlice = migrate.migrator({ - name: "version.slice", - migrations: MIGRATIONS, - def: ZERO_SLICE_STATE, -}); - export const { actions, reducer } = createSlice({ name: SLICE_NAME, initialState: ZERO_SLICE_STATE, reducers: { set: (state, { payload: version }: SetVersionAction) => { - state.version = version; + if (state.consoleVersion === version) return; + state.consoleVersion = version; + state.updateNotificationsSilenced = false; + }, + silenceUpdateNotifications: (state) => { + state.updateNotificationsSilenced = true; }, }, }); -export const { set } = actions; +export const { set, silenceUpdateNotifications } = actions; export type Action = ReturnType<(typeof actions)[keyof typeof actions]>; -export type Payload = Action["payload"]; diff --git a/pluto/src/button/Button.tsx b/pluto/src/button/Button.tsx index 3a7869b989..9eebaa97f8 100644 --- a/pluto/src/button/Button.tsx +++ b/pluto/src/button/Button.tsx @@ -167,6 +167,7 @@ export const Button = Tooltip.wrap( noWrap style={pStyle} startIcon={startIcon} + color={color} {...props} > {children} diff --git a/x/media/src/Icon/index.tsx b/x/media/src/Icon/index.tsx index 5c4a7cc39e..f63890d3cc 100644 --- a/x/media/src/Icon/index.tsx +++ b/x/media/src/Icon/index.tsx @@ -46,7 +46,7 @@ import { GrAttachment, GrDrag, GrPan } from "react-icons/gr"; import { HiDownload, HiLightningBolt, HiOutlinePlus } from "react-icons/hi"; import { HiSquare3Stack3D } from "react-icons/hi2"; import { IoMdRefresh } from "react-icons/io"; -import { IoBookSharp, IoCopy, IoTime } from "react-icons/io5"; +import { IoBookSharp, IoCopy, IoNotificationsOff, IoTime } from "react-icons/io5"; import { MdAlignHorizontalCenter, MdAlignHorizontalLeft, @@ -371,6 +371,7 @@ export const Icon: IconType = { SplitY: wrapIcon(VscSplitVertical, "split-y"), AutoFitWidth: wrapIcon(TbArrowAutofitWidth, "auto-fit-width"), Commit: wrapIcon(MdCommit, "commit"), + Snooze: wrapIcon(IoNotificationsOff, "snooze"), }; export interface IconType { @@ -462,6 +463,7 @@ export interface IconType { Zoom: IconFC; Pan: IconFC; Selection: IconFC; + Snooze: IconFC; Tooltip: IconFC; Annotate: IconFC; Rule: IconFC;