diff --git a/application/single_app/.gitignore b/application/single_app/.gitignore index 0fdc4b750..6fa4e8671 100644 --- a/application/single_app/.gitignore +++ b/application/single_app/.gitignore @@ -195,6 +195,6 @@ cython_debug/ # refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore - +.env.* deploy.zip \ No newline at end of file diff --git a/application/single_app/app.py b/application/single_app/app.py index 81e38efb5..05fa0c8de 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -29,6 +29,7 @@ from functions_authentication import * from functions_content import * from functions_documents import * +from functions_latest_features_nav import should_hide_latest_features_nav from functions_search import * from functions_settings import * from functions_appinsights import * @@ -115,6 +116,7 @@ executor.init_app(app) app.config['SESSION_TYPE'] = SESSION_TYPE app.config['VERSION'] = VERSION +app.config['IS_DEVELOPMENT'] = IS_DEVELOPMENT app.config['SECRET_KEY'] = SECRET_KEY app.config['SESSION_COOKIE_SAMESITE'] = SESSION_COOKIE_SAMESITE app.config['SESSION_COOKIE_HTTPONLY'] = SESSION_COOKIE_HTTPONLY @@ -572,19 +574,29 @@ def inject_settings(): log_event(f"[CustomPages] Error injecting custom page navigation: {e}", level=logging.ERROR, exceptionTraceback=True) # Inject per-user settings if logged in user_settings = {} + latest_features_nav_hidden = IS_DEVELOPMENT try: user_id = get_current_user_id() if user_id: from functions_settings import get_user_ui_settings user_settings = get_user_ui_settings(user_id) or {} + latest_features_nav_hidden = should_hide_latest_features_nav( + user_settings, + VERSION, + is_development=IS_DEVELOPMENT + ) except Exception as e: print(f"Error injecting user settings: {e}") log_event(f"Error injecting user settings: {e}", level=logging.ERROR) user_settings = {} + latest_features_nav_hidden = IS_DEVELOPMENT return dict( app_settings=public_settings, user_settings=user_settings, custom_pages_nav=custom_pages_nav, + latest_features_current_version=VERSION, + latest_features_nav_hidden=latest_features_nav_hidden, + latest_features_nav_hidden_by_development=IS_DEVELOPMENT, idle_timeout_enabled=idle_timeout_enabled, idle_timeout_minutes=idle_timeout_minutes, idle_warning_minutes=idle_warning_minutes diff --git a/application/single_app/config.py b/application/single_app/config.py index e8c8af0ab..4c9b91a3e 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -32,6 +32,7 @@ import glob import jwt import pandas +from functions_latest_features_nav import is_development_env_enabled # Add dotenv import from dotenv import load_dotenv @@ -96,7 +97,8 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.057" +VERSION = "0.250.059" +IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() != 'false' diff --git a/application/single_app/functions_latest_features_nav.py b/application/single_app/functions_latest_features_nav.py new file mode 100644 index 000000000..2c70c8106 --- /dev/null +++ b/application/single_app/functions_latest_features_nav.py @@ -0,0 +1,50 @@ +# functions_latest_features_nav.py + +import os + + +LATEST_FEATURES_HIDDEN_VERSION_SETTING = "latestFeaturesHiddenVersion" + + +def is_development_env_enabled(value=None): + """Return True only when the development env flag is explicitly true.""" + raw_value = os.getenv("is_development", "") if value is None else value + return str(raw_value or "").strip().lower() == "true" + + +def normalize_latest_features_hidden_version(value): + """Normalize a hidden-version setting value for storage and comparison.""" + if value is None: + return None + + normalized_value = str(value).strip() + return normalized_value or None + + +def extract_latest_features_hidden_version(user_settings_payload): + """Extract the hidden-version value from either a settings dict or user settings document.""" + if not isinstance(user_settings_payload, dict): + return None + + settings = user_settings_payload.get("settings") + if isinstance(settings, dict): + return normalize_latest_features_hidden_version( + settings.get(LATEST_FEATURES_HIDDEN_VERSION_SETTING) + ) + + return normalize_latest_features_hidden_version( + user_settings_payload.get(LATEST_FEATURES_HIDDEN_VERSION_SETTING) + ) + + +def should_hide_latest_features_nav(user_settings_payload, current_version, is_development=False): + """Determine whether Latest Features nav entries should be hidden.""" + if bool(is_development): + return True + + normalized_current_version = normalize_latest_features_hidden_version(current_version) + if not normalized_current_version: + return False + + hidden_version = extract_latest_features_hidden_version(user_settings_payload) + return hidden_version == normalized_current_version diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index df351737c..d54d3824f 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -9,6 +9,7 @@ from functions_cosmos_throughput import get_default_cosmos_throughput_settings from functions_document_actions import get_default_document_action_capabilities from functions_icon_utils import normalize_icon_payload +from functions_latest_features_nav import LATEST_FEATURES_HIDDEN_VERSION_SETTING from functions_service_health import get_default_service_health import app_settings_cache import inspect @@ -33,6 +34,7 @@ "notifications_per_page", "sidebarToggleStyle", "sidebarMenuState", + LATEST_FEATURES_HIDDEN_VERSION_SETTING, ) ADMIN_SETTINGS_SECRET_REDACTED_VALUE = "***REDACTED***" ADMIN_SETTINGS_FORM_SECRET_FIELDS = ( diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 6e8c0b631..643acac61 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -18,6 +18,10 @@ get_user_role_in_group, update_active_group_for_user, ) +from functions_latest_features_nav import ( + LATEST_FEATURES_HIDDEN_VERSION_SETTING, + normalize_latest_features_hidden_version, +) from functions_public_workspaces import update_active_public_workspace_for_user from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -503,6 +507,7 @@ def user_settings(): 'navbar_layout', 'chatLayout', 'showChatTitle', 'chatSplitSizes', 'deepResearchDefaultEnabled', 'sidebarToggleStyle', 'sidebarMenuState', + LATEST_FEATURES_HIDDEN_VERSION_SETTING, # Microphone permission settings 'microphonePermissionPreference', 'microphonePermissionState', # Text-to-speech settings @@ -557,6 +562,14 @@ def user_settings(): settings_to_update["sidebarMenuState"] = normalized_sidebar_menu_state + if LATEST_FEATURES_HIDDEN_VERSION_SETTING in settings_to_update: + hidden_version = normalize_latest_features_hidden_version( + settings_to_update.get(LATEST_FEATURES_HIDDEN_VERSION_SETTING) + ) + if hidden_version is not None and hidden_version != VERSION: + return jsonify({"error": "Invalid Latest Features hidden version"}), 400 + settings_to_update[LATEST_FEATURES_HIDDEN_VERSION_SETTING] = hidden_version + active_group_updated = False active_public_workspace_updated = False diff --git a/application/single_app/static/js/latest-features-nav.js b/application/single_app/static/js/latest-features-nav.js new file mode 100644 index 000000000..9da07dac1 --- /dev/null +++ b/application/single_app/static/js/latest-features-nav.js @@ -0,0 +1,265 @@ +// latest-features-nav.js + +(function () { + const config = window.simplechatLatestFeaturesNav || {}; + const settingKey = config.settingKey || 'latestFeaturesHiddenVersion'; + + function getCurrentVersion() { + return String(config.currentVersion || '').trim(); + } + + function isHiddenByDevelopment() { + return Boolean(config.hiddenByDevelopment); + } + + function getUserSettingsCache() { + if (!window.simplechatUserSettings || typeof window.simplechatUserSettings !== 'object') { + window.simplechatUserSettings = {}; + } + + return window.simplechatUserSettings; + } + + function getHiddenVersion() { + const settings = getUserSettingsCache(); + return String(settings[settingKey] || '').trim(); + } + + function setHiddenVersionCache(hiddenVersion) { + const settings = getUserSettingsCache(); + settings[settingKey] = hiddenVersion || null; + } + + function showLatestFeaturesToast(message, type = 'success') { + if (typeof window.showToastMessage === 'function') { + window.showToastMessage(message, type); + return; + } + + if (typeof bootstrap === 'undefined' || !bootstrap.Toast) { + return; + } + + let toastContainer = document.getElementById('latest-features-toast-container'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.id = 'latest-features-toast-container'; + toastContainer.className = 'toast-container position-fixed bottom-0 end-0 p-3'; + toastContainer.setAttribute('aria-live', 'polite'); + toastContainer.setAttribute('aria-atomic', 'true'); + document.body.appendChild(toastContainer); + } + + const toast = document.createElement('div'); + const bgClass = type === 'danger' ? 'bg-danger' : type === 'info' ? 'bg-info' : 'bg-success'; + toast.className = `toast align-items-center text-white ${bgClass} border-0`; + toast.setAttribute('role', 'alert'); + + const toastBodyRow = document.createElement('div'); + toastBodyRow.className = 'd-flex'; + + const toastBody = document.createElement('div'); + toastBody.className = 'toast-body'; + toastBody.textContent = message; + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'btn-close btn-close-white me-2 m-auto'; + closeButton.setAttribute('data-bs-dismiss', 'toast'); + closeButton.setAttribute('aria-label', 'Close'); + + toastBodyRow.appendChild(toastBody); + toastBodyRow.appendChild(closeButton); + toast.appendChild(toastBodyRow); + toastContainer.appendChild(toast); + + const toastInstance = new bootstrap.Toast(toast); + toast.addEventListener('hidden.bs.toast', function () { + toast.remove(); + }); + toastInstance.show(); + } + + async function saveHiddenVersion(hiddenVersion) { + const response = await fetch('/api/user/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ + settings: { + [settingKey]: hiddenVersion || null + } + }) + }); + + if (!response.ok) { + throw new Error('Unable to save Latest Features navigation preference.'); + } + + setHiddenVersionCache(hiddenVersion); + } + + function hasVisibleSupportDestination(section) { + const destinations = section.querySelectorAll('[data-support-menu-destination]'); + for (const destination of destinations) { + if (!destination.classList.contains('d-none')) { + return true; + } + } + + return false; + } + + function updateSupportSections() { + document.querySelectorAll('[data-support-menu-nav-section]').forEach(function (section) { + section.classList.toggle('d-none', !hasVisibleSupportDestination(section)); + }); + } + + function hideLatestFeaturesNavItems() { + document.querySelectorAll('[data-latest-features-nav-item]').forEach(function (item) { + item.classList.add('d-none'); + }); + updateSupportSections(); + } + + function showLatestFeaturesNavItems() { + if (isHiddenByDevelopment()) { + return; + } + + document.querySelectorAll('[data-latest-features-nav-item]').forEach(function (item) { + item.classList.remove('d-none'); + }); + document.querySelectorAll('[data-support-menu-nav-section]').forEach(function (section) { + section.classList.remove('d-none'); + }); + } + + function setProfileStatusMessage(message, type = 'muted') { + const status = document.getElementById('latest-features-nav-preference-status'); + if (!status) { + return; + } + + const classMap = { + muted: 'text-muted', + info: 'text-info', + success: 'text-success', + danger: 'text-danger' + }; + status.textContent = message || ''; + status.className = `preference-status small mt-3 ${classMap[type] || classMap.muted}`; + } + + function renderProfileLatestFeaturesStatus() { + const badge = document.getElementById('latest-features-nav-status-badge'); + const detail = document.getElementById('latest-features-nav-status-detail'); + const unhideButton = document.getElementById('unhide-latest-features-nav-btn'); + if (!badge || !detail) { + return; + } + + const currentVersion = getCurrentVersion(); + const hiddenVersion = getHiddenVersion(); + const hiddenForCurrentVersion = Boolean(currentVersion && hiddenVersion === currentVersion); + + badge.className = 'badge'; + if (isHiddenByDevelopment()) { + badge.classList.add('bg-warning', 'text-dark'); + badge.textContent = 'Hidden in development'; + detail.textContent = 'Latest Features navigation is hidden because the is_development environment flag is true.'; + } else if (hiddenForCurrentVersion) { + badge.classList.add('bg-secondary'); + badge.textContent = 'Hidden'; + detail.textContent = `Latest Features navigation is hidden for version ${currentVersion}.`; + } else { + badge.classList.add('bg-success'); + badge.textContent = 'Visible'; + detail.textContent = hiddenVersion + ? `Latest Features navigation is visible because your saved hide preference was for version ${hiddenVersion}, and the current version is ${currentVersion}.` + : `Latest Features navigation is visible for version ${currentVersion}.`; + } + + if (unhideButton) { + unhideButton.classList.toggle('d-none', !hiddenForCurrentVersion); + unhideButton.disabled = false; + } + } + + async function handleHideLatestFeatures(event) { + event.preventDefault(); + event.stopPropagation(); + + const currentVersion = getCurrentVersion(); + if (!currentVersion) { + showLatestFeaturesToast('Unable to hide Latest Features because the app version is unavailable.', 'danger'); + return; + } + + const actionButton = event.currentTarget; + actionButton.disabled = true; + try { + await saveHiddenVersion(currentVersion); + hideLatestFeaturesNavItems(); + renderProfileLatestFeaturesStatus(); + showLatestFeaturesToast('Latest Features navigation is hidden for this version.', 'success'); + } catch (error) { + console.error('Error hiding Latest Features navigation:', error); + showLatestFeaturesToast('Failed to hide Latest Features navigation. Please try again.', 'danger'); + } finally { + actionButton.disabled = false; + } + } + + async function handleUnhideLatestFeatures() { + const unhideButton = document.getElementById('unhide-latest-features-nav-btn'); + if (!unhideButton) { + return; + } + + const originalText = unhideButton.textContent; + unhideButton.disabled = true; + unhideButton.textContent = 'Saving...'; + setProfileStatusMessage('Restoring Latest Features navigation...', 'info'); + + try { + await saveHiddenVersion(null); + showLatestFeaturesNavItems(); + renderProfileLatestFeaturesStatus(); + setProfileStatusMessage('Latest Features navigation has been restored.', 'success'); + showLatestFeaturesToast('Latest Features navigation restored.', 'success'); + } catch (error) { + console.error('Error restoring Latest Features navigation:', error); + setProfileStatusMessage('Failed to restore Latest Features navigation. Please try again.', 'danger'); + showLatestFeaturesToast('Failed to restore Latest Features navigation. Please try again.', 'danger'); + } finally { + unhideButton.disabled = false; + unhideButton.textContent = originalText; + } + } + + function initializeLatestFeaturesNavPreferences() { + document.querySelectorAll('[data-latest-features-hide-action]').forEach(function (button) { + button.addEventListener('click', handleHideLatestFeatures); + }); + + const unhideButton = document.getElementById('unhide-latest-features-nav-btn'); + if (unhideButton) { + unhideButton.addEventListener('click', handleUnhideLatestFeatures); + } + + renderProfileLatestFeaturesStatus(); + } + + document.addEventListener('DOMContentLoaded', initializeLatestFeaturesNavPreferences); + + window.SimpleChatLatestFeaturesNav = { + hideLatestFeaturesNavItems, + renderProfileLatestFeaturesStatus, + saveHiddenVersion, + showLatestFeaturesNavItems + }; +})(); diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index 5cd8a2275..cf2c0966d 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -5,6 +5,7 @@ {% set sidebar_menu_state_raw = user_settings.get('settings', {}).get('sidebarMenuState', {}) %} {% set sidebar_menu_state = sidebar_menu_state_raw if sidebar_menu_state_raw is mapping else {} %} {% set sidebar_settings = settings if settings is defined else app_settings %} +{% set latest_features_nav_is_hidden = latest_features_nav_hidden | default(false) %}