chatwoot/app/javascript/shared/helpers/platform.js
Sivin Varghese 437dd9d38c
fix: prevent Ctrl+Enter adding extra line break on send (Windows) (#14077)
# Pull Request Template

## Description

This PR includes,
On Windows, pressing **Ctrl+Enter** in the reply editor was inserting an
unintended line break before sending. This led to two issues:

* **Unexpected blank lines**
After adding a line break with Shift+Enter and removing it with
Backspace, the editor looked correct. However, sending with Ctrl+Enter
reintroduced a hidden break, resulting in an extra blank line in the
final message.

* **Selected text being replaced**
When text was selected and Ctrl+Enter was pressed, the selection was
replaced with a line break instead of being sent.

Fixes
https://linear.app/chatwoot/issue/CW-6840/newline-bug-in-the-editor


### **Cause**

Two keyboard handlers responded to **Ctrl+Enter** on Windows:

* ProseMirror (`Mod-Enter`) inserted a hard break
* ReplyBox (`$mod+Enter`) triggered send

The existing guard only checked `metaKey` (Cmd), so it never worked on
Windows. As a result, a line break was inserted just before sending.

### **Solution**

Make the modifier check platform-aware so the editor correctly
intercepts the send shortcut:

* Added `detectOS`, `isMac`, and `OS` constants
* Introduced `hasPressedMod` (uses `metaKey` on macOS, `ctrlKey`
elsewhere)

This ensures Ctrl+Enter sends the message without modifying content,
while keeping existing behavior unchanged.


**NB:** macOS behavior with Cmd+Enter remains unchanged



## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Case 1: line break**

1. Type `hello`
2. Press Shift+Enter, then Backspace
3. Press Ctrl+Enter
   → Message contains an unexpected blank new line

**Case 2: Selection replaced**

1. Type two lines using Shift+Enter
2. Select text on the second line
3. Press Ctrl+Enter
   → Selected text is replaced and not sent

### Screencast

**Before**


https://github.com/user-attachments/assets/d6d285a9-260b-4711-8bbd-d0c8519e8d20

**After**



https://github.com/user-attachments/assets/c0ace1f7-5d22-44a2-8e08-22190ee21e61



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-04-20 18:16:41 +05:30

51 lines
1.4 KiB
JavaScript

// 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;
};