diff --git a/internal/browser/input_mode_test.go b/internal/browser/input_mode_test.go new file mode 100644 index 0000000..6afce6e --- /dev/null +++ b/internal/browser/input_mode_test.go @@ -0,0 +1,170 @@ +//go:build !short + +package browser_test + +import ( + "context" + "testing" + "time" + + "github.com/emilhauk/msg/internal/model" + "github.com/emilhauk/msg/internal/testutil" + "github.com/go-rod/rod/lib/input" + "github.com/stretchr/testify/require" +) + +// TestInputMode_PhysicalKeyboard_EnterSends verifies that on a device with a +// fine pointer (physical keyboard), pressing Enter in the message textarea +// submits the form instead of inserting a newline. +func TestInputMode_PhysicalKeyboard_EnterSends(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping browser test in short mode") + } + const room = "room-input-phys-send" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Input Mode Test"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + + // Wait for SSE to establish. + time.Sleep(300 * time.Millisecond) + + // Default Chromium emulates pointer: fine — so Enter should send. + // Ensure __isVirtualKeyboard returns false. + isVirtual := page.MustEval(`() => window.__isVirtualKeyboard()`).Bool() + require.False(t, isVirtual, "desktop browser should report physical keyboard") + + // Type a message and press Enter. + ta := page.MustElement(".message-form__textarea") + ta.MustClick() + ta.MustInput("hello from physical keyboard") + page.Keyboard.MustType(input.Enter) + + // The message should appear via SSE (form was submitted). + page.Timeout(5 * time.Second).MustElement("article.message") + text := page.MustElement("article.message .message__text").MustText() + require.Contains(t, text, "hello from physical keyboard") + + // Textarea should be cleared after submit. + val := page.MustEval(`() => document.querySelector('.message-form__textarea').value`).String() + require.Empty(t, val, "textarea should be cleared after send") +} + +// TestInputMode_VirtualKeyboard_EnterNewline verifies that when the input mode +// is virtual (touch), pressing Enter inserts a newline instead of submitting. +func TestInputMode_VirtualKeyboard_EnterNewline(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping browser test in short mode") + } + const room = "room-input-virt-newline" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Input Mode Test"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + + // Wait for SSE to establish. + time.Sleep(300 * time.Millisecond) + + // Force virtual keyboard mode by overriding the flag. + page.MustEval(`() => { window.__isVirtualKeyboard = () => true; }`) + + // Type text and press Enter. + ta := page.MustElement(".message-form__textarea") + ta.MustClick() + ta.MustInput("line one") + page.Keyboard.MustType(input.Enter) + + // Give a moment for any submission to happen (it shouldn't). + time.Sleep(200 * time.Millisecond) + + // No message article should have been created. + articles := page.MustElements("article.message") + require.Empty(t, articles, "Enter on virtual keyboard should not send the message") + + // The textarea should still contain the text (Enter produced a newline). + val := page.MustEval(`() => document.querySelector('.message-form__textarea').value`).String() + require.Contains(t, val, "line one", "textarea should still contain the typed text") +} + +// TestInputMode_VirtualKeyboard_EditEnterNewline verifies that when in virtual +// keyboard mode, pressing Enter in an edit textarea inserts a newline instead +// of submitting the edit. +func TestInputMode_VirtualKeyboard_EditEnterNewline(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping browser test in short mode") + } + const room = "room-input-virt-edit" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Input Mode Test"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + + msg := seedMessage(t, ts, alice, room, "original text") + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + page.Timeout(5 * time.Second).MustElement("article.message") + + // Force virtual keyboard mode. + page.MustEval(`() => { window.__isVirtualKeyboard = () => true; }`) + + // Open the edit form. + page.MustEval(`(id) => window.__openEdit(id)`, msg.ID) + editTA := page.Timeout(2 * time.Second).MustElement("#edit-form-" + msg.ID + " textarea") + + // Press Enter in the edit textarea. + editTA.MustClick() + page.Keyboard.MustType(input.Enter) + + // Give a moment for any submission to happen (it shouldn't). + time.Sleep(200 * time.Millisecond) + + // The edit form should still be visible (not submitted and closed). + formHidden := page.MustEval(`(id) => document.getElementById('edit-form-' + id).hidden`, msg.ID).Bool() + require.False(t, formHidden, "edit form should remain open (Enter should not submit on virtual keyboard)") +} + +// TestInputMode_PhysicalKeyboard_EditEnterSubmits verifies that on a physical +// keyboard, pressing Enter in an edit textarea submits the edit. +func TestInputMode_PhysicalKeyboard_EditEnterSubmits(t *testing.T) { + t.Parallel() + if testing.Short() { + t.Skip("skipping browser test in short mode") + } + const room = "room-input-phys-edit" + ts := testutil.NewTestServer(t) + ts.SeedRoom(t, model.Room{ID: room, Name: "Input Mode Test"}) + require.NoError(t, ts.Redis.CreateUser(context.Background(), alice)) + + msg := seedMessage(t, ts, alice, room, "original text") + + b := newBrowser(t) + page := authPage(t, b, ts, alice, room) + page.Timeout(5 * time.Second).MustElement("article.message") + + // Wait for SSE. + time.Sleep(300 * time.Millisecond) + + // Ensure physical keyboard mode. + isVirtual := page.MustEval(`() => window.__isVirtualKeyboard()`).Bool() + require.False(t, isVirtual) + + // Open the edit form. + page.MustEval(`(id) => window.__openEdit(id)`, msg.ID) + editTA := page.Timeout(2 * time.Second).MustElement("#edit-form-" + msg.ID + " textarea") + + // Clear and type new text, then press Enter. + editTA.MustSelectAllText().MustInput("edited text") + page.Keyboard.MustType(input.Enter) + + // The edit form should close (optimistic close on successful PATCH). + time.Sleep(500 * time.Millisecond) + formHidden := page.MustEval(`(id) => document.getElementById('edit-form-' + id).hidden`, msg.ID).Bool() + require.True(t, formHidden, "edit form should close after Enter submits on physical keyboard") +} diff --git a/web/static/room.js b/web/static/room.js index 6c6325b..d271700 100644 --- a/web/static/room.js +++ b/web/static/room.js @@ -1,3 +1,4 @@ +import '/static/room/input-mode.js'; import '/static/room/owner-controls.js'; import '/static/room/scroll.js'; import '/static/room/textarea.js'; diff --git a/web/static/room/edit.js b/web/static/room/edit.js index b312a6e..33b238c 100644 --- a/web/static/room/edit.js +++ b/web/static/room/edit.js @@ -83,9 +83,13 @@ document.addEventListener('click', (e) => { }); // Enter (without Shift) submits the edit form; Shift+Enter inserts newline. +// On virtual (touch) keyboards Enter always inserts a newline — the user taps +// the submit button instead. // Skip if autocomplete already handled the event (e.g. inserted an emoji). + document.addEventListener('keydown', (e) => { if (e.key !== 'Enter' || e.shiftKey || e.defaultPrevented) return; + if (window.__isVirtualKeyboard?.()) return; const ta = e.target; if (!ta || !ta.classList.contains('message-edit-form__textarea')) return; const form = ta.closest('.message-edit-form'); diff --git a/web/static/room/input-mode.js b/web/static/room/input-mode.js new file mode 100644 index 0000000..1501ecf --- /dev/null +++ b/web/static/room/input-mode.js @@ -0,0 +1,30 @@ +// Tracks whether the user is likely using a virtual (touch) or physical keyboard. +// +// Strategy: default to `pointer: coarse` media query (covers phones/tablets), +// then let actual pointer events override — so a tablet user who attaches a +// Bluetooth keyboard (and starts using a mouse/trackpad) gets physical-keyboard +// behavior, and vice versa. +// +// Exported flag `virtualKeyboard` is read by the Enter-to-send logic in +// room.html (inline handler) and edit.js to decide whether Enter inserts a +// newline (virtual) or submits the form (physical). + +const coarse = matchMedia('(pointer: coarse)'); +let _virtual = coarse.matches; + +// Live media-query changes (e.g. tablet docked/undocked). +coarse.addEventListener('change', (e) => { _virtual = e.matches; }); + +// Override based on actual pointer interaction with any textarea. +document.addEventListener('pointerdown', (e) => { + if (!e.target || !e.target.closest('textarea')) return; + _virtual = e.pointerType === 'touch'; +}); + +/** @returns {boolean} true when the user is likely using a virtual keyboard */ +export function isVirtualKeyboard() { + return _virtual; +} + +// Expose globally so the inline onkeydown in room.html can access it. +window.__isVirtualKeyboard = isVirtualKeyboard; diff --git a/web/templates/message.html b/web/templates/message.html index 57517b7..f5308f7 100644 --- a/web/templates/message.html +++ b/web/templates/message.html @@ -51,7 +51,7 @@