diff --git a/apps/client/src/layouts/layout_commons.ts b/apps/client/src/layouts/layout_commons.ts
index c2802c963..5ee261317 100644
--- a/apps/client/src/layouts/layout_commons.ts
+++ b/apps/client/src/layouts/layout_commons.ts
@@ -30,6 +30,7 @@ import ClassicEditorToolbar from "../widgets/ribbon_widgets/classic_editor_toolb
import PromotedAttributesWidget from "../widgets/ribbon_widgets/promoted_attributes.js";
import NoteDetailWidget from "../widgets/note_detail.js";
import NoteListWidget from "../widgets/note_list.js";
+import { CallToActionDialog } from "../widgets/dialogs/call_to_action.jsx";
export function applyModals(rootContainer: RootContainer) {
rootContainer
@@ -66,4 +67,5 @@ export function applyModals(rootContainer: RootContainer) {
.child(new PromotedAttributesWidget())
.child(new NoteDetailWidget())
.child(new NoteListWidget(true)))
+ .child(new CallToActionDialog());
}
diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index f5a6656ec..28c22cf34 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -1992,5 +1992,13 @@
"modal": {
"close": "Close",
"help_title": "Display more information about this screen"
+ },
+ "call_to_action": {
+ "next_theme_title": "TriliumNext theme is now stable",
+ "next_theme_message": "For a while now, we've been working on a new theme to give the application a more modern look.",
+ "next_theme_button": "Switch to the TriliumNext theme",
+ "background_effects_title": "Background effects are now stable",
+ "background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.",
+ "background_effects_button": "Enable background effects"
}
}
diff --git a/apps/client/src/widgets/dialogs/call_to_action.tsx b/apps/client/src/widgets/dialogs/call_to_action.tsx
new file mode 100644
index 000000000..2bdb97866
--- /dev/null
+++ b/apps/client/src/widgets/dialogs/call_to_action.tsx
@@ -0,0 +1,58 @@
+import { useState } from "preact/hooks";
+import Button from "../react/Button";
+import Modal from "../react/Modal";
+import ReactBasicWidget from "../react/ReactBasicWidget";
+import { CallToAction, dismissCallToAction, getCallToActions } from "./call_to_action_definitions";
+
+function CallToActionDialogComponent({ activeCallToActions }: { activeCallToActions: CallToAction[] }) {
+ if (!activeCallToActions.length) {
+ return <>>;
+ }
+
+ const [ activeIndex, setActiveIndex ] = useState(0);
+ const [ shown, setShown ] = useState(true);
+ const activeItem = activeCallToActions[activeIndex];
+
+ function goToNext() {
+ if (activeIndex + 1 < activeCallToActions.length) {
+ setActiveIndex(activeIndex + 1);
+ } else {
+ setShown(false);
+ }
+ }
+
+ return (
+ setShown(false)}
+ footerAlignment="between"
+ footer={<>
+
+ )
+}
+
+export class CallToActionDialog extends ReactBasicWidget {
+
+ get component() {
+ return
+ }
+
+}
\ No newline at end of file
diff --git a/apps/client/src/widgets/dialogs/call_to_action_definitions.ts b/apps/client/src/widgets/dialogs/call_to_action_definitions.ts
new file mode 100644
index 000000000..93454cf35
--- /dev/null
+++ b/apps/client/src/widgets/dialogs/call_to_action_definitions.ts
@@ -0,0 +1,116 @@
+import utils from "../../services/utils";
+import options from "../../services/options";
+import { t } from "../../services/i18n";
+
+/**
+ * A "call-to-action" is an interactive message for the user, generally to present new features.
+ */
+export interface CallToAction {
+ /**
+ * A unique identifier to allow the call-to-action to be dismissed by the user and then never shown again.
+ */
+ id: string;
+ /**
+ * The title of the call-to-action, displayed as a heading in the message.
+ */
+ title: string;
+ /**
+ * The message body of the call-to-action.
+ */
+ message: string;
+ /**
+ * Function that determines whether the call-to-action can be displayed to the user. The check can be based on options or
+ * the platform of the user. If the user already dismissed this call-to-action, the value of this function doesn't matter.
+ *
+ * @returns whether to allow this call-to-action or to skip it, based on the user's environment.
+ */
+ enabled: () => boolean;
+ /**
+ * A list of buttons to display in the footer of the modal.
+ */
+ buttons: {
+ /**
+ * The text displayed on the button.
+ */
+ text: string;
+ /**
+ * The listener that will be called when the button is pressed. Async methods are supported and will be awaited before proceeding to the next step.
+ */
+ onClick: () => (void | Promise);
+ }[];
+}
+
+function isNextTheme() {
+ return [ "next", "next-light", "next-dark" ].includes(options.get("theme"));
+}
+
+const CALL_TO_ACTIONS: CallToAction[] = [
+ {
+ id: "next_theme",
+ title: t("call_to_action.next_theme_title"),
+ message: t("call_to_action.next_theme_message"),
+ enabled: () => !isNextTheme(),
+ buttons: [
+ {
+ text: t("call_to_action.next_theme_button"),
+ async onClick() {
+ await options.save("theme", "next");
+ await options.save("backgroundEffects", "true");
+ utils.reloadFrontendApp("call-to-action");
+ }
+ }
+ ]
+ },
+ {
+ id: "background_effects",
+ title: t("call_to_action.background_effects_title"),
+ message: t("call_to_action.background_effects_message"),
+ enabled: () => utils.isElectron() && window.glob.platform === "win32" && isNextTheme() && !options.is("backgroundEffects"),
+ buttons: [
+ {
+ text: t("call_to_action.background_effects_button"),
+ async onClick() {
+ await options.save("backgroundEffects", "true");
+ utils.restartDesktopApp();
+ }
+ }
+ ]
+ }
+];
+
+/**
+ * Obtains the list of available call-to-actions, meaning those that are enabled based on the user's environment but also with
+ * without the call-to-actions already dismissed by the user.
+ *
+ * @returns a list iof call to actions to display to the user.
+ */
+export function getCallToActions() {
+ const seenCallToActions = new Set(getSeenCallToActions());
+
+ return CALL_TO_ACTIONS.filter((callToAction) =>
+ !seenCallToActions.has(callToAction.id) && callToAction.enabled());
+}
+
+/**
+ * Marks the call-to-action as dismissed by the user, meaning that {@link getCallToActions()} will no longer list this particular action.
+ *
+ * @param id the corresponding ID of the call to action.
+ * @returns a promise with the option saving. Generally it's best to wait for the promise to resolve before refreshing the page.
+ */
+export async function dismissCallToAction(id: string) {
+ const seenCallToActions = getSeenCallToActions();
+ if (seenCallToActions.find(seenId => seenId === id)) {
+ return;
+ }
+
+ seenCallToActions.push(id);
+ await options.save("seenCallToActions", JSON.stringify(seenCallToActions));
+}
+
+function getSeenCallToActions() {
+ try {
+ return JSON.parse(options.get("seenCallToActions")) as string[];
+ } catch (e) {
+ return [];
+ }
+}
diff --git a/apps/server/spec/db/document.db b/apps/server/spec/db/document.db
index 50b477ea7..264a9ff0d 100644
Binary files a/apps/server/spec/db/document.db and b/apps/server/spec/db/document.db differ
diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts
index 077c8899b..76a57a846 100644
--- a/apps/server/src/routes/api/options.ts
+++ b/apps/server/src/routes/api/options.ts
@@ -93,6 +93,7 @@ const ALLOWED_OPTIONS = new Set([
"redirectBareDomain",
"showLoginInShareTheme",
"splitEditorOrientation",
+ "seenCallToActions",
// AI/LLM integration options
"aiEnabled",
diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts
index cb05feb2b..b7e2b80e7 100644
--- a/apps/server/src/services/options_init.ts
+++ b/apps/server/src/services/options_init.ts
@@ -183,7 +183,7 @@ const defaultOptions: DefaultOption[] = [
// HTML import configuration
{ name: "layoutOrientation", value: "vertical", isSynced: false },
- { name: "backgroundEffects", value: "false", isSynced: false },
+ { name: "backgroundEffects", value: "true", isSynced: false },
{
name: "allowedHtmlTags",
value: JSON.stringify(DEFAULT_ALLOWED_TAGS),
@@ -206,11 +206,11 @@ const defaultOptions: DefaultOption[] = [
{ name: "ollamaEnabled", value: "false", isSynced: true },
{ name: "ollamaDefaultModel", value: "", isSynced: true },
{ name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true },
-
- // Adding missing AI options
{ name: "aiTemperature", value: "0.7", isSynced: true },
{ name: "aiSystemPrompt", value: "", isSynced: true },
{ name: "aiSelectedProvider", value: "openai", isSynced: true },
+
+ { name: "seenCallToActions", value: "[]", isSynced: true }
];
/**
diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts
index 1cc6b419f..e0ce0f1cb 100644
--- a/packages/commons/src/lib/options_interface.ts
+++ b/packages/commons/src/lib/options_interface.ts
@@ -145,7 +145,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions