fix: make the context menu work on desktop+touch

- get rid of the MouseArea, replace with Tap/HoverHandlers
- suppress synthesized hover events on a tuch screen inside
  StatusMessage
This commit is contained in:
Lukáš Tinkl
2026-08-27 00:12:37 +02:00
parent d1ed216a4d
commit 070be83e8c
6 changed files with 479 additions and 70 deletions
@@ -165,6 +165,24 @@ Item {
compare(control.selectedText, "")
}
// Selection is mouse-only: a touch drag over a selectable view must not select anything
// (touch is handled one level above — tap activates links, long press opens the menu).
function test_touchDragDoesNotSelect() {
control.selectable = true
control.blocks = [
{ type: "text", html: "first line" },
{ type: "text", html: "second line" }
]
tryVerify(() => control.implicitHeight > 0)
const touch = touchEvent(control)
touch.press(0, control, 0, 3).commit()
touch.move(0, control, control.width - 4, control.implicitHeight - 4).commit()
touch.release(0, control, control.width - 4, control.implicitHeight - 4).commit()
compare(control.selectedText, "", "touch must not select text")
}
// With `selectable`, dragging from the first block into the second selects across
// both — the combined selectedText contains text from each.
function test_crossBlockSelection() {
@@ -512,6 +530,46 @@ Item {
compare(mentionSpy.signalArguments[0][0], "0xabc")
}
// Touch input doesn't select; links are activated via the public activateLinkAt(),
// which the message view calls from its touch TapHandler.
function test_activateLinkAt_link() {
control.selectable = true
control.blocks = [{ type: "text", html: '<a href="https://status.im">https://status.im</a>' }]
tryVerify(() => control.implicitHeight > 0)
linkSpy.clear()
control.activateLinkAt(Qt.point(10, 5))
compare(linkSpy.count, 1)
compare(linkSpy.signalArguments[0][0], "https://status.im")
}
function test_activateLinkAt_mention() {
control.selectable = true
control.blocks = [{ type: "text", html: '<a href="0xabc">@alice</a>' }]
tryVerify(() => control.implicitHeight > 0)
mentionSpy.clear()
control.activateLinkAt(Qt.point(5, 5))
compare(mentionSpy.count, 1)
compare(mentionSpy.signalArguments[0][0], "0xabc")
}
// A real touch tap (not the direct activateLinkAt() API call above) must also activate
// a link. The read-only TextEdit backing each block grabs touch points exclusively by
// default, so this guards against that grab silently swallowing the tap.
function test_realTouchTapActivatesLink() {
control.selectable = true
control.blocks = [{ type: "text", html: '<a href="https://status.im">https://status.im</a>' }]
tryVerify(() => control.implicitHeight > 0)
linkSpy.clear()
const touch = touchEvent(control)
touch.press(0, control, 10, 5).commit()
touch.release(0, control, 10, 5).commit()
compare(linkSpy.count, 1)
compare(linkSpy.signalArguments[0][0], "https://status.im")
}
// A drag (selection) must NOT be treated as a link click.
function test_dragDoesNotClickLink() {
control.selectable = true
@@ -0,0 +1,120 @@
import QtQuick
import QtTest
import StatusQ.Core
import StatusQ.Components
// A mouse text-selection drag inside a scrollable list (the chat's `StatusListView`) must
// extend the selection without the list scrolling underneath: a `Flickable` starts dragging
// at its own drag threshold and jumps the content before a pointer handler in the delegate
// can take the grab, which used to make selecting multiple lines nearly impossible.
Item {
id: root
width: 600
height: 500
Component {
id: componentUnderTest
StatusListView {
id: listView
width: 400
height: 300
// Mirrors the chat message list.
verticalLayoutDirection: ListView.BottomToTop
property var views: ({})
model: 20
delegate: Item {
width: listView.width
implicitHeight: textView.implicitHeight + 10
ChatTextView {
id: textView
width: parent.width
selectable: true
font.pixelSize: 15
blocks: [
{ type: "text", html: "Msg " + index + " line one here" },
{ type: "text", html: "Msg " + index + " line two here" },
{ type: "text", html: "Msg " + index + " line three here" }
]
Component.onCompleted: listView.views[index] = textView
}
}
}
}
TestCase {
name: "ChatTextViewListSelection"
when: windowShown
// Drives a drag as a sequence of moves, synchronising on rendering instead of
// sleeping for a fixed amount of time.
function dragBy(listView, fromX, fromY, toX, toY, step) {
mousePress(listView, fromX, fromY)
const dx = (toX - fromX) / step
const dy = (toY - fromY) / step
for (let i = 1; i <= step; ++i) {
mouseMove(listView, fromX + dx * i, fromY + dy * i)
waitForRendering(listView)
}
}
function selectedTexts(listView) {
return Object.keys(listView.views)
.map(key => listView.views[key] ? listView.views[key].selectedText : "")
.filter(text => !!text)
}
function test_selectionDragDoesNotScrollTheList() {
const listView = createTemporaryObject(componentUnderTest, root)
verify(listView)
tryVerify(() => !!listView.views[0] && listView.views[0].implicitHeight > 0)
// Let the list settle at its initial position before sampling it.
tryVerify(() => !listView.moving)
waitForRendering(listView)
const contentYBefore = listView.contentY
dragBy(listView, 5, 5, 65, 60, 11)
compare(listView.contentY, contentYBefore,
"the list must not scroll while a selection drag is in progress")
const selected = selectedTexts(listView)
compare(selected.length, 1, "exactly one message should hold the selection")
verify(selected[0].length > 0, "the drag should have selected text")
mouseRelease(listView, 65, 60)
}
// Suspending the list while selecting must not leave it permanently unscrollable.
function test_listStaysScrollableAfterSelectionDrag() {
const listView = createTemporaryObject(componentUnderTest, root)
verify(listView)
tryVerify(() => !!listView.views[0] && listView.views[0].implicitHeight > 0)
tryVerify(() => !listView.moving)
waitForRendering(listView)
dragBy(listView, 5, 5, 65, 60, 11)
mouseRelease(listView, 65, 60)
tryVerify(() => listView.interactive,
5000, "the list must be interactive again after the drag")
const contentYBefore = listView.contentY
dragBy(listView, listView.width - 10, 20, listView.width - 10, 120, 10)
mouseRelease(listView, listView.width - 10, 120)
tryVerify(() => listView.contentY !== contentYBefore,
5000, "the list should scroll again")
}
}
}
@@ -0,0 +1,100 @@
import QtQuick
import QtTest
import StatusQ.Components
// Hover reporting must be exclusive to real pointing devices. A touch tap makes Qt synthesize
// a mouse move ("core pointer"), which used to set `hovered` and pop up the hover-driven
// quick-actions context menu on top of the long-press one.
Item {
id: root
width: 600
height: 400
Component {
id: componentUnderTest
StatusMessage {
width: 500
messageId: "m1"
messageDetails: StatusMessageDetails {
messageText: "hello"
unparsedText: "hello"
}
}
}
SignalSpy {
id: hoverSpy
signalName: "hoverChanged"
}
TestCase {
id: testCase
name: "StatusMessageHover"
when: windowShown
// Touch taps are expected NOT to produce a hover. Waiting for a fixed amount of time
// would only make the test slow and flaky, so instead a subsequent real mouse hover is
// used as a synchronisation point: once it has been reported, any (synthesized) hover
// from the preceding touch tap would certainly have been delivered as well.
function syncOnMouseHover(control) {
mouseMove(control, 50, 10)
tryVerify(() => control.hovered, 5000, "the mouse hover was never delivered")
}
function createControl() {
const control = createTemporaryObject(componentUnderTest, root)
verify(control)
hoverSpy.target = control
hoverSpy.clear()
return control
}
// A mouse move over the message reports hovered=true.
function test_mouseHoverEmitsHoverChanged() {
const control = createControl()
mouseMove(control, 50, 10)
tryVerify(() => hoverSpy.count > 0, 2000, "mouse hover must emit hoverChanged")
compare(hoverSpy.signalArguments[hoverSpy.count - 1][0], "m1")
compare(hoverSpy.signalArguments[hoverSpy.count - 1][1], true)
// Move away again: hover ends.
mouseMove(control, 50, root.height - 1)
tryCompare(control, "effectiveHovered", false)
}
// A touch tap must not report hover at all (the synthesized mouse move is suppressed).
// Note the synthesized hover latches on and does not clear by itself, so this checks that
// hover never pulses true at any point, not merely that it settled back to false.
function test_touchTapDoesNotEmitHoverChanged() {
const control = createControl()
const touch = touchEvent(control)
touch.press(0, control, 50, 10).commit()
touch.release(0, control, 50, 10).commit()
syncOnMouseHover(control)
compare(hoverSpy.count, 0, "touch tap must not emit hoverChanged")
compare(control.effectiveHovered, false)
}
// The suppression must not be sticky: a real mouse hover after a touch tap still works.
function test_mouseHoverAfterTouchTapStillWorks() {
const control = createControl()
const touch = touchEvent(control)
touch.press(0, control, 50, 10).commit()
touch.release(0, control, 50, 10).commit()
// Leave the message, then come back with the mouse. (That the tap itself reports no
// hover is covered by `test_touchTapDoesNotEmitHoverChanged`.)
mouseMove(control, 50, root.height - 1)
tryVerify(() => !control.hovered, 5000, "the mouse never left the message")
mouseMove(control, 50, 10)
tryVerify(() => hoverSpy.count > 0, 2000, "mouse hover after a touch tap must work")
compare(hoverSpy.signalArguments[hoverSpy.count - 1][1], true)
}
}
}
@@ -73,6 +73,15 @@ Control {
ClipboardUtils.setText(root.selectedText)
}
// Activates the link (mention or url) at `pos`, given in this item's coordinates; a no-op
// when there is no link there. Text selection is a mouse-only interaction (see the overlay
// below), so touch input is routed here from one level above.
function activateLinkAt(pos) {
const p = root.contentItem.mapFromItem(root, pos.x, pos.y)
d.collectEditors()
d.activateLinkAt(p.x, p.y)
}
onSelectableChanged: d.clearSelection()
onBlocksChanged: d.clearSelection()
onEditedChanged: d.clearSelection()
@@ -152,6 +161,23 @@ Control {
text: effectiveStyle + d.wrapContent(content)
onHoveredLinkChanged: d.hoveredLink = hoveredLink
// A plain TextEdit grabs touch points exclusively (even with selectByMouse:
// false), so a TapHandler placed anywhere above it in the hierarchy (e.g. in
// StatusTextMessage, one level up) never sees touch taps. Handling the touch tap
// here, as a direct child of the TextEdit, is the only place the grab can be
// taken over from.
TapHandler {
acceptedDevices: PointerDevice.TouchScreen
gesturePolicy: TapHandler.ReleaseWithinBounds
grabPermissions: PointerHandler.CanTakeOverFromItems
| PointerHandler.CanTakeOverFromHandlersOfDifferentType
onSingleTapped: eventPoint => {
const link = textEdit.linkAt(eventPoint.position.x, eventPoint.position.y)
if (link)
d.activateLink(link)
}
}
}
}
@@ -370,19 +396,31 @@ Control {
root.mentionClicked(href)
}
// Selectable mode: the overlay swallows clicks, so resolve the link ourselves by finding
// the editor under (x, y) (in contentItem coords) and asking it for the link there.
function activateLinkAt(x, y) {
// Selectable mode: the overlay swallows hover, so resolve the link under (x, y) (in
// contentItem coords) ourselves and publish it as the hovered link.
function updateHoveredLinkAt(x, y) {
if (editors.length === 0)
collectEditors()
hoveredLink = linkAt(x, y)
}
// Returns the link href at (x, y) (in contentItem coords), or "" when there is none.
function linkAt(x, y) {
for (let i = 0; i < editors.length; ++i) {
const editor = editors[i]
const point = editor.mapFromItem(root.contentItem, x, y)
if (editor.contains(point)) {
const link = editor.linkAt(point.x, point.y)
if (link)
activateLink(link)
return
}
if (editor.contains(point))
return editor.linkAt(point.x, point.y) || ""
}
return ""
}
// Selectable mode: the overlay swallows clicks, so resolve the link ourselves by finding
// the editor under (x, y) (in contentItem coords) and asking it for the link there.
function activateLinkAt(x, y) {
const link = linkAt(x, y)
if (link)
activateLink(link)
}
function applySelection(a, b) {
@@ -538,16 +576,25 @@ Control {
// Cross-block selection: one overlay (over the whole content) drives the per-editor
// selection from a single press+drag. It is a child of the Control (not the Column), so
// it can fill the content area.
MouseArea {
//
// Selection is a mouse-only interaction: the handlers below accept pointing devices only,
// so touch events pass through untouched and are handled one level above (tap to activate
// a link, long press to open the message context menu).
Item {
id: selectionOverlay
anchors.fill: root.contentItem
z: 100
enabled: root.selectable
visible: root.selectable
cursorShape: !!root.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
preventStealing: true
readonly property int pointingDevices: PointerDevice.Mouse | PointerDevice.TouchPad
| PointerDevice.Stylus
property real pressX: 0
property real pressY: 0
property real lastX: 0
property real lastY: 0
property bool moved: false
// Multi-click tracking. Clicks in place cycle: 1 click, 2 word, 3 line, 4 deselect,
@@ -556,18 +603,19 @@ Control {
property real lastPressTime: 0
readonly property int clickSlop: 4
onPressed: (mouse) => {
function handlePress(x, y) {
root.forceActiveFocus() // Grab focus (deselects other views)
const now = Date.now()
const near = Math.abs(mouse.x - pressX) <= clickSlop
&& Math.abs(mouse.y - pressY) <= clickSlop
const near = Math.abs(x - pressX) <= clickSlop && Math.abs(y - pressY) <= clickSlop
clickCount = (now - lastPressTime <= Qt.styleHints.mouseDoubleClickInterval && near)
? clickCount + 1 : 1
lastPressTime = now
pressX = mouse.x
pressY = mouse.y
pressX = x
pressY = y
lastX = x
lastY = y
moved = false
d.collectEditors()
@@ -575,30 +623,120 @@ Control {
const mode = (clickCount - 1) % 4
if (mode === 1) {
d.anchor = null // discrete word selection, no drag-extend
d.selectWordAt(mouse.x, mouse.y)
d.selectWordAt(x, y)
moved = true // suppress link activation on release
} else if (mode === 2) {
d.anchor = null
d.selectLineAt(mouse.x, mouse.y)
d.selectLineAt(x, y)
moved = true
} else if (mode === 3) {
d.clearSelection()
moved = true
} else {
d.anchor = d.hitTest(mouse.x, mouse.y)
d.anchor = d.hitTest(x, y)
d.applySelection(d.anchor, d.anchor)
}
}
onPositionChanged: (mouse) => {
if (Math.abs(mouse.x - pressX) > 3 || Math.abs(mouse.y - pressY) > 3)
function handleMove(x, y) {
lastX = x
lastY = y
if (Math.abs(x - pressX) > 3 || Math.abs(y - pressY) > 3)
moved = true
if (d.anchor)
d.applySelection(d.anchor, d.hitTest(mouse.x, mouse.y))
d.applySelection(d.anchor, d.hitTest(x, y))
}
// A click (no drag) on a link activates it; a drag selects text instead.
onReleased: (mouse) => {
if (!moved)
d.activateLinkAt(mouse.x, mouse.y)
// The overlay covers the editors, so they never see hover events in selectable mode;
// resolve the link under the pointer here instead (drives the link hover highlight
// and the cursor shape).
HoverHandler {
acceptedDevices: selectionOverlay.pointingDevices
cursorShape: !!root.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
onPointChanged: if (hovered) d.updateHoveredLinkAt(point.position.x, point.position.y)
onHoveredChanged: if (!hovered) d.hoveredLink = ""
}
TapHandler {
acceptedButtons: Qt.LeftButton
acceptedDevices: selectionOverlay.pointingDevices
// Keep the exclusive grab for the whole press, so a drag keeps extending the
// selection even when it leaves the message bounds (mimics `preventStealing`).
gesturePolicy: TapHandler.ReleaseWithinBounds
grabPermissions: PointerHandler.CanTakeOverFromItems
| PointerHandler.CanTakeOverFromHandlersOfDifferentType
| PointerHandler.ApprovesTakeOverByHandlersOfSameType
onPressedChanged: {
if (pressed) {
selectionOverlay.suspendFlicking()
selectionOverlay.handlePress(point.position.x, point.position.y)
} else {
selectionOverlay.resumeFlicking()
}
}
onPointChanged: if (pressed)
selectionOverlay.handleMove(point.position.x, point.position.y)
// A click (no drag) on a link activates it; a drag selects text instead.
onTapped: if (!selectionOverlay.moved)
d.activateLinkAt(selectionOverlay.lastX, selectionOverlay.lastY)
}
// Ancestor `Flickable` (a `StatusListView` in the chat) whose interactivity is
// suspended while a selection drag is in progress. Pointer handler grab permissions
// alone aren't enough: a `Flickable` starts dragging at its own drag threshold and
// applies a one-shot content jump before any handler here can take the grab, which
// makes precise text selection (especially across lines) impossible.
property Flickable ancestorFlickable: null
property bool flickableWasInteractive: false
function findAncestorFlickable() {
let item = root.parent
while (item) {
if (item instanceof Flickable)
return item
item = item.parent
}
return null
}
function suspendFlicking() {
ancestorFlickable = findAncestorFlickable()
if (!ancestorFlickable)
return
flickableWasInteractive = ancestorFlickable.interactive
ancestorFlickable.interactive = false
}
function resumeFlicking() {
if (!ancestorFlickable)
return
ancestorFlickable.interactive = flickableWasInteractive
ancestorFlickable = null
}
// A `TapHandler` alone (even with an exclusive grabbing `gesturePolicy`) is treated as
// a passive grab by ancestor `Flickable`s once the pointer moves past their drag
// threshold, so a `StatusListView`/`ListView` ancestor steals the gesture and scrolls
// instead of extending the text selection. Only a `DragHandler` takes the kind of
// exclusive (active) grab a `Flickable` respects, so one is added here to hold the
// grab for the drag part of the gesture; it moves nothing (`target: null`).
//
// Because it takes the grab away from the `TapHandler`, the `TapHandler` stops getting
// point updates once dragging starts, so the selection has to be extended from here
// for the rest of the gesture (the `TapHandler` still handles the press and the
// no-drag tap that activates links).
DragHandler {
target: null
acceptedButtons: Qt.LeftButton
acceptedDevices: selectionOverlay.pointingDevices
grabPermissions: PointerHandler.CanTakeOverFromItems
| PointerHandler.CanTakeOverFromHandlersOfDifferentType
onCentroidChanged: if (active)
selectionOverlay.handleMove(centroid.position.x,
centroid.position.y)
}
}
@@ -123,8 +123,14 @@ Control {
})
}
HoverHandler {
acceptedDevices: PointerDevice.TouchScreen
onHoveredChanged: if (hovered) d.touchHoverSuppressed = true
}
hoverEnabled: (!root.isActiveMessage && !root.disableHover)
onHoveredChanged: root.hoverChanged(root.messageId, root.hovered)
onHoveredChanged: if (!hovered) d.touchHoverSuppressed = false
readonly property alias effectiveHovered: d.effectiveHovered
background: Rectangle {
color: {
@@ -401,6 +407,21 @@ Control {
QtObject {
id: d
property string selectedText
// Hover must be reported for real pointing devices only. A touch tap makes Qt synthesize a
// mouse move (a "core pointer" event, indistinguishable from a real mouse by
// `acceptedDevices` on a HoverHandler), which would otherwise set `hovered` and trigger
// hover-driven UI such as the quick-actions context menu. On touch that menu is the
// long-press gesture instead, so a touch-originated hover is latched as suppressed until
// the hover genuinely ends. A latch rather than a timer is required because the synthesized
// hover stays active for as long as the pointer "rests" on the message after the tap.
//
// A HoverHandler (passive, never takes a grab) is used deliberately here: the read-only
// TextEdits rendering the message text take the exclusive grab on any touch point, which
// would keep a PointHandler at this level from ever becoming active.
property bool touchHoverSuppressed: false
readonly property bool effectiveHovered: root.hovered && !d.touchHoverSuppressed
onEffectiveHoveredChanged: root.hoverChanged(root.messageId, d.effectiveHovered)
}
component StatusTextMessageCommon: StatusTextMessage {
+14 -42
View File
@@ -11425,25 +11425,21 @@ to load</source>
</context>
<context>
<name>MessageContextMenuView</name>
<message>
<source>Reply to</source>
<translation>Відповісти</translation>
</message>
<message>
<source>Edit message</source>
<translation>Редагувати</translation>
</message>
<message>
<source>Copy</source>
<translation>Копіювати</translation>
</message>
<message>
<source>Copy message</source>
<translation>Копіювати</translation>
</message>
<message>
<source>Copy Message Id</source>
<translation>Копіювати ID</translation>
<source>Reply</source>
<translation type="unfinished">Відповісти</translation>
</message>
<message>
<source>Edit</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Copy selected</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Copy link to message</source>
@@ -11458,12 +11454,12 @@ to load</source>
<translation>Закріпити</translation>
</message>
<message>
<source>Mark as unread</source>
<translation>Непрочитане</translation>
<source>Delete</source>
<translation type="unfinished">Видалити</translation>
</message>
<message>
<source>Delete message</source>
<translation>Видалити</translation>
<source>Mark as unread</source>
<translation>Непрочитане</translation>
</message>
</context>
<context>
@@ -11531,30 +11527,6 @@ to load</source>
<source>GIF</source>
<translation>GIF</translation>
</message>
<message>
<source>Reply</source>
<translation>Відповісти</translation>
</message>
<message>
<source>Edit</source>
<translation>Редагувати</translation>
</message>
<message>
<source>Unpin</source>
<translation>Відкріпити</translation>
</message>
<message>
<source>Pin</source>
<translation>Закріпити</translation>
</message>
<message>
<source>Mark as unread</source>
<translation>Непрочитане</translation>
</message>
<message>
<source>Delete</source>
<translation>Видалити</translation>
</message>
<message>
<source>Unknown message. Trying to recover it</source>
<translation>Невідоме повідомлення. Спроба відновлення</translation>