Initial implementation of message selection
This commit is contained in:
parent
aaaff814a1
commit
016f76167f
4
TODO.md
4
TODO.md
|
@ -1,3 +1,7 @@
|
||||||
|
- Message selection
|
||||||
|
- Make scroll wheel usable
|
||||||
|
- Copying messages (menu & shortcut)
|
||||||
|
|
||||||
- Refactoring
|
- Refactoring
|
||||||
- Banners
|
- Banners
|
||||||
- Composer
|
- Composer
|
||||||
|
|
|
@ -3,6 +3,7 @@ import QtQuick.Controls 2.12
|
||||||
|
|
||||||
ListView {
|
ListView {
|
||||||
id: listView
|
id: listView
|
||||||
|
interactive: enableFlicking
|
||||||
currentIndex: -1
|
currentIndex: -1
|
||||||
keyNavigationWraps: true
|
keyNavigationWraps: true
|
||||||
highlightMoveDuration: theme.animationDuration
|
highlightMoveDuration: theme.animationDuration
|
||||||
|
@ -13,6 +14,8 @@ ListView {
|
||||||
preferredHighlightEnd: height / 2 + currentItemHeight
|
preferredHighlightEnd: height / 2 + currentItemHeight
|
||||||
|
|
||||||
|
|
||||||
|
property bool enableFlicking: true
|
||||||
|
|
||||||
readonly property int currentItemHeight:
|
readonly property int currentItemHeight:
|
||||||
currentItem ? currentItem.height : 0
|
currentItem ? currentItem.height : 0
|
||||||
|
|
||||||
|
@ -21,7 +24,9 @@ ListView {
|
||||||
color: theme.controls.listView.highlight
|
color: theme.controls.listView.highlight
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollBar.vertical: ScrollBar { visible: listView.interactive }
|
ScrollBar.vertical: ScrollBar {
|
||||||
|
visible: listView.interactive || ! listView.enableFlicking
|
||||||
|
}
|
||||||
|
|
||||||
add: Transition {
|
add: Transition {
|
||||||
ParallelAnimation {
|
ParallelAnimation {
|
||||||
|
@ -45,6 +50,5 @@ ListView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
populate: add
|
|
||||||
displaced: move
|
displaced: move
|
||||||
}
|
}
|
||||||
|
|
119
src/qml/Base/HSelectableLabel.qml
Normal file
119
src/qml/Base/HSelectableLabel.qml
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
|
||||||
|
TextEdit {
|
||||||
|
id: label
|
||||||
|
font.family: theme.fontFamily.sans
|
||||||
|
font.pixelSize: theme.fontSize.normal
|
||||||
|
color: theme.colors.text
|
||||||
|
|
||||||
|
textFormat: Label.PlainText
|
||||||
|
tabStopDistance: 4 * 4 // 4 spaces
|
||||||
|
|
||||||
|
readOnly: true
|
||||||
|
persistentSelection: true
|
||||||
|
|
||||||
|
onLinkActivated: Qt.openUrlExternally(link)
|
||||||
|
|
||||||
|
|
||||||
|
property HSelectableLabelContainer container
|
||||||
|
property int index
|
||||||
|
|
||||||
|
|
||||||
|
function updateSelection() {
|
||||||
|
if (! container.reversed &&
|
||||||
|
container.selectionStart <= container.selectionEnd ||
|
||||||
|
|
||||||
|
container.reversed &&
|
||||||
|
container.selectionStart > container.selectionEnd)
|
||||||
|
{
|
||||||
|
var first = container.selectionStart
|
||||||
|
var firstPos = container.selectionStartPosition
|
||||||
|
var last = container.selectionEnd
|
||||||
|
var lastPos = container.selectionEndPosition
|
||||||
|
} else {
|
||||||
|
var first = container.selectionEnd
|
||||||
|
var firstPos = container.selectionEndPosition
|
||||||
|
var last = container.selectionStart
|
||||||
|
var lastPos = container.selectionStartPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first == index && last == index) {
|
||||||
|
select(
|
||||||
|
label.positionAt(firstPos.x, firstPos.y),
|
||||||
|
label.positionAt(lastPos.x, lastPos.y),
|
||||||
|
)
|
||||||
|
|
||||||
|
} else if ((! container.reversed && first < index && index < last) ||
|
||||||
|
(container.reversed && first > index && index > last))
|
||||||
|
{
|
||||||
|
label.selectAll()
|
||||||
|
|
||||||
|
} else if (first == index) {
|
||||||
|
label.select(positionAt(firstPos.x, firstPos.y), length)
|
||||||
|
|
||||||
|
} else if (last == index) {
|
||||||
|
label.select(0, positionAt(lastPos.x, lastPos.y))
|
||||||
|
|
||||||
|
} else {
|
||||||
|
label.deselect()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContainerSelectedTexts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContainerSelectedTexts() {
|
||||||
|
container.selectedTexts[index] = selectedText
|
||||||
|
container.selectedTextsChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWordAt(position) {
|
||||||
|
container.clearSelection()
|
||||||
|
label.cursorPosition = positionAt(position.x, position.y)
|
||||||
|
label.selectWord()
|
||||||
|
updateContainerSelectedTexts()
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllText() {
|
||||||
|
container.clearSelection()
|
||||||
|
label.selectAll()
|
||||||
|
updateContainerSelectedTexts()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: container
|
||||||
|
onSelectionInfoChanged: updateSelection()
|
||||||
|
onDeselectAll: deselect()
|
||||||
|
}
|
||||||
|
|
||||||
|
DropArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onPositionChanged: {
|
||||||
|
if (! container.selecting) {
|
||||||
|
container.clearSelection()
|
||||||
|
container.selectionStart = index
|
||||||
|
container.selectionStartPosition = Qt.point(drag.x, drag.y)
|
||||||
|
container.selecting = true
|
||||||
|
} else {
|
||||||
|
container.selectionEnd = index
|
||||||
|
container.selectionEndPosition = Qt.point(drag.x, drag.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
acceptedButtons: Qt.LeftButton
|
||||||
|
onTapped: {
|
||||||
|
tapCount == 2 ? selectWordAt(eventPoint.position) :
|
||||||
|
tapCount == 3 ? selectAllText() :
|
||||||
|
container.clearSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: label
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
cursorShape: label.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
|
||||||
|
}
|
||||||
|
}
|
72
src/qml/Base/HSelectableLabelContainer.qml
Normal file
72
src/qml/Base/HSelectableLabelContainer.qml
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import QtQuick 2.12
|
||||||
|
|
||||||
|
Item {
|
||||||
|
signal deselectAll()
|
||||||
|
|
||||||
|
|
||||||
|
property bool reversed: false
|
||||||
|
|
||||||
|
readonly property bool dragging: pointHandler.active || dragHandler.active
|
||||||
|
// onDraggingChanged: print(dragging)
|
||||||
|
property bool selecting: false
|
||||||
|
property int selectionStart: -1
|
||||||
|
property int selectionEnd: -1
|
||||||
|
property point selectionStartPosition: Qt.point(-1, -1)
|
||||||
|
property point selectionEndPosition: Qt.point(-1, -1)
|
||||||
|
property var selectedTexts: ({})
|
||||||
|
|
||||||
|
readonly property var selectionInfo: [
|
||||||
|
selectionStart, selectionStartPosition,
|
||||||
|
selectionEnd, selectionEndPosition,
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly property alias dragPoint: dragHandler.centroid
|
||||||
|
readonly property alias dragPosition: dragHandler.centroid.position
|
||||||
|
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selecting = false
|
||||||
|
selectionStart = -1
|
||||||
|
selectionEnd = -1
|
||||||
|
selectionStartPosition = Qt.point(-1, -1)
|
||||||
|
selectionEndPosition = Qt.point(-1, -1)
|
||||||
|
deselectAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
function copySelection() {
|
||||||
|
let toCopy = []
|
||||||
|
|
||||||
|
for (let key of Object.keys(selectedTexts).sort()) {
|
||||||
|
if (selectedTexts[key]) toCopy.push(selectedTexts[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call some function to copy to clipboard here instead
|
||||||
|
print("Copy: <" + toCopy.join("\n\n") + ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Item { id: dragPoint }
|
||||||
|
|
||||||
|
DragHandler {
|
||||||
|
id: dragHandler
|
||||||
|
target: dragPoint
|
||||||
|
onActiveChanged: {
|
||||||
|
if (active) {
|
||||||
|
target.Drag.active = true
|
||||||
|
} else {
|
||||||
|
target.Drag.drop()
|
||||||
|
target.Drag.active = false
|
||||||
|
selecting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
acceptedButtons: Qt.LeftButton
|
||||||
|
onTapped: clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
PointHandler {
|
||||||
|
id: pointHandler
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,15 +8,6 @@ Row {
|
||||||
spacing: theme.spacing / 2
|
spacing: theme.spacing / 2
|
||||||
|
|
||||||
readonly property string eventText: Utils.processedEventText(model)
|
readonly property string eventText: Utils.processedEventText(model)
|
||||||
readonly property real lineHeight:
|
|
||||||
! eventText.match(/<img .+\/?>/) && multiline ? 1.25 : 1.0
|
|
||||||
readonly property bool multiline:
|
|
||||||
(eventText.match(/(\n|<br\/?>)/) || []).length > 0 ||
|
|
||||||
contentLabel.contentWidth < (
|
|
||||||
contentLabel.implicitWidth -
|
|
||||||
contentLabel.leftPadding -
|
|
||||||
contentLabel.rightPadding
|
|
||||||
)
|
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
width: hideAvatar ? 0 : 48
|
width: hideAvatar ? 0 : 48
|
||||||
|
@ -64,16 +55,17 @@ Row {
|
||||||
textFormat: Text.StyledText
|
textFormat: Text.StyledText
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
horizontalAlignment: onRight ? Text.AlignRight : Text.AlignLeft
|
horizontalAlignment: onRight ? Text.AlignRight : Text.AlignLeft
|
||||||
lineHeight: eventContent.lineHeight
|
|
||||||
|
|
||||||
leftPadding: theme.spacing
|
leftPadding: theme.spacing
|
||||||
rightPadding: leftPadding
|
rightPadding: leftPadding
|
||||||
topPadding: theme.spacing / 2 * lineHeight
|
topPadding: theme.spacing / 2 * lineHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
HRichLabel {
|
HSelectableLabel {
|
||||||
id: contentLabel
|
id: contentLabel
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
container: selectableLabelContainer
|
||||||
|
index: model.index
|
||||||
|
|
||||||
text: theme.chat.message.styleInclude +
|
text: theme.chat.message.styleInclude +
|
||||||
eventContent.eventText +
|
eventContent.eventText +
|
||||||
|
@ -87,14 +79,14 @@ Row {
|
||||||
" <font size=" + theme.fontSize.small +
|
" <font size=" + theme.fontSize.small +
|
||||||
"px>⏳</font>" : "")
|
"px>⏳</font>" : "")
|
||||||
|
|
||||||
lineHeight: eventContent.lineHeight
|
|
||||||
color: theme.chat.message.body
|
color: theme.chat.message.body
|
||||||
wrapMode: Text.Wrap
|
wrapMode: Text.Wrap
|
||||||
|
textFormat: Text.RichText
|
||||||
|
|
||||||
leftPadding: theme.spacing
|
leftPadding: theme.spacing
|
||||||
rightPadding: leftPadding
|
rightPadding: leftPadding
|
||||||
topPadding: nameLabel.visible ? 0 : bottomPadding
|
topPadding: nameLabel.visible ? 0 : bottomPadding
|
||||||
bottomPadding: theme.spacing / 2 * lineHeight
|
bottomPadding: theme.spacing / 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,95 +7,149 @@ Rectangle {
|
||||||
|
|
||||||
color: theme.chat.eventList.background
|
color: theme.chat.eventList.background
|
||||||
|
|
||||||
HListView {
|
HSelectableLabelContainer {
|
||||||
id: eventList
|
id: selectableLabelContainer
|
||||||
clip: true
|
|
||||||
Component.onCompleted: shortcuts.flickTarget = eventList
|
|
||||||
|
|
||||||
function canCombine(item, itemAfter) {
|
|
||||||
if (! item || ! itemAfter) return false
|
|
||||||
|
|
||||||
return Boolean(
|
|
||||||
! canTalkBreak(item, itemAfter) &&
|
|
||||||
! canDayBreak(item, itemAfter) &&
|
|
||||||
item.sender_id === itemAfter.sender_id &&
|
|
||||||
Utils.minutesBetween(item.date, itemAfter.date) <= 5
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function canTalkBreak(item, itemAfter) {
|
|
||||||
if (! item || ! itemAfter) return false
|
|
||||||
|
|
||||||
return Boolean(
|
|
||||||
! canDayBreak(item, itemAfter) &&
|
|
||||||
Utils.minutesBetween(item.date, itemAfter.date) >= 20
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function canDayBreak(item, itemAfter) {
|
|
||||||
if (itemAfter && itemAfter.event_type == "RoomCreateEvent")
|
|
||||||
return true
|
|
||||||
|
|
||||||
if (! item || ! itemAfter || ! item.date || ! itemAfter.date)
|
|
||||||
return false
|
|
||||||
|
|
||||||
return item.date.getDate() != itemAfter.date.getDate()
|
|
||||||
}
|
|
||||||
|
|
||||||
model: HListModel {
|
|
||||||
keyField: "client_id"
|
|
||||||
source:
|
|
||||||
modelSources[["Event", chatPage.userId, chatPage.roomId]] || []
|
|
||||||
}
|
|
||||||
|
|
||||||
property bool ownEventsOnRight:
|
|
||||||
width < theme.chat.eventList.ownEventsOnRightUnderWidth
|
|
||||||
|
|
||||||
delegate: EventDelegate {}
|
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.leftMargin: theme.spacing
|
reversed: eventList.verticalLayoutDirection == ListView.BottomToTop
|
||||||
anchors.rightMargin: theme.spacing
|
|
||||||
|
|
||||||
topMargin: theme.spacing
|
onDragPositionChanged: {
|
||||||
bottomMargin: theme.spacing
|
let boost = 20 * (
|
||||||
verticalLayoutDirection: ListView.BottomToTop
|
dragPosition.y < 50 ?
|
||||||
|
-dragPosition.y : -(height - dragPosition.y)
|
||||||
|
)
|
||||||
|
|
||||||
// Keep x scroll pages cached, to limit images having to be
|
dragFlicker.speed =
|
||||||
// reloaded from network.
|
dragPosition.x == 0 && dragPosition.y == 0 ? 0 :
|
||||||
cacheBuffer: height * 4
|
dragPosition.y < 50 ? 1000 + boost:
|
||||||
|
dragPosition.y > height - 50 ? -1000 + -boost :
|
||||||
// Declaring this as "alias" provides the on... signal
|
0
|
||||||
property real yPos: visibleArea.yPosition
|
|
||||||
property bool canLoad: true
|
|
||||||
onYPosChanged: if (canLoad && yPos < 0.1) Qt.callLater(loadPastEvents)
|
|
||||||
|
|
||||||
function loadPastEvents() {
|
|
||||||
// try/catch blocks to hide pyotherside error when the
|
|
||||||
// component is destroyed but func is still running
|
|
||||||
|
|
||||||
try {
|
|
||||||
eventList.canLoad = false
|
|
||||||
|
|
||||||
py.callClientCoro(
|
|
||||||
chatPage.userId, "load_past_events", [chatPage.roomId],
|
|
||||||
moreToLoad => {
|
|
||||||
try {
|
|
||||||
eventList.canLoad = moreToLoad
|
|
||||||
} catch (err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
property string inviter: chatPage.roomInfo.inviter || ""
|
Timer {
|
||||||
// When an invited room becomes joined, we should now be able to fetch
|
id: dragFlicker
|
||||||
// past events.
|
interval: 100
|
||||||
onInviterChanged: canLoad = true
|
running: speed != 0
|
||||||
|
repeat: true
|
||||||
|
|
||||||
|
onTriggered: {
|
||||||
|
if (eventList.verticalOvershoot != 0) return
|
||||||
|
if (speed < 0 && eventList.atYEnd) return
|
||||||
|
if (eventList.atYBeggining) {
|
||||||
|
if (bouncedStart) { return } else { bouncedStart = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
eventList.flick(0, speed * acceleration)
|
||||||
|
acceleration = Math.min(8, acceleration * 1.05)
|
||||||
|
}
|
||||||
|
onRunningChanged: if (! running) {
|
||||||
|
acceleration = 1.0
|
||||||
|
bouncedStart = false
|
||||||
|
eventList.cancelFlick()
|
||||||
|
eventList.returnToBounds()
|
||||||
|
}
|
||||||
|
|
||||||
|
property real speed: 0.0
|
||||||
|
property real acceleration: 1.0
|
||||||
|
property bool bouncedStart: false
|
||||||
|
}
|
||||||
|
|
||||||
|
HListView {
|
||||||
|
id: eventList
|
||||||
|
clip: true
|
||||||
|
enableFlicking: false
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: theme.spacing
|
||||||
|
anchors.rightMargin: theme.spacing
|
||||||
|
|
||||||
|
topMargin: theme.spacing
|
||||||
|
bottomMargin: theme.spacing
|
||||||
|
verticalLayoutDirection: ListView.BottomToTop
|
||||||
|
|
||||||
|
// Keep x scroll pages cached, to limit images having to be
|
||||||
|
// reloaded from network.
|
||||||
|
cacheBuffer: height * 4
|
||||||
|
|
||||||
|
onYPosChanged:
|
||||||
|
if (canLoad && yPos < 0.1) Qt.callLater(loadPastEvents)
|
||||||
|
|
||||||
|
// When an invited room becomes joined, we should now be able to
|
||||||
|
// fetch past events.
|
||||||
|
onInviterChanged: canLoad = true
|
||||||
|
|
||||||
|
Component.onCompleted: shortcuts.flickTarget = eventList
|
||||||
|
|
||||||
|
|
||||||
|
property string inviter: chatPage.roomInfo.inviter || ""
|
||||||
|
property real yPos: visibleArea.yPosition
|
||||||
|
property bool canLoad: true
|
||||||
|
|
||||||
|
property bool ownEventsOnRight:
|
||||||
|
width < theme.chat.eventList.ownEventsOnRightUnderWidth
|
||||||
|
|
||||||
|
|
||||||
|
function canCombine(item, itemAfter) {
|
||||||
|
if (! item || ! itemAfter) return false
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
! canTalkBreak(item, itemAfter) &&
|
||||||
|
! canDayBreak(item, itemAfter) &&
|
||||||
|
item.sender_id === itemAfter.sender_id &&
|
||||||
|
Utils.minutesBetween(item.date, itemAfter.date) <= 5
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canTalkBreak(item, itemAfter) {
|
||||||
|
if (! item || ! itemAfter) return false
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
! canDayBreak(item, itemAfter) &&
|
||||||
|
Utils.minutesBetween(item.date, itemAfter.date) >= 20
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDayBreak(item, itemAfter) {
|
||||||
|
if (itemAfter && itemAfter.event_type == "RoomCreateEvent")
|
||||||
|
return true
|
||||||
|
|
||||||
|
if (! item || ! itemAfter || ! item.date || ! itemAfter.date)
|
||||||
|
return false
|
||||||
|
|
||||||
|
return item.date.getDate() != itemAfter.date.getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPastEvents() {
|
||||||
|
// try/catch blocks to hide pyotherside error when the
|
||||||
|
// component is destroyed but func is still running
|
||||||
|
|
||||||
|
try {
|
||||||
|
eventList.canLoad = false
|
||||||
|
|
||||||
|
py.callClientCoro(
|
||||||
|
chatPage.userId, "load_past_events", [chatPage.roomId],
|
||||||
|
moreToLoad => {
|
||||||
|
try {
|
||||||
|
eventList.canLoad = moreToLoad
|
||||||
|
} catch (err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
model: HListModel {
|
||||||
|
keyField: "client_id"
|
||||||
|
source: modelSources[[
|
||||||
|
"Event", chatPage.userId, chatPage.roomId
|
||||||
|
]] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: EventDelegate {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HNoticePage {
|
HNoticePage {
|
||||||
|
|
|
@ -169,8 +169,10 @@ function getItem(array, mainKey, value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function smartVerticalFlick(flickable, baseVelocity, fastMultiply=3) {
|
function smartVerticalFlick(flickable, baseVelocity, fastMultiply=4) {
|
||||||
if (! flickable.interactive) { return }
|
if (! flickable.interactive && flickable.enableFlicking) return
|
||||||
|
if (flickable.verticalOvershoot != 0) return
|
||||||
|
if (baseVelocity > 0 && flickable.atYEnd) return
|
||||||
|
|
||||||
baseVelocity = -baseVelocity
|
baseVelocity = -baseVelocity
|
||||||
let vel = -flickable.verticalVelocity
|
let vel = -flickable.verticalVelocity
|
||||||
|
|
Loading…
Reference in New Issue
Block a user