diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 1e075e60f..505cb4a72 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -755,6 +755,7 @@ "SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name", "ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email", "ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.", + "ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.", "LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing", "LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts", "INBOX_UPDATE_TITLE": "Inbox Settings", diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 0d6a0013f..a26eb0e18 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -114,9 +114,33 @@ export default { ...mapGetters({ accountId: 'getCurrentAccountId', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', + isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', uiFlags: 'inboxes/getUIFlags', portals: 'portals/allPortals', }), + isInboundEmailEnabled() { + return this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.INBOUND_EMAILS + ); + }, + showContinuityToggle() { + if (this.isInboundEmailEnabled) return true; + return this.isOnChatwootCloud; + }, + isContinuityDisabled() { + return this.isOnChatwootCloud && !this.isInboundEmailEnabled; + }, + continuityDescription() { + if (this.isContinuityDisabled) { + return this.$t( + 'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT' + ); + } + return this.$t( + 'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT' + ); + }, selectedTabKey() { return this.tabs[this.selectedTabIndex]?.key; }, @@ -559,7 +583,8 @@ export default { welcome_tagline: this.channelWelcomeTagline || '', selectedFeatureFlags: this.selectedFeatureFlags, reply_time: this.replyTime || 'in_a_few_minutes', - continuity_via_email: this.continuityViaEmail, + continuity_via_email: + this.isInboundEmailEnabled && this.continuityViaEmail, }, }; if (this.avatarFile) { @@ -1164,15 +1189,15 @@ export default { /> diff --git a/app/javascript/shared/helpers/KeyboardHelpers.js b/app/javascript/shared/helpers/KeyboardHelpers.js index fdd64a083..d89cbba4e 100644 --- a/app/javascript/shared/helpers/KeyboardHelpers.js +++ b/app/javascript/shared/helpers/KeyboardHelpers.js @@ -1,3 +1,5 @@ +import { isApple } from './platform'; + export const isEnter = e => { return e.key === 'Enter'; }; @@ -14,13 +16,20 @@ export const hasPressedCommand = e => { return e.metaKey; }; +// True when the platform's "command" modifier is held: Cmd (metaKey) on +// Apple platforms (macOS, iOS/iPadOS hardware keyboards), Ctrl (ctrlKey) +// elsewhere. Mirrors the `$mod` convention used by tinykeys and +// prosemirror-keymap so the editor and the app agree on what counts as the +// send modifier. +export const hasPressedMod = e => Boolean(isApple() ? e.metaKey : e.ctrlKey); + export const hasPressedEnterAndNotCmdOrShift = e => { - return isEnter(e) && !hasPressedCommand(e) && !hasPressedShift(e); + return isEnter(e) && !hasPressedMod(e) && !hasPressedShift(e); }; -export const hasPressedCommandAndEnter = e => { - return hasPressedCommand(e) && isEnter(e); -}; +// Detects the platform-aware "send" shortcut: Cmd+Enter on Apple platforms, +// Ctrl+Enter on Windows/Linux. +export const hasPressedCommandAndEnter = e => hasPressedMod(e) && isEnter(e); // If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue // https://github.com/chatwoot/chatwoot/issues/9492 diff --git a/app/javascript/shared/helpers/platform.js b/app/javascript/shared/helpers/platform.js new file mode 100644 index 000000000..7ec897936 --- /dev/null +++ b/app/javascript/shared/helpers/platform.js @@ -0,0 +1,50 @@ +// Detects the current OS using the modern User-Agent Client Hints API, +// falling back to userAgent parsing on Safari/Firefox where it is unavailable. +// Treats iPad on iOS 13+ (which spoofs Macintosh) as iOS via maxTouchPoints. + +export const OS = Object.freeze({ + MAC: 'macos', + WINDOWS: 'windows', + LINUX: 'linux', + ANDROID: 'android', + IOS: 'ios', + UNKNOWN: 'unknown', +}); + +// navigator.userAgentData.platform → OS constant (lowercased keys) +const UAD_MAP = { + macos: OS.MAC, + windows: OS.WINDOWS, + linux: OS.LINUX, + android: OS.ANDROID, + ios: OS.IOS, +}; + +export function detectOS() { + if (typeof navigator === 'undefined') return OS.UNKNOWN; + + // Trust userAgentData only when it maps to a known OS; otherwise fall + // through to UA parsing so unmapped values (e.g. "Chrome OS") don't leak. + const uad = navigator.userAgentData?.platform?.toLowerCase(); + if (uad && UAD_MAP[uad]) return UAD_MAP[uad]; + + const ua = navigator.userAgent || ''; + if (/android/i.test(ua)) return OS.ANDROID; + if (/iPhone|iPod/.test(ua)) return OS.IOS; + if ( + /iPad/.test(ua) || + (/Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1) + ) { + return OS.IOS; + } + if (/Win/i.test(ua)) return OS.WINDOWS; + if (/Mac/i.test(ua)) return OS.MAC; + if (/Linux/i.test(ua)) return OS.LINUX; + + return OS.UNKNOWN; +} + +export const isApple = () => { + const os = detectOS(); + return os === OS.MAC || os === OS.IOS; +}; diff --git a/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js b/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js index 06b490b6c..8e996d0fb 100644 --- a/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js +++ b/app/javascript/shared/helpers/specs/KeyboardHelpers.spec.js @@ -3,9 +3,29 @@ import { isEscape, hasPressedShift, hasPressedCommand, + hasPressedMod, + hasPressedCommandAndEnter, + hasPressedEnterAndNotCmdOrShift, isActiveElementTypeable, } from '../KeyboardHelpers'; +const setNavigator = navigatorValue => { + Object.defineProperty(global, 'navigator', { + value: navigatorValue, + configurable: true, + writable: true, + }); +}; + +const onMac = () => setNavigator({ userAgentData: { platform: 'macOS' } }); +const onWindows = () => + setNavigator({ userAgentData: { platform: 'Windows' } }); +const onIOS = () => + setNavigator({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15', + }); + describe('#KeyboardHelpers', () => { describe('#isEnter', () => { it('return correct values', () => { @@ -30,6 +50,112 @@ describe('#KeyboardHelpers', () => { expect(hasPressedCommand({ metaKey: true })).toEqual(true); }); }); + + describe('#hasPressedMod', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + setNavigator(originalNavigator); + }); + + it('uses metaKey on macOS', () => { + onMac(); + expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true); + expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false); + }); + + it('uses ctrlKey on Windows', () => { + onWindows(); + expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(true); + expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(false); + }); + + it('uses metaKey on iOS hardware keyboards', () => { + onIOS(); + expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true); + expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false); + }); + + it('returns false when no modifier is held', () => { + onWindows(); + expect(hasPressedMod({ metaKey: false, ctrlKey: false })).toBe(false); + }); + }); + + describe('#hasPressedCommandAndEnter', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + setNavigator(originalNavigator); + }); + + it('returns true for Cmd+Enter on macOS', () => { + onMac(); + expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe( + true + ); + }); + + it('returns true for Ctrl+Enter on Windows (CW-6859 fix)', () => { + onWindows(); + expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe( + true + ); + }); + + it('returns false for Ctrl+Enter on macOS (Mac uses Cmd, not Ctrl)', () => { + onMac(); + expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe( + false + ); + }); + + it('returns true for Cmd+Enter on iOS hardware keyboards', () => { + onIOS(); + expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe( + true + ); + }); + + it('returns false for plain Enter', () => { + onWindows(); + expect(hasPressedCommandAndEnter({ key: 'Enter' })).toBe(false); + }); + }); + + describe('#hasPressedEnterAndNotCmdOrShift', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + setNavigator(originalNavigator); + }); + + it('returns true for plain Enter on Windows', () => { + onWindows(); + expect(hasPressedEnterAndNotCmdOrShift({ key: 'Enter' })).toBe(true); + }); + + it('returns false for Ctrl+Enter on Windows (mod is held)', () => { + onWindows(); + expect( + hasPressedEnterAndNotCmdOrShift({ key: 'Enter', ctrlKey: true }) + ).toBe(false); + }); + + it('returns false for Cmd+Enter on macOS (mod is held)', () => { + onMac(); + expect( + hasPressedEnterAndNotCmdOrShift({ key: 'Enter', metaKey: true }) + ).toBe(false); + }); + + it('returns false for Shift+Enter', () => { + onWindows(); + expect( + hasPressedEnterAndNotCmdOrShift({ key: 'Enter', shiftKey: true }) + ).toBe(false); + }); + }); }); describe('isActiveElementTypeable', () => { diff --git a/app/javascript/shared/helpers/specs/platform.spec.js b/app/javascript/shared/helpers/specs/platform.spec.js new file mode 100644 index 000000000..29bcc4339 --- /dev/null +++ b/app/javascript/shared/helpers/specs/platform.spec.js @@ -0,0 +1,186 @@ +import { detectOS, isApple, OS } from '../platform'; + +const setNavigator = ({ userAgentData, userAgent, maxTouchPoints } = {}) => { + Object.defineProperty(global, 'navigator', { + value: { userAgentData, userAgent, maxTouchPoints }, + configurable: true, + writable: true, + }); +}; + +describe('detectOS', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + configurable: true, + writable: true, + }); + }); + + describe('with userAgentData available', () => { + it('returns OS.MAC for macOS', () => { + setNavigator({ userAgentData: { platform: 'macOS' } }); + expect(detectOS()).toBe(OS.MAC); + }); + + it('returns OS.WINDOWS for Windows', () => { + setNavigator({ userAgentData: { platform: 'Windows' } }); + expect(detectOS()).toBe(OS.WINDOWS); + }); + + it('returns OS.LINUX for Linux', () => { + setNavigator({ userAgentData: { platform: 'Linux' } }); + expect(detectOS()).toBe(OS.LINUX); + }); + + it('returns OS.ANDROID for Android', () => { + setNavigator({ userAgentData: { platform: 'Android' } }); + expect(detectOS()).toBe(OS.ANDROID); + }); + + it('falls through to userAgent for unmapped values like "Chrome OS"', () => { + setNavigator({ + userAgentData: { platform: 'Chrome OS' }, + userAgent: 'Mozilla/5.0 (X11; CrOS x86_64) AppleWebKit/537.36', + }); + // Not a mapped UAD value AND not a recognized UA pattern → unknown + expect(detectOS()).toBe(OS.UNKNOWN); + }); + + it('prefers userAgentData over userAgent when value is mapped', () => { + setNavigator({ + userAgentData: { platform: 'Windows' }, + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + }); + expect(detectOS()).toBe(OS.WINDOWS); + }); + }); + + describe('with userAgent fallback', () => { + it('detects macOS from Safari userAgent', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + }); + expect(detectOS()).toBe(OS.MAC); + }); + + it('detects Windows from userAgent', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }); + expect(detectOS()).toBe(OS.WINDOWS); + }); + + it('detects Linux from userAgent', () => { + setNavigator({ + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + }); + expect(detectOS()).toBe(OS.LINUX); + }); + + it('detects Android from userAgent (before Linux match)', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36', + }); + expect(detectOS()).toBe(OS.ANDROID); + }); + + it('detects iOS from iPhone userAgent', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15', + }); + expect(detectOS()).toBe(OS.IOS); + }); + + it('detects iPadOS spoofing Macintosh via maxTouchPoints', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + maxTouchPoints: 5, + }); + expect(detectOS()).toBe(OS.IOS); + }); + + it('returns OS.UNKNOWN when no match', () => { + setNavigator({ userAgent: 'SomeRandomBot/1.0' }); + expect(detectOS()).toBe(OS.UNKNOWN); + }); + + it('returns OS.UNKNOWN when userAgent is missing', () => { + setNavigator({}); + expect(detectOS()).toBe(OS.UNKNOWN); + }); + }); + + describe('without navigator', () => { + it('returns OS.UNKNOWN when navigator is undefined', () => { + Object.defineProperty(global, 'navigator', { + value: undefined, + configurable: true, + writable: true, + }); + expect(detectOS()).toBe(OS.UNKNOWN); + }); + }); +}); + +describe('isApple', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + configurable: true, + writable: true, + }); + }); + + it('returns true on macOS', () => { + setNavigator({ userAgentData: { platform: 'macOS' } }); + expect(isApple()).toBe(true); + }); + + it('returns true on iOS (iPhone)', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15', + }); + expect(isApple()).toBe(true); + }); + + it('returns true on iPadOS spoofing Macintosh', () => { + setNavigator({ + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15', + maxTouchPoints: 5, + }); + expect(isApple()).toBe(true); + }); + + it('returns false on Windows', () => { + setNavigator({ userAgentData: { platform: 'Windows' } }); + expect(isApple()).toBe(false); + }); + + it('returns false on Linux', () => { + setNavigator({ userAgentData: { platform: 'Linux' } }); + expect(isApple()).toBe(false); + }); + + it('returns false on Android', () => { + setNavigator({ userAgentData: { platform: 'Android' } }); + expect(isApple()).toBe(false); + }); +}); + +describe('OS constants', () => { + it('is frozen so callers cannot mutate it', () => { + expect(Object.isFrozen(OS)).toBe(true); + }); +}); diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb index 116d0a3fc..d119d6345 100644 --- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -52,9 +52,9 @@ class Internal::Accounts::InternalAttributesService # Get list of valid features that can be manually managed def valid_feature_list - # Business and Enterprise plan features only Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES + - Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES + Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES + + %w[inbound_emails] end # Account notes functionality removed for now diff --git a/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb index 97b21090a..a4a18a9b9 100644 --- a/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb +++ b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb @@ -2,9 +2,8 @@ # Get all feature names and their display names all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names - # Business and Enterprise plan features only - premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES + - Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES + # Features that can be manually managed + premium_features = Internal::Accounts::InternalAttributesService.new(field.resource).valid_feature_list # Get only premium features with display names premium_features_with_display = premium_features.map do |feature|