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;