1
0
Fork 0
mirror of https://git.jami.net/savoirfairelinux/jami-client-qt.git synced 2025-08-04 06:45:45 +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/commoncomponents/MessageDelegate.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>
</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/\";
Image {
anchors.fill: parent;
source: JamiResources.baseline_error_outline_24dp_svg;
source: JamiResources.error_outline_black_24dp_svg;
mipmap: true;}", spinnerLabel)
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.Layouts 1.15
import QtGraphicalEffects 1.15
import QtWebEngine 1.10
import net.jami.Models 1.1
@ -10,33 +30,213 @@ import net.jami.Constants 1.1
Control {
id: root
readonly property ListView listView: ListView.view
readonly property bool isGenerated: Type === Interaction.Type.CALL ||
Type === Interaction.Type.CONTACT
readonly property string author: Author
readonly property var body: Body
readonly property var timestamp: Timestamp
readonly property bool isOutgoing: model.Author === ""
readonly property var formattedTime: MessagesAdapter.getFormattedTime(Timestamp)
readonly property bool isImage: MessagesAdapter.isImage(Body)
readonly property bool isAnimatedImage: MessagesAdapter.isAnimatedImage(Body)
readonly property var linkPreviewInfo: LinkPreviewInfo
readonly property var linkInfo: LinkPreviewInfo
property var mediaInfo
readonly property var body: Body
readonly property real msgMargin: 64
readonly property real senderMargin: 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
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 {
id: loader
property alias isOutgoing: root.isOutgoing
property alias isGenerated: root.isGenerated
readonly property var author: Author
readonly property var body: Body
width: root.width
height: sourceComponent.height
sourceComponent: isGenerated ?
generatedMsgComp :
userMsgComp
sourceComponent: {
switch (Type) {
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 {
@ -45,13 +245,14 @@ Control {
Column {
width: root.width
spacing: 2
topPadding: 12
bottomPadding: 12
TextArea {
Label {
width: parent.width
text: body
horizontalAlignment: Qt.AlignHCenter
readOnly: true
font.pointSize: 11
font.pointSize: 12
color: JamiTheme.chatviewTextColor
}
@ -61,131 +262,303 @@ Control {
width: parent.width
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 {
id: userMsgComp
id: dataTransferMsgComp
GridLayout {
id: gridLayout
SBSMessageBase {
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
rows: 2
Layout.margins: 12
columnSpacing: 2
rowSpacing: 2
Column {
id: msgCell
Layout.column: isOutgoing ? 0 : 1
Layout.row: 0
Layout.fillWidth: true
Layout.maximumWidth: 640
Layout.preferredHeight: childrenRect.height
Layout.alignment: isOutgoing ? Qt.AlignRight : Qt.AlignLeft
Layout.leftMargin: isOutgoing ? msgMargin : 0
Layout.rightMargin: isOutgoing ? 0 : msgMargin
Control {
id: msgBlock
width: parent.width
contentItem: Column {
id: msgContent
property real txtWidth: ta.contentWidth + 3 * ta.padding
TextArea {
id: ta
width: parent.width
text: body
padding: 10
sourceComponent: {
switch (Status) {
case Interaction.Status.TRANSFER_CANCELED:
case Interaction.Status.TRANSFER_ERROR:
case Interaction.Status.TRANSFER_UNJOINABLE_PEER:
case Interaction.Status.TRANSFER_TIMEOUT_EXPIRED:
iconSourceA = JamiResources.error_outline_black_24dp_svg
return terminatedComp
case Interaction.Status.TRANSFER_CREATED:
case Interaction.Status.TRANSFER_FINISHED:
iconSourceA = JamiResources.link_black_24dp_svg
return terminatedComp
case Interaction.Status.TRANSFER_AWAITING_HOST:
iconSourceA = JamiResources.download_black_24dp_svg
iconSourceB = JamiResources.close_black_24dp_svg
return optionsComp
case Interaction.Status.TRANSFER_ONGOING:
iconSourceA = JamiResources.close_black_24dp_svg
return optionsComp
default:
iconSourceA = JamiResources.error_outline_black_24dp_svg
return terminatedComp
}
}
Component {
id: terminatedComp
ResponsiveImage {
source: buttonsLoader.iconSourceA
Layout.leftMargin: 12
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.hintingPreference: Font.PreferNoHinting
renderType: Text.NativeRendering
textFormat: TextEdit.RichText
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
transform: Translate { x: bg.x }
rightPadding: isOutgoing ? padding * 1.5 : 0
readOnly: true
color: isOutgoing ?
JamiTheme.messageOutTxtColor :
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
}
}
}
Item {
id: infoCell
Layout.column: isOutgoing ? 0 : 1
Layout.row: 1
Layout.fillWidth: true
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
,ProgressBar {
id: progressBar
visible: Status === Interaction.Status.TRANSFER_ONGOING
height: visible * implicitHeight
value: transferStats.progress / transferStats.totalSize
width: transferItem.width
anchors.right: isOutgoing ? parent.right : undefined
}
}
]
}
}
Label {
id: timestampLabel
Component {
id: localMediaMsgComp
text: formattedTime
color: JamiTheme.timestampColor
anchors.right: isGenerated || !isOutgoing ? undefined : parent.right
anchors.rightMargin: 6
anchors.left: isGenerated || isOutgoing ? undefined : parent.left
anchors.leftMargin: 6
anchors.horizontalCenter: isGenerated ? parent.horizontalCenter : undefined
SBSMessageBase {
isOutgoing: root.isOutgoing
showTime: root.showTime
seq: root.seq
author: root.author
formattedTime: root.formattedTime
bubble.visible: false
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
Behavior on opacity { NumberAnimation { duration: 40 } }
Component.onCompleted: {
opacity = 1
if (!Linkified && !isImage && !isAnimatedImage) {
MessagesAdapter.parseMessageUrls(Id, Body)
}
}
Component.onCompleted: opacity = 1
}

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 jamiDarkBlue: darkTheme ? "#28b1ed" : "#003b4e"
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 messageOutTxtColor: chatviewTextColor
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
height: parent.height
width: parent.width
displayMarginBeginning: 2048
displayMarginEnd: 2048
// this offscreen caching is pretty huge
// displayMarginEnd may be removed
displayMarginBeginning: 4096
displayMarginEnd: 4096
maximumFlickVelocity: 2048
verticalLayoutDirection: ListView.BottomToTop
clip: true
@ -72,7 +74,121 @@ ListView {
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() {
const scrollDiff = ScrollBar.vertical.position -

View file

@ -196,7 +196,7 @@ MessagesAdapter::acceptFile(const QString& interactionId)
}
void
MessagesAdapter::refuseFile(const QString& interactionId)
MessagesAdapter::cancelFile(const QString& interactionId)
{
const auto convUid = lrcInstance_->get_selectedConvUid();
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
MessagesAdapter::userIsComposing(bool isComposing)
{
@ -371,18 +415,53 @@ MessagesAdapter::onMessageLinkified(const QString& messageId, const QString& lin
}
bool
MessagesAdapter::isImage(const QString& message)
MessagesAdapter::isLocalImage(const QString& msg)
{
QRegularExpression pattern("[^\\s]+(.*?)\\.(jpg|jpeg|png)$",
QRegularExpression::CaseInsensitiveOption);
QRegularExpressionMatch match = pattern.match(message);
return match.hasMatch();
QImageReader reader;
reader.setDecideFormatFromContent(true);
reader.setFileName(msg);
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
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);
QRegularExpressionMatch match = pattern.match(msg);
return match.hasMatch();

View file

@ -44,7 +44,8 @@ public:
{
auto index = sourceModel()->index(sourceRow, 0, sourceParent);
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
@ -92,18 +93,21 @@ protected:
Q_INVOKABLE void sendMessage(const QString& message);
Q_INVOKABLE void sendFile(const QString& message);
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 openFile(const QString& arg);
Q_INVOKABLE void retryInteraction(const QString& interactionId);
Q_INVOKABLE void deleteInteraction(const QString& interactionId);
Q_INVOKABLE void copyToDownloads(const QString& interactionId, const QString& displayName);
Q_INVOKABLE void userIsComposing(bool isComposing);
Q_INVOKABLE bool isImage(const QString& msg);
Q_INVOKABLE bool isAnimatedImage(const QString& msg);
Q_INVOKABLE bool isLocalImage(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 void parseMessageUrls(const QString& messageId, const QString& msg);
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.
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_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/MsgSeq.qml", MsgSeq);
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "ScreenInfo")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance")