1
0
Fork 0
mirror of https://git.jami.net/savoirfairelinux/jami-client-qt.git synced 2025-08-04 14:55:43 +02:00

chatview: implement side-by-side styling

+ fix multiline richtext
+ add SBS msg bubbles
+ limit timestamp visibility
+ render link previews
+ render data transfer interactions

Gitlab: #467
Change-Id: I80b6db33d786180d479730213855b9816bea4793
This commit is contained in:
Andreas Traczyk 2021-09-27 10:29:07 -04:00 committed by Ming Rui Zhang
parent fe9745ce23
commit e85d4506de
15 changed files with 883 additions and 135 deletions

View file

@ -163,5 +163,8 @@
<file>src/mainview/components/ReadOnlyFooter.qml</file> <file>src/mainview/components/ReadOnlyFooter.qml</file>
<file>src/commoncomponents/MessageDelegate.qml</file> <file>src/commoncomponents/MessageDelegate.qml</file>
<file>src/mainview/components/MessageListView.qml</file> <file>src/mainview/components/MessageListView.qml</file>
<file>src/commoncomponents/MessageBubble.qml</file>
<file>src/constant/MsgSeq.qml</file>
<file>src/commoncomponents/SBSMessageBase.qml</file>
</qresource> </qresource>
</RCC> </RCC>

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg>

Before

Width:  |  Height:  |  Size: 307 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>

After

Width:  |  Height:  |  Size: 268 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5z"/></svg>

After

Width:  |  Height:  |  Size: 234 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M11 15h2v2h-2v-2zm0-8h2v6h-2V7zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg>

After

Width:  |  Height:  |  Size: 292 B

View file

@ -518,7 +518,7 @@ Window {
import \"qrc:/src/constant/\"; import \"qrc:/src/constant/\";
Image { Image {
anchors.fill: parent; anchors.fill: parent;
source: JamiResources.baseline_error_outline_24dp_svg; source: JamiResources.error_outline_black_24dp_svg;
mipmap: true;}", spinnerLabel) mipmap: true;}", spinnerLabel)
break break
} }

View file

@ -0,0 +1,46 @@
/*
* Copyright (C) 2021 by Savoir-faire Linux
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import QtQuick 2.15
import QtGraphicalEffects 1.0
import net.jami.Constants 1.1
Rectangle {
id: root
property bool out: true
property int type: MsgSeq.single
Rectangle {
id: mask
visible: type !== MsgSeq.single
z: -1
radius: 2
color: root.color
anchors {
fill: parent
leftMargin: out ? root.width - root.radius : 0
rightMargin: out ? 0 : root.width - root.radius
topMargin: type === MsgSeq.first ? root.height - root.radius : 0
bottomMargin: type === MsgSeq.last ? root.height - root.radius : 0
}
}
}

View file

@ -1,6 +1,26 @@
import QtQuick 2.15 /*
* Copyright (C) 2021 by Savoir-faire Linux
* Author: Trevor Tabah <trevor.tabah@savoirfairelinux.com>
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import QtQuick 2.15
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import QtGraphicalEffects 1.15
import QtWebEngine 1.10 import QtWebEngine 1.10
import net.jami.Models 1.1 import net.jami.Models 1.1
@ -10,33 +30,213 @@ import net.jami.Constants 1.1
Control { Control {
id: root id: root
readonly property ListView listView: ListView.view
readonly property bool isGenerated: Type === Interaction.Type.CALL || readonly property bool isGenerated: Type === Interaction.Type.CALL ||
Type === Interaction.Type.CONTACT Type === Interaction.Type.CONTACT
readonly property string author: Author readonly property string author: Author
readonly property var body: Body
readonly property var timestamp: Timestamp readonly property var timestamp: Timestamp
readonly property bool isOutgoing: model.Author === "" readonly property bool isOutgoing: model.Author === ""
readonly property var formattedTime: MessagesAdapter.getFormattedTime(Timestamp) readonly property var formattedTime: MessagesAdapter.getFormattedTime(Timestamp)
readonly property bool isImage: MessagesAdapter.isImage(Body) readonly property var linkInfo: LinkPreviewInfo
readonly property bool isAnimatedImage: MessagesAdapter.isAnimatedImage(Body) property var mediaInfo
readonly property var linkPreviewInfo: LinkPreviewInfo
readonly property var body: Body readonly property real senderMargin: 64
readonly property real msgMargin: 64 readonly property real avatarSize: 32
readonly property real msgRadius: 18
readonly property real hMargin: 12
property bool showTime: false
property int seq: MsgSeq.single
width: parent ? parent.width : 0 width: parent ? parent.width : 0
height: loader.height height: loader.height
// message interaction
property string hoveredLink
MouseArea {
id: itemMouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton
onClicked: {
if (root.hoveredLink)
Qt.openUrlExternally(root.hoveredLink)
}
}
Loader { Loader {
id: loader id: loader
property alias isOutgoing: root.isOutgoing width: root.width
property alias isGenerated: root.isGenerated height: sourceComponent.height
readonly property var author: Author
readonly property var body: Body
sourceComponent: isGenerated ? sourceComponent: {
generatedMsgComp : switch (Type) {
userMsgComp case Interaction.Type.TEXT: return textMsgComp
case Interaction.Type.CALL:
case Interaction.Type.CONTACT: return generatedMsgComp
case Interaction.Type.DATA_TRANSFER:
if (Status === Interaction.Status.TRANSFER_FINISHED) {
mediaInfo = MessagesAdapter.getMediaInfo(Body)
if (Object.keys(mediaInfo).length !== 0)
return localMediaMsgComp
}
return dataTransferMsgComp
default:
// if this happens, adjust FilteredMsgListModel
console.warn("Invalid message type has not been filtered.")
return null
}
}
}
Component {
id: textMsgComp
SBSMessageBase {
property real maxMsgWidth: root.width - senderMargin - 2 * hMargin - avatarBlockWidth
property bool isRemoteImage
isOutgoing: root.isOutgoing
showTime: root.showTime
seq: root.seq
author: root.author
formattedTime: root.formattedTime
extraHeight: extraContent.active && !isRemoteImage ? msgRadius : -isRemoteImage
innerContent.children: [
TextEdit {
padding: 10
anchors.right: isOutgoing ? parent.right : undefined
text: '<span style="white-space: pre-wrap">' + body + '</span>'
width: {
if (extraContent.active)
Math.max(extraContent.width,
Math.min(implicitWidth - avatarBlockWidth,
extraContent.minSize) - senderMargin)
else
Math.min(implicitWidth, innerContent.width - senderMargin)
}
height: implicitHeight
wrapMode: Label.WrapAtWordBoundaryOrAnywhere
selectByMouse: true
font.pointSize: 11
font.hintingPreference: Font.PreferNoHinting
renderType: Text.NativeRendering
textFormat: TextEdit.RichText
onLinkHovered: root.hoveredLink = hoveredLink
onLinkActivated: Qt.openUrlExternally(hoveredLink)
readOnly: true
color: isOutgoing ?
JamiTheme.messageOutTxtColor :
JamiTheme.messageInTxtColor
},
Loader {
id: extraContent
width: sourceComponent.width
height: sourceComponent.height
anchors.right: isOutgoing ? parent.right : undefined
property real minSize: 192
property real maxSize: 320
active: linkInfo.url !== undefined
sourceComponent: ColumnLayout {
id: previewContent
spacing: 12
Component.onCompleted: {
isRemoteImage = MessagesAdapter.isRemoteImage(linkInfo.url)
}
HoverHandler {
target: previewContent
onHoveredChanged: {
root.hoveredLink = hovered ? linkInfo.url : ""
}
cursorShape: Qt.PointingHandCursor
}
AnimatedImage {
id: img
cache: true
source: isRemoteImage ?
linkInfo.url :
(hasImage ? linkInfo.image : "")
fillMode: Image.PreserveAspectCrop
mipmap: true
antialiasing: true
autoTransform: true
asynchronous: true
readonly property bool hasImage: linkInfo.image !== null
property real aspectRatio: implicitWidth / implicitHeight
property real adjustedWidth: Math.min(extraContent.maxSize,
Math.max(extraContent.minSize,
maxMsgWidth))
Layout.preferredWidth: adjustedWidth
Layout.preferredHeight: Math.ceil(adjustedWidth / aspectRatio)
Rectangle {
color: JamiTheme.previewImageBackgroundColor
z: -1
anchors.fill: parent
}
layer.enabled: isRemoteImage
layer.effect: OpacityMask {
maskSource: MessageBubble {
Rectangle { height: msgRadius; width: parent.width }
out: isOutgoing
type: seq
width: img.width
height: img.height
radius: msgRadius
}
}
}
Column {
opacity: img.status !== Image.Loading
visible: !isRemoteImage
Layout.preferredWidth: img.width - 2 * hMargin
Layout.leftMargin: hMargin
Layout.rightMargin: hMargin
spacing: 6
Label {
width: parent.width
font.pointSize: 10
font.hintingPreference: Font.PreferNoHinting
wrapMode: Label.WrapAtWordBoundaryOrAnywhere
renderType: Text.NativeRendering
textFormat: TextEdit.RichText
color: JamiTheme.previewTitleColor
visible: linkInfo.title !== null
text: linkInfo.title
}
Label {
width: parent.width
font.pointSize: 11
font.hintingPreference: Font.PreferNoHinting
wrapMode: Label.WrapAtWordBoundaryOrAnywhere
renderType: Text.NativeRendering
textFormat: TextEdit.RichText
color: JamiTheme.previewSubtitleColor
visible: linkInfo.description !== null
text: '<a href=" " style="text-decoration: ' +
( hoveredLink ? 'underline' : 'none') + ';"' +
'>' + linkInfo.description + '</a>'
}
Label {
width: parent.width
font.pointSize: 10
font.hintingPreference: Font.PreferNoHinting
wrapMode: Label.WrapAtWordBoundaryOrAnywhere
renderType: Text.NativeRendering
textFormat: TextEdit.RichText
color: JamiTheme.previewSubtitleColor
text: linkInfo.domain
}
}
}
}
]
Component.onCompleted: {
if (!Linkified) {
MessagesAdapter.parseMessageUrls(Id, Body)
}
}
}
} }
Component { Component {
@ -45,13 +245,14 @@ Control {
Column { Column {
width: root.width width: root.width
spacing: 2 spacing: 2
topPadding: 12
bottomPadding: 12
TextArea { Label {
width: parent.width width: parent.width
text: body text: body
horizontalAlignment: Qt.AlignHCenter horizontalAlignment: Qt.AlignHCenter
readOnly: true font.pointSize: 12
font.pointSize: 11
color: JamiTheme.chatviewTextColor color: JamiTheme.chatviewTextColor
} }
@ -61,131 +262,303 @@ Control {
width: parent.width width: parent.width
height: childrenRect.height height: childrenRect.height
Component.onCompleted: children = timestampLabel Label {
} text: formattedTime
color: JamiTheme.timestampColor
visible: showTime || seq === MsgSeq.last
height: visible * implicitHeight
font.pointSize: 9
bottomPadding: 12 anchors.horizontalCenter: parent.horizontalCenter
}
}
} }
} }
Component { Component {
id: userMsgComp id: dataTransferMsgComp
GridLayout { SBSMessageBase {
id: gridLayout id: dataTransferItem
property var transferStats: MessagesAdapter.getTransferStats(Id, Status)
property bool canOpen: Status === Interaction.Status.TRANSFER_FINISHED || isOutgoing
property real maxMsgWidth: root.width - senderMargin -
2 * hMargin - avatarBlockWidth
- buttonsLoader.width - 24 - 6 - 24
isOutgoing: root.isOutgoing
showTime: root.showTime
seq: root.seq
author: root.author
formattedTime: root.formattedTime
extraHeight: progressBar.visible ? 18 : 0
innerContent.children: [
RowLayout {
id: transferItem
spacing: 6
anchors.right: isOutgoing ? parent.right : undefined
HoverHandler {
target: parent
enabled: canOpen
onHoveredChanged: {
root.hoveredLink = enabled && hovered ?
("file:///" + body) :
""
}
cursorShape: enabled ?
Qt.PointingHandCursor :
Qt.ArrowCursor
}
Loader {
id: buttonsLoader
width: root.width property string iconSourceA
property string iconSourceB
columns: 2 Layout.margins: 12
rows: 2
columnSpacing: 2 sourceComponent: {
rowSpacing: 2 switch (Status) {
case Interaction.Status.TRANSFER_CANCELED:
Column { case Interaction.Status.TRANSFER_ERROR:
id: msgCell case Interaction.Status.TRANSFER_UNJOINABLE_PEER:
case Interaction.Status.TRANSFER_TIMEOUT_EXPIRED:
Layout.column: isOutgoing ? 0 : 1 iconSourceA = JamiResources.error_outline_black_24dp_svg
Layout.row: 0 return terminatedComp
Layout.fillWidth: true case Interaction.Status.TRANSFER_CREATED:
Layout.maximumWidth: 640 case Interaction.Status.TRANSFER_FINISHED:
Layout.preferredHeight: childrenRect.height iconSourceA = JamiResources.link_black_24dp_svg
Layout.alignment: isOutgoing ? Qt.AlignRight : Qt.AlignLeft return terminatedComp
Layout.leftMargin: isOutgoing ? msgMargin : 0 case Interaction.Status.TRANSFER_AWAITING_HOST:
Layout.rightMargin: isOutgoing ? 0 : msgMargin iconSourceA = JamiResources.download_black_24dp_svg
iconSourceB = JamiResources.close_black_24dp_svg
Control { return optionsComp
id: msgBlock case Interaction.Status.TRANSFER_ONGOING:
iconSourceA = JamiResources.close_black_24dp_svg
width: parent.width return optionsComp
default:
contentItem: Column { iconSourceA = JamiResources.error_outline_black_24dp_svg
id: msgContent return terminatedComp
}
property real txtWidth: ta.contentWidth + 3 * ta.padding }
Component {
TextArea { id: terminatedComp
id: ta ResponsiveImage {
width: parent.width source: buttonsLoader.iconSourceA
text: body Layout.leftMargin: 12
padding: 10 Layout.preferredWidth: 24
Layout.preferredHeight: 24
}
}
Component {
id: optionsComp
ColumnLayout {
Layout.leftMargin: 12
PushButton {
source: buttonsLoader.iconSourceA
normalColor: JamiTheme.chatviewBgColor
imageColor: JamiTheme.chatviewButtonColor
onClicked: {
switch (Status) {
case Interaction.Status.TRANSFER_ONGOING:
return MessagesAdapter.cancelFile(Id)
case Interaction.Status.TRANSFER_AWAITING_HOST:
return MessagesAdapter.acceptFile(Id)
default: break
}
}
}
PushButton {
visible: !CurrentConversation.isSwarm
height: visible * implicitHeight
source: buttonsLoader.iconSourceB
normalColor: JamiTheme.chatviewBgColor
imageColor: JamiTheme.chatviewButtonColor
onClicked: {
switch (Status) {
case Interaction.Status.TRANSFER_AWAITING_HOST:
return MessagesAdapter.cancelFile(Id)
default: break
}
}
}
}
}
}
Column {
Layout.rightMargin: 24
spacing: 6
TextEdit {
id: transferName
width: Math.min(implicitWidth, maxMsgWidth)
topPadding: 10
text: CurrentConversation.isSwarm ?
TransferName :
body
wrapMode: Label.WrapAtWordBoundaryOrAnywhere
font.weight: Font.DemiBold
font.pointSize: 11 font.pointSize: 11
font.hintingPreference: Font.PreferNoHinting
renderType: Text.NativeRendering renderType: Text.NativeRendering
textFormat: TextEdit.RichText readOnly: true
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
transform: Translate { x: bg.x }
rightPadding: isOutgoing ? padding * 1.5 : 0
color: isOutgoing ? color: isOutgoing ?
JamiTheme.messageOutTxtColor : JamiTheme.messageOutTxtColor :
JamiTheme.messageInTxtColor JamiTheme.messageInTxtColor
MouseArea {
anchors.fill: parent
cursorShape: canOpen ?
Qt.PointingHandCursor :
Qt.ArrowCursor
onClicked: if(canOpen) itemMouseArea.clicked(mouse)
}
}
Label {
id: transferInfo
width: Math.min(implicitWidth, maxMsgWidth)
bottomPadding: 10
text: {
var res = formattedTime + " - "
if (transferStats.totalSize !== undefined) {
if (transferStats.progress !== 0 &&
transferStats.progress !== transferStats.totalSize) {
res += UtilsAdapter.humanFileSize(transferStats.progress) + " / "
}
res += UtilsAdapter.humanFileSize(transferStats.totalSize)
}
return res + " - " + MessagesAdapter.getStatusString(Status)
}
wrapMode: Label.WrapAtWordBoundaryOrAnywhere
font.pointSize: 10
renderType: Text.NativeRendering
color: Qt.lighter((isOutgoing ?
JamiTheme.messageOutTxtColor :
JamiTheme.messageInTxtColor), 1.5)
} }
} }
background: Rectangle {
id: bg
anchors.right: isOutgoing ? msgContent.right : undefined
width: msgContent.txtWidth
radius: 18
color: isOutgoing ?
JamiTheme.messageOutBgColor :
JamiTheme.messageInBgColor
}
} }
} ,ProgressBar {
Item { id: progressBar
id: infoCell visible: Status === Interaction.Status.TRANSFER_ONGOING
height: visible * implicitHeight
Layout.column: isOutgoing ? 0 : 1 value: transferStats.progress / transferStats.totalSize
Layout.row: 1 width: transferItem.width
Layout.fillWidth: true anchors.right: isOutgoing ? parent.right : undefined
Layout.preferredHeight: childrenRect.height
Component.onCompleted: children = timestampLabel
}
Item {
id: avatarCell
Layout.column: isOutgoing ? 1 : 0
Layout.row: 0
Layout.preferredWidth: isOutgoing ? 16 : avatar.width
Layout.preferredHeight: msgCell.height
Layout.leftMargin: isOutgoing ? 0 : 6
Layout.rightMargin: Layout.leftMargin
Avatar {
id: avatar
visible: !isOutgoing
anchors.bottom: parent.bottom
width: 32
height: 32
imageId: author
showPresenceIndicator: false
mode: Avatar.Mode.Contact
} }
} ]
} }
} }
Label { Component {
id: timestampLabel id: localMediaMsgComp
text: formattedTime SBSMessageBase {
color: JamiTheme.timestampColor isOutgoing: root.isOutgoing
showTime: root.showTime
anchors.right: isGenerated || !isOutgoing ? undefined : parent.right seq: root.seq
anchors.rightMargin: 6 author: root.author
anchors.left: isGenerated || isOutgoing ? undefined : parent.left formattedTime: root.formattedTime
anchors.leftMargin: 6 bubble.visible: false
anchors.horizontalCenter: isGenerated ? parent.horizontalCenter : undefined innerContent.children: [
Loader {
id: localMediaCompLoader
anchors.right: isOutgoing ? parent.right : undefined
width: sourceComponent.width
height: sourceComponent.height
sourceComponent: mediaInfo.isImage !== undefined ?
imageComp :
avComp
Component {
id: avComp
WebEngineView {
id: wev
anchors.right: isOutgoing ? parent.right : undefined
readonly property real minSize: 192
readonly property real maxSize: 256
readonly property real aspectRatio: 1 / .75
readonly property real adjustedWidth: Math.min(maxSize,
Math.max(minSize,
innerContent.width - senderMargin))
width: isFullScreen ? parent.width : adjustedWidth
height: mediaInfo.isVideo ?
isFullScreen ?
parent.height :
Math.ceil(adjustedWidth / aspectRatio) :
54
settings.fullScreenSupportEnabled: mediaInfo.isVideo
settings.javascriptCanOpenWindows: false
Component.onCompleted: loadHtml(mediaInfo.html, 'file://')
layer.enabled: parent !== appContainer && !appWindow.isFullScreen
layer.effect: OpacityMask {
maskSource: MessageBubble {
out: isOutgoing
type: seq
width: wev.width
height: wev.height
radius: msgRadius
}
}
onFullScreenRequested: function(request) {
if (JamiQmlUtils.callIsFullscreen)
return
if (request.toggleOn && !appWindow.isFullScreen) {
parent = appContainer
appWindow.toggleFullScreen()
} else if (!request.toggleOn && appWindow.isFullScreen) {
parent = localMediaCompLoader
appWindow.toggleFullScreen()
}
request.accept()
}
}
}
Component {
id: imageComp
AnimatedImage {
id: img
anchors.right: isOutgoing ? parent.right : undefined
property real minSize: 192
property real maxSize: 256
cache: true
fillMode: Image.PreserveAspectCrop
mipmap: true
antialiasing: true
autoTransform: false
asynchronous: true
source: "file:///" + body
property real aspectRatio: implicitWidth / implicitHeight
property real adjustedWidth: Math.min(maxSize,
Math.max(minSize,
innerContent.width - senderMargin))
width: adjustedWidth
height: Math.ceil(adjustedWidth / aspectRatio)
Rectangle {
color: JamiTheme.previewImageBackgroundColor
z: -1
anchors.fill: parent
}
layer.enabled: true
layer.effect: OpacityMask {
maskSource: MessageBubble {
out: isOutgoing
type: seq
width: img.width
height: img.height
radius: msgRadius
}
}
HoverHandler {
target : parent
onHoveredChanged: {
root.hoveredLink = hovered ? img.source : ""
}
cursorShape: Qt.PointingHandCursor
}
}
}
}
]
}
} }
opacity: 0 opacity: 0
Behavior on opacity { NumberAnimation { duration: 40 } } Behavior on opacity { NumberAnimation { duration: 40 } }
Component.onCompleted: opacity = 1
Component.onCompleted: {
opacity = 1
if (!Linkified && !isImage && !isAnimatedImage) {
MessagesAdapter.parseMessageUrls(Id, Body)
}
}
} }

View file

@ -0,0 +1,115 @@
/*
* Copyright (C) 2021 by Savoir-faire Linux
* Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtGraphicalEffects 1.15
import net.jami.Models 1.1
import net.jami.Adapters 1.1
import net.jami.Constants 1.1
ColumnLayout {
id: root
property alias avatarBlockWidth: avatarBlock.width
property alias innerContent: innerContent
property alias bubble: bubble
property real extraHeight: 0
// these MUST be set but we won't use the 'required' keyword yet
property bool isOutgoing
property bool showTime
property int seq
property string author
property string formattedTime
readonly property real senderMargin: 64
readonly property real avatarSize: 32
readonly property real msgRadius: 18
readonly property real hMargin: 12
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: hMargin
anchors.rightMargin: hMargin
spacing: 2
RowLayout {
Layout.preferredHeight: innerContent.height + root.extraHeight
Layout.topMargin: (seq === MsgSeq.first || seq === MsgSeq.single) ? 6 : 0
spacing: 0
Item {
id: avatarBlock
Layout.preferredWidth: isOutgoing ? 0 : avatar.width + hMargin
Layout.preferredHeight: isOutgoing ? 0 : bubble.height
Avatar {
id: avatar
visible: !isOutgoing && (seq === MsgSeq.last || seq === MsgSeq.single)
anchors.bottom: parent.bottom
width: avatarSize
height: avatarSize
imageId: author
showPresenceIndicator: false
mode: Avatar.Mode.Contact
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
Column {
id: innerContent
width: parent.width
// place actual content here
}
MessageBubble {
id: bubble
z:-1
out: isOutgoing
type: seq
color: isOutgoing ?
JamiTheme.messageOutBgColor :
JamiTheme.messageInBgColor
radius: msgRadius
anchors.right: isOutgoing ? parent.right : undefined
width: innerContent.childrenRect.width
height: innerContent.childrenRect.height + (visible ? root.extraHeight : 0)
}
}
}
Item {
id: infoCell
Layout.preferredWidth: parent.width
Layout.preferredHeight: childrenRect.height
Label {
text: formattedTime
color: JamiTheme.timestampColor
visible: showTime || seq === MsgSeq.last
height: visible * implicitHeight
font.pointSize: 9
anchors.right: !isOutgoing ? undefined : parent.right
anchors.rightMargin: 8
anchors.left: isOutgoing ? undefined : parent.left
anchors.leftMargin: avatarBlockWidth + 6
}
}
}

View file

@ -148,7 +148,7 @@ Item {
property color jamiLightBlue: darkTheme ? "#003b4e" : Qt.rgba(59, 193, 211, 0.3) property color jamiLightBlue: darkTheme ? "#003b4e" : Qt.rgba(59, 193, 211, 0.3)
property color jamiDarkBlue: darkTheme ? "#28b1ed" : "#003b4e" property color jamiDarkBlue: darkTheme ? "#28b1ed" : "#003b4e"
property color chatviewTextColor: darkTheme ? "#f0f0f0" : "#353637" property color chatviewTextColor: darkTheme ? "#f0f0f0" : "#353637"
property color timestampColor: darkTheme ? "#bbb" : "#333" property color timestampColor: darkTheme ? "#bbb" : "#777"
property color messageOutBgColor: darkTheme ? "#28b1ed" : "#cfd8dc" property color messageOutBgColor: darkTheme ? "#28b1ed" : "#cfd8dc"
property color messageOutTxtColor: chatviewTextColor property color messageOutTxtColor: chatviewTextColor
property color messageInBgColor: darkTheme? "#616161" : "#cfebf5" property color messageInBgColor: darkTheme? "#616161" : "#cfebf5"

9
src/constant/MsgSeq.qml Normal file
View file

@ -0,0 +1,9 @@
pragma Singleton
import QtQml 2.15
QtObject {
//readonly property int unknown: -1
readonly property int single: 0
readonly property int first: 1
readonly property int middle: 2
readonly property int last: 3
}

View file

@ -60,8 +60,10 @@ ListView {
anchors.centerIn: parent anchors.centerIn: parent
height: parent.height height: parent.height
width: parent.width width: parent.width
displayMarginBeginning: 2048 // this offscreen caching is pretty huge
displayMarginEnd: 2048 // displayMarginEnd may be removed
displayMarginBeginning: 4096
displayMarginEnd: 4096
maximumFlickVelocity: 2048 maximumFlickVelocity: 2048
verticalLayoutDirection: ListView.BottomToTop verticalLayoutDirection: ListView.BottomToTop
clip: true clip: true
@ -72,7 +74,121 @@ ListView {
model: MessagesAdapter.messageListModel model: MessagesAdapter.messageListModel
delegate: MessageDelegate {} delegate: MessageDelegate {
// sequencing/timestamps (2-sided style)
function computeTimestampVisibility() {
if (listView === undefined)
return
var nItem = listView.itemAtIndex(index - 1)
if (nItem && index !== listView.count - 1) {
showTime = (nItem.timestamp - timestamp) > 60 &&
nItem.formattedTime !== formattedTime
} else {
showTime = true
var pItem = listView.itemAtIndex(index + 1)
if (pItem) {
pItem.showTime = (timestamp - pItem.timestamp) > 60 &&
pItem.formattedTime !== formattedTime
}
}
}
function computeSequencing() {
if (listView === undefined)
return
var cItem = {
'author': author,
'isGenerated': isGenerated,
'showTime': showTime
}
var pItem = listView.itemAtIndex(index + 1)
var nItem = listView.itemAtIndex(index - 1)
let isSeq = (item0, item1) =>
item0.author === item1.author &&
!(item0.isGenerated || item1.isGenerated) &&
!item0.showTime
let setSeq = function (newSeq, item) {
if (item === undefined)
seq = isGenerated ? MsgSeq.single : newSeq
else
item.seq = item.isGenerated ? MsgSeq.single : newSeq
}
let rAdjustSeq = function (item) {
if (item.seq === MsgSeq.last)
item.seq = MsgSeq.middle
else if (item.seq === MsgSeq.single)
setSeq(MsgSeq.first, item)
}
let adjustSeq = function (item) {
if (item.seq === MsgSeq.first)
item.seq = MsgSeq.middle
else if (item.seq === MsgSeq.single)
setSeq(MsgSeq.last, item)
}
if (pItem && !nItem) {
if (!isSeq(pItem, cItem)) {
seq = MsgSeq.single
} else {
seq = MsgSeq.last
rAdjustSeq(pItem)
}
} else if (nItem && !pItem) {
if (!isSeq(cItem, nItem)) {
seq = MsgSeq.single
} else {
setSeq(MsgSeq.first)
adjustSeq(nItem)
}
} else if (!nItem && !pItem) {
seq = MsgSeq.single
} else {
if (isSeq(pItem, nItem)) {
if (isSeq(pItem, cItem)) {
seq = MsgSeq.middle
} else {
seq = MsgSeq.single
if (pItem.seq === MsgSeq.first)
pItem.seq = MsgSeq.single
else if (item.seq === MsgSeq.middle)
pItem.seq = MsgSeq.last
if (nItem.seq === MsgSeq.last)
nItem.seq = MsgSeq.single
else if (nItem.seq === MsgSeq.middle)
nItem.seq = MsgSeq.first
}
} else {
if (!isSeq(pItem, cItem)) {
seq = MsgSeq.first
adjustSeq(pItem)
} else {
seq = MsgSeq.last
rAdjustSeq(nItem)
}
}
}
if (seq === MsgSeq.last) {
showTime = true
}
}
Component.onCompleted: {
if (index) {
computeTimestampVisibility()
computeSequencing()
} else {
Qt.callLater(computeTimestampVisibility)
Qt.callLater(computeSequencing)
}
}
}
function getDistanceToBottom() { function getDistanceToBottom() {
const scrollDiff = ScrollBar.vertical.position - const scrollDiff = ScrollBar.vertical.position -

View file

@ -196,7 +196,7 @@ MessagesAdapter::acceptFile(const QString& interactionId)
} }
void void
MessagesAdapter::refuseFile(const QString& interactionId) MessagesAdapter::cancelFile(const QString& interactionId)
{ {
const auto convUid = lrcInstance_->get_selectedConvUid(); const auto convUid = lrcInstance_->get_selectedConvUid();
lrcInstance_->getCurrentConversationModel()->cancelTransfer(convUid, interactionId); lrcInstance_->getCurrentConversationModel()->cancelTransfer(convUid, interactionId);
@ -240,6 +240,50 @@ MessagesAdapter::onPaste()
} }
} }
QString
MessagesAdapter::getStatusString(int status)
{
switch (static_cast<interaction::Status>(status)) {
case interaction::Status::SENDING:
return QObject::tr("Sending");
case interaction::Status::FAILURE:
return QObject::tr("Failure");
case interaction::Status::SUCCESS:
return QObject::tr("Sent");
case interaction::Status::TRANSFER_CREATED:
return QObject::tr("Connecting");
case interaction::Status::TRANSFER_ACCEPTED:
return QObject::tr("Accept");
case interaction::Status::TRANSFER_CANCELED:
return QObject::tr("Canceled");
case interaction::Status::TRANSFER_ERROR:
case interaction::Status::TRANSFER_UNJOINABLE_PEER:
return QObject::tr("Unable to make contact");
case interaction::Status::TRANSFER_ONGOING:
return QObject::tr("Ongoing");
case interaction::Status::TRANSFER_AWAITING_PEER:
return QObject::tr("Waiting for contact");
case interaction::Status::TRANSFER_AWAITING_HOST:
return QObject::tr("Incoming transfer");
case interaction::Status::TRANSFER_TIMEOUT_EXPIRED:
return QObject::tr("Timed out waiting for contact");
case interaction::Status::TRANSFER_FINISHED:
return QObject::tr("Finished");
default:
return {};
}
}
QVariantMap
MessagesAdapter::getTransferStats(const QString& msgId, int status)
{
Q_UNUSED(status)
auto convModel = lrcInstance_->getCurrentConversationModel();
lrc::api::datatransfer::Info info = {};
convModel->getTransferInfo(lrcInstance_->get_selectedConvUid(), msgId, info);
return {{"totalSize", qint64(info.totalSize)}, {"progress", qint64(info.progress)}};
}
void void
MessagesAdapter::userIsComposing(bool isComposing) MessagesAdapter::userIsComposing(bool isComposing)
{ {
@ -371,18 +415,53 @@ MessagesAdapter::onMessageLinkified(const QString& messageId, const QString& lin
} }
bool bool
MessagesAdapter::isImage(const QString& message) MessagesAdapter::isLocalImage(const QString& msg)
{ {
QRegularExpression pattern("[^\\s]+(.*?)\\.(jpg|jpeg|png)$", QImageReader reader;
QRegularExpression::CaseInsensitiveOption); reader.setDecideFormatFromContent(true);
QRegularExpressionMatch match = pattern.match(message); reader.setFileName(msg);
return match.hasMatch(); return !reader.read().isNull();
}
QVariantMap
MessagesAdapter::getMediaInfo(const QString& msg)
{
auto filePath = QFileInfo(msg).absoluteFilePath();
static const QString html
= "<body style='margin:0;padding:0;'>"
"<%1 style='width:100%;height:%2;outline:none;background-color:#f1f3f4;"
"object-fit:cover;' "
"controls controlsList='nodownload' src='file://%3' type='%4'/></body>";
if (isLocalImage(msg)) {
return {{"isImage", true}};
}
QRegularExpression vPattern("[^\\s]+(.*?)\\.(avi|mov|webm|webp|rmvb)$",
QRegularExpression::CaseInsensitiveOption);
QString type = vPattern.match(filePath).captured(2);
if (!type.isEmpty()) {
return {
{"isVideo", true},
{"html", html.arg("video", "100%", filePath, "video/" + type)},
};
} else {
QRegularExpression aPattern("[^\\s]+(.*?)\\.(ogg|flac|wav|mpeg|mp3)$",
QRegularExpression::CaseInsensitiveOption);
type = aPattern.match(filePath).captured(2);
if (!type.isEmpty()) {
return {
{"isVideo", false},
{"html", html.arg("audio", "54px", filePath, "audio/" + type)},
};
}
}
return {};
} }
bool bool
MessagesAdapter::isAnimatedImage(const QString& msg) MessagesAdapter::isRemoteImage(const QString& msg)
{ {
QRegularExpression pattern("[^\\s]+(.*?)\\.(gif|apng|webp|avif|flif)$", // TODO: test if all these open in the AnimatedImage component
QRegularExpression pattern("[^\\s]+(.*?)\\.(jpg|jpeg|png|gif|apng|webp|avif|flif)$",
QRegularExpression::CaseInsensitiveOption); QRegularExpression::CaseInsensitiveOption);
QRegularExpressionMatch match = pattern.match(msg); QRegularExpressionMatch match = pattern.match(msg);
return match.hasMatch(); return match.hasMatch();

View file

@ -44,7 +44,8 @@ public:
{ {
auto index = sourceModel()->index(sourceRow, 0, sourceParent); auto index = sourceModel()->index(sourceRow, 0, sourceParent);
auto type = sourceModel()->data(index, MessageList::Role::Type).toInt(); auto type = sourceModel()->data(index, MessageList::Role::Type).toInt();
return static_cast<interaction::Type>(type) != interaction::Type::MERGE; auto hasBody = !sourceModel()->data(index, MessageList::Role::Body).toString().isEmpty();
return static_cast<interaction::Type>(type) != interaction::Type::MERGE && hasBody;
}; };
bool lessThan(const QModelIndex& left, const QModelIndex& right) const override bool lessThan(const QModelIndex& left, const QModelIndex& right) const override
@ -92,18 +93,21 @@ protected:
Q_INVOKABLE void sendMessage(const QString& message); Q_INVOKABLE void sendMessage(const QString& message);
Q_INVOKABLE void sendFile(const QString& message); Q_INVOKABLE void sendFile(const QString& message);
Q_INVOKABLE void acceptFile(const QString& arg); Q_INVOKABLE void acceptFile(const QString& arg);
Q_INVOKABLE void refuseFile(const QString& arg); Q_INVOKABLE void cancelFile(const QString& arg);
Q_INVOKABLE void openUrl(const QString& url); Q_INVOKABLE void openUrl(const QString& url);
Q_INVOKABLE void openFile(const QString& arg); Q_INVOKABLE void openFile(const QString& arg);
Q_INVOKABLE void retryInteraction(const QString& interactionId); Q_INVOKABLE void retryInteraction(const QString& interactionId);
Q_INVOKABLE void deleteInteraction(const QString& interactionId); Q_INVOKABLE void deleteInteraction(const QString& interactionId);
Q_INVOKABLE void copyToDownloads(const QString& interactionId, const QString& displayName); Q_INVOKABLE void copyToDownloads(const QString& interactionId, const QString& displayName);
Q_INVOKABLE void userIsComposing(bool isComposing); Q_INVOKABLE void userIsComposing(bool isComposing);
Q_INVOKABLE bool isImage(const QString& msg); Q_INVOKABLE bool isLocalImage(const QString& msg);
Q_INVOKABLE bool isAnimatedImage(const QString& msg); Q_INVOKABLE QVariantMap getMediaInfo(const QString& msg);
Q_INVOKABLE bool isRemoteImage(const QString& msg);
Q_INVOKABLE QString getFormattedTime(const quint64 timestamp); Q_INVOKABLE QString getFormattedTime(const quint64 timestamp);
Q_INVOKABLE void parseMessageUrls(const QString& messageId, const QString& msg); Q_INVOKABLE void parseMessageUrls(const QString& messageId, const QString& msg);
Q_INVOKABLE void onPaste(); Q_INVOKABLE void onPaste();
Q_INVOKABLE QString getStatusString(int status);
Q_INVOKABLE QVariantMap getTransferStats(const QString& messageId, int);
// Run corrsponding js functions, c++ to qml. // Run corrsponding js functions, c++ to qml.
void setMessagesImageContent(const QString& path, bool isBased64 = false); void setMessagesImageContent(const QString& path, bool isBased64 = false);

View file

@ -177,6 +177,7 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERSINGLETONTYPE_URL(NS_MODELS, "qrc:/src/constant/JamiQmlUtils.qml", JamiQmlUtils); QML_REGISTERSINGLETONTYPE_URL(NS_MODELS, "qrc:/src/constant/JamiQmlUtils.qml", JamiQmlUtils);
QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/JamiStrings.qml", JamiStrings); QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/JamiStrings.qml", JamiStrings);
QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/JamiResources.qml", JamiResources); QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/JamiResources.qml", JamiResources);
QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/MsgSeq.qml", MsgSeq);
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "ScreenInfo") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "ScreenInfo")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance")