diff --git a/chat/app.js b/chat/app.js index d44ac43..31850d6 100644 --- a/chat/app.js +++ b/chat/app.js @@ -524,6 +524,34 @@ class ChatApp { return this.signInPolicy; } + /** 'tickets' or 'zkapi'; 'tickets' on a build without payment modes. */ + getPaymentMode(session = undefined) { + if (typeof this.runtime?.getMode !== 'function') return 'tickets'; + try { + return this.runtime.getMode(session) === 'zkapi' ? 'zkapi' : 'tickets'; + } catch { + return 'tickets'; + } + } + + hasPaymentModes() { + return typeof this.runtime?.changeMode === 'function'; + } + + async changePaymentMode(mode) { + if (!this.hasPaymentModes()) throw new Error('This build has one payment mode.'); + await this.runtime.changeMode(mode); + } + + /** + * Sign-in is a Tickets requirement, not a page requirement: zkAPI pays + * from a private balance and needs no account. So the policy applies + * only while the payment mode is Tickets. + */ + signInRequiredNow() { + return this.signInPolicy.required === true && this.getPaymentMode() !== 'zkapi'; + } + /** * On a host that requires sign-in, a page that loads without an unlocked * account (a refresh after Log out, a new tab, a mode that needs an @@ -531,7 +559,7 @@ class ChatApp { * landing page: that page is for a first visit only. */ async openSignInIfRequired() { - if (!this.signInPolicy.required || !this.accountModal) return false; + if (!this.signInRequiredNow() || !this.accountModal) return false; const state = await accountService.waitForAuthBootstrap(); if (state?.accountId && state.status === 'unlocked') return false; this.accountModal.open?.(); @@ -642,9 +670,17 @@ class ChatApp { ), registerShortageHandler: handler => this.registerTicketShortageHandler(handler) }), + payments: Object.freeze({ + // 'tickets' or 'zkapi'. Hosts use it to keep ticket + // onboarding (the Welcome offers) on the Tickets side. + getMode: () => this.getPaymentMode(), + available: () => this.hasPaymentModes(), + setMode: mode => this.changePaymentMode(mode) + }), ui: Object.freeze({ persistNavigationForReturn: () => { saveNavigationSelection(this.state.currentSessionId); }, openAccount: () => this.accountModal?.open?.(), + openDeleteAccount: () => this.settingsDialog?.openDeleteAccount?.(), closeWelcome: () => this.welcomePanel?.close?.(), closeAccount: () => this.accountModal?.handleCloseAttempt?.(), ensureTicketStatusVisible: () => this.rightPanel?.show?.(), @@ -868,6 +904,7 @@ class ChatApp { if (this.getPendingSend(null) || this.featureOperations?.size) throw new Error('Wait for the message to finish sending before changing payment mode.'); this.inferenceService.setDefaultBackendId(backendId); await this.refreshBackendPresentation(null); + this.askToSignInForBackend(backendId); return { sessionId: null, backendId }; } const session = this.state.sessionsById.get(sessionId); @@ -901,12 +938,23 @@ class ChatApp { Object.assign(session, stagedSession); this.clearMemoryApiOverrideContent(sessionId); await this.refreshBackendPresentation(session); + this.askToSignInForBackend(backendId); return { sessionId, backendId }; } finally { this.endSessionMutation(sessionId, reservation); } } + /** + * Switching a signed-out chat to Tickets asks for the account Tickets + * needs. The dialog offers "Use zkAPI instead", which undoes the switch. + */ + askToSignInForBackend(backendId) { + if (backendId === 'zkapi' || !this.signInPolicy.required) return; + if (accountService.getState()?.accountId) return; + this.accountModal?.open?.(); + } + async refreshBackendPresentation(session) { if (this.state.currentSessionId !== (session?.id || null)) return; this.cachedModelDisplayMetadata = this.inferenceService.getCachedModels(session); @@ -2593,6 +2641,7 @@ class ChatApp { const route = this.features.accounts ? await routeAuthenticationIntent({ accountService, accountModal: this.accountModal, + changePaymentMode: this.hasPaymentModes() ? mode => this.changePaymentMode(mode) : null, locationImpl: window.location, historyImpl: window.history }) : null; @@ -4925,7 +4974,7 @@ class ChatApp { // Signed out on a host that requires sign-in: the answer is the Log // in dialog, not a ticket shortage (which would open the Welcome // offers over a page that cannot buy anything). - if (this.signInPolicy.required && !accountService.getState()?.accountId) { + if (this.signInRequiredNow() && !accountService.getState()?.accountId) { this.accountModal?.open?.(); return false; } diff --git a/chat/application/authIntent.js b/chat/application/authIntent.js index 5b2c9e8..314337a 100644 --- a/chat/application/authIntent.js +++ b/chat/application/authIntent.js @@ -1,8 +1,12 @@ const GOOGLE_AUTH_INTENT = 'google'; const USERNAME_AUTH_INTENT = 'username'; +// Not a sign-in: the landing page's zkAPI entry. The chat opens in zkAPI +// mode (no account needed) and offers to fund the private balance. +const ZKAPI_INTENT = 'zkapi'; const AUTHENTICATION_INTENTS = new Set([ GOOGLE_AUTH_INTENT, - USERNAME_AUTH_INTENT + USERNAME_AUTH_INTENT, + ZKAPI_INTENT ]); function normalizeUsername(value) { @@ -78,11 +82,28 @@ export function clearAuthenticationIntent( export async function routeAuthenticationIntent({ accountService, accountModal, + changePaymentMode = null, locationImpl = globalThis.location, historyImpl = globalThis.history }) { const intent = getAuthenticationIntent(locationImpl); if (!intent) return Object.freeze({ handled: false, action: 'none' }); + if (intent === ZKAPI_INTENT) { + clearAuthenticationIntent(locationImpl, historyImpl); + if (typeof changePaymentMode !== 'function') { + // A build without zkAPI: the arrival is an ordinary visit. + return Object.freeze({ handled: false, action: 'none' }); + } + await accountService.waitForAuthBootstrap(); + try { + // Selecting zkAPI also runs the send preflight, which opens the + // funding dialog when the private balance is empty. + await changePaymentMode(ZKAPI_INTENT); + } catch (error) { + console.warn('zkAPI could not be selected on arrival:', error); + } + return Object.freeze({ handled: true, action: 'zkapi' }); + } const username = intent === USERNAME_AUTH_INTENT ? getUsernameAuthenticationValue(locationImpl) : null; diff --git a/chat/components/AccountModal.js b/chat/components/AccountModal.js index 9516570..d55e60d 100644 --- a/chat/components/AccountModal.js +++ b/chat/components/AccountModal.js @@ -92,10 +92,28 @@ class AccountModal { const nav = document.getElementById('account-nav'); if (this.menuOpen && !nav?.contains(event.target)) this.closeAccountMenu(); }; + // A press on the dimmed page outside the card is a close, the same + // as the X or Escape (and refused in the same cases). + this.onOverlayPointerDown = event => { + if (this.isOpen && event.target === this.overlay) this.handleCloseAttempt(); + }; + this.overlay?.addEventListener?.('pointerdown', this.onOverlayPointerDown); this.accountUnsubscribe = this.accountService.subscribe(state => { + const previous = this.accountState || {}; this.accountState = state; this.updateTabIndicator(); + // The session ended under this tab (Log out or deletion in + // another tab, an expired session). On the Tickets side that is + // a signed-out page: show Log in rather than leave a dead chat. + if ( + previous.sessionVerified === true && state?.sessionVerified !== true && + !this.isOpen && !this.loggingOut && + this.app?.signInRequiredNow?.() === true + ) { + this.open(); + return; + } if ( this.isOpen && !this.shouldSuppressAuthenticationExitRender(state) && @@ -322,7 +340,9 @@ class AccountModal { ? 'Finish account setup' : needsEncryptionUnlock ? 'Unlock encrypted data' - : 'Account'; + : this.app?.getSignInPolicy?.()?.required === true + ? 'Log in' + : 'Account'; if (identityLabel) identityLabel.textContent = identityText; if (bootstrapStatus) { bootstrapStatus.textContent = isAuthResolving ? 'Restoring account' : ''; @@ -515,6 +535,9 @@ class AccountModal { */ mustStaySignedIn() { if (this.app?.getSignInPolicy?.()?.required !== true) return false; + // With zkAPI in the build there is always a way to chat without an + // account, so the dialog is an offer with a close button, not a wall. + if (this.app?.hasPaymentModes?.() === true) return false; const state = this.accountState || {}; return !(state.accountId && state.status === 'unlocked'); } @@ -1260,6 +1283,12 @@ class AccountModal { // if it does, the dimmed page stays until the new page paints. if (this.app?.notifyLoggedOut?.() === true) return; this.loggingOut = false; + if (this.app?.getPaymentMode?.() === 'zkapi') { + // Logged out of the mode that needs no account: nothing to ask. + this.close(); + this.app?.showToast?.('Logged out', 'success'); + return; + } this.render(); this.app?.showToast?.('Logged out', 'success'); } @@ -2303,6 +2332,7 @@ class AccountModal { this.clearAnimationTimeouts(); this.closeAccountMenu(); document.removeEventListener?.('pointerdown', this.onDocumentPointerDown); + this.overlay?.removeEventListener?.('pointerdown', this.onOverlayPointerDown); if (this.accountUnsubscribe) { this.accountUnsubscribe(); this.accountUnsubscribe = null; diff --git a/chat/components/ChatInput.js b/chat/components/ChatInput.js index e8e1813..02ee730 100644 --- a/chat/components/ChatInput.js +++ b/chat/components/ChatInput.js @@ -1897,9 +1897,21 @@ export default class ChatInput { toggle.classList.toggle('switch-active', enabled); toggle.classList.toggle('switch-inactive', !enabled); // The row label names the switch and aria-checked carries its state; - // a native title on top of that showed two tooltips at once. + // a native title on top of that showed two tooltips at once. The + // bubble says what the switch does (the markup's description, kept + // on first sight), not its state, which the switch already shows. toggle.removeAttribute('title'); - toggle.dataset.tooltip = enabled ? enabledTitle : disabledTitle; + if (!toggle.dataset.description && toggle.dataset.tooltip) toggle.dataset.description = toggle.dataset.tooltip; + toggle.dataset.tooltip = toggle.dataset.description || (enabled ? enabledTitle : disabledTitle); + } + + /** Dims a gear row and puts the reason in its switch's bubble while the + * feature is out in this payment mode; restores the description after. */ + markSwitchAvailability(toggle, available, reason) { + if (!toggle) return; + toggle.closest?.('.settings-row')?.classList?.toggle?.('is-disabled', !available); + toggle.dataset.featureUnavailable = String(!available); + if (!available) toggle.dataset.tooltip = reason; } refreshMemorySettingsUI() { @@ -1916,10 +1928,7 @@ export default class ChatInput { if (featureToggle) { featureToggle.disabled = !memorySupported; featureToggle.setAttribute('aria-disabled', String(!memorySupported)); - if (!memorySupported) { - featureToggle.title = memoryUnavailableReason; - featureToggle.dataset.tooltip = memoryUnavailableReason; - } + this.markSwitchAvailability(featureToggle, memorySupported, memoryUnavailableReason); } const memoryAutoIncludeToggle = document.getElementById('memory-auto-include-toggle'); @@ -1933,6 +1942,7 @@ export default class ChatInput { if (memoryAutoIncludeToggle) { memoryAutoIncludeToggle.disabled = !memoryFeatureEnabled; memoryAutoIncludeToggle.setAttribute('aria-disabled', String(!memoryFeatureEnabled)); + this.markSwitchAvailability(memoryAutoIncludeToggle, memoryFeatureEnabled, memoryUnavailableReason); } if (this.scrubberModelSelect) { @@ -1956,6 +1966,7 @@ export default class ChatInput { this.memoryAgentModelSelect.title = memoryFeatureEnabled ? 'Memory agent model' : memoryUnavailableReason; + this.memoryAgentModelSelect.closest?.('.settings-row')?.classList?.toggle?.('is-disabled', !memoryFeatureEnabled); } document.querySelectorAll('[data-memory-requires-feature]').forEach((element) => { @@ -2186,7 +2197,8 @@ export default class ChatInput { button.dataset.tooltip = button.disabled ? this.getFeatureUnavailableReason('council') : (isParallel ? 'Parallel' : 'Chat'); - button.title = button.dataset.tooltip; + // The app tooltip says it; a native title would say it twice. + button.removeAttribute('title'); button.tabIndex = button.disabled ? -1 : 0; }); @@ -2198,11 +2210,8 @@ export default class ChatInput { memoryButton.setAttribute('aria-disabled', String(!memoryFeatureEnabled)); memoryButton.classList.toggle('memory-active', memoryEnabled); memoryButton.classList.toggle('memory-disabled', !memoryFeatureEnabled); - memoryButton.title = memoryFeatureEnabled - ? (memoryEnabled - ? 'Auto-attach memory is on. Double-click to open memory.' - : 'Auto-attach memory is off. Double-click to open memory.') - : this.getMemoryUnavailableReason(); + // The button carries its own hover card; no native title on top of it. + memoryButton.removeAttribute('title'); const tooltipText = memoryButton.querySelector('[data-memory-tooltip-text]'); const tooltipDetail = memoryButton.querySelector('[data-memory-tooltip-detail]'); @@ -2752,15 +2761,30 @@ export default class ChatInput { councilReviewToggle.setAttribute('aria-checked', String(isCouncilReviewEnabled)); councilReviewToggle.classList.toggle('switch-active', isCouncilReviewEnabled); councilReviewToggle.classList.toggle('switch-inactive', !isCouncilReviewEnabled); - councilReviewToggle.title = isCouncilReviewEnabled - ? 'Council review is on' - : 'Council review is off'; + // The bubble describes the switch; aria-checked carries its state. + councilReviewToggle.removeAttribute('title'); } if (councilReviewModelRow) { // The row is always there, dimmed while review is off: turning the // switch on must not grow the panel under the pointer. - councilReviewModelRow.classList.toggle('is-disabled', !isCouncilReviewEnabled); + councilReviewModelRow.classList.toggle('is-disabled', !isCouncilReviewEnabled || !councilSupported); + } + + // In zkAPI mode the Council rows dim and the switch's bubble says + // why, so a greyed switch never reads as "off" when it is "not here". + const councilReviewRow = document.getElementById('council-review-row'); + if (councilReviewRow) councilReviewRow.classList.toggle('is-disabled', !councilSupported); + if (councilReviewToggle) { + if (councilSupported) { + if (councilReviewToggle.dataset.availableTooltip) { + councilReviewToggle.dataset.tooltip = councilReviewToggle.dataset.availableTooltip; + delete councilReviewToggle.dataset.availableTooltip; + } + } else if (!councilReviewToggle.dataset.availableTooltip) { + councilReviewToggle.dataset.availableTooltip = councilReviewToggle.dataset.tooltip || ''; + councilReviewToggle.dataset.tooltip = this.getFeatureUnavailableReason('council'); + } } if (councilReviewModelSelect) { @@ -2818,6 +2842,8 @@ export default class ChatInput { control.dataset.featureUnavailable = String(!councilSupported); control.setAttribute('aria-disabled', String(control.disabled)); } + // The switch has an app bubble; a native title would say it twice. + if (councilReviewToggle && !councilSupported) councilReviewToggle.removeAttribute('title'); } escapeOptionValue(value) { @@ -2892,6 +2918,7 @@ export default class ChatInput { */ formatThemeName(theme) { if (!theme) return ''; + if (theme === 'purple') return 'EF purple'; return theme.charAt(0).toUpperCase() + theme.slice(1); } diff --git a/chat/components/SettingsDialog.js b/chat/components/SettingsDialog.js index 1987e67..9aeb1c2 100644 --- a/chat/components/SettingsDialog.js +++ b/chat/components/SettingsDialog.js @@ -1,11 +1,9 @@ /** - * Settings dialog: what you set once and leave. Data controls, Appearance, - * feedback, and the account actions (Log out, Delete account). The gear on - * the composer keeps what changes between one prompt and the next. - * - * The markup lives in index.html (#settings-dialog); the Appearance - * controls keep their ids, so ChatInput binds them as before. This class - * only opens, closes, and routes the data-action buttons. + * Data controls, Appearance and Share feedback live in the composer gear for + * every mode, so a zkAPI user without an account finds them too; this class + * runs the gear's data actions (export, import). What remains account-bound + * — Delete account — is a row in the commercial Billing dialog, which opens + * it straight into its confirmation over the #settings-dialog overlay. */ import { exportChats, exportAllData } from '../services/globalExport.js'; @@ -55,10 +53,76 @@ class SettingsDialog { this.overlay.addEventListener('pointerdown', this.onOverlayPointerDown); this.overlay.addEventListener('click', this.onClick); } - document.getElementById('account-preferences-menu-item')?.addEventListener('click', () => { - this.app.accountModal?.closeAccountMenu?.(); - this.open(); - }); + // The gear's Data controls: exports ask first (a download should not + // be a surprise), on the dialog's backdrop; imports open their pickers. + this.dataSection = document.getElementById('data-management-section'); + this.onDataClick = event => this.handleDataClick(event); + this.dataSection?.addEventListener('click', this.onDataClick); + } + + /** From Billing's account row: only the confirmation, over the backdrop. */ + openDeleteAccount(returnFocusEl = null) { + if (!this.overlay) return; + this.open(returnFocusEl); + this.dialog?.setAttribute?.('hidden', ''); + this.standaloneConfirm = true; + this.showDeleteConfirm(); + } + + /** The gear is a z-100 popover; the confirmation lives on the dialog's + * backdrop beneath it, so the gear closes first. */ + closeGearMenu() { + this.app.elements?.settingsMenu?.classList?.add?.('hidden'); + this.app.elements?.settingsBtn?.classList?.remove?.('tooltip-disabled'); + this.app.chatInput?.closeSettingsMenu?.(); + } + + /** Puts the gear back the way it was: the card stepped in front of it, + * it did not replace it. The gear button's own handler positions it. */ + reopenGearMenu() { + const menu = this.app.elements?.settingsMenu; + const button = this.app.elements?.settingsBtn; + if (!menu || !button?.click) return; + const reopen = () => { if (menu.classList?.contains?.('hidden')) button.click(); }; + // After the current click has finished bubbling, so the document's + // outside-click handler does not close what was just reopened. + if (typeof setTimeout === 'function') setTimeout(reopen, 0); else reopen(); + } + + /** From the gear: only the export confirmation, over the backdrop. */ + openExportConfirm(action, returnFocusEl = null) { + if (!this.overlay) return; + this.closeGearMenu(); + this.reopenGearOnClose = true; + this.open(returnFocusEl); + this.dialog?.setAttribute?.('hidden', ''); + this.standaloneConfirm = true; + this.showExportConfirm(action); + } + + async handleDataClick(event) { + const button = event.target.closest?.('button[data-action]'); + if (!button || !this.dataSection?.contains(button)) return; + event.stopPropagation(); + switch (button.dataset.action) { + case 'export-chats': + case 'export-all-data': + case 'export-memory': + this.openExportConfirm(button.dataset.action, button); + break; + case 'import-data': + document.getElementById('global-import-input')?.click?.(); + break; + case 'import-memory': + document.getElementById('memory-import-input')?.click?.(); + break; + case 'import-history': + this.closeGearMenu(); + this.app.chatHistoryImportModal?.open?.(); + break; + default: + break; + } } open(returnFocusEl = null) { @@ -74,11 +138,17 @@ class SettingsDialog { if (!this.isOpen || !this.overlay) return; this.isOpen = false; this.dismissDeleteConfirm(); + this.dialog?.removeAttribute?.('hidden'); + this.standaloneConfirm = false; this.overlay.classList.add('hidden'); document.removeEventListener('keydown', this.onKeydown); const target = this.returnFocusEl; this.returnFocusEl = null; if (target?.focus && document.contains?.(target)) target.focus({ preventScroll: true }); + if (this.reopenGearOnClose) { + this.reopenGearOnClose = false; + this.reopenGearMenu(); + } } handleKeydown(event) { @@ -268,6 +338,8 @@ class SettingsDialog { const target = this.confirmReturnFocus; this.confirmReturnFocus = null; target?.focus?.(); + // Opened from the account menu there is nothing behind the card. + if (this.standaloneConfirm && this.isOpen) this.close(); } /** diff --git a/chat/index.html b/chat/index.html index e48558d..71fb578 100644 --- a/chat/index.html +++ b/chat/index.html @@ -12,7 +12,7 @@ const storageKey = 'oa-theme-preference'; const stored = localStorage.getItem(storageKey); const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - const preference = stored === 'light' || stored === 'dark' ? stored : 'system'; + const preference = stored === 'light' || stored === 'dark' || stored === 'purple' ? stored : 'system'; const effectiveTheme = preference === 'dark' || (preference === 'system' && prefersDark) ? 'dark' : 'light'; const root = document.documentElement; @@ -26,6 +26,7 @@ root.classList.add('theme-light'); root.classList.remove('dark', 'theme-dark'); } + root.classList.toggle('theme-purple', preference === 'purple'); } catch (error) { console.warn('Unable to apply stored theme preference before hydration:', error); } @@ -491,12 +492,6 @@ + +
+ Font +
+
+ + +
+
+
+ Theme +
+
+ + + + +
+
+ + +
+

Data controls

+
+ All +
+ + +
+
+
+ Chat history +
+ + +
+
+
+ ChatGPT +
+ +
+
+
+ Memories +
+ + +
+
+ + + +
+
+ + Share feedback + + +

Privacy

@@ -750,11 +832,11 @@

Memory

Tools

Web search -
-
+
Council review -
-

Data controls

-
- All -
- - -
-
-
- Chat history -
- - -
-
-
- ChatGPT -
- -
-
-
- Memories -
- - -
-
- - - -
-
-

Appearance

-
- Layout -
-
- - -
-
-
- Font -
-
- - -
-
-
- Theme -
-
- - - -
-
- -
-
- - Share feedback - - +
Log out
diff --git a/chat/services/accountService.js b/chat/services/accountService.js index 0cc3cd3..00c1eb8 100644 --- a/chat/services/accountService.js +++ b/chat/services/accountService.js @@ -30,6 +30,7 @@ import { ORG_API_BASE, ORG_AUTH_ORIGIN } from './orgEndpoints.js'; import { chatDB } from '../db.js'; import { generateRecoveryCode, isValidRecoveryCode, normalizeRecoveryCode } from './recoveryCode.js'; import sessionService from './sessionService.js'; +import storageEvents from './storageEvents.js'; import syncService from './encryptedSyncService.js'; import { createEncryptionKeyWrapper, @@ -40,6 +41,8 @@ import { import { withAccountDataLock } from './accountDataLock.js'; const ACCOUNT_SETTINGS_KEY = 'account-settings'; +// Cross-tab storage event: one tab logged out; the session is gone for all. +export const ACCOUNT_SIGNED_OUT_EVENT = 'account-signed-out'; const ACCOUNT_KEY_BUNDLE = 'account-key-bundle-v1'; const ACCOUNT_MASTER_CRYPTO_KEY = 'account-master-crypto-key'; const ACCOUNT_MASTER_KEY_BYTES = 'account-master-key-bytes'; // Legacy; removed after migration @@ -1106,6 +1109,7 @@ class AccountService { async init() { if (this.state.isReady) return; + this.listenForSignOutElsewhere(); try { await sessionService.init(); if (!chatDB) { @@ -2815,6 +2819,10 @@ class AccountService { */ async logout() { this.syncInitializationGeneration += 1; + // Other tabs share this browser's ticket store but not this object: + // tell them the session is ending before anything shared is cleared, + // so they show Log in rather than an empty wallet's welcome dialog. + this.broadcastSignedOut(); // The wallet is about to be emptied on this device. Say so first: // extensions read readyForAutomaticBilling from these flags, and an // empty wallet that still looked verified opened the welcome dialog @@ -2873,6 +2881,34 @@ class AccountService { ); } + /** Log out in one tab ends the session for every tab of this browser. */ + broadcastSignedOut() { + if (!this.state.accountId) return; + try { + storageEvents.init(); + storageEvents.broadcast(ACCOUNT_SIGNED_OUT_EVENT, { accountId: this.state.accountId }); + } catch (error) { + console.warn('[AccountService] Could not announce sign-out to other tabs:', error); + } + } + + listenForSignOutElsewhere() { + if (this.signOutElsewhereUnsubscribe) return; + try { + storageEvents.init(); + this.signOutElsewhereUnsubscribe = storageEvents.on(ACCOUNT_SIGNED_OUT_EVENT, payload => { + const accountId = payload?.accountId; + if (!accountId || this.state.accountId !== accountId) return; + if (!this.state.sessionVerified && this.state.status !== 'unlocked') return; + // The server session is already revoked; treat it exactly as + // an expired session here (locked, keys cleared, UI notified). + void this.handleTokenInvalidation(); + }); + } catch (error) { + console.warn('[AccountService] Could not listen for sign-out in other tabs:', error); + } + } + async clearLocalAccount() { await this.logout(); // Use logout instead of lock for full cleanup this.cancelPendingOAuthAccount(); diff --git a/chat/services/preferencesStore.js b/chat/services/preferencesStore.js index 876e5e1..360cd0a 100644 --- a/chat/services/preferencesStore.js +++ b/chat/services/preferencesStore.js @@ -176,7 +176,7 @@ class PreferencesStore { { key: PREF_KEYS.theme, storageKey: LOCAL_STORAGE_KEYS.theme, - parse: (value) => (value === 'light' || value === 'dark' || value === 'system') ? value : null + parse: (value) => (value === 'light' || value === 'dark' || value === 'purple' || value === 'system') ? value : null }, { key: PREF_KEYS.wideMode, @@ -590,7 +590,7 @@ class PreferencesStore { let serialized = null; if (key === PREF_KEYS.theme) { - serialized = (value === 'light' || value === 'dark') ? value : null; + serialized = (value === 'light' || value === 'dark' || value === 'purple') ? value : null; } else if (key === PREF_KEYS.fontMode) { serialized = value === 'serif' ? 'serif' : 'sans'; } else if (key === PREF_KEYS.flatMode) { diff --git a/chat/services/themeManager.js b/chat/services/themeManager.js index 5734b5c..c1353b8 100644 --- a/chat/services/themeManager.js +++ b/chat/services/themeManager.js @@ -1,7 +1,9 @@ import preferencesStore, { PREF_KEYS } from './preferencesStore.js'; const PREFERENCE_SYSTEM = 'system'; -const VALID_PREFERENCES = new Set(['light', 'dark', PREFERENCE_SYSTEM]); +// 'purple' is a light-family theme in Ethereum's hue (html.theme-purple); +// it keeps every light rule and overrides the colour tokens. +const VALID_PREFERENCES = new Set(['light', 'dark', 'purple', PREFERENCE_SYSTEM]); class ThemeManager { constructor() { @@ -91,12 +93,14 @@ class ThemeManager { const hasMatchingThemeClass = effectiveTheme === 'dark' ? root.classList.contains('theme-dark') && !root.classList.contains('theme-light') : root.classList.contains('theme-light') && !root.classList.contains('theme-dark'); + const hasMatchingPurpleClass = (effectiveTheme === 'purple') === root.classList.contains('theme-purple'); if ( currentTheme === effectiveTheme && currentPreference === this.preference && hasMatchingDarkClass && - hasMatchingThemeClass + hasMatchingThemeClass && + hasMatchingPurpleClass ) { return; } @@ -113,13 +117,14 @@ class ThemeManager { root.classList.remove('dark'); } - if (effectiveTheme === 'light') { - root.classList.add('theme-light'); - root.classList.remove('theme-dark'); - } else { + if (effectiveTheme === 'dark') { root.classList.add('theme-dark'); root.classList.remove('theme-light'); + } else { + root.classList.add('theme-light'); + root.classList.remove('theme-dark'); } + root.classList.toggle('theme-purple', effectiveTheme === 'purple'); // Re-enable transitions after a frame (colors already applied) requestAnimationFrame(() => { diff --git a/chat/styles.css b/chat/styles.css index 9380ee5..e5451da 100644 --- a/chat/styles.css +++ b/chat/styles.css @@ -127,6 +127,46 @@ --color-ring: 0 0% 80%; } +/* Purple: a light-family theme in Ethereum's hue (#627EEA ~ hsl(228 76% 65%)). + html carries theme-light AND theme-purple, so every light rule applies and + only the colour tokens change: lavender ground, a deeper sidebar tint, a + near-white card, indigo text, the send button in the hue's darker step. */ +.theme-purple { + --color-hover: 232 50% 92%; + + --slate-1: hsl(232, 60%, 98%); + --slate-2: hsl(232, 55%, 96.5%); + --slate-3: hsl(232, 50%, 94%); + --slate-4: hsl(232, 45%, 92%); + --slate-200: hsl(232, 35%, 86%); + --slate-800: hsl(232, 40%, 20%); + --slate-6: hsl(232, 25%, 80%); + + --color-accent-primary: 228 76% 65%; + --color-focus-ring: 228 76% 65%; + + --color-background: 232 60% 96%; + --color-foreground: 232 45% 14%; + --color-card: 232 65% 97.5%; + --color-card-foreground: 232 45% 14%; + --color-popover: 232 65% 97.5%; + --color-popover-foreground: 232 45% 14%; + --color-primary: 228 70% 55%; + --color-primary-foreground: 0 0% 100%; + --color-secondary: 232 50% 92%; + --color-secondary-foreground: 232 45% 14%; + --color-muted: 232 50% 92%; + --color-muted-foreground: 232 22% 44%; + --color-accent: 232 50% 92%; + --color-accent-foreground: 232 45% 14%; + --color-destructive: 0 62.8% 30.6%; + --color-destructive-foreground: 0 0% 95%; + --color-border: 232 35% 86%; + --color-input: 232 35% 86%; + --color-ring: 228 76% 65%; +} +.theme-swatch-purple { fill: hsl(228 76% 65%); } + :root, .dark { --tw-ring-offset-color: hsl(var(--color-background)); @@ -2893,6 +2933,17 @@ a.quick-ask-source:focus-visible { } /* Match loading toast spinner to scrubbing blue border glow palette */ +/* The app toast: one line of 13px, tight padding, a 10px radius. Wallet + progress and feature notices all pass through it, so it stays small. */ +#app-toast { + max-width: min(24rem, calc(100vw - 2rem)); + padding: 0.5rem 0.875rem; + border-radius: 0.625rem; + font-size: 0.8125rem; + line-height: 1.45; + text-wrap: pretty; + font-variant-numeric: tabular-nums; +} #app-toast .link-preview-spinner { border-color: rgba(59, 130, 246, 0.35); border-top-color: rgba(96, 165, 250, 0.95); @@ -8476,6 +8527,16 @@ html[data-keyboard-nav] .settings-link:focus-visible { .settings-panel .settings-switch.switch-inactive { background-color: hsl(var(--color-foreground) / 0.18); } .dark .settings-panel .settings-switch.switch-inactive { background-color: hsl(var(--color-foreground) / 0.22); } +/* Export / Import in the gear: text actions at the gear's 13px, not the + Account dialog's 15px. */ +#settings-menu .settings-text-action { + height: var(--settings-control-h); + padding: 0 0.5rem; + font-size: 0.8125rem; +} +#settings-menu .settings-button-pair { gap: 0.125rem; margin-right: -0.5rem; } +#settings-menu .settings-link-icon { width: 0.875rem; height: 0.875rem; } + /* Segmented controls: thinking effort, layout, font, theme. The selected segment is a raised pill; no sliding indicator (its offsets assumed the old fixed-width buttons). */ @@ -8520,18 +8581,33 @@ html[data-keyboard-nav] .settings-link:focus-visible { .settings-panel .reasoning-effort-toggle.is-disabled { opacity: 0.55; } .settings-panel .settings-row.is-disabled { opacity: 0.55; } .settings-panel .settings-row.is-disabled select { cursor: default; } +/* A switch that is out in this payment mode still takes the pointer, so + its bubble says why, the same as the Parallel icon in the composer. + The row already dims; the switch does not dim again on top. */ +.settings-panel .settings-row.is-disabled .settings-switch:disabled { + opacity: 1; + pointer-events: auto; + cursor: default; +} /* Share feedback: a row that is a link. */ .settings-link { + /* A row that is a link: it lights up as one, edge to edge, on hover. */ + margin: 0 -0.5rem; + padding: 0 0.5rem; + border-radius: 0.5rem; color: inherit; text-decoration: none; cursor: pointer; + transition: background-color 160ms cubic-bezier(.2, 0, 0, 1); } +.settings-link:hover { background: hsl(var(--color-foreground) / 0.05); } .settings-link:hover .settings-row-label { color: hsl(var(--color-foreground)); } .settings-link-icon { width: 1rem; height: 1rem; color: hsl(var(--color-muted-foreground)); + transition: color 160ms cubic-bezier(.2, 0, 0, 1); } .settings-link:hover .settings-link-icon { color: hsl(var(--color-foreground)); } @@ -8610,23 +8686,25 @@ html[data-keyboard-nav] .settings-link:focus-visible { right edge, so the tooltip hangs from its right corner and grows leftward instead of running into the scroll edge. */ /* Same tooltip as Billing's Share / Import: a small card above the action, - right-aligned to it, wrapping text, no arrow. */ -.settings-panel .settings-text-action[data-tooltip]:hover::after { + right-aligned to it, wrapping text, no arrow. A switch that is out in + this payment mode gets the same bubble, saying why. */ +.settings-panel .settings-text-action[data-tooltip]:hover::after, +.settings-panel .settings-switch[data-tooltip]:hover::after { display: block; top: auto; bottom: calc(100% + 0.625rem); left: auto; right: 0; width: max-content; - max-width: 15rem; - padding: 0.625rem 0.875rem; + max-width: 14rem; + padding: 0.375rem 0.625rem; border: 0; - border-radius: 0.75rem; + border-radius: 0.5rem; background: hsl(var(--color-popover)); color: hsl(var(--color-foreground)); - font-size: 0.875rem; + font-size: 0.75rem; font-weight: 400; - line-height: 1.45; + line-height: 1.4; text-align: left; text-wrap: pretty; white-space: normal; @@ -8636,7 +8714,8 @@ html[data-keyboard-nav] .settings-link:focus-visible { transform: translateY(0); animation: settingsTipIn 140ms ease; } -.settings-panel .settings-text-action[data-tooltip]:hover::before { display: none; } +.settings-panel .settings-text-action[data-tooltip]:hover::before, +.settings-panel .settings-switch[data-tooltip]:hover::before { display: none; } @keyframes settingsTipIn { from { opacity: 0; transform: translateY(0.25rem); } to { opacity: 1; transform: translateY(0); } @@ -8762,3 +8841,38 @@ html[data-keyboard-nav] .settings-link:focus-visible { .settings-button-danger:disabled { opacity: 0.45; } .dark .settings-button-danger { background: hsl(0 66% 50%); border-color: hsl(0 66% 50%); } .dark .settings-button-danger:hover:not(:disabled) { background: hsl(0 66% 44%); border-color: hsl(0 66% 44%); } + +/* --------------------------------------------------------------------- + One tooltip everywhere: the card Billing shows over Share and Import. + A small rounded panel, 12px wrapping text, no arrow, a soft shadow. + Positions (below / above / right) and the keyboard-only rules stay as + they are; only the skin is unified here, so this block comes last. + --------------------------------------------------------------------- */ +[data-tooltip]:hover::after, +.chat-mode-html-tooltip { + width: max-content; + max-width: 14rem; + padding: 0.375rem 0.625rem; + border: 0; + border-radius: 0.5rem; + background: hsl(var(--color-popover)); + color: hsl(var(--color-foreground)); + font-size: 0.75rem; + font-weight: 400; + line-height: 1.4; + text-align: left; + text-wrap: pretty; + white-space: normal; + box-shadow: + 0 0 0 1px hsl(var(--color-foreground) / .06), + 0 4px 16px -4px rgb(0 0 0 / .18); +} +.dark [data-tooltip]:hover::after, +.dark .chat-mode-html-tooltip { + box-shadow: + 0 0 0 1px hsl(var(--color-foreground) / .1), + 0 4px 16px -4px rgb(0 0 0 / .5); +} +[data-tooltip]:hover::before, +.chat-mode-html-tooltip::before { display: none !important; } +.chat-mode-html-tooltip { gap: 0.25rem; } diff --git a/chat/ui/appInterface.js b/chat/ui/appInterface.js index 14ee16f..569e2ac 100644 --- a/chat/ui/appInterface.js +++ b/chat/ui/appInterface.js @@ -253,6 +253,10 @@ const COMPONENT_APP_KEYS = new Set([ 'getPendingCouncilConfig', 'getSessionListEmptyText', 'getSignInPolicy', + 'getPaymentMode', + 'signInRequiredNow', + 'hasPaymentModes', + 'changePaymentMode', 'handleMemoryApprovalDecision', 'handleEditFileUpload', 'hasActiveSessionListCriteria', diff --git a/chat/zkapi/components/AccountModal.js b/chat/zkapi/components/AccountModal.js index 0c44d1f..326c961 100644 --- a/chat/zkapi/components/AccountModal.js +++ b/chat/zkapi/components/AccountModal.js @@ -1,5 +1,6 @@ import zkapiClient from '@openanonymity/zkapi-browser-sdk/client'; import { walletErrorMessage } from '@openanonymity/zkapi-browser-sdk/wallet-error'; +import { explainZkapiError, isIndexerLag } from '../services/zkapiErrorCopy.mjs'; import { updateZkapiBalanceControl } from './ZkapiStateExperience.js'; import { captureFundingSetupView, fundingSetupGuide, restoreFundingSetupView } from './FundingSetupGuide.js'; import { @@ -8,7 +9,13 @@ import { restorePrivateBalanceHelpFocus, updatePrivateBalanceExpiryState } from './PrivateBalanceHelp.js'; -const MODAL_CLASSES = 'w-full max-w-md rounded-xl border border-border bg-background shadow-2xl mx-4 flex flex-col overflow-hidden'; +// The OA dialog frame (Account, Welcome): 560px, 24px radius, 40px inset, no +// header hairline. Sizing and colour live in zkapi.css (.zkapi-dialog). +const MODAL_CLASSES = 'zkapi-dialog'; +// Wallet progress ("Approving USDC… confirm in MetaMask") shows as a toast +// under the dialog that holds until the next step replaces it; the final +// result gets an ordinary timed toast from run(). +const PROGRESS_TOAST_MS = 120000; export default class AccountModal { constructor(app, { triggerId = 'account-tab-btn', overlayId = 'account-modal' } = {}) { @@ -22,6 +29,7 @@ export default class AccountModal { this.status = ''; this.statusError = false; this.depositAmount = null; + this.historyOpen = false; this.returnFocusEl = null; this.escapeHandler = null; this.unsubscribe = zkapiClient.subscribe((_snapshot, detail) => { @@ -117,13 +125,9 @@ export default class AccountModal { if (!zkapiClient.activeLease) { this.overlay?.querySelector('[data-active-lease-notice]')?.remove(); } - const element = this.overlay?.querySelector('[data-payment-status]'); - if (element) { - element.textContent = message; - element.classList.toggle('text-destructive', isError); - element.classList.toggle('text-muted-foreground', !isError); - element.classList.toggle('hidden', !message); - } + // Progress lives in the toast, not in a line inside the card. While a + // wallet step runs the toast holds; run() replaces it with the result. + if (message && this.busy && !isError) this.app?.showToast?.(message, 'info', PROGRESS_TOAST_MS); } handleZkapiClock(now = Date.now()) { @@ -213,9 +217,12 @@ export default class AccountModal { || error?.error?.code === 4001 || error?.code === 'ACTION_REJECTED' || /(?:user|wallet).*(?:reject|denied|cancel)|request rejected/i.test(error?.message || ''); + // An indexer that has not caught up is a wait, not a fault. + const indexerLag = isIndexerLag(error); + explainZkapiError(error); this.setStatus(rejected ? error.shortMessage || 'MetaMask canceled the transaction. No funds moved; you can safely try again.' - : walletErrorMessage(error), !rejected && !confirmationPending); + : walletErrorMessage(error), !rejected && !confirmationPending && !indexerLag); if (activityId) { if (confirmationPending) zkapiClient.completeActivity(activityId, { title: 'Withdrawal transaction mined', @@ -228,7 +235,7 @@ export default class AccountModal { // Closing a wallet prompt is an ordinary user decision. The // durable recovery path above has already put the operation into a // safe retry/canceled state, so do not present it as an app error. - this.app.showToast?.(this.status, confirmationPending ? 'info' : rejected ? 'success' : 'error', 6000); + this.app.showToast?.(this.status, confirmationPending || indexerLag ? 'info' : rejected ? 'success' : 'error', indexerLag ? 9000 : 6000); } finally { this.busy = false; this.backgroundProgress = null; @@ -257,16 +264,32 @@ export default class AccountModal { Number(note.current_balance) / Number(note.deposit_amount) * 100)); } + /** + * The SDK method that drops a prepared (never broadcast) deposit, under + * whichever name this SDK version gives it; null when it has none, in + * which case the option is not offered. + */ + pendingDepositDiscarder() { + for (const name of ['discardPendingDeposit', 'cancelPendingDeposit', 'clearPendingDeposit', 'abandonPendingDeposit', 'resetPendingDeposit']) { + if (typeof zkapiClient[name] === 'function') return () => zkapiClient[name](); + } + return null; + } + renderWithdrawalStatusLink() { const records = zkapiClient.withdrawals.filter(record => !this.hasVerifiedExpiryClaim(record)); const lateAttempts = zkapiClient.unresolvedLateWithdrawals; const open = records.filter(record => !['closed', 'closed_unconfirmed'].includes(record.phase)); const toCheck = open.length + lateAttempts.length; + const expanded = Boolean(this.historyOpen); return ` - `; +
+ +
${expanded ? this.renderWithdrawalRecords({ inline: true }) : ''}
+
`; } withdrawalRecordLabel(record) { @@ -420,14 +443,14 @@ export default class AccountModal { return hashes.size > 0 && identities.size === 1; } - renderWithdrawalRecords() { + renderWithdrawalRecords({ inline = false } = {}) { const deposits = zkapiClient.deposits || []; const expiries = zkapiClient.expiryHistory; const records = zkapiClient.withdrawals.filter(record => !this.hasVerifiedExpiryClaim(record, expiries)); this.renderedExpiryHistorySignature = this.expiryHistorySignature(); const lateAttempts = zkapiClient.unresolvedLateWithdrawals; if (!records.length && !lateAttempts.length && !deposits.length) { - return '

Your deposits and withdrawals will appear here.

'; + return `

Your deposits and withdrawals will appear here.

${inline ? '' : ''}
`; } const payments = [...deposits.map(record => ({ type: 'deposit', record })), ...expiries.map(record => ({ type: 'expiry', record })), @@ -517,8 +540,8 @@ export default class AccountModal { }).join('')}
${hasPendingReturn ? `` : ''} - ${!hasSelectedNote ? `` : ''} - + ${!hasSelectedNote && hasPendingDeposit ? '' : ''} + ${inline ? '' : ``}
`; } @@ -535,7 +558,7 @@ export default class AccountModal { ? 'Waiting for MetaMask' : 'Deposit status unknown'; return ` -
+

${title}

@@ -556,24 +579,30 @@ export default class AccountModal { ? zkapiClient.formatBillingAmount(pendingDeposit.amount) : this.depositAmount ?? zkapiClient.suggestedDeposit.toFixed(zkapiClient.suggestedDeposit < 0.01 ? 6 : 2); + const mainnet = zkapiClient.isMainnetFunding; + const demoMintEnabled = zkapiClient.config?.funding?.demo_mint_enabled; + const helper = mainnet + ? 'USDC on Ethereum · ETH in the same account covers the fee' + : demoMintEnabled + ? 'Sepolia testnet · demo billing tokens are provided when needed' + : 'Install MetaMask to get started'; return ` -
-
-

${resumingDeposit ? 'Private deposit ready to resume' : 'Fund once, chat privately'}

-

${resumingDeposit ? 'No funds moved when the earlier MetaMask prompt closed. The same saved private note will be reused.' : 'MetaMask deposits billing tokens into a private prepaid note. The note secret and chat history remain on this machine.'}

-
- ${fundingSetupGuide({ mainnet: zkapiClient.isMainnetFunding, demoMintEnabled: zkapiClient.config?.funding?.demo_mint_enabled, open: fundingSetup?.open })} -