Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions internal/browser/input_mode_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
1 change: 1 addition & 0 deletions web/static/room.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
4 changes: 4 additions & 0 deletions web/static/room/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
30 changes: 30 additions & 0 deletions web/static/room/input-mode.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion web/templates/message.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
<form class="message-edit-form" id="edit-form-{{.ID}}" hidden
hx-patch="/rooms/{{.RoomID}}/messages/{{.ID}}"
hx-swap="none">
<textarea class="message-edit-form__textarea" name="text" rows="1">{{.Text}}</textarea>
<textarea class="message-edit-form__textarea" name="text" rows="1" enterkeyhint="enter">{{.Text}}</textarea>
<div class="message-edit-form__actions">
<button type="submit" class="btn btn--primary btn--sm">Save</button>
<button type="button" class="btn btn--ghost btn--sm" data-edit-cancel="{{.ID}}">Cancel</button>
Expand Down
3 changes: 2 additions & 1 deletion web/templates/room.html
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,8 @@ <h1 class="room-main__title"># {{.Data.Room.Name}}</h1>
placeholder="Message #{{.Data.Room.Name}}"
rows="1"
autocomplete="off"
onkeydown="if(event.key==='Enter'&&!event.shiftKey&&document.getElementById('emoji-autocomplete').hidden){event.preventDefault();this.closest('form').requestSubmit();}"
enterkeyhint="enter"
onkeydown="if(event.key==='Enter'&&!event.shiftKey&&!window.__isVirtualKeyboard()&&document.getElementById('emoji-autocomplete').hidden){event.preventDefault();this.closest('form').requestSubmit();}"
></textarea>
<button class="btn btn--primary message-form__send" type="submit" aria-label="Send">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
Expand Down
Loading