Merge branch 'feat/voice-as-twilio-capability' into feat/voice-call-model-wiring

This commit is contained in:
Muhsin Keloth
2026-04-20 20:28:13 +04:00
committed by GitHub
8 changed files with 411 additions and 15 deletions
@@ -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",
@@ -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 {
/>
<SettingsToggleSection
v-if="isAWebWidgetInbox"
v-if="isAWebWidgetInbox && showContinuityToggle"
v-model="continuityViaEmail"
:header="
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL')
"
:description="
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
)
:description="continuityDescription"
:hide-toggle="isContinuityDisabled"
:class="
isContinuityDisabled ? 'cursor-not-allowed opacity-50' : ''
"
/>
</SettingsAccordion>
@@ -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
+50
View File
@@ -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;
};
@@ -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', () => {
@@ -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);
});
});
@@ -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
@@ -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|