Compare commits
1 Commits
master
...
image-moda
Author | SHA1 | Date |
---|---|---|
Adasauce | 47d884c996 | 5 years ago |
@ -1,5 +1,5 @@ |
|||||||
hunter_config( |
hunter_config( |
||||||
Boost |
Boost |
||||||
VERSION "1.70.0-p1" |
VERSION "1.70.0-p0" |
||||||
CMAKE_ARGS IOSTREAMS_NO_BZIP2=1 |
CMAKE_ARGS IOSTREAMS_NO_BZIP2=1 |
||||||
) |
) |
||||||
|
Before Width: | Height: | Size: 643 B |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 759 B |
Before Width: | Height: | Size: 573 B |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 385 B |
Before Width: | Height: | Size: 741 B |
@ -1 +0,0 @@ |
|||||||
*.qm |
|
@ -1,5 +0,0 @@ |
|||||||
The below media files were obtained from https://github.com/matrix-org/matrix-react-sdk/tree/develop/res/media |
|
||||||
|
|
||||||
callend.ogg |
|
||||||
ringback.ogg |
|
||||||
ring.ogg |
|
@ -1,67 +1,56 @@ |
|||||||
<?xml version="1.0" encoding="UTF-8"?> |
<?xml version="1.0" encoding="utf-8"?> |
||||||
<!-- Copyright 2020 mujx, nheko reborn developers --> |
<!-- Copyright 2019 mujx, nheko reborn developers --> |
||||||
<component type="desktop"> |
<component type="desktop"> |
||||||
<id>nheko.desktop</id> |
<id>nheko.desktop</id> |
||||||
<metadata_license>CC0-1.0</metadata_license> |
<metadata_license>CC0-1.0</metadata_license> |
||||||
<project_license>GPL-3.0-or-later and CC-BY</project_license> |
<project_license>GPL-3.0-or-later and CC-BY</project_license> |
||||||
<name>nheko</name> |
<name>nheko</name> |
||||||
<summary>Desktop client for the Matrix protocol</summary> |
<summary>Desktop client for the Matrix protocol</summary> |
||||||
<description> |
<description> |
||||||
<p>The motivation behind the project is to provide a native |
<p>The motivation behind the project is to provide a native |
||||||
desktop app for Matrix that feels more like a mainstream |
desktop app for Matrix that feels more like a mainstream |
||||||
chat app.</p> |
chat app.</p> |
||||||
</description> |
</description> |
||||||
<translation/> |
<translation/> |
||||||
<languages> |
<languages> |
||||||
<lang>de</lang> |
<lang>de</lang> |
||||||
<lang>el</lang> |
<lang>el</lang> |
||||||
<lang>en</lang> |
<lang>en</lang> |
||||||
<lang>fr</lang> |
<lang>fr</lang> |
||||||
<lang>nl</lang> |
<lang>nl</lang> |
||||||
<lang>pl</lang> |
<lang>pl</lang> |
||||||
<lang>ru</lang> |
<lang>ru</lang> |
||||||
<lang>zh_CN</lang> |
<lang>zh_CN</lang> |
||||||
</languages> |
</languages> |
||||||
<content_rating type="oars-1.0"> |
<content_rating type="oars-1.0"> |
||||||
<content_attribute id="social-chat">intense</content_attribute> |
<content_attribute id="social-chat">intense</content_attribute> |
||||||
<content_attribute id="social-audio">intense</content_attribute> |
<content_attribute id="social-audio">intense</content_attribute> |
||||||
</content_rating> |
</content_rating> |
||||||
<screenshots> |
<screenshots> |
||||||
<screenshot type="default"> |
<screenshot type="default"> |
||||||
<image>https://nheko-reborn.github.io/images/screenshots/chat-resized.png</image> |
<image>https://nheko-reborn.github.io/images/screenshots/chat-resized.png</image> |
||||||
</screenshot> |
</screenshot> |
||||||
<screenshot> |
<screenshot> |
||||||
<image>https://nheko-reborn.github.io/images/screenshots/Start-resized.png</image> |
<image>https://nheko-reborn.github.io/images/screenshots/Start-resized.png</image> |
||||||
</screenshot> |
</screenshot> |
||||||
<screenshot> |
<screenshot> |
||||||
<image>https://nheko-reborn.github.io/images/screenshots/settings-resized.png</image> |
<image>https://nheko-reborn.github.io/images/screenshots/settings-resized.png</image> |
||||||
</screenshot> |
</screenshot> |
||||||
<screenshot> |
<screenshot> |
||||||
<image>https://nheko-reborn.github.io/images/screenshots/login-resized.png</image> |
<image>https://nheko-reborn.github.io/images/screenshots/login-resized.png</image> |
||||||
</screenshot> |
</screenshot> |
||||||
</screenshots> |
</screenshots> |
||||||
<url type="homepage">https://github.com/Nheko-Reborn/nheko</url> |
<url type="homepage">https://github.com/Nheko-Reborn/nheko</url> |
||||||
<update_contact>https://github.com/Nheko-Reborn</update_contact> |
<update_contact>https://github.com/Nheko-Reborn</update_contact> |
||||||
<releases> |
<releases> |
||||||
<release date="2020-06-12" version="0.7.2"/> |
<release version="0.6.4" date="2019-05-22" /> |
||||||
<release date="2020-04-24" version="0.7.1"/> |
<release version="0.6.3" date="2019-02-08" /> |
||||||
<release date="2020-04-19" version="0.7.0"/> |
<release version="0.6.2" date="2018-10-07" /> |
||||||
<release date="2019-05-22" version="0.6.4"/> |
<release version="0.6.1" date="2018-09-26" /> |
||||||
<release date="2019-02-08" version="0.6.3"/> |
<release version="0.6.0" date="2018-09-21" /> |
||||||
<release date="2018-10-07" version="0.6.2"/> |
<release version="0.5.5" date="2018-09-01" /> |
||||||
<release date="2018-09-26" version="0.6.1"/> |
<release version="0.5.4" date="2018-08-21" /> |
||||||
<release date="2018-09-21" version="0.6.0"/> |
<release version="0.5.3" date="2018-08-12" /> |
||||||
<release date="2018-09-01" version="0.5.5"/> |
<release version="0.5.2" date="2018-07-28" /> |
||||||
<release date="2018-08-21" version="0.5.4"/> |
</releases> |
||||||
<release date="2018-08-12" version="0.5.3"/> |
|
||||||
<release date="2018-07-28" version="0.5.2"/> |
|
||||||
</releases> |
|
||||||
|
|
||||||
<developer_name>Nheko Reborn</developer_name> |
|
||||||
|
|
||||||
<url type="bugtracker">https://github.com/Nheko-Reborn/nheko/issues</url> |
|
||||||
|
|
||||||
<url type="help">https://github.com/Nheko-Reborn/nheko/</url> |
|
||||||
|
|
||||||
<url type="translate">https://weblate.nheko.im/projects/nheko/</url> |
|
||||||
</component> |
</component> |
||||||
|
@ -1,113 +0,0 @@ |
|||||||
import QtQuick 2.9 |
|
||||||
import QtQuick.Controls 2.3 |
|
||||||
import QtQuick.Layouts 1.2 |
|
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
id: activeCallBar |
|
||||||
|
|
||||||
visible: TimelineManager.callState != WebRTCState.DISCONNECTED |
|
||||||
color: "#2ECC71" |
|
||||||
implicitHeight: rowLayout.height + 8 |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
id: rowLayout |
|
||||||
|
|
||||||
anchors.left: parent.left |
|
||||||
anchors.right: parent.right |
|
||||||
anchors.verticalCenter: parent.verticalCenter |
|
||||||
anchors.leftMargin: 8 |
|
||||||
|
|
||||||
Avatar { |
|
||||||
width: avatarSize |
|
||||||
height: avatarSize |
|
||||||
url: TimelineManager.callPartyAvatarUrl.replace("mxc://", "image://MxcImage/") |
|
||||||
displayName: TimelineManager.callPartyName |
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
font.pointSize: fontMetrics.font.pointSize * 1.1 |
|
||||||
text: " " + TimelineManager.callPartyName + " " |
|
||||||
} |
|
||||||
|
|
||||||
Image { |
|
||||||
Layout.preferredWidth: 24 |
|
||||||
Layout.preferredHeight: 24 |
|
||||||
source: "qrc:/icons/icons/ui/place-call.png" |
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
id: callStateLabel |
|
||||||
|
|
||||||
font.pointSize: fontMetrics.font.pointSize * 1.1 |
|
||||||
} |
|
||||||
|
|
||||||
Connections { |
|
||||||
function onCallStateChanged(state) { |
|
||||||
switch (state) { |
|
||||||
case WebRTCState.INITIATING: |
|
||||||
callStateLabel.text = qsTr("Initiating..."); |
|
||||||
break; |
|
||||||
case WebRTCState.OFFERSENT: |
|
||||||
callStateLabel.text = qsTr("Calling..."); |
|
||||||
break; |
|
||||||
case WebRTCState.CONNECTING: |
|
||||||
callStateLabel.text = qsTr("Connecting..."); |
|
||||||
break; |
|
||||||
case WebRTCState.CONNECTED: |
|
||||||
callStateLabel.text = "00:00"; |
|
||||||
var d = new Date(); |
|
||||||
callTimer.startTime = Math.floor(d.getTime() / 1000); |
|
||||||
break; |
|
||||||
case WebRTCState.DISCONNECTED: |
|
||||||
callStateLabel.text = ""; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
target: TimelineManager |
|
||||||
} |
|
||||||
|
|
||||||
Timer { |
|
||||||
id: callTimer |
|
||||||
|
|
||||||
property int startTime |
|
||||||
|
|
||||||
function pad(n) { |
|
||||||
return (n < 10) ? ("0" + n) : n; |
|
||||||
} |
|
||||||
|
|
||||||
interval: 1000 |
|
||||||
running: TimelineManager.callState == WebRTCState.CONNECTED |
|
||||||
repeat: true |
|
||||||
onTriggered: { |
|
||||||
var d = new Date(); |
|
||||||
let seconds = Math.floor(d.getTime() / 1000 - startTime); |
|
||||||
let s = Math.floor(seconds % 60); |
|
||||||
let m = Math.floor(seconds / 60) % 60; |
|
||||||
let h = Math.floor(seconds / 3600); |
|
||||||
callStateLabel.text = (h ? (pad(h) + ":") : "") + pad(m) + ":" + pad(s); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Item { |
|
||||||
Layout.fillWidth: true |
|
||||||
} |
|
||||||
|
|
||||||
ImageButton { |
|
||||||
width: 24 |
|
||||||
height: 24 |
|
||||||
buttonTextColor: "#000000" |
|
||||||
image: TimelineManager.isMicMuted ? ":/icons/icons/ui/microphone-unmute.png" : ":/icons/icons/ui/microphone-mute.png" |
|
||||||
hoverEnabled: true |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: TimelineManager.isMicMuted ? qsTr("Unmute Mic") : qsTr("Mute Mic") |
|
||||||
onClicked: TimelineManager.toggleMicMute() |
|
||||||
} |
|
||||||
|
|
||||||
Item { |
|
||||||
implicitWidth: 16 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,75 +1,46 @@ |
|||||||
import QtGraphicalEffects 1.0 |
|
||||||
import QtQuick 2.6 |
import QtQuick 2.6 |
||||||
import QtQuick.Controls 2.3 |
import QtGraphicalEffects 1.0 |
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
Rectangle { |
Rectangle { |
||||||
id: avatar |
id: avatar |
||||||
|
width: 48 |
||||||
property alias url: img.source |
height: 48 |
||||||
property string userid |
radius: settings.avatar_circles ? height/2 : 3 |
||||||
property string displayName |
|
||||||
|
property alias url: img.source |
||||||
width: 48 |
property string displayName |
||||||
height: 48 |
|
||||||
radius: Settings.avatarCircles ? height / 2 : 3 |
Text { |
||||||
color: colors.base |
anchors.fill: parent |
||||||
|
text: chat.model.escapeEmoji(String.fromCodePoint(displayName.codePointAt(0))) |
||||||
Label { |
textFormat: Text.RichText |
||||||
anchors.fill: parent |
color: colors.text |
||||||
text: TimelineManager.escapeEmoji(displayName ? String.fromCodePoint(displayName.codePointAt(0)) : "") |
font.pixelSize: avatar.height/2 |
||||||
textFormat: Text.RichText |
verticalAlignment: Text.AlignVCenter |
||||||
font.pixelSize: avatar.height / 2 |
horizontalAlignment: Text.AlignHCenter |
||||||
verticalAlignment: Text.AlignVCenter |
visible: img.status != Image.Ready |
||||||
horizontalAlignment: Text.AlignHCenter |
} |
||||||
visible: img.status != Image.Ready |
|
||||||
color: colors.text |
Image { |
||||||
} |
id: img |
||||||
|
anchors.fill: parent |
||||||
Image { |
asynchronous: true |
||||||
id: img |
fillMode: Image.PreserveAspectCrop |
||||||
|
mipmap: true |
||||||
anchors.fill: parent |
smooth: false |
||||||
asynchronous: true |
|
||||||
fillMode: Image.PreserveAspectCrop |
sourceSize.width: avatar.width |
||||||
mipmap: true |
sourceSize.height: avatar.height |
||||||
smooth: false |
|
||||||
sourceSize.width: avatar.width |
layer.enabled: true |
||||||
sourceSize.height: avatar.height |
layer.effect: OpacityMask { |
||||||
layer.enabled: true |
maskSource: Rectangle { |
||||||
|
anchors.fill: parent |
||||||
layer.effect: OpacityMask { |
width: avatar.width |
||||||
|
height: avatar.height |
||||||
maskSource: Rectangle { |
radius: settings.avatar_circles ? height/2 : 3 |
||||||
anchors.fill: parent |
} |
||||||
width: avatar.width |
} |
||||||
height: avatar.height |
} |
||||||
radius: Settings.avatarCircles ? height / 2 : 3 |
color: colors.base |
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
anchors.bottom: avatar.bottom |
|
||||||
anchors.right: avatar.right |
|
||||||
visible: !!userid |
|
||||||
height: avatar.height / 6 |
|
||||||
width: height |
|
||||||
radius: Settings.avatarCircles ? height / 2 : height / 4 |
|
||||||
color: { |
|
||||||
switch (TimelineManager.userPresence(userid)) { |
|
||||||
case "online": |
|
||||||
return "#00cc66"; |
|
||||||
case "unavailable": |
|
||||||
return "#ff9933"; |
|
||||||
case "offline": |
|
||||||
default: |
|
||||||
// return "#a82353" don't show anything if offline, since it is confusing, if presence is disabled |
|
||||||
"transparent"; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
} |
||||||
|
@ -1,30 +1,29 @@ |
|||||||
import QtQuick 2.3 |
import QtQuick 2.3 |
||||||
import QtQuick.Controls 2.3 |
import QtQuick.Controls 2.3 |
||||||
|
|
||||||
AbstractButton { |
Button { |
||||||
id: button |
property string image: undefined |
||||||
|
|
||||||
property string image: undefined |
id: button |
||||||
property color highlightColor: colors.highlight |
|
||||||
property color buttonTextColor: colors.buttonText |
flat: true |
||||||
|
|
||||||
width: 16 |
// disable background, because we don't want a border on hover |
||||||
height: 16 |
background: Item { |
||||||
|
} |
||||||
Image { |
|
||||||
id: buttonImg |
Image { |
||||||
|
id: buttonImg |
||||||
// Workaround, can't get icon.source working for now... |
// Workaround, can't get icon.source working for now... |
||||||
anchors.fill: parent |
anchors.fill: parent |
||||||
source: "image://colorimage/" + image + "?" + (button.hovered ? highlightColor : buttonTextColor) |
source: "image://colorimage/" + image + "?" + (button.hovered ? colors.highlight : colors.buttonText) |
||||||
} |
} |
||||||
|
|
||||||
MouseArea { |
MouseArea |
||||||
id: mouseArea |
{ |
||||||
|
id: mouseArea |
||||||
anchors.fill: parent |
anchors.fill: parent |
||||||
onPressed: mouse.accepted = false |
onPressed: mouse.accepted = false |
||||||
cursorShape: Qt.PointingHandCursor |
cursorShape: Qt.PointingHandCursor |
||||||
} |
} |
||||||
|
|
||||||
} |
} |
||||||
|
@ -1,37 +1,32 @@ |
|||||||
import QtQuick 2.5 |
import QtQuick 2.5 |
||||||
import QtQuick.Controls 2.3 |
import QtQuick.Controls 2.3 |
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
TextEdit { |
TextEdit { |
||||||
textFormat: TextEdit.RichText |
textFormat: TextEdit.RichText |
||||||
readOnly: true |
readOnly: true |
||||||
wrapMode: Text.Wrap |
wrapMode: Text.Wrap |
||||||
selectByMouse: true |
selectByMouse: true |
||||||
activeFocusOnPress: false |
color: colors.text |
||||||
color: colors.text |
|
||||||
onLinkActivated: { |
|
||||||
if (/^https:\/\/matrix.to\/#\/(@.*)$/.test(link)) { |
|
||||||
chat.model.openUserProfile(/^https:\/\/matrix.to\/#\/(@.*)$/.exec(link)[1]); |
|
||||||
} else if (/^https:\/\/matrix.to\/#\/(![^\/]*)$/.test(link)) { |
|
||||||
TimelineManager.setHistoryView(/^https:\/\/matrix.to\/#\/(!.*)$/.exec(link)[1]); |
|
||||||
} else if (/^https:\/\/matrix.to\/#\/(![^\/]*)\/(\$.*)$/.test(link)) { |
|
||||||
var match = /^https:\/\/matrix.to\/#\/(![^\/]*)\/(\$.*)$/.exec(link); |
|
||||||
TimelineManager.setHistoryView(match[1]); |
|
||||||
chat.positionViewAtIndex(chat.model.idToIndex(match[2]), ListView.Contain); |
|
||||||
} else { |
|
||||||
TimelineManager.openLink(link); |
|
||||||
} |
|
||||||
} |
|
||||||
ToolTip.visible: hoveredLink |
|
||||||
ToolTip.text: hoveredLink |
|
||||||
|
|
||||||
MouseArea { |
onLinkActivated: { |
||||||
id: ma |
if (/^https:\/\/matrix.to\/#\/(@.*)$/.test(link)) chat.model.openUserProfile(/^https:\/\/matrix.to\/#\/(@.*)$/.exec(link)[1]) |
||||||
|
else if (/^https:\/\/matrix.to\/#\/(![^\/]*)$/.test(link)) timelineManager.setHistoryView(/^https:\/\/matrix.to\/#\/(!.*)$/.exec(link)[1]) |
||||||
anchors.fill: parent |
else if (/^https:\/\/matrix.to\/#\/(![^\/]*)\/(\$.*)$/.test(link)) { |
||||||
propagateComposedEvents: true |
var match = /^https:\/\/matrix.to\/#\/(![^\/]*)\/(\$.*)$/.exec(link) |
||||||
acceptedButtons: Qt.NoButton |
timelineManager.setHistoryView(match[1]) |
||||||
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor |
chat.positionViewAtIndex(chat.model.idToIndex(match[2]), ListView.Contain) |
||||||
} |
} |
||||||
|
else Qt.openUrlExternally(link) |
||||||
|
} |
||||||
|
MouseArea |
||||||
|
{ |
||||||
|
id: ma |
||||||
|
anchors.fill: parent |
||||||
|
propagateComposedEvents: true |
||||||
|
acceptedButtons: Qt.NoButton |
||||||
|
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor |
||||||
|
} |
||||||
|
|
||||||
|
ToolTip.visible: hoveredLink |
||||||
|
ToolTip.text: hoveredLink |
||||||
} |
} |
||||||
|
@ -1,95 +0,0 @@ |
|||||||
import QtQuick 2.6 |
|
||||||
import QtQuick.Controls 2.2 |
|
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
// This class is for showing Reactions in the timeline row, not for |
|
||||||
// adding new reactions via the emoji picker |
|
||||||
Flow { |
|
||||||
id: reactionFlow |
|
||||||
|
|
||||||
// highlight colors for selfReactedEvent background |
|
||||||
property real highlightHue: colors.highlight.hslHue |
|
||||||
property real highlightSat: colors.highlight.hslSaturation |
|
||||||
property real highlightLight: colors.highlight.hslLightness |
|
||||||
property string eventId |
|
||||||
property alias reactions: repeater.model |
|
||||||
|
|
||||||
anchors.left: parent.left |
|
||||||
anchors.right: parent.right |
|
||||||
spacing: 4 |
|
||||||
|
|
||||||
Repeater { |
|
||||||
id: repeater |
|
||||||
|
|
||||||
delegate: AbstractButton { |
|
||||||
id: reaction |
|
||||||
|
|
||||||
hoverEnabled: true |
|
||||||
implicitWidth: contentItem.childrenRect.width + contentItem.leftPadding * 2 |
|
||||||
implicitHeight: contentItem.childrenRect.height |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: modelData.users |
|
||||||
onClicked: { |
|
||||||
console.debug("Picked " + modelData.key + "in response to " + reactionFlow.eventId + ". selfReactedEvent: " + modelData.selfReactedEvent); |
|
||||||
TimelineManager.queueReactionMessage(reactionFlow.eventId, modelData.key); |
|
||||||
} |
|
||||||
|
|
||||||
contentItem: Row { |
|
||||||
anchors.centerIn: parent |
|
||||||
spacing: reactionText.implicitHeight / 4 |
|
||||||
leftPadding: reactionText.implicitHeight / 2 |
|
||||||
rightPadding: reactionText.implicitHeight / 2 |
|
||||||
|
|
||||||
TextMetrics { |
|
||||||
id: textMetrics |
|
||||||
|
|
||||||
font.family: Settings.emojiFont |
|
||||||
elide: Text.ElideRight |
|
||||||
elideWidth: 150 |
|
||||||
text: modelData.key |
|
||||||
} |
|
||||||
|
|
||||||
Text { |
|
||||||
id: reactionText |
|
||||||
|
|
||||||
anchors.baseline: reactionCounter.baseline |
|
||||||
text: textMetrics.elidedText + (textMetrics.elidedText == modelData.key ? "" : "…") |
|
||||||
font.family: Settings.emojiFont |
|
||||||
color: reaction.hovered ? colors.highlight : colors.text |
|
||||||
maximumLineCount: 1 |
|
||||||
} |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
id: divider |
|
||||||
|
|
||||||
height: Math.floor(reactionCounter.implicitHeight * 1.4) |
|
||||||
width: 1 |
|
||||||
color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text |
|
||||||
} |
|
||||||
|
|
||||||
Text { |
|
||||||
id: reactionCounter |
|
||||||
|
|
||||||
anchors.verticalCenter: divider.verticalCenter |
|
||||||
text: modelData.count |
|
||||||
font: reaction.font |
|
||||||
color: reaction.hovered ? colors.highlight : colors.text |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
background: Rectangle { |
|
||||||
anchors.centerIn: parent |
|
||||||
implicitWidth: reaction.implicitWidth |
|
||||||
implicitHeight: reaction.implicitHeight |
|
||||||
border.color: (reaction.hovered || modelData.selfReactedEvent !== '') ? colors.highlight : colors.text |
|
||||||
color: modelData.selfReactedEvent !== '' ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.2) : colors.base |
|
||||||
border.width: 1 |
|
||||||
radius: reaction.height / 2 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,108 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright (C) 2016 Michael Bohlender, <michael.bohlender@kdemail.net> |
|
||||||
* Copyright (C) 2017 Christian Mollekopf, <mollekopf@kolabsystems.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 2 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, write to the Free Software Foundation, Inc., |
|
||||||
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
||||||
*/ |
|
||||||
/* |
|
||||||
* Shamelessly stolen from: |
|
||||||
* https://cgit.kde.org/kube.git/tree/framework/qml/ScrollHelper.qml |
|
||||||
* |
|
||||||
* The MouseArea + interactive: false + maximumFlickVelocity are required |
|
||||||
* to fix scrolling for desktop systems where we don't want flicking behaviour. |
|
||||||
* |
|
||||||
* See also: |
|
||||||
* ScrollView.qml in qtquickcontrols |
|
||||||
* qquickwheelarea.cpp in qtquickcontrols |
|
||||||
*/ |
|
||||||
|
|
||||||
import QtQuick 2.9 |
|
||||||
import QtQuick.Controls 2.3 |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
// console.warn("Delta: ", wheel.pixelDelta.y); |
|
||||||
// console.warn("Old position: ", flickable.contentY); |
|
||||||
// console.warn("New position: ", newPos); |
|
||||||
|
|
||||||
id: root |
|
||||||
|
|
||||||
property Flickable flickable |
|
||||||
property alias enabled: root.enabled |
|
||||||
|
|
||||||
function calculateNewPosition(flickableItem, wheel) { |
|
||||||
//Nothing to scroll |
|
||||||
if (flickableItem.contentHeight < flickableItem.height) |
|
||||||
return flickableItem.contentY; |
|
||||||
|
|
||||||
//Ignore 0 events (happens at least with Christians trackpad) |
|
||||||
if (wheel.pixelDelta.y == 0 && wheel.angleDelta.y == 0) |
|
||||||
return flickableItem.contentY; |
|
||||||
|
|
||||||
//pixelDelta seems to be the same as angleDelta/8 |
|
||||||
var pixelDelta = 0; |
|
||||||
//The pixelDelta is a smaller number if both are provided, so pixelDelta can be 0 while angleDelta is still something. So we check the angleDelta |
|
||||||
if (wheel.angleDelta.y) { |
|
||||||
var wheelScrollLines = 3; //Default value of QApplication wheelScrollLines property |
|
||||||
var pixelPerLine = 20; //Default value in Qt, originally comes from QTextEdit |
|
||||||
var ticks = (wheel.angleDelta.y / 8) / 15; //Divide by 8 gives us pixels typically come in 15pixel steps. |
|
||||||
pixelDelta = ticks * pixelPerLine * wheelScrollLines; |
|
||||||
} else { |
|
||||||
pixelDelta = wheel.pixelDelta.y; |
|
||||||
} |
|
||||||
pixelDelta = Math.round(pixelDelta); |
|
||||||
if (!pixelDelta) |
|
||||||
return flickableItem.contentY; |
|
||||||
|
|
||||||
var minYExtent = flickableItem.originY + flickableItem.topMargin; |
|
||||||
var maxYExtent = (flickableItem.contentHeight + flickableItem.bottomMargin + flickableItem.originY) - flickableItem.height; |
|
||||||
if (typeof (flickableItem.headerItem) !== "undefined" && flickableItem.headerItem) |
|
||||||
minYExtent += flickableItem.headerItem.height; |
|
||||||
|
|
||||||
//Avoid overscrolling |
|
||||||
return Math.max(minYExtent, Math.min(maxYExtent, flickableItem.contentY - pixelDelta)); |
|
||||||
} |
|
||||||
|
|
||||||
propagateComposedEvents: true |
|
||||||
//Place the mouse area under the flickable |
|
||||||
z: -1 |
|
||||||
onFlickableChanged: { |
|
||||||
if (enabled) { |
|
||||||
flickable.maximumFlickVelocity = 100000; |
|
||||||
flickable.boundsBehavior = Flickable.StopAtBounds; |
|
||||||
root.parent = flickable; |
|
||||||
} |
|
||||||
} |
|
||||||
acceptedButtons: Qt.NoButton |
|
||||||
onWheel: { |
|
||||||
var newPos = calculateNewPosition(flickable, wheel); |
|
||||||
// Show the scrollbars |
|
||||||
flickable.flick(0, 0); |
|
||||||
flickable.contentY = newPos; |
|
||||||
cancelFlickStateTimer.start(); |
|
||||||
} |
|
||||||
|
|
||||||
Timer { |
|
||||||
id: cancelFlickStateTimer |
|
||||||
|
|
||||||
//How long the scrollbar will remain visible |
|
||||||
interval: 500 |
|
||||||
// Hide the scrollbars |
|
||||||
onTriggered: { |
|
||||||
flickable.cancelFlick(); |
|
||||||
flickable.movementEnded(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,148 +1,120 @@ |
|||||||
import "./delegates" |
|
||||||
import "./emoji" |
|
||||||
import QtQuick 2.6 |
import QtQuick 2.6 |
||||||
import QtQuick.Controls 2.3 |
import QtQuick.Controls 2.3 |
||||||
import QtQuick.Layouts 1.2 |
import QtQuick.Layouts 1.2 |
||||||
import QtQuick.Window 2.2 |
import QtQuick.Window 2.2 |
||||||
|
|
||||||
import im.nheko 1.0 |
import im.nheko 1.0 |
||||||
|
|
||||||
Item { |
import "./delegates" |
||||||
anchors.left: parent.left |
|
||||||
anchors.right: parent.right |
|
||||||
height: row.height |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
anchors.fill: parent |
|
||||||
propagateComposedEvents: true |
|
||||||
preventStealing: true |
|
||||||
hoverEnabled: true |
|
||||||
acceptedButtons: Qt.AllButtons |
|
||||||
onClicked: { |
|
||||||
if (mouse.button === Qt.RightButton) |
|
||||||
messageContextMenu.show(model.id, model.type, model.isEncrypted, row); |
|
||||||
|
|
||||||
} |
|
||||||
onPressAndHold: { |
|
||||||
messageContextMenu.show(model.id, model.type, model.isEncrypted, row, mapToItem(timelineRoot, mouse.x, mouse.y)); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
color: (Settings.messageHoverHighlight && parent.containsMouse) ? colors.base : "transparent" |
|
||||||
anchors.fill: row |
|
||||||
} |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
id: row |
|
||||||
|
|
||||||
anchors.leftMargin: avatarSize + 16 |
|
||||||
anchors.left: parent.left |
|
||||||
anchors.right: parent.right |
|
||||||
|
|
||||||
Column { |
|
||||||
Layout.fillWidth: true |
|
||||||
Layout.alignment: Qt.AlignTop |
|
||||||
spacing: 4 |
|
||||||
|
|
||||||
// fancy reply, if this is a reply |
|
||||||
Reply { |
|
||||||
visible: model.replyTo |
|
||||||
modelData: chat.model.getDump(model.replyTo, model.id) |
|
||||||
userColor: TimelineManager.userColor(modelData.userId, colors.window) |
|
||||||
} |
|
||||||
|
|
||||||
// actual message content |
|
||||||
MessageDelegate { |
|
||||||
id: contentItem |
|
||||||
|
|
||||||
width: parent.width |
|
||||||
modelData: model |
|
||||||
} |
|
||||||
|
|
||||||
Reactions { |
|
||||||
id: reactionRow |
|
||||||
|
|
||||||
reactions: model.reactions |
|
||||||
eventId: model.id |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
StatusIndicator { |
|
||||||
state: model.state |
|
||||||
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
|
||||||
Layout.preferredHeight: 16 |
|
||||||
width: 16 |
|
||||||
} |
|
||||||
|
|
||||||
EncryptionIndicator { |
|
||||||
visible: model.isRoomEncrypted |
|
||||||
encrypted: model.isEncrypted |
|
||||||
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
|
||||||
Layout.preferredHeight: 16 |
|
||||||
width: 16 |
|
||||||
} |
|
||||||
|
|
||||||
EmojiButton { |
|
||||||
id: reactButton |
|
||||||
|
|
||||||
visible: Settings.buttonsInTimeline |
|
||||||
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
|
||||||
Layout.preferredHeight: 16 |
|
||||||
width: 16 |
|
||||||
hoverEnabled: true |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: qsTr("React") |
|
||||||
emojiPicker: emojiPopup |
|
||||||
event_id: model.id |
|
||||||
} |
|
||||||
|
|
||||||
ImageButton { |
|
||||||
id: replyButton |
|
||||||
|
|
||||||
visible: Settings.buttonsInTimeline |
|
||||||
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
|
||||||
Layout.preferredHeight: 16 |
|
||||||
width: 16 |
|
||||||
hoverEnabled: true |
|
||||||
image: ":/icons/icons/ui/mail-reply.png" |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: qsTr("Reply") |
|
||||||
onClicked: chat.model.replyAction(model.id) |
|
||||||
} |
|
||||||
|
|
||||||
ImageButton { |
|
||||||
id: optionsButton |
|
||||||
|
|
||||||
visible: Settings.buttonsInTimeline |
|
||||||
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
|
||||||
Layout.preferredHeight: 16 |
|
||||||
width: 16 |
|
||||||
hoverEnabled: true |
|
||||||
image: ":/icons/icons/ui/vertical-ellipsis.png" |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: qsTr("Options") |
|
||||||
onClicked: messageContextMenu.show(model.id, model.type, model.isEncrypted, optionsButton) |
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
|
||||||
text: model.timestamp.toLocaleTimeString("HH:mm") |
|
||||||
width: Math.max(implicitWidth, text.length * fontMetrics.maximumCharacterWidth) |
|
||||||
color: inactiveColors.text |
|
||||||
ToolTip.visible: ma.containsMouse |
|
||||||
ToolTip.text: Qt.formatDateTime(model.timestamp, Qt.DefaultLocaleLongDate) |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
id: ma |
|
||||||
|
|
||||||
anchors.fill: parent |
|
||||||
hoverEnabled: true |
|
||||||
propagateComposedEvents: true |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
|
MouseArea { |
||||||
|
anchors.left: parent.left |
||||||
|
anchors.right: parent.right |
||||||
|
height: row.height |
||||||
|
propagateComposedEvents: true |
||||||
|
preventStealing: true |
||||||
|
|
||||||
|
acceptedButtons: Qt.LeftButton | Qt.RightButton |
||||||
|
onClicked: { |
||||||
|
if (mouse.button === Qt.RightButton) |
||||||
|
messageContextMenu.show(model.id, model.type, row) |
||||||
|
} |
||||||
|
onPressAndHold: { |
||||||
|
if (mouse.source === Qt.MouseEventNotSynthesized) |
||||||
|
messageContextMenu.show(model.id, model.type, row) |
||||||
|
} |
||||||
|
|
||||||
|
RowLayout { |
||||||
|
id: row |
||||||
|
|
||||||
|
anchors.leftMargin: avatarSize + 4 |
||||||
|
anchors.left: parent.left |
||||||
|
anchors.right: parent.right |
||||||
|
|
||||||
|
|
||||||
|
Column { |
||||||
|
Layout.fillWidth: true |
||||||
|
Layout.alignment: Qt.AlignTop |
||||||
|
spacing: 4 |
||||||
|
|
||||||
|
// fancy reply, if this is a reply |
||||||
|
Reply { |
||||||
|
visible: model.replyTo |
||||||
|
modelData: chat.model.getDump(model.replyTo) |
||||||
|
userColor: timelineManager.userColor(modelData.userId, colors.window) |
||||||
|
} |
||||||
|
|
||||||
|
// actual message content |
||||||
|
MessageDelegate { |
||||||
|
id: contentItem |
||||||
|
|
||||||
|
width: parent.width |
||||||
|
|
||||||
|
modelData: model |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
ImageButton { |
||||||
|
visible: timelineSettings.buttons |
||||||
|
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
||||||
|
Layout.preferredHeight: 16 |
||||||
|
width: 16 |
||||||
|
id: replyButton |
||||||
|
hoverEnabled: true |
||||||
|
|
||||||
|
|
||||||
|
image: ":/icons/icons/ui/mail-reply.png" |
||||||
|
|
||||||
|
ToolTip.visible: hovered |
||||||
|
ToolTip.text: qsTr("Reply") |
||||||
|
|
||||||
|
onClicked: chat.model.replyAction(model.id) |
||||||
|
} |
||||||
|
ImageButton { |
||||||
|
visible: timelineSettings.buttons |
||||||
|
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
||||||
|
Layout.preferredHeight: 16 |
||||||
|
width: 16 |
||||||
|
id: optionsButton |
||||||
|
hoverEnabled: true |
||||||
|
|
||||||
|
image: ":/icons/icons/ui/vertical-ellipsis.png" |
||||||
|
|
||||||
|
ToolTip.visible: hovered |
||||||
|
ToolTip.text: qsTr("Options") |
||||||
|
|
||||||
|
onClicked: messageContextMenu.show(model.id, model.type, optionsButton) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
StatusIndicator { |
||||||
|
state: model.state |
||||||
|
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
||||||
|
Layout.preferredHeight: 16 |
||||||
|
width: 16 |
||||||
|
} |
||||||
|
|
||||||
|
EncryptionIndicator { |
||||||
|
visible: model.isEncrypted |
||||||
|
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
||||||
|
Layout.preferredHeight: 16 |
||||||
|
width: 16 |
||||||
|
} |
||||||
|
|
||||||
|
Text { |
||||||
|
Layout.alignment: Qt.AlignRight | Qt.AlignTop |
||||||
|
text: model.timestamp.toLocaleTimeString("HH:mm") |
||||||
|
color: inactiveColors.text |
||||||
|
|
||||||
|
MouseArea{ |
||||||
|
id: ma |
||||||
|
anchors.fill: parent |
||||||
|
hoverEnabled: true |
||||||
|
propagateComposedEvents: true |
||||||
|
} |
||||||
|
|
||||||
|
ToolTip.visible: ma.containsMouse |
||||||
|
ToolTip.text: Qt.formatDateTime(model.timestamp, Qt.DefaultLocaleLongDate) |
||||||
|
} |
||||||
|
} |
||||||
} |
} |
||||||
|
@ -1,582 +1,328 @@ |
|||||||
import "./delegates" |
|
||||||
import "./device-verification" |
|
||||||
import "./emoji" |
|
||||||
import QtGraphicalEffects 1.0 |
|
||||||
import QtQuick 2.9 |
import QtQuick 2.9 |
||||||
import QtQuick.Controls 2.3 |
import QtQuick.Controls 2.3 |
||||||
import QtQuick.Layouts 1.2 |
import QtQuick.Layouts 1.2 |
||||||
|
import QtGraphicalEffects 1.0 |
||||||
import QtQuick.Window 2.2 |
import QtQuick.Window 2.2 |
||||||
import im.nheko 1.0 |
import Qt.labs.settings 1.0 |
||||||
import im.nheko.EmojiModel 1.0 |
|
||||||
|
|
||||||
Page { |
|
||||||
id: timelineRoot |
|
||||||
|
|
||||||
property var colors: currentActivePalette |
|
||||||
property var systemInactive |
|
||||||
property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive |
|
||||||
property int avatarSize: 40 |
|
||||||
property real highlightHue: colors.highlight.hslHue |
|
||||||
property real highlightSat: colors.highlight.hslSaturation |
|
||||||
property real highlightLight: colors.highlight.hslLightness |
|
||||||
|
|
||||||
palette: colors |
|
||||||
|
|
||||||
FontMetrics { |
|
||||||
id: fontMetrics |
|
||||||
} |
|
||||||
|
|
||||||
EmojiPicker { |
|
||||||
id: emojiPopup |
|
||||||
|
|
||||||
width: 7 * 52 + 20 |
|
||||||
height: 6 * 52 |
|
||||||
colors: palette |
|
||||||
|
|
||||||
model: EmojiProxyModel { |
|
||||||
category: EmojiCategory.People |
|
||||||
|
|
||||||
sourceModel: EmojiModel { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Menu { |
|
||||||
id: messageContextMenu |
|
||||||
|
|
||||||
property string eventId |
|
||||||
property int eventType |
|
||||||
property bool isEncrypted |
|
||||||
|
|
||||||
function show(eventId_, eventType_, isEncrypted_, showAt_, position) { |
|
||||||
eventId = eventId_; |
|
||||||
eventType = eventType_; |
|
||||||
isEncrypted = isEncrypted_; |
|
||||||
if (position) |
|
||||||
popup(position, showAt_); |
|
||||||
else |
|
||||||
popup(showAt_); |
|
||||||
} |
|
||||||
|
|
||||||
modal: true |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("React") |
|
||||||
onClicked: emojiPopup.show(messageContextMenu.parent, messageContextMenu.eventId) |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("Reply") |
|
||||||
onClicked: chat.model.replyAction(messageContextMenu.eventId) |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("Read receipts") |
|
||||||
onTriggered: chat.model.readReceiptsAction(messageContextMenu.eventId) |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("Mark as read") |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("View raw message") |
|
||||||
onTriggered: chat.model.viewRawMessage(messageContextMenu.eventId) |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
visible: messageContextMenu.isEncrypted |
|
||||||
height: visible ? implicitHeight : 0 |
|
||||||
text: qsTr("View decrypted raw message") |
|
||||||
onTriggered: chat.model.viewDecryptedRawMessage(messageContextMenu.eventId) |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("Redact message") |
|
||||||
onTriggered: chat.model.redactEvent(messageContextMenu.eventId) |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker |
|
||||||
height: visible ? implicitHeight : 0 |
|
||||||
text: qsTr("Save as") |
|
||||||
onTriggered: TimelineManager.timeline.saveMedia(messageContextMenu.eventId) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
anchors.fill: parent |
|
||||||
color: colors.window |
|
||||||
|
|
||||||
Component { |
|
||||||
id: deviceVerificationDialog |
|
||||||
|
|
||||||
DeviceVerification { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Connections { |
|
||||||
function onNewDeviceVerificationRequest(flow, transactionId, userId, deviceId, isRequest) { |
|
||||||
var dialog = deviceVerificationDialog.createObject(timelineRoot, { |
|
||||||
"flow": flow |
|
||||||
}); |
|
||||||
dialog.show(); |
|
||||||
} |
|
||||||
|
|
||||||
target: TimelineManager |
|
||||||
} |
|
||||||
|
|
||||||
Connections { |
|
||||||
function onOpenProfile(profile) { |
|
||||||
var userProfile = userProfileComponent.createObject(timelineRoot, { |
|
||||||
"profile": profile |
|
||||||
}); |
|
||||||
userProfile.show(); |
|
||||||
} |
|
||||||
|
|
||||||
target: TimelineManager.timeline |
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
visible: !TimelineManager.timeline && !TimelineManager.isInitialSync |
|
||||||
anchors.centerIn: parent |
|
||||||
text: qsTr("No room open") |
|
||||||
font.pointSize: 24 |
|
||||||
color: colors.text |
|
||||||
} |
|
||||||
|
|
||||||
BusyIndicator { |
|
||||||
visible: running |
|
||||||
anchors.centerIn: parent |
|
||||||
running: TimelineManager.isInitialSync |
|
||||||
height: 200 |
|
||||||
width: 200 |
|
||||||
z: 3 |
|
||||||
} |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
anchors.fill: parent |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
id: topBar |
|
||||||
|
|
||||||
Layout.fillWidth: true |
|
||||||
implicitHeight: topLayout.height + 16 |
|
||||||
z: 3 |
|
||||||
color: colors.base |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
anchors.fill: parent |
|
||||||
onClicked: TimelineManager.openRoomSettings() |
|
||||||
} |
|
||||||
|
|
||||||
GridLayout { |
|
||||||
//Layout.margins: 8 |
|
||||||
|
|
||||||
id: topLayout |
|
||||||
|
|
||||||
anchors.left: parent.left |
|
||||||
anchors.right: parent.right |
|
||||||
anchors.margins: 8 |
|
||||||
anchors.verticalCenter: parent.verticalCenter |
|
||||||
|
|
||||||
ImageButton { |
|
||||||
id: backToRoomsButton |
|
||||||
|
|
||||||
Layout.column: 0 |
|
||||||
Layout.row: 0 |
|
||||||
Layout.rowSpan: 2 |
|
||||||
Layout.alignment: Qt.AlignVCenter |
|
||||||
visible: TimelineManager.isNarrowView |
|
||||||
image: ":/icons/icons/ui/angle-pointing-to-left.png" |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: qsTr("Back to room list") |
|
||||||
onClicked: TimelineManager.backToRooms() |
|
||||||
} |
|
||||||
|
|
||||||
Avatar { |
|
||||||
Layout.column: 1 |
|
||||||
Layout.row: 0 |
|
||||||
Layout.rowSpan: 2 |
|
||||||
Layout.alignment: Qt.AlignVCenter |
|
||||||
width: avatarSize |
|
||||||
height: avatarSize |
|
||||||
url: chat.model ? chat.model.roomAvatarUrl.replace("mxc://", "image://MxcImage/") : "" |
|
||||||
displayName: chat.model ? chat.model.roomName : qsTr("No room selected") |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
anchors.fill: parent |
|
||||||
onClicked: TimelineManager.openRoomSettings() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
Layout.fillWidth: true |
|
||||||
Layout.column: 2 |
|
||||||
Layout.row: 0 |
|
||||||
color: colors.text |
|
||||||
font.pointSize: fontMetrics.font.pointSize * 1.1 |
|
||||||
text: chat.model ? chat.model.roomName : qsTr("No room selected") |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
anchors.fill: parent |
|
||||||
onClicked: TimelineManager.openRoomSettings() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
MatrixText { |
|
||||||
Layout.fillWidth: true |
|
||||||
Layout.column: 2 |
|
||||||
Layout.row: 1 |
|
||||||
Layout.maximumHeight: fontMetrics.lineSpacing * 2 // show 2 lines |
|
||||||
clip: true |
|
||||||
text: chat.model ? chat.model.roomTopic : "" |
|
||||||
} |
|
||||||
|
|
||||||
ImageButton { |
|
||||||
id: roomOptionsButton |
|
||||||
|
|
||||||
Layout.column: 3 |
|
||||||
Layout.row: 0 |
|
||||||
Layout.rowSpan: 2 |
|
||||||
Layout.alignment: Qt.AlignVCenter |
|
||||||
image: ":/icons/icons/ui/vertical-ellipsis.png" |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: qsTr("Room options") |
|
||||||
onClicked: roomOptionsMenu.popup(roomOptionsButton) |
|
||||||
|
|
||||||
Menu { |
|
||||||
id: roomOptionsMenu |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("Invite users") |
|
||||||
onTriggered: TimelineManager.openInviteUsersDialog() |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("Members") |
|
||||||
onTriggered: TimelineManager.openMemberListDialog() |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("Leave room") |
|
||||||
onTriggered: TimelineManager.openLeaveRoomDialog() |
|
||||||
} |
|
||||||
|
|
||||||
MenuItem { |
|
||||||
text: qsTr("Settings") |
|
||||||
onTriggered: TimelineManager.openRoomSettings() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
ListView { |
|
||||||
id: chat |
|
||||||
|
|
||||||
property int delegateMaxWidth: (Settings.timelineMaxWidth > 100 && (parent.width - Settings.timelineMaxWidth) > scrollbar.width * 2) ? Settings.timelineMaxWidth : (parent.width - scrollbar.width * 2) |
|
||||||
|
|
||||||
visible: TimelineManager.timeline != null |
|
||||||
cacheBuffer: 400 |
|
||||||
Layout.fillWidth: true |
|
||||||
Layout.fillHeight: true |
|
||||||
model: TimelineManager.timeline |
|
||||||
boundsBehavior: Flickable.StopAtBounds |
|
||||||
pixelAligned: true |
|
||||||
spacing: 4 |
|
||||||
verticalLayoutDirection: ListView.BottomToTop |
|
||||||
onCountChanged: { |
|
||||||
if (atYEnd) |
|
||||||
model.currentIndex = 0; |
|
||||||
|
|
||||||
} // Mark last event as read, since we are at the bottom |
|
||||||
|
|
||||||
ScrollHelper { |
|
||||||
flickable: parent |
|
||||||
anchors.fill: parent |
|
||||||
} |
|
||||||
|
|
||||||
Shortcut { |
|
||||||
sequence: StandardKey.MoveToPreviousPage |
|
||||||
onActivated: { |
|
||||||
chat.contentY = chat.contentY - chat.height / 2; |
|
||||||
chat.returnToBounds(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Shortcut { |
|
||||||
sequence: StandardKey.MoveToNextPage |
|
||||||
onActivated: { |
|
||||||
chat.contentY = chat.contentY + chat.height / 2; |
|
||||||
chat.returnToBounds(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Shortcut { |
|
||||||
sequence: StandardKey.Cancel |
|
||||||
onActivated: chat.model.reply = undefined |
|
||||||
} |
|
||||||
|
|
||||||
Shortcut { |
|
||||||
sequence: "Alt+Up" |
|
||||||
onActivated: chat.model.reply = chat.model.indexToId(chat.model.reply ? chat.model.idToIndex(chat.model.reply) + 1 : 0) |
|
||||||
} |
|
||||||
|
|
||||||
Shortcut { |
|
||||||
sequence: "Alt+Down" |
|
||||||
onActivated: { |
|
||||||
var idx = chat.model.reply ? chat.model.idToIndex(chat.model.reply) - 1 : -1; |
|
||||||
chat.model.reply = idx >= 0 ? chat.model.indexToId(idx) : undefined; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Component { |
|
||||||
id: userProfileComponent |
|
||||||
|
|
||||||
UserProfile { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
section { |
|
||||||
property: "section" |
|
||||||
} |
|
||||||
|
|
||||||
Component { |
|
||||||
id: sectionHeader |
|
||||||
|
|
||||||
Column { |
|
||||||
property var modelData |
|
||||||
property string section |
|
||||||
property string nextSection |
|
||||||
|
|
||||||
topPadding: 4 |
|
||||||
bottomPadding: 4 |
|
||||||
spacing: 8 |
|
||||||
visible: !!modelData |
|
||||||
width: parent.width |
|
||||||
height: (section.includes(" ") ? dateBubble.height + 8 + userName.height : userName.height) + 8 |
|
||||||
|
|
||||||
Label { |
|
||||||
id: dateBubble |
|
||||||
|
|
||||||
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined |
|
||||||
visible: section.includes(" ") |
|
||||||
text: chat.model.formatDateSeparator(modelData.timestamp) |
|
||||||
color: colors.text |
|
||||||
height: fontMetrics.height * 1.4 |
|
||||||
width: contentWidth * 1.2 |
|
||||||
horizontalAlignment: Text.AlignHCenter |
|
||||||
verticalAlignment: Text.AlignVCenter |
|
||||||
|
|
||||||
background: Rectangle { |
|
||||||
radius: parent.height / 2 |
|
||||||
color: colors.base |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Row { |
|
||||||
height: userName.height |
|
||||||
spacing: 8 |
|
||||||
|
|
||||||
Avatar { |
|
||||||
width: avatarSize |
|
||||||
height: avatarSize |
|
||||||
url: chat.model.avatarUrl(modelData.userId).replace("mxc://", "image://MxcImage/") |
|
||||||
displayName: modelData.userName |
|
||||||
userid: modelData.userId |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
anchors.fill: parent |
|
||||||
onClicked: chat.model.openUserProfile(modelData.userId) |
|
||||||
cursorShape: Qt.PointingHandCursor |
|
||||||
propagateComposedEvents: true |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
id: userName |
|
||||||
|
|
||||||
text: TimelineManager.escapeEmoji(modelData.userName) |
|
||||||
color: TimelineManager.userColor(modelData.userId, colors.window) |
|
||||||
textFormat: Text.RichText |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
anchors.fill: parent |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
onClicked: chat.model.openUserProfile(modelData.userId) |
|
||||||
cursorShape: Qt.PointingHandCursor |
|
||||||
propagateComposedEvents: true |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
ScrollBar.vertical: ScrollBar { |
|
||||||
id: scrollbar |
|
||||||
} |
|
||||||
|
|
||||||
delegate: Item { |
|
||||||
id: wrapper |
|
||||||
|
|
||||||
// This would normally be previousSection, but our model's order is inverted. |
|
||||||
property bool sectionBoundary: (ListView.nextSection != "" && ListView.nextSection !== ListView.section) || model.index === chat.count - 1 |
|
||||||
property Item section |
|
||||||
|
|
||||||
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined |
|
||||||
width: chat.delegateMaxWidth |
|
||||||
height: section ? section.height + timelinerow.height : timelinerow.height |
|
||||||
onSectionBoundaryChanged: { |
|
||||||
if (sectionBoundary) { |
|
||||||
var properties = { |
|
||||||
"modelData": model.dump, |
|
||||||
"section": ListView.section, |
|
||||||
"nextSection": ListView.nextSection |
|
||||||
}; |
|
||||||
section = sectionHeader.createObject(wrapper, properties); |
|
||||||
} else { |
|
||||||
section.destroy(); |
|
||||||
section = null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
TimelineRow { |
|
||||||
id: timelinerow |
|
||||||
|
|
||||||
y: section ? section.y + section.height : 0 |
|
||||||
} |
|
||||||
|
|
||||||
Connections { |
|
||||||
function onMovementEnded() { |
|
||||||
if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height) |
|
||||||
chat.model.currentIndex = index; |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
target: chat |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
footer: BusyIndicator { |
|
||||||
anchors.horizontalCenter: parent.horizontalCenter |
|
||||||
running: chat.model && chat.model.paginationInProgress |
|
||||||
height: 50 |
|
||||||
width: 50 |
|
||||||
z: 3 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Item { |
|
||||||
id: chatFooter |
|
||||||
|
|
||||||
implicitHeight: Math.max(fontMetrics.height * 1.2, footerContent.height) |
|
||||||
Layout.fillWidth: true |
|
||||||
z: 3 |
|
||||||
|
|
||||||
Column { |
|
||||||
id: footerContent |
|
||||||
|
|
||||||
anchors.left: parent.left |
|
||||||
anchors.right: parent.right |
|
||||||
anchors.bottom: parent.bottom |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
id: typingRect |
|
||||||
|
|
||||||
anchors.left: parent.left |
|
||||||
anchors.right: parent.right |
|
||||||
color: (chat.model && chat.model.typingUsers.length > 0) ? colors.window : "transparent" |
|
||||||
height: typingDisplay.height |
|
||||||
|
|
||||||
Label { |
|
||||||
id: typingDisplay |
|
||||||
|
|
||||||
anchors.left: parent.left |
|
||||||
anchors.leftMargin: 10 |
|
||||||
anchors.right: parent.right |
|
||||||
anchors.rightMargin: 10 |
|
||||||
color: colors.text |
|
||||||
text: chat.model ? chat.model.formatTypingUsers(chat.model.typingUsers, colors.window) : "" |
|
||||||
textFormat: Text.RichText |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
id: replyPopup |
|
||||||
|
|
||||||
anchors.left: parent.left |
|
||||||
anchors.right: parent.right |
|
||||||
visible: chat.model && chat.model.reply |
|
||||||
// Height of child, plus margins, plus border |
|
||||||
height: replyPreview.height + 10 |
|
||||||
color: colors.base |
|
||||||
|
|
||||||
Reply { |
|
||||||
id: replyPreview |
|
||||||
|
|
||||||
anchors.left: parent.left |
|
||||||
anchors.leftMargin: 10 |
|
||||||
anchors.right: closeReplyButton.left |
|
||||||
anchors.rightMargin: 20 |
|
||||||
anchors.bottom: parent.bottom |
|
||||||
modelData: chat.model ? chat.model.getDump(chat.model.reply, chat.model.id) : { |
|
||||||
} |
|
||||||
userColor: TimelineManager.userColor(modelData.userId, colors.window) |
|
||||||
} |
|
||||||
|
|
||||||
ImageButton { |
|
||||||
id: closeReplyButton |
|
||||||
|
|
||||||
anchors.right: parent.right |
|
||||||
anchors.rightMargin: 15 |
|
||||||
anchors.top: replyPreview.top |
|
||||||
hoverEnabled: true |
|
||||||
width: 16 |
|
||||||
height: 16 |
|
||||||
image: ":/icons/icons/ui/remove-symbol.png" |
|
||||||
ToolTip.visible: closeReplyButton.hovered |
|
||||||
ToolTip.text: qsTr("Close") |
|
||||||
onClicked: chat.model.reply = undefined |
|
||||||
} |
|
||||||
|
|
||||||
} |
import im.nheko 1.0 |
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
ActiveCallBar { |
|
||||||
Layout.fillWidth: true |
|
||||||
z: 3 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
import "./delegates" |
||||||
|
|
||||||
systemInactive: SystemPalette { |
|
||||||
colorGroup: SystemPalette.Disabled |
|
||||||
} |
|
||||||
|
|
||||||
|
Item { |
||||||
|
property var colors: currentActivePalette |
||||||
|
property var systemInactive: SystemPalette { colorGroup: SystemPalette.Disabled } |
||||||
|
property var inactiveColors: currentInactivePalette ? currentInactivePalette : systemInactive |
||||||
|
property int avatarSize: 40 |
||||||
|
|
||||||
|
Settings { |
||||||
|
id: settings |
||||||
|
category: "user" |
||||||
|
property bool avatar_circles: true |
||||||
|
} |
||||||
|
|
||||||
|
Settings { |
||||||
|
id: timelineSettings |
||||||
|
category: "user/timeline" |
||||||
|
property bool buttons: true |
||||||
|
} |
||||||
|
|
||||||
|
Menu { |
||||||
|
id: messageContextMenu |
||||||
|
palette: colors |
||||||
|
modal: true |
||||||
|
|
||||||
|
function show(eventId_, eventType_, showAt) { |
||||||
|
eventId = eventId_ |
||||||
|
eventType = eventType_ |
||||||
|
popup(showAt) |
||||||
|
} |
||||||
|
|
||||||
|
property string eventId |
||||||
|
property int eventType |
||||||
|
|
||||||
|
MenuItem { |
||||||
|
text: qsTr("Reply") |
||||||
|
onClicked: chat.model.replyAction(messageContextMenu.eventId) |
||||||
|
} |
||||||
|
MenuItem { |
||||||
|
text: qsTr("Read receipts") |
||||||
|
onTriggered: chat.model.readReceiptsAction(messageContextMenu.eventId) |
||||||
|
} |
||||||
|
MenuItem { |
||||||
|
text: qsTr("Mark as read") |
||||||
|
} |
||||||
|
MenuItem { |
||||||
|
text: qsTr("View raw message") |
||||||
|
onTriggered: chat.model.viewRawMessage(messageContextMenu.eventId) |
||||||
|
} |
||||||
|
MenuItem { |
||||||
|
text: qsTr("Redact message") |
||||||
|
onTriggered: chat.model.redactEvent(messageContextMenu.eventId) |
||||||
|
} |
||||||
|
MenuItem { |
||||||
|
visible: messageContextMenu.eventType == MtxEvent.ImageMessage || messageContextMenu.eventType == MtxEvent.VideoMessage || messageContextMenu.eventType == MtxEvent.AudioMessage || messageContextMenu.eventType == MtxEvent.FileMessage || messageContextMenu.eventType == MtxEvent.Sticker |
||||||
|
text: qsTr("Save as") |
||||||
|
onTriggered: timelineManager.timeline.saveMedia(messageContextMenu.eventId) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
id: timelineRoot |
||||||
|
|
||||||
|
Rectangle { |
||||||
|
anchors.fill: parent |
||||||
|
color: colors.window |
||||||
|
|
||||||
|
Text { |
||||||
|
visible: !timelineManager.timeline && !timelineManager.isInitialSync |
||||||
|
anchors.centerIn: parent |
||||||
|
text: qsTr("No room open") |
||||||
|
font.pointSize: 24 |
||||||
|
color: colors.windowText |
||||||
|
} |
||||||
|
|
||||||
|
BusyIndicator { |
||||||
|
anchors.centerIn: parent |
||||||
|
running: timelineManager.isInitialSync |
||||||
|
height: 200 |
||||||
|
width: 200 |
||||||
|
z: 3 |
||||||
|
} |
||||||
|
|
||||||
|
ListView { |
||||||
|
id: chat |
||||||
|
|
||||||
|
visible: timelineManager.timeline != null |
||||||
|
|
||||||
|
anchors.left: parent.left |
||||||
|
anchors.right: parent.right |
||||||
|
anchors.top: parent.top |
||||||
|
anchors.bottom: chatFooter.top |
||||||
|
|
||||||
|
anchors.leftMargin: 4 |
||||||
|
anchors.rightMargin: scrollbar.width |
||||||
|
|
||||||
|
model: timelineManager.timeline |
||||||
|
|
||||||
|
boundsBehavior: Flickable.StopAtBounds |
||||||
|
pixelAligned: true |
||||||
|
|
||||||
|
MouseArea { |
||||||
|
anchors.fill: parent |
||||||
|
acceptedButtons: Qt.NoButton |
||||||
|
propagateComposedEvents: true |
||||||
|
z: -1 |
||||||
|
onWheel: { |
||||||
|
if (wheel.angleDelta != 0) { |
||||||
|
chat.contentY = chat.contentY - wheel.angleDelta.y |
||||||
|
wheel.accepted = true |
||||||
|
chat.returnToBounds() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Shortcut { |
||||||
|
sequence: StandardKey.MoveToPreviousPage |
||||||
|
onActivated: { chat.contentY = chat.contentY - chat.height / 2; chat.returnToBounds(); } |
||||||
|
} |
||||||
|
Shortcut { |
||||||
|
sequence: StandardKey.MoveToNextPage |
||||||
|
onActivated: { chat.contentY = chat.contentY + chat.height / 2; chat.returnToBounds(); } |
||||||
|
} |
||||||
|
|
||||||
|
ScrollBar.vertical: ScrollBar { |
||||||
|
id: scrollbar |
||||||
|
parent: chat.parent |
||||||
|
anchors.top: chat.top |
||||||
|
anchors.left: chat.right |
||||||
|
anchors.bottom: chat.bottom |
||||||
|
} |
||||||
|
|
||||||
|
spacing: 4 |
||||||
|
verticalLayoutDirection: ListView.BottomToTop |
||||||
|
|
||||||
|
onCountChanged: if (atYEnd) model.currentIndex = 0 // Mark last event as read, since we are at the bottom |
||||||
|
|
||||||
|
delegate: Rectangle { |
||||||
|
// This would normally be previousSection, but our model's order is inverted. |
||||||
|
property bool sectionBoundary: (ListView.nextSection != "" && ListView.nextSection !== ListView.section) || model.index === chat.count - 1 |
||||||
|
|
||||||
|
id: wrapper |
||||||
|
property Item section |
||||||
|
width: chat.width |
||||||
|
height: section ? section.height + timelinerow.height : timelinerow.height |
||||||
|
color: "transparent" |
||||||
|
|
||||||
|
TimelineRow { |
||||||
|
id: timelinerow |
||||||
|
y: section ? section.y + section.height : 0 |
||||||
|
} |
||||||
|
|
||||||
|
onSectionBoundaryChanged: { |
||||||
|
if (sectionBoundary) { |
||||||
|
var properties = { |
||||||
|
'modelData': model.dump, |
||||||
|
'section': ListView.section, |
||||||
|
'nextSection': ListView.nextSection |
||||||
|
} |
||||||
|
section = sectionHeader.createObject(wrapper, properties) |
||||||
|
} else { |
||||||
|
section.destroy() |
||||||
|
section = null |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Binding { |
||||||
|
target: chat.model |
||||||
|
property: "currentIndex" |
||||||
|
when: y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height |
||||||
|
value: index |
||||||
|
delayed: true |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
section { |
||||||
|
property: "section" |
||||||
|
} |
||||||
|
Component { |
||||||
|
id: sectionHeader |
||||||
|
Column { |
||||||
|
property var modelData |
||||||
|
property string section |
||||||
|
property string nextSection |
||||||
|
|
||||||
|
topPadding: 4 |
||||||
|
bottomPadding: 4 |
||||||
|
spacing: 8 |
||||||
|
|
||||||
|
visible: !!modelData |
||||||
|
|
||||||
|
width: parent.width |
||||||
|
height: (section.includes(" ") ? dateBubble.height + 8 + userName.height : userName.height) + 8 |
||||||
|
|
||||||
|
Label { |
||||||
|
id: dateBubble |
||||||
|
anchors.horizontalCenter: parent ? parent.horizontalCenter : undefined |
||||||
|
visible: section.includes(" ") |
||||||
|
text: chat.model.formatDateSeparator(modelData.timestamp) |
||||||
|
color: colors.windowText |
||||||
|
|
||||||
|
height: contentHeight * 1.2 |
||||||
|
width: contentWidth * 1.2 |
||||||
|
horizontalAlignment: Text.AlignHCenter |
||||||
|
background: Rectangle { |
||||||
|
radius: parent.height / 2 |
||||||
|
color: colors.base |
||||||
|
} |
||||||
|
} |
||||||
|
Row { |
||||||
|
height: userName.height |
||||||
|
spacing: 4 |
||||||
|
Avatar { |
||||||
|
width: avatarSize |
||||||
|
height: avatarSize |
||||||
|
url: chat.model.avatarUrl(modelData.userId).replace("mxc://", "image://MxcImage/") |
||||||
|
displayName: modelData.userName |
||||||
|
|
||||||
|
MouseArea { |
||||||
|
anchors.fill: parent |
||||||
|
onClicked: chat.model.openUserProfile(modelData.userId) |
||||||
|
cursorShape: Qt.PointingHandCursor |
||||||
|
propagateComposedEvents: true |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Text { |
||||||
|
id: userName |
||||||
|
text: chat.model.escapeEmoji(modelData.userName) |
||||||
|
color: timelineManager.userColor(modelData.userId, colors.window) |
||||||
|
textFormat: Text.RichText |
||||||
|
|
||||||
|
MouseArea { |
||||||
|
anchors.fill: parent |
||||||
|
onClicked: chat.model.openUserProfile(section.split(" ")[0]) |
||||||
|
cursorShape: Qt.PointingHandCursor |
||||||
|
propagateComposedEvents: true |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
Rectangle { |
||||||
|
id: chatFooter |
||||||
|
|
||||||
|
height: Math.max(16, footerContent.height) |
||||||
|
anchors.left: parent.left |
||||||
|
anchors.right: parent.right |
||||||
|
anchors.bottom: parent.bottom |
||||||
|
z: 3 |
||||||
|
|
||||||
|
color: "transparent" |
||||||
|
|
||||||
|
Column { |
||||||
|
id: footerContent |
||||||
|
anchors.left: parent.left |
||||||
|
anchors.right: parent.right |
||||||
|
|
||||||
|
Text { |
||||||
|
id: typingDisplay |
||||||
|
anchors.left: parent.left |
||||||
|
anchors.right: parent.right |
||||||
|
anchors.leftMargin: 10 |
||||||
|
anchors.rightMargin: 10 |
||||||
|
|
||||||
|
text: chat.model ? chat.model.formatTypingUsers(chat.model.typingUsers, colors.window) : "" |
||||||
|
textFormat: Text.RichText |
||||||
|
color: colors.windowText |
||||||
|
} |
||||||
|
|
||||||
|
Rectangle { |
||||||
|
anchors.left: parent.left |
||||||
|
anchors.right: parent.right |
||||||
|
|
||||||
|
id: replyPopup |
||||||
|
|
||||||
|
visible: timelineManager.replyingEvent && chat.model |
||||||
|
// Height of child, plus margins, plus border |
||||||
|
height: replyPreview.height + 10 |
||||||
|
color: colors.base |
||||||
|
|
||||||
|
|
||||||
|
Reply { |
||||||
|
id: replyPreview |
||||||
|
|
||||||
|
anchors.left: parent.left |
||||||
|
anchors.leftMargin: 10 |
||||||
|
anchors.right: closeReplyButton.left |
||||||
|
anchors.rightMargin: 20 |
||||||
|
anchors.bottom: parent.bottom |
||||||
|
|
||||||
|
modelData: chat.model ? chat.model.getDump(timelineManager.replyingEvent) : {} |
||||||
|
userColor: timelineManager.userColor(modelData.userId, colors.window) |
||||||
|
} |
||||||
|
|
||||||
|
ImageButton { |
||||||
|
id: closeReplyButton |
||||||
|
|
||||||
|
anchors.right: parent.right |
||||||
|
anchors.rightMargin: 15 |
||||||
|
anchors.top: replyPreview.top |
||||||
|
hoverEnabled: true |
||||||
|
width: 16 |
||||||
|
height: 16 |
||||||
|
|
||||||
|
image: ":/icons/icons/ui/remove-symbol.png" |
||||||
|
ToolTip.visible: closeReplyButton.hovered |
||||||
|
ToolTip.text: qsTr("Close") |
||||||
|
|
||||||
|
onClicked: timelineManager.closeReply() |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
} |
} |
||||||
|
@ -1,175 +0,0 @@ |
|||||||
import "./device-verification" |
|
||||||
import QtQuick 2.9 |
|
||||||
import QtQuick.Controls 2.3 |
|
||||||
import QtQuick.Layouts 1.2 |
|
||||||
import QtQuick.Window 2.3 |
|
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
ApplicationWindow { |
|
||||||
id: userProfileDialog |
|
||||||
|
|
||||||
property var profile |
|
||||||
|
|
||||||
height: 650 |
|
||||||
width: 420 |
|
||||||
minimumHeight: 420 |
|
||||||
palette: colors |
|
||||||
|
|
||||||
Component { |
|
||||||
id: deviceVerificationDialog |
|
||||||
|
|
||||||
DeviceVerification { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
id: contentL |
|
||||||
|
|
||||||
anchors.fill: parent |
|
||||||
anchors.margins: 10 |
|
||||||
spacing: 10 |
|
||||||
|
|
||||||
Avatar { |
|
||||||
url: profile.avatarUrl.replace("mxc://", "image://MxcImage/") |
|
||||||
height: 130 |
|
||||||
width: 130 |
|
||||||
displayName: profile.displayName |
|
||||||
userid: profile.userid |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
text: profile.displayName |
|
||||||
fontSizeMode: Text.HorizontalFit |
|
||||||
font.pixelSize: 20 |
|
||||||
color: TimelineManager.userColor(profile.userid, colors.window) |
|
||||||
font.bold: true |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
} |
|
||||||
|
|
||||||
MatrixText { |
|
||||||
text: profile.userid |
|
||||||
font.pixelSize: 15 |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
} |
|
||||||
|
|
||||||
Button { |
|
||||||
id: verifyUserButton |
|
||||||
|
|
||||||
text: qsTr("Verify") |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
enabled: !profile.isUserVerified |
|
||||||
visible: !profile.isUserVerified |
|
||||||
onClicked: profile.verify() |
|
||||||
} |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
spacing: 8 |
|
||||||
|
|
||||||
ImageButton { |
|
||||||
image: ":/icons/icons/ui/do-not-disturb-rounded-sign.png" |
|
||||||
hoverEnabled: true |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: qsTr("Ban the user") |
|
||||||
onClicked: profile.banUser() |
|
||||||
} |
|
||||||
// ImageButton{ |
|
||||||
|
|
||||||
// image:":/icons/icons/ui/volume-off-indicator.png" |
|
||||||
// Layout.margins: { |
|
||||||
// left: 5 |
|
||||||
// right: 5 |
|
||||||
// } |
|
||||||
// ToolTip.visible: hovered |
|
||||||
// ToolTip.text: qsTr("Ignore messages from this user") |
|
||||||
// onClicked : { |
|
||||||
// profile.ignoreUser() |
|
||||||
// } |
|
||||||
// } |
|
||||||
ImageButton { |
|
||||||
image: ":/icons/icons/ui/black-bubble-speech.png" |
|
||||||
hoverEnabled: true |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: qsTr("Start a private chat") |
|
||||||
onClicked: profile.startChat() |
|
||||||
} |
|
||||||
|
|
||||||
ImageButton { |
|
||||||
image: ":/icons/icons/ui/round-remove-button.png" |
|
||||||
hoverEnabled: true |
|
||||||
ToolTip.visible: hovered |
|
||||||
ToolTip.text: qsTr("Kick the user") |
|
||||||
onClicked: profile.kickUser() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
ListView { |
|
||||||
id: devicelist |
|
||||||
|
|
||||||
Layout.fillHeight: true |
|
||||||
Layout.minimumHeight: 200 |
|
||||||
Layout.fillWidth: true |
|
||||||
clip: true |
|
||||||
spacing: 8 |
|
||||||
boundsBehavior: Flickable.StopAtBounds |
|
||||||
model: profile.deviceList |
|
||||||
|
|
||||||
delegate: RowLayout { |
|
||||||
width: devicelist.width |
|
||||||
spacing: 4 |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
spacing: 0 |
|
||||||
|
|
||||||
Text { |
|
||||||
Layout.fillWidth: true |
|
||||||
Layout.alignment: Qt.AlignLeft |
|
||||||
elide: Text.ElideRight |
|
||||||
font.bold: true |
|
||||||
color: colors.text |
|
||||||
text: model.deviceId |
|
||||||
} |
|
||||||
|
|
||||||
Text { |
|
||||||
Layout.fillWidth: true |
|
||||||
Layout.alignment: Qt.AlignRight |
|
||||||
elide: Text.ElideRight |
|
||||||
color: colors.text |
|
||||||
text: model.deviceName |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Image { |
|
||||||
Layout.preferredHeight: 16 |
|
||||||
Layout.preferredWidth: 16 |
|
||||||
source: ((model.verificationStatus == VerificationStatus.VERIFIED) ? "image://colorimage/:/icons/icons/ui/lock.png?green" : ((model.verificationStatus == VerificationStatus.UNVERIFIED) ? "image://colorimage/:/icons/icons/ui/unlock.png?yellow" : "image://colorimage/:/icons/icons/ui/unlock.png?red")) |
|
||||||
} |
|
||||||
|
|
||||||
Button { |
|
||||||
id: verifyButton |
|
||||||
|
|
||||||
text: (model.verificationStatus != VerificationStatus.VERIFIED) ? "Verify" : "Unverify" |
|
||||||
onClicked: { |
|
||||||
if (model.verificationStatus == VerificationStatus.VERIFIED) |
|
||||||
profile.unverify(model.deviceId); |
|
||||||
else |
|
||||||
profile.verify(model.deviceId); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
footer: DialogButtonBox { |
|
||||||
standardButtons: DialogButtonBox.Ok |
|
||||||
onAccepted: userProfileDialog.close() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,75 +1,57 @@ |
|||||||
import QtQuick 2.6 |
import QtQuick 2.6 |
||||||
import QtQuick.Layouts 1.2 |
import QtQuick.Layouts 1.2 |
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
Item { |
|
||||||
height: row.height + 24 |
|
||||||
width: parent ? parent.width : undefined |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
id: row |
|
||||||
|
|
||||||
anchors.centerIn: parent |
|
||||||
width: parent.width - 24 |
|
||||||
spacing: 15 |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
id: button |
|
||||||
|
|
||||||
color: colors.light |
|
||||||
radius: 22 |
|
||||||
height: 44 |
|
||||||
width: 44 |
|
||||||
|
|
||||||
Image { |
|
||||||
id: img |
|
||||||
|
|
||||||
anchors.centerIn: parent |
|
||||||
source: "qrc:/icons/icons/ui/arrow-pointing-down.png" |
|
||||||
fillMode: Image.Pad |
|
||||||
} |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
anchors.fill: parent |
|
||||||
onClicked: TimelineManager.timeline.saveMedia(model.data.id) |
|
||||||
cursorShape: Qt.PointingHandCursor |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
id: col |
|
||||||
|
|
||||||
Text { |
|
||||||
id: filename |
|
||||||
|
|
||||||
Layout.fillWidth: true |
|
||||||
text: model.data.filename |
|
||||||
textFormat: Text.PlainText |
|
||||||
elide: Text.ElideRight |
|
||||||
color: colors.text |
|
||||||
} |
|
||||||
|
|
||||||
Text { |
|
||||||
id: filesize |
|
||||||
|
|
||||||
Layout.fillWidth: true |
|
||||||
text: model.data.filesize |
|
||||||
textFormat: Text.PlainText |
|
||||||
elide: Text.ElideRight |
|
||||||
color: colors.text |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
color: colors.dark |
|
||||||
z: -1 |
|
||||||
radius: 10 |
|
||||||
height: row.height + 24 |
|
||||||
width: 44 + 24 + 24 + Math.max(Math.min(filesize.width, filesize.implicitWidth), Math.min(filename.width, filename.implicitWidth)) |
|
||||||
} |
|
||||||
|
|
||||||
|
Rectangle { |
||||||
|
radius: 10 |
||||||
|
color: colors.base |
||||||
|
height: row.height + 24 |
||||||
|
width: parent ? parent.width : undefined |
||||||
|
|
||||||
|
RowLayout { |
||||||
|
id: row |
||||||
|
|
||||||
|
anchors.centerIn: parent |
||||||
|
width: parent.width - 24 |
||||||
|
|
||||||
|
spacing: 15 |
||||||
|
|
||||||
|
Rectangle { |
||||||
|
id: button |
||||||
|
color: colors.light |
||||||
|
radius: 22 |
||||||
|
height: 44 |
||||||
|
width: 44 |
||||||
|
Image { |
||||||
|
id: img |
||||||
|
anchors.centerIn: parent |
||||||
|
|
||||||
|
source: "qrc:/icons/icons/ui/arrow-pointing-down.png" |
||||||
|
fillMode: Image.Pad |
||||||
|
|
||||||
|
} |
||||||
|
MouseArea { |
||||||
|
anchors.fill: parent |
||||||
|
onClicked: timelineManager.timeline.saveMedia(model.data.id) |
||||||
|
cursorShape: Qt.PointingHandCursor |
||||||
|
} |
||||||
|
} |
||||||
|
ColumnLayout { |
||||||
|
id: col |
||||||
|
|
||||||
|
Text { |
||||||
|
Layout.fillWidth: true |
||||||
|
text: model.data.body |
||||||
|
textFormat: Text.PlainText |
||||||
|
elide: Text.ElideRight |
||||||
|
color: colors.text |
||||||
|
} |
||||||
|
Text { |
||||||
|
Layout.fillWidth: true |
||||||
|
text: model.data.filesize |
||||||
|
textFormat: Text.PlainText |
||||||
|
elide: Text.ElideRight |
||||||
|
color: colors.text |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
} |
} |
||||||
|
@ -1,70 +1,41 @@ |
|||||||
import QtQuick 2.6 |
import QtQuick 2.6 |
||||||
|
|
||||||
import im.nheko 1.0 |
import im.nheko 1.0 |
||||||
|
|
||||||
Item { |
Item { |
||||||
property double tempWidth: Math.min(parent ? parent.width : undefined, model.data.width < 1 ? parent.width : model.data.width) |
property double tempWidth: Math.min(parent ? parent.width : undefined, model.data.width < 1 ? parent.width : model.data.width) |
||||||
property double tempHeight: tempWidth * model.data.proportionalHeight |
property double tempHeight: tempWidth * model.data.proportionalHeight |
||||||
property double divisor: model.isReply ? 4 : 2 |
|
||||||
property bool tooHigh: tempHeight > timelineRoot.height / divisor |
|
||||||
|
|
||||||
height: Math.round(tooHigh ? timelineRoot.height / divisor : tempHeight) |
|
||||||
width: Math.round(tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth) |
|
||||||
|
|
||||||
Image { |
property bool tooHigh: tempHeight > timelineRoot.height / 2 |
||||||
id: blurhash |
|
||||||
|
|
||||||
anchors.fill: parent |
height: tooHigh ? timelineRoot.height / 2 : tempHeight |
||||||
visible: img.status != Image.Ready |
width: tooHigh ? (timelineRoot.height / 2) / model.data.proportionalHeight : tempWidth |
||||||
source: model.data.blurhash ? ("image://blurhash/" + model.data.blurhash) : ("image://colorimage/:/icons/icons/ui/do-not-disturb-rounded-sign@2x.png?" + colors.buttonText) |
|
||||||
asynchronous: true |
|
||||||
fillMode: Image.PreserveAspectFit |
|
||||||
sourceSize.width: parent.width |
|
||||||
sourceSize.height: parent.height |
|
||||||
} |
|
||||||
|
|
||||||
Image { |
Image { |
||||||
id: img |
id: blurhash |
||||||
|
anchors.fill: parent |
||||||
|
visible: img.status != Image.Ready |
||||||
|
|
||||||
anchors.fill: parent |
source: model.data.blurhash ? ("image://blurhash/" + model.data.blurhash) : ("image://colorimage/:/icons/icons/ui/do-not-disturb-rounded-sign@2x.png?"+colors.buttonText) |
||||||
source: model.data.url.replace("mxc://", "image://MxcImage/") |
asynchronous: true |
||||||
asynchronous: true |
fillMode: Image.PreserveAspectFit |
||||||
fillMode: Image.PreserveAspectFit |
|
||||||
|
|
||||||
MouseArea { |
sourceSize.width: parent.width |
||||||
id: mouseArea |
sourceSize.height: parent.height |
||||||
enabled: model.data.type == MtxEvent.ImageMessage && img.status == Image.Ready |
} |
||||||
hoverEnabled: true |
|
||||||
anchors.fill: parent |
|
||||||
onClicked: TimelineManager.openImageOverlay(model.data.url, model.data.id) |
|
||||||
} |
|
||||||
|
|
||||||
Item { |
Image { |
||||||
id: overlay |
id: img |
||||||
|
anchors.fill: parent |
||||||
anchors.fill: parent |
|
||||||
visible: mouseArea.containsMouse |
|
||||||
|
|
||||||
Rectangle { |
source: model.data.url.replace("mxc://", "image://MxcImage/") |
||||||
id: container |
asynchronous: true |
||||||
|
fillMode: Image.PreserveAspectFit |
||||||
width: parent.width |
|
||||||
implicitHeight: imgcaption.implicitHeight |
|
||||||
anchors.bottom: overlay.bottom |
|
||||||
color: colors.window |
|
||||||
opacity: 0.75 |
|
||||||
} |
|
||||||
|
|
||||||
Text { |
MouseArea { |
||||||
id: imgcaption |
enabled: model.data.type == MtxEvent.ImageMessage |
||||||
|
anchors.fill: parent |
||||||
anchors.fill: container |
onClicked: timelineManager.openImageOverlay(model.data.url, model.data.id) |
||||||
elide: Text.ElideMiddle |
} |
||||||
horizontalAlignment: Text.AlignHCenter |
} |
||||||
verticalAlignment: Text.AlignVCenter |
|
||||||
// See this MSC: https://github.com/matrix-org/matrix-doc/pull/2530 |
|
||||||
text: model.data.filename ? model.data.filename : model.data.body |
|
||||||
color: colors.text |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
} |
||||||
|
@ -1,6 +1,4 @@ |
|||||||
TextMessage { |
TextMessage { |
||||||
font.italic: true |
font.italic: true |
||||||
color: colors.buttonText |
color: inactiveColors.text |
||||||
height: isReply ? Math.min(chat.height / 8, implicitHeight) : undefined |
|
||||||
clip: true |
|
||||||
} |
} |
||||||
|
@ -1,7 +1,7 @@ |
|||||||
import ".." |
import ".." |
||||||
|
|
||||||
MatrixText { |
MatrixText { |
||||||
text: qsTr("unimplemented event: ") + model.data.typeString |
text: qsTr("unimplemented event: ") + model.data.typeString |
||||||
width: parent ? parent.width : undefined |
width: parent ? parent.width : undefined |
||||||
color: inactiveColors.text |
color: inactiveColors.text |
||||||
} |
} |
||||||
|
@ -1,216 +1,167 @@ |
|||||||
import QtMultimedia 5.6 |
|
||||||
import QtQuick 2.6 |
import QtQuick 2.6 |
||||||
import QtQuick.Controls 2.1 |
|
||||||
import QtQuick.Layouts 1.2 |
import QtQuick.Layouts 1.2 |
||||||
|
import QtQuick.Controls 2.1 |
||||||
|
import QtMultimedia 5.6 |
||||||
|
|
||||||
import im.nheko 1.0 |
import im.nheko 1.0 |
||||||
|
|
||||||
Rectangle { |
Rectangle { |
||||||
id: bg |
id: bg |
||||||
|
radius: 10 |
||||||
radius: 10 |
color: colors.base |
||||||
color: colors.dark |
height: content.height + 24 |
||||||
height: Math.round(content.height + 24) |
width: parent ? parent.width : undefined |
||||||
width: parent ? parent.width : undefined |
|
||||||
|
Column { |
||||||
Column { |
id: content |
||||||
id: content |
width: parent.width - 24 |
||||||
|
anchors.centerIn: parent |
||||||
width: parent.width - 24 |
|
||||||
anchors.centerIn: parent |
Rectangle { |
||||||
|
id: videoContainer |
||||||
Rectangle { |
visible: model.data.type == MtxEvent.VideoMessage |
||||||
id: videoContainer |
width: Math.min(parent.width, model.data.width ? model.data.width : 400) // some media has 0 as size... |
||||||
|
height: width*model.data.proportionalHeight |
||||||
property double tempWidth: Math.min(parent ? parent.width : undefined, model.data.width < 1 ? 400 : model.data.width) |
Image { |
||||||
property double tempHeight: tempWidth * model.data.proportionalHeight |
anchors.fill: parent |
||||||
property double divisor: model.isReply ? 4 : 2 |
source: model.data.thumbnailUrl.replace("mxc://", "image://MxcImage/") |
||||||
property bool tooHigh: tempHeight > timelineRoot.height / divisor |
asynchronous: true |
||||||
|
fillMode: Image.PreserveAspectFit |
||||||
visible: model.data.type == MtxEvent.VideoMessage |
|
||||||
height: tooHigh ? timelineRoot.height / divisor : tempHeight |
VideoOutput { |
||||||
width: tooHigh ? (timelineRoot.height / divisor) / model.data.proportionalHeight : tempWidth |
anchors.fill: parent |
||||||
|
fillMode: VideoOutput.PreserveAspectFit |
||||||
Image { |
source: media |
||||||
anchors.fill: parent |
} |
||||||
source: model.data.thumbnailUrl.replace("mxc://", "image://MxcImage/") |
} |
||||||
asynchronous: true |
} |
||||||
fillMode: Image.PreserveAspectFit |
|
||||||
|
RowLayout { |
||||||
VideoOutput { |
width: parent.width |
||||||
anchors.fill: parent |
Text { |
||||||
fillMode: VideoOutput.PreserveAspectFit |
id: positionText |
||||||
source: media |
text: "--:--:--" |
||||||
} |
color: colors.text |
||||||
|
} |
||||||
} |
Slider { |
||||||
|
Layout.fillWidth: true |
||||||
} |
id: progress |
||||||
|
value: media.position |
||||||
RowLayout { |
from: 0 |
||||||
width: parent.width |
to: media.duration |
||||||
|
|
||||||
Text { |
onMoved: media.seek(value) |
||||||
id: positionText |
//indeterminate: true |
||||||
|
function updatePositionTexts() { |
||||||
text: "--:--:--" |
function formatTime(date) { |
||||||
color: colors.text |
var hh = date.getUTCHours(); |
||||||
} |
var mm = date.getUTCMinutes(); |
||||||
|
var ss = date.getSeconds(); |
||||||
Slider { |
if (hh < 10) {hh = "0"+hh;} |
||||||
id: progress |
if (mm < 10) {mm = "0"+mm;} |
||||||
|
if (ss < 10) {ss = "0"+ss;} |
||||||
//indeterminate: true |
return hh+":"+mm+":"+ss; |
||||||
function updatePositionTexts() { |
} |
||||||
function formatTime(date) { |
positionText.text = formatTime(new Date(media.position)) |
||||||
var hh = date.getUTCHours(); |
durationText.text = formatTime(new Date(media.duration)) |
||||||
var mm = date.getUTCMinutes(); |
} |
||||||
var ss = date.getSeconds(); |
onValueChanged: updatePositionTexts() |
||||||
if (hh < 10) |
|
||||||
hh = "0" + hh; |
palette: colors |
||||||
|
} |
||||||
if (mm < 10) |
Text { |
||||||
mm = "0" + mm; |
id: durationText |
||||||
|
text: "--:--:--" |
||||||
if (ss < 10) |
color: colors.text |
||||||
ss = "0" + ss; |
} |
||||||
|
} |
||||||
return hh + ":" + mm + ":" + ss; |
|
||||||
} |
RowLayout { |
||||||
|
width: parent.width |
||||||
positionText.text = formatTime(new Date(media.position)); |
|
||||||
durationText.text = formatTime(new Date(media.duration)); |
spacing: 15 |
||||||
} |
|
||||||
|
Rectangle { |
||||||
Layout.fillWidth: true |
id: button |
||||||
value: media.position |
color: colors.window |
||||||
from: 0 |
radius: 22 |
||||||
to: media.duration |
height: 44 |
||||||
onMoved: media.seek(value) |
width: 44 |
||||||
onValueChanged: updatePositionTexts() |
Image { |
||||||
palette: colors |
id: img |
||||||
} |
anchors.centerIn: parent |
||||||
|
z: 3 |
||||||
Text { |
|
||||||
id: durationText |
source: "image://colorimage/:/icons/icons/ui/arrow-pointing-down.png?"+colors.text |
||||||
|
fillMode: Image.Pad |
||||||
text: "--:--:--" |
|
||||||
color: colors.text |
} |
||||||
} |
MouseArea { |
||||||
|
anchors.fill: parent |
||||||
} |
onClicked: { |
||||||
|
switch (button.state) { |
||||||
RowLayout { |
case "": timelineManager.timeline.cacheMedia(model.data.id); break; |
||||||
width: parent.width |
case "stopped": |
||||||
spacing: 15 |
media.play(); console.log("play"); |
||||||
|
button.state = "playing" |
||||||
Rectangle { |
break |
||||||
id: button |
case "playing": |
||||||
|
media.pause(); console.log("pause"); |
||||||
color: colors.window |
button.state = "stopped" |
||||||
radius: 22 |
break |
||||||
height: 44 |
} |
||||||
width: 44 |
} |
||||||
states: [ |
cursorShape: Qt.PointingHandCursor |
||||||
State { |
} |
||||||
name: "stopped" |
MediaPlayer { |
||||||
|
id: media |
||||||
PropertyChanges { |
onError: console.log(errorString) |
||||||
target: img |
onStatusChanged: if(status == MediaPlayer.Loaded) progress.updatePositionTexts() |
||||||
source: "image://colorimage/:/icons/icons/ui/play-sign.png?" + colors.text |
onStopped: button.state = "stopped" |
||||||
} |
} |
||||||
|
|
||||||
}, |
Connections { |
||||||
State { |
target: timelineManager.timeline |
||||||
name: "playing" |
onMediaCached: { |
||||||
|
if (mxcUrl == model.data.url) { |
||||||
PropertyChanges { |
media.source = "file://" + cacheUrl |
||||||
target: img |
button.state = "stopped" |
||||||
source: "image://colorimage/:/icons/icons/ui/pause-symbol.png?" + colors.text |
console.log("media loaded: " + mxcUrl + " at " + cacheUrl) |
||||||
} |
} |
||||||
|
console.log("media cached: " + mxcUrl + " at " + cacheUrl) |
||||||
} |
} |
||||||
] |
} |
||||||
|
|
||||||
Image { |
states: [ |
||||||
id: img |
State { |
||||||
|
name: "stopped" |
||||||
anchors.centerIn: parent |
PropertyChanges { target: img; source: "image://colorimage/:/icons/icons/ui/play-sign.png?"+colors.text } |
||||||
z: 3 |
}, |
||||||
source: "image://colorimage/:/icons/icons/ui/arrow-pointing-down.png?" + colors.text |
State { |
||||||
fillMode: Image.Pad |
name: "playing" |
||||||
} |
PropertyChanges { target: img; source: "image://colorimage/:/icons/icons/ui/pause-symbol.png?"+colors.text } |
||||||
|
} |
||||||
MouseArea { |
] |
||||||
anchors.fill: parent |
} |
||||||
onClicked: { |
ColumnLayout { |
||||||
switch (button.state) { |
id: col |
||||||
case "": |
|
||||||
TimelineManager.timeline.cacheMedia(model.data.id); |
Text { |
||||||
break; |
Layout.fillWidth: true |
||||||
case "stopped": |
text: model.data.body |
||||||
media.play(); |
textFormat: Text.PlainText |
||||||
console.log("play"); |
elide: Text.ElideRight |
||||||
button.state = "playing"; |
color: colors.text |
||||||
break; |
} |
||||||
case "playing": |
Text { |
||||||
media.pause(); |
Layout.fillWidth: true |
||||||
console.log("pause"); |
text: model.data.filesize |
||||||
button.state = "stopped"; |
textFormat: Text.PlainText |
||||||
break; |
elide: Text.ElideRight |
||||||
} |
color: colors.text |
||||||
} |
} |
||||||
cursorShape: Qt.PointingHandCursor |
} |
||||||
} |
} |
||||||
|
} |
||||||
MediaPlayer { |
|
||||||
id: media |
|
||||||
|
|
||||||
onError: console.log(errorString) |
|
||||||
onStatusChanged: { |
|
||||||
if (status == MediaPlayer.Loaded) |
|
||||||
progress.updatePositionTexts(); |
|
||||||
|
|
||||||
} |
|
||||||
onStopped: button.state = "stopped" |
|
||||||
} |
|
||||||
|
|
||||||
Connections { |
|
||||||
target: TimelineManager.timeline |
|
||||||
onMediaCached: { |
|
||||||
if (mxcUrl == model.data.url) { |
|
||||||
media.source = "file://" + cacheUrl; |
|
||||||
button.state = "stopped"; |
|
||||||
console.log("media loaded: " + mxcUrl + " at " + cacheUrl); |
|
||||||
} |
|
||||||
console.log("media cached: " + mxcUrl + " at " + cacheUrl); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
id: col |
|
||||||
|
|
||||||
Text { |
|
||||||
Layout.fillWidth: true |
|
||||||
text: model.data.body |
|
||||||
textFormat: Text.PlainText |
|
||||||
elide: Text.ElideRight |
|
||||||
color: colors.text |
|
||||||
} |
|
||||||
|
|
||||||
Text { |
|
||||||
Layout.fillWidth: true |
|
||||||
text: model.data.filesize |
|
||||||
textFormat: Text.PlainText |
|
||||||
elide: Text.ElideRight |
|
||||||
color: colors.text |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
} |
||||||
|
|
||||||
|
@ -1,12 +1,7 @@ |
|||||||
import ".." |
import ".." |
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
MatrixText { |
MatrixText { |
||||||
property string formatted: model.data.formattedBody |
property string formatted: model.data.formattedBody |
||||||
|
text: "<style type=\"text/css\">a { color:"+colors.link+";}</style>" + formatted.replace("<pre>", "<pre style='white-space: pre-wrap'>") |
||||||
text: "<style type=\"text/css\">a { color:" + colors.link + ";}</style>" + formatted.replace("<pre>", "<pre style='white-space: pre-wrap'>") |
width: parent ? parent.width : undefined |
||||||
width: parent ? parent.width : undefined |
|
||||||
height: isReply ? Math.round(Math.min(timelineRoot.height / 8, implicitHeight)) : undefined |
|
||||||
clip: true |
|
||||||
font.pointSize: (Settings.enlargeEmojiOnlyMessages && model.data.isOnlyEmoji > 0 && model.data.isOnlyEmoji < 4) ? Settings.fontSize * 3 : Settings.fontSize |
|
||||||
} |
} |
||||||
|
@ -1,46 +0,0 @@ |
|||||||
import QtQuick 2.3 |
|
||||||
import QtQuick.Controls 2.10 |
|
||||||
import QtQuick.Layouts 1.10 |
|
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
Pane { |
|
||||||
property string title: qsTr("Awaiting Confirmation") |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
spacing: 16 |
|
||||||
|
|
||||||
Label { |
|
||||||
id: content |
|
||||||
|
|
||||||
Layout.maximumWidth: 400 |
|
||||||
Layout.fillHeight: true |
|
||||||
Layout.fillWidth: true |
|
||||||
wrapMode: Text.Wrap |
|
||||||
text: qsTr("Waiting for other side to complete verification.") |
|
||||||
color: colors.text |
|
||||||
verticalAlignment: Text.AlignVCenter |
|
||||||
} |
|
||||||
|
|
||||||
BusyIndicator { |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
} |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
Button { |
|
||||||
Layout.alignment: Qt.AlignLeft |
|
||||||
text: qsTr("Cancel") |
|
||||||
onClicked: { |
|
||||||
flow.cancel(); |
|
||||||
dialog.close(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Item { |
|
||||||
Layout.fillWidth: true |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,144 +0,0 @@ |
|||||||
import QtQuick 2.10 |
|
||||||
import QtQuick.Controls 2.10 |
|
||||||
import QtQuick.Window 2.10 |
|
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
ApplicationWindow { |
|
||||||
id: dialog |
|
||||||
|
|
||||||
property var flow |
|
||||||
|
|
||||||
onClosing: TimelineManager.removeVerificationFlow(flow) |
|
||||||
title: stack.currentItem.title |
|
||||||
flags: Qt.Dialog |
|
||||||
palette: colors |
|
||||||
height: stack.implicitHeight |
|
||||||
width: stack.implicitWidth |
|
||||||
|
|
||||||
StackView { |
|
||||||
id: stack |
|
||||||
|
|
||||||
initialItem: newVerificationRequest |
|
||||||
implicitWidth: currentItem.implicitWidth |
|
||||||
implicitHeight: currentItem.implicitHeight |
|
||||||
} |
|
||||||
|
|
||||||
Component { |
|
||||||
id: newVerificationRequest |
|
||||||
|
|
||||||
NewVerificationRequest { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Component { |
|
||||||
id: waiting |
|
||||||
|
|
||||||
Waiting { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Component { |
|
||||||
id: success |
|
||||||
|
|
||||||
Success { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Component { |
|
||||||
id: failed |
|
||||||
|
|
||||||
Failed { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Component { |
|
||||||
id: digitVerification |
|
||||||
|
|
||||||
DigitVerification { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Component { |
|
||||||
id: emojiVerification |
|
||||||
|
|
||||||
EmojiVerification { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Item { |
|
||||||
state: flow.state |
|
||||||
states: [ |
|
||||||
State { |
|
||||||
name: "PromptStartVerification" |
|
||||||
|
|
||||||
StateChangeScript { |
|
||||||
script: stack.replace(newVerificationRequest) |
|
||||||
} |
|
||||||
|
|
||||||
}, |
|
||||||
State { |
|
||||||
name: "CompareEmoji" |
|
||||||
|
|
||||||
StateChangeScript { |
|
||||||
script: stack.replace(emojiVerification) |
|
||||||
} |
|
||||||
|
|
||||||
}, |
|
||||||
State { |
|
||||||
name: "CompareNumber" |
|
||||||
|
|
||||||
StateChangeScript { |
|
||||||
script: stack.replace(digitVerification) |
|
||||||
} |
|
||||||
|
|
||||||
}, |
|
||||||
State { |
|
||||||
name: "WaitingForKeys" |
|
||||||
|
|
||||||
StateChangeScript { |
|
||||||
script: stack.replace(waiting) |
|
||||||
} |
|
||||||
|
|
||||||
}, |
|
||||||
State { |
|
||||||
name: "WaitingForOtherToAccept" |
|
||||||
|
|
||||||
StateChangeScript { |
|
||||||
script: stack.replace(waiting) |
|
||||||
} |
|
||||||
|
|
||||||
}, |
|
||||||
State { |
|
||||||
name: "WaitingForMac" |
|
||||||
|
|
||||||
StateChangeScript { |
|
||||||
script: stack.replace(waiting) |
|
||||||
} |
|
||||||
|
|
||||||
}, |
|
||||||
State { |
|
||||||
name: "Success" |
|
||||||
|
|
||||||
StateChangeScript { |
|
||||||
script: stack.replace(success) |
|
||||||
} |
|
||||||
|
|
||||||
}, |
|
||||||
State { |
|
||||||
name: "Failed" |
|
||||||
|
|
||||||
StateChangeScript { |
|
||||||
script: stack.replace(failed) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
] |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,69 +0,0 @@ |
|||||||
import QtQuick 2.3 |
|
||||||
import QtQuick.Controls 2.10 |
|
||||||
import QtQuick.Layouts 1.10 |
|
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
Pane { |
|
||||||
property string title: qsTr("Verification Code") |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
spacing: 16 |
|
||||||
|
|
||||||
Label { |
|
||||||
Layout.maximumWidth: 400 |
|
||||||
Layout.fillHeight: true |
|
||||||
Layout.fillWidth: true |
|
||||||
wrapMode: Text.Wrap |
|
||||||
text: qsTr("Please verify the following digits. You should see the same numbers on both sides. If they differ, please press 'They do not match!' to abort verification!") |
|
||||||
color: colors.text |
|
||||||
verticalAlignment: Text.AlignVCenter |
|
||||||
} |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
|
|
||||||
Label { |
|
||||||
font.pixelSize: Qt.application.font.pixelSize * 2 |
|
||||||
text: flow.sasList[0] |
|
||||||
color: colors.text |
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
font.pixelSize: Qt.application.font.pixelSize * 2 |
|
||||||
text: flow.sasList[1] |
|
||||||
color: colors.text |
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
font.pixelSize: Qt.application.font.pixelSize * 2 |
|
||||||
text: flow.sasList[2] |
|
||||||
color: colors.text |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
Button { |
|
||||||
Layout.alignment: Qt.AlignLeft |
|
||||||
text: qsTr("They do not match!") |
|
||||||
onClicked: { |
|
||||||
flow.cancel(); |
|
||||||
dialog.close(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Item { |
|
||||||
Layout.fillWidth: true |
|
||||||
} |
|
||||||
|
|
||||||
Button { |
|
||||||
Layout.alignment: Qt.AlignRight |
|
||||||
text: qsTr("They match!") |
|
||||||
onClicked: flow.next() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,33 +0,0 @@ |
|||||||
import QtQuick 2.3 |
|
||||||
import QtQuick.Controls 2.10 |
|
||||||
import QtQuick.Layouts 1.10 |
|
||||||
|
|
||||||
Rectangle { |
|
||||||
color: "red" |
|
||||||
implicitHeight: Qt.application.font.pixelSize * 4 |
|
||||||
implicitWidth: col.width |
|
||||||
height: Qt.application.font.pixelSize * 4 |
|
||||||
width: col.width |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
id: col |
|
||||||
|
|
||||||
property var emoji: emojis.mapping[Math.floor(Math.random() * 64)] |
|
||||||
|
|
||||||
anchors.bottom: parent.bottom |
|
||||||
|
|
||||||
Label { |
|
||||||
height: font.pixelSize * 2 |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
text: col.emoji.emoji |
|
||||||
font.pixelSize: Qt.application.font.pixelSize * 2 |
|
||||||
} |
|
||||||
|
|
||||||
Label { |
|
||||||
Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom |
|
||||||
text: col.emoji.description |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,56 +0,0 @@ |
|||||||
import QtQuick 2.3 |
|
||||||
import QtQuick.Controls 2.10 |
|
||||||
import QtQuick.Layouts 1.10 |
|
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
Pane { |
|
||||||
property string title: qsTr("Verification failed") |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
spacing: 16 |
|
||||||
|
|
||||||
Text { |
|
||||||
id: content |
|
||||||
|
|
||||||
Layout.maximumWidth: 400 |
|
||||||
Layout.fillHeight: true |
|
||||||
Layout.fillWidth: true |
|
||||||
wrapMode: Text.Wrap |
|
||||||
text: { |
|
||||||
switch (flow.error) { |
|
||||||
case DeviceVerificationFlow.UnknownMethod: |
|
||||||
return qsTr("Other client does not support our verification protocol."); |
|
||||||
case DeviceVerificationFlow.MismatchedCommitment: |
|
||||||
case DeviceVerificationFlow.MismatchedSAS: |
|
||||||
case DeviceVerificationFlow.KeyMismatch: |
|
||||||
return qsTr("Key mismatch detected!"); |
|
||||||
case DeviceVerificationFlow.Timeout: |
|
||||||
return qsTr("Device verification timed out."); |
|
||||||
case DeviceVerificationFlow.User: |
|
||||||
return qsTr("Other party canceled the verification."); |
|
||||||
case DeviceVerificationFlow.OutOfOrder: |
|
||||||
return qsTr("Device verification timed out."); |
|
||||||
default: |
|
||||||
return "Unknown verification error."; |
|
||||||
} |
|
||||||
} |
|
||||||
color: colors.text |
|
||||||
verticalAlignment: Text.AlignVCenter |
|
||||||
} |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
Item { |
|
||||||
Layout.fillWidth: true |
|
||||||
} |
|
||||||
|
|
||||||
Button { |
|
||||||
Layout.alignment: Qt.AlignRight |
|
||||||
text: qsTr("Close") |
|
||||||
onClicked: dialog.close() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,46 +0,0 @@ |
|||||||
import QtQuick 2.3 |
|
||||||
import QtQuick.Controls 2.10 |
|
||||||
import QtQuick.Layouts 1.10 |
|
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
Pane { |
|
||||||
property string title: flow.sender ? qsTr("Send Device Verification Request") : qsTr("Recieved Device Verification Request") |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
spacing: 16 |
|
||||||
|
|
||||||
Label { |
|
||||||
Layout.maximumWidth: 400 |
|
||||||
Layout.fillHeight: true |
|
||||||
Layout.fillWidth: true |
|
||||||
wrapMode: Text.Wrap |
|
||||||
text: flow.sender ? qsTr("To ensure that no malicious user can eavesdrop on your encrypted communications, you can verify this device.") : qsTr("The device was requested to be verified") |
|
||||||
color: colors.text |
|
||||||
verticalAlignment: Text.AlignVCenter |
|
||||||
} |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
Button { |
|
||||||
Layout.alignment: Qt.AlignLeft |
|
||||||
text: flow.sender ? qsTr("Cancel") : qsTr("Deny") |
|
||||||
onClicked: { |
|
||||||
flow.cancel(); |
|
||||||
dialog.close(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Item { |
|
||||||
Layout.fillWidth: true |
|
||||||
} |
|
||||||
|
|
||||||
Button { |
|
||||||
Layout.alignment: Qt.AlignRight |
|
||||||
text: flow.sender ? qsTr("Start verification") : qsTr("Accept") |
|
||||||
onClicked: flow.next() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,38 +0,0 @@ |
|||||||
import QtQuick 2.3 |
|
||||||
import QtQuick.Controls 2.10 |
|
||||||
import QtQuick.Layouts 1.10 |
|
||||||
|
|
||||||
Pane { |
|
||||||
property string title: qsTr("Successful Verification") |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
spacing: 16 |
|
||||||
|
|
||||||
Label { |
|
||||||
id: content |
|
||||||
|
|
||||||
Layout.maximumWidth: 400 |
|
||||||
Layout.fillHeight: true |
|
||||||
Layout.fillWidth: true |
|
||||||
wrapMode: Text.Wrap |
|
||||||
text: qsTr("Verification successful! Both sides verified their devices!") |
|
||||||
color: colors.text |
|
||||||
verticalAlignment: Text.AlignVCenter |
|
||||||
} |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
Item { |
|
||||||
Layout.fillWidth: true |
|
||||||
} |
|
||||||
|
|
||||||
Button { |
|
||||||
Layout.alignment: Qt.AlignRight |
|
||||||
text: qsTr("Close") |
|
||||||
onClicked: dialog.close() |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,56 +0,0 @@ |
|||||||
import QtQuick 2.3 |
|
||||||
import QtQuick.Controls 2.10 |
|
||||||
import QtQuick.Layouts 1.10 |
|
||||||
import im.nheko 1.0 |
|
||||||
|
|
||||||
Pane { |
|
||||||
property string title: qsTr("Waiting for other party") |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
spacing: 16 |
|
||||||
|
|
||||||
Label { |
|
||||||
id: content |
|
||||||
|
|
||||||
Layout.maximumWidth: 400 |
|
||||||
Layout.fillHeight: true |
|
||||||
Layout.fillWidth: true |
|
||||||
wrapMode: Text.Wrap |
|
||||||
text: { |
|
||||||
switch (flow.state) { |
|
||||||
case "WaitingForOtherToAccept": |
|
||||||
return qsTr("Waiting for other side to accept the verification request."); |
|
||||||
case "WaitingForKeys": |
|
||||||
return qsTr("Waiting for other side to continue the verification request."); |
|
||||||
case "WaitingForMac": |
|
||||||
return qsTr("Waiting for other side to complete the verification request."); |
|
||||||
} |
|
||||||
} |
|
||||||
color: colors.text |
|
||||||
verticalAlignment: Text.AlignVCenter |
|
||||||
} |
|
||||||
|
|
||||||
BusyIndicator { |
|
||||||
Layout.alignment: Qt.AlignHCenter |
|
||||||
palette: colors |
|
||||||
} |
|
||||||
|
|
||||||
RowLayout { |
|
||||||
Button { |
|
||||||
Layout.alignment: Qt.AlignLeft |
|
||||||
text: qsTr("Cancel") |
|
||||||
onClicked: { |
|
||||||
flow.cancel(); |
|
||||||
dialog.close(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
Item { |
|
||||||
Layout.fillWidth: true |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,16 +0,0 @@ |
|||||||
import "../" |
|
||||||
import QtQuick 2.10 |
|
||||||
import QtQuick.Controls 2.1 |
|
||||||
import im.nheko 1.0 |
|
||||||
import im.nheko.EmojiModel 1.0 |
|
||||||
|
|
||||||
ImageButton { |
|
||||||
id: emojiButton |
|
||||||
|
|
||||||
property var colors: currentActivePalette |
|
||||||
property var emojiPicker |
|
||||||
property string event_id |
|
||||||
|
|
||||||
image: ":/icons/icons/ui/smile.png" |
|
||||||
onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, event_id) |
|
||||||
} |
|
@ -1,332 +0,0 @@ |
|||||||
import "../" |
|
||||||
import QtGraphicalEffects 1.0 |
|
||||||
import QtQuick 2.9 |
|
||||||
import QtQuick.Controls 2.3 |
|
||||||
import QtQuick.Layouts 1.3 |
|
||||||
import im.nheko 1.0 |
|
||||||
import im.nheko.EmojiModel 1.0 |
|
||||||
|
|
||||||
Popup { |
|
||||||
id: emojiPopup |
|
||||||
|
|
||||||
property string event_id |
|
||||||
property var colors |
|
||||||
property alias model: gridView.model |
|
||||||
property var textArea |
|
||||||
property string emojiCategory: "people" |
|
||||||
property real highlightHue: colors.highlight.hslHue |
|
||||||
property real highlightSat: colors.highlight.hslSaturation |
|
||||||
property real highlightLight: colors.highlight.hslLightness |
|
||||||
|
|
||||||
function show(showAt, event_id) { |
|
||||||
console.debug("Showing emojiPicker for " + event_id); |
|
||||||
if (showAt) { |
|
||||||
parent = showAt; |
|
||||||
x = Math.round((showAt.width - width) / 2); |
|
||||||
y = showAt.height; |
|
||||||
} |
|
||||||
emojiPopup.event_id = event_id; |
|
||||||
open(); |
|
||||||
} |
|
||||||
|
|
||||||
margins: 0 |
|
||||||
bottomPadding: 1 |
|
||||||
leftPadding: 1 |
|
||||||
rightPadding: 1 |
|
||||||
modal: true |
|
||||||
focus: true |
|
||||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside |
|
||||||
|
|
||||||
ColumnLayout { |
|
||||||
id: columnView |
|
||||||
|
|
||||||
anchors.fill: parent |
|
||||||
spacing: 0 |
|
||||||
Layout.bottomMargin: 0 |
|
||||||
Layout.leftMargin: 3 |
|
||||||
Layout.rightMargin: 3 |
|
||||||
Layout.topMargin: 2 |
|
||||||
|
|
||||||
// emoji grid |
|
||||||
GridView { |
|
||||||
id: gridView |
|
||||||
|
|
||||||
Layout.preferredHeight: emojiPopup.height |
|
||||||
Layout.fillWidth: true |
|
||||||
Layout.fillHeight: true |
|
||||||
Layout.leftMargin: 4 |
|
||||||
cellWidth: 52 |
|
||||||
cellHeight: 52 |
|
||||||
boundsBehavior: Flickable.StopAtBounds |
|
||||||
clip: true |
|
||||||
currentIndex: -1 // prevent sorting from stealing focus |
|
||||||
|
|
||||||
// Individual emoji |
|
||||||
delegate: AbstractButton { |
|
||||||
width: 48 |
|
||||||
height: 48 |
|
||||||
hoverEnabled: true |
|
||||||
ToolTip.text: model.shortName |
|
||||||
ToolTip.visible: hovered |
|
||||||
// TODO: maybe add favorites at some point? |
|
||||||
onClicked: { |
|
||||||
console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id); |
|
||||||
emojiPopup.close(); |
|
||||||
TimelineManager.queueReactionMessage(emojiPopup.event_id, model.unicode); |
|
||||||
} |
|
||||||
|
|
||||||
// give the emoji a little oomf |
|
||||||
DropShadow { |
|
||||||
width: parent.width |
|
||||||
height: parent.height |
|
||||||
horizontalOffset: 3 |
|
||||||
verticalOffset: 3 |
|
||||||
radius: 8 |
|
||||||
samples: 17 |
|
||||||
color: "#80000000" |
|
||||||
source: parent.contentItem |
|
||||||
} |
|
||||||
|
|
||||||
contentItem: Text { |
|
||||||
horizontalAlignment: Text.AlignHCenter |
|
||||||
verticalAlignment: Text.AlignVCenter |
|
||||||
font.family: Settings.emojiFont |
|
||||||
font.pixelSize: 36 |
|
||||||
text: model.unicode |
|
||||||
} |
|
||||||
|
|
||||||
background: Rectangle { |
|
||||||
anchors.fill: parent |
|
||||||
color: hovered ? colors.highlight : 'transparent' |
|
||||||
radius: 5 |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
// Search field |
|
||||||
header: TextField { |
|
||||||
id: emojiSearch |
|
||||||
|
|
||||||
anchors.left: parent.left |
|
||||||
anchors.right: parent.right |
|
||||||
anchors.rightMargin: emojiScroll.width + 4 |
|
||||||
placeholderText: qsTr("Search") |
|
||||||
selectByMouse: true |
|
||||||
rightPadding: clearSearch.width |
|
||||||
onTextChanged: searchTimer.restart() |
|
||||||
onVisibleChanged: { |
|
||||||
if (visible) |
|
||||||
forceActiveFocus(); |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
Timer { |
|
||||||
id: searchTimer |
|
||||||
|
|
||||||
interval: 350 // tweak as needed? |
|
||||||
onTriggered: { |
|
||||||
emojiPopup.model.filter = emojiSearch.text; |
|
||||||
emojiPopup.model.category = EmojiCategory.Search; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
ToolButton { |
|
||||||
id: clearSearch |
|
||||||
|
|
||||||
visible: emojiSearch.text !== '' |
|
||||||
icon.source: "image://colorimage/:/icons/icons/ui/round-remove-button.png?" + (clearSearch.hovered ? colors.highlight : colors.buttonText) |
|
||||||
focusPolicy: Qt.NoFocus |
|
||||||
onClicked: emojiSearch.clear() |
|
||||||
|
|
||||||
anchors { |
|
||||||
verticalCenter: parent.verticalCenter |
|
||||||
right: parent.right |
|
||||||
} |
|
||||||
// clear the default hover effects. |
|
||||||
|
|
||||||
background: Item { |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
ScrollBar.vertical: ScrollBar { |
|
||||||
id: emojiScroll |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
// Separator |
|
||||||
Rectangle { |
|
||||||
Layout.fillWidth: true |
|
||||||
Layout.preferredHeight: 1 |
|
||||||
color: emojiPopup.colors.dark |
|
||||||
} |
|
||||||
|
|
||||||
// Category picker row |
|
||||||
RowLayout { |
|
||||||
Layout.bottomMargin: 0 |
|
||||||
Layout.preferredHeight: 42 |
|
||||||
implicitHeight: 42 |
|
||||||
Layout.alignment: Qt.AlignHCenter | Qt.AlignBottom |
|
||||||
|
|
||||||
// Display the normal categories |
|
||||||
Repeater { |
|
||||||
|
|
||||||
model: ListModel { |
|
||||||
// TODO: Would like to get 'simple' icons for the categories |
|
||||||
ListElement { |
|
||||||
image: ":/icons/icons/emoji-categories/people.png" |
|
||||||
category: EmojiCategory.People |
|
||||||
} |
|
||||||
|
|
||||||
ListElement { |
|
||||||
image: ":/icons/icons/emoji-categories/nature.png" |
|
||||||
category: EmojiCategory.Nature |
|
||||||
} |
|
||||||
|
|
||||||
ListElement { |
|
||||||
image: ":/icons/icons/emoji-categories/foods.png" |
|
||||||
category: EmojiCategory.Food |
|
||||||
} |
|
||||||
|
|
||||||
ListElement { |
|
||||||
image: ":/icons/icons/emoji-categories/activity.png" |
|
||||||
category: EmojiCategory.Activity |
|
||||||
} |
|
||||||
|
|
||||||
ListElement { |
|
||||||
image: ":/icons/icons/emoji-categories/travel.png" |
|
||||||
category: EmojiCategory.Travel |
|
||||||
} |
|
||||||
|
|
||||||
ListElement { |
|
||||||
image: ":/icons/icons/emoji-categories/objects.png" |
|
||||||
category: EmojiCategory.Objects |
|
||||||
} |
|
||||||
|
|
||||||
ListElement { |
|
||||||
image: ":/icons/icons/emoji-categories/symbols.png" |
|
||||||
category: EmojiCategory.Symbols |
|
||||||
} |
|
||||||
|
|
||||||
ListElement { |
|
||||||
image: ":/icons/icons/emoji-categories/flags.png" |
|
||||||
category: EmojiCategory.Flags |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
delegate: AbstractButton { |
|
||||||
Layout.preferredWidth: 36 |
|
||||||
Layout.preferredHeight: 36 |
|
||||||
hoverEnabled: true |
|
||||||
ToolTip.text: { |
|
||||||
switch (model.category) { |
|
||||||
case EmojiCategory.People: |
|
||||||
return qsTr('People'); |
|
||||||
case EmojiCategory.Nature: |
|
||||||
return qsTr('Nature'); |
|
||||||
case EmojiCategory.Food: |
|
||||||
return qsTr('Food'); |
|
||||||
case EmojiCategory.Activity: |
|
||||||
return qsTr('Activity'); |
|
||||||
case EmojiCategory.Travel: |
|
||||||
return qsTr('Travel'); |
|
||||||
case EmojiCategory.Objects: |
|
||||||
return qsTr('Objects'); |
|
||||||
case EmojiCategory.Symbols: |
|
||||||
return qsTr('Symbols'); |
|
||||||
case EmojiCategory.Flags: |
|
||||||
return qsTr('Flags'); |
|
||||||
} |
|
||||||
} |
|
||||||
ToolTip.visible: hovered |
|
||||||
onClicked: { |
|
||||||
emojiPopup.model.category = model.category; |
|
||||||
} |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
id: mouseArea |
|
||||||
|
|
||||||
anchors.fill: parent |
|
||||||
onPressed: mouse.accepted = false |
|
||||||
cursorShape: Qt.PointingHandCursor |
|
||||||
} |
|
||||||
|
|
||||||
contentItem: Image { |
|
||||||
horizontalAlignment: Image.AlignHCenter |
|
||||||
verticalAlignment: Image.AlignVCenter |
|
||||||
fillMode: Image.Pad |
|
||||||
sourceSize.width: 32 |
|
||||||
sourceSize.height: 32 |
|
||||||
source: "image://colorimage/" + model.image + "?" + (hovered ? colors.highlight : colors.buttonText) |
|
||||||
} |
|
||||||
|
|
||||||
background: Rectangle { |
|
||||||
anchors.fill: parent |
|
||||||
color: emojiPopup.model.category === model.category ? Qt.hsla(highlightHue, highlightSat, highlightLight, 0.2) : 'transparent' |
|
||||||
radius: 5 |
|
||||||
border.color: emojiPopup.model.category === model.category ? colors.highlight : 'transparent' |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
// Separator |
|
||||||
Rectangle { |
|
||||||
Layout.fillHeight: true |
|
||||||
Layout.preferredWidth: 1 |
|
||||||
implicitWidth: 1 |
|
||||||
height: parent.height |
|
||||||
color: emojiPopup.colors.dark |
|
||||||
} |
|
||||||
|
|
||||||
// Search Button is special |
|
||||||
AbstractButton { |
|
||||||
id: searchBtn |
|
||||||
|
|
||||||
hoverEnabled: true |
|
||||||
Layout.alignment: Qt.AlignRight |
|
||||||
Layout.bottomMargin: 0 |
|
||||||
ToolTip.text: qsTr("Search") |
|
||||||
ToolTip.visible: hovered |
|
||||||
onClicked: { |
|
||||||
// clear any filters |
|
||||||
emojiPopup.model.category = EmojiCategory.Search; |
|
||||||
gridView.positionViewAtBeginning(); |
|
||||||
emojiSearch.forceActiveFocus(); |
|
||||||
} |
|
||||||
Layout.preferredWidth: 36 |
|
||||||
Layout.preferredHeight: 36 |
|
||||||
implicitWidth: 36 |
|
||||||
implicitHeight: 36 |
|
||||||
|
|
||||||
MouseArea { |
|
||||||
id: mouseArea |
|
||||||
|
|
||||||
anchors.fill: parent |
|
||||||
onPressed: mouse.accepted = false |
|
||||||
cursorShape: Qt.PointingHandCursor |
|
||||||
} |
|
||||||
|
|
||||||
contentItem: Image { |
|
||||||
anchors.right: parent.right |
|
||||||
horizontalAlignment: Image.AlignHCenter |
|
||||||
verticalAlignment: Image.AlignVCenter |
|
||||||
sourceSize.width: 32 |
|
||||||
sourceSize.height: 32 |
|
||||||
fillMode: Image.Pad |
|
||||||
smooth: true |
|
||||||
source: "image://colorimage/:/icons/icons/ui/search.png?" + (parent.hovered ? colors.highlight : colors.buttonText) |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -1,2 +0,0 @@ |
|||||||
[Controls] |
|
||||||
FallbackStyle=Fusion |
|
@ -1,457 +0,0 @@ |
|||||||
#include <algorithm> |
|
||||||
#include <cctype> |
|
||||||
#include <chrono> |
|
||||||
#include <cstdint> |
|
||||||
|
|
||||||
#include <QMediaPlaylist> |
|
||||||
#include <QUrl> |
|
||||||
|
|
||||||
#include "Cache.h" |
|
||||||
#include "CallManager.h" |
|
||||||
#include "ChatPage.h" |
|
||||||
#include "Logging.h" |
|
||||||
#include "MainWindow.h" |
|
||||||
#include "MatrixClient.h" |
|
||||||
#include "UserSettingsPage.h" |
|
||||||
#include "WebRTCSession.h" |
|
||||||
#include "dialogs/AcceptCall.h" |
|
||||||
|
|
||||||
#include "mtx/responses/turn_server.hpp" |
|
||||||
|
|
||||||
Q_DECLARE_METATYPE(std::vector<mtx::events::msg::CallCandidates::Candidate>) |
|
||||||
Q_DECLARE_METATYPE(mtx::events::msg::CallCandidates::Candidate) |
|
||||||
Q_DECLARE_METATYPE(mtx::responses::TurnServer) |
|
||||||
|
|
||||||
using namespace mtx::events; |
|
||||||
using namespace mtx::events::msg; |
|
||||||
|
|
||||||
// https://github.com/vector-im/riot-web/issues/10173
|
|
||||||
#define STUN_SERVER "stun://turn.matrix.org:3478"
|
|
||||||
|
|
||||||
namespace { |
|
||||||
std::vector<std::string> |
|
||||||
getTurnURIs(const mtx::responses::TurnServer &turnServer); |
|
||||||
} |
|
||||||
|
|
||||||
CallManager::CallManager(QSharedPointer<UserSettings> userSettings) |
|
||||||
: QObject() |
|
||||||
, session_(WebRTCSession::instance()) |
|
||||||
, turnServerTimer_(this) |
|
||||||
, settings_(userSettings) |
|
||||||
{ |
|
||||||
qRegisterMetaType<std::vector<mtx::events::msg::CallCandidates::Candidate>>(); |
|
||||||
qRegisterMetaType<mtx::events::msg::CallCandidates::Candidate>(); |
|
||||||
qRegisterMetaType<mtx::responses::TurnServer>(); |
|
||||||
|
|
||||||
connect( |
|
||||||
&session_, |
|
||||||
&WebRTCSession::offerCreated, |
|
||||||
this, |
|
||||||
[this](const std::string &sdp, const std::vector<CallCandidates::Candidate> &candidates) { |
|
||||||
nhlog::ui()->debug("WebRTC: call id: {} - sending offer", callid_); |
|
||||||
emit newMessage(roomid_, CallInvite{callid_, sdp, 0, timeoutms_}); |
|
||||||
emit newMessage(roomid_, CallCandidates{callid_, candidates, 0}); |
|
||||||
QTimer::singleShot(timeoutms_, this, [this]() { |
|
||||||
if (session_.state() == webrtc::State::OFFERSENT) { |
|
||||||
hangUp(CallHangUp::Reason::InviteTimeOut); |
|
||||||
emit ChatPage::instance()->showNotification( |
|
||||||
"The remote side failed to pick up."); |
|
||||||
} |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
connect( |
|
||||||
&session_, |
|
||||||
&WebRTCSession::answerCreated, |
|
||||||
this, |
|
||||||
[this](const std::string &sdp, const std::vector<CallCandidates::Candidate> &candidates) { |
|
||||||
nhlog::ui()->debug("WebRTC: call id: {} - sending answer", callid_); |
|
||||||
emit newMessage(roomid_, CallAnswer{callid_, sdp, 0}); |
|
||||||
emit newMessage(roomid_, CallCandidates{callid_, candidates, 0}); |
|
||||||
}); |
|
||||||
|
|
||||||
connect(&session_, |
|
||||||
&WebRTCSession::newICECandidate, |
|
||||||
this, |
|
||||||
[this](const CallCandidates::Candidate &candidate) { |
|
||||||
nhlog::ui()->debug("WebRTC: call id: {} - sending ice candidate", callid_); |
|
||||||
emit newMessage(roomid_, CallCandidates{callid_, {candidate}, 0}); |
|
||||||
}); |
|
||||||
|
|
||||||
connect(&turnServerTimer_, &QTimer::timeout, this, &CallManager::retrieveTurnServer); |
|
||||||
|
|
||||||
connect(this, |
|
||||||
&CallManager::turnServerRetrieved, |
|
||||||
this, |
|
||||||
[this](const mtx::responses::TurnServer &res) { |
|
||||||
nhlog::net()->info("TURN server(s) retrieved from homeserver:"); |
|
||||||
nhlog::net()->info("username: {}", res.username); |
|
||||||
nhlog::net()->info("ttl: {} seconds", res.ttl); |
|
||||||
for (const auto &u : res.uris) |
|
||||||
nhlog::net()->info("uri: {}", u); |
|
||||||
|
|
||||||
// Request new credentials close to expiry
|
|
||||||
// See https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
|
|
||||||
turnURIs_ = getTurnURIs(res); |
|
||||||
uint32_t ttl = std::max(res.ttl, UINT32_C(3600)); |
|
||||||
if (res.ttl < 3600) |
|
||||||
nhlog::net()->warn("Setting ttl to 1 hour"); |
|
||||||
turnServerTimer_.setInterval(ttl * 1000 * 0.9); |
|
||||||
}); |
|
||||||
|
|
||||||
connect(&session_, &WebRTCSession::stateChanged, this, [this](webrtc::State state) { |
|
||||||
switch (state) { |
|
||||||
case webrtc::State::DISCONNECTED: |
|
||||||
playRingtone("qrc:/media/media/callend.ogg", false); |
|
||||||
clear(); |
|
||||||
break; |
|
||||||
case webrtc::State::ICEFAILED: { |
|
||||||
QString error("Call connection failed."); |
|
||||||
if (turnURIs_.empty()) |
|
||||||
error += " Your homeserver has no configured TURN server."; |
|
||||||
emit ChatPage::instance()->showNotification(error); |
|
||||||
hangUp(CallHangUp::Reason::ICEFailed); |
|
||||||
break; |
|
||||||
} |
|
||||||
default: |
|
||||||
break; |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
connect(&player_, |
|
||||||
&QMediaPlayer::mediaStatusChanged, |
|
||||||
this, |
|
||||||
[this](QMediaPlayer::MediaStatus status) { |
|
||||||
if (status == QMediaPlayer::LoadedMedia) |
|
||||||
player_.play(); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::sendInvite(const QString &roomid) |
|
||||||
{ |
|
||||||
if (onActiveCall()) |
|
||||||
return; |
|
||||||
|
|
||||||
auto roomInfo = cache::singleRoomInfo(roomid.toStdString()); |
|
||||||
if (roomInfo.member_count != 2) { |
|
||||||
emit ChatPage::instance()->showNotification( |
|
||||||
"Voice calls are limited to 1:1 rooms."); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
std::string errorMessage; |
|
||||||
if (!session_.init(&errorMessage)) { |
|
||||||
emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage)); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
roomid_ = roomid; |
|
||||||
session_.setStunServer(settings_->useStunServer() ? STUN_SERVER : ""); |
|
||||||
session_.setTurnServers(turnURIs_); |
|
||||||
|
|
||||||
generateCallID(); |
|
||||||
nhlog::ui()->debug("WebRTC: call id: {} - creating invite", callid_); |
|
||||||
std::vector<RoomMember> members(cache::getMembers(roomid.toStdString())); |
|
||||||
const RoomMember &callee = |
|
||||||
members.front().user_id == utils::localUser() ? members.back() : members.front(); |
|
||||||
callPartyName_ = callee.display_name.isEmpty() ? callee.user_id : callee.display_name; |
|
||||||
callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url); |
|
||||||
emit newCallParty(); |
|
||||||
playRingtone("qrc:/media/media/ringback.ogg", true); |
|
||||||
if (!session_.createOffer()) { |
|
||||||
emit ChatPage::instance()->showNotification("Problem setting up call."); |
|
||||||
endCall(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
namespace { |
|
||||||
std::string |
|
||||||
callHangUpReasonString(CallHangUp::Reason reason) |
|
||||||
{ |
|
||||||
switch (reason) { |
|
||||||
case CallHangUp::Reason::ICEFailed: |
|
||||||
return "ICE failed"; |
|
||||||
case CallHangUp::Reason::InviteTimeOut: |
|
||||||
return "Invite time out"; |
|
||||||
default: |
|
||||||
return "User"; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::hangUp(CallHangUp::Reason reason) |
|
||||||
{ |
|
||||||
if (!callid_.empty()) { |
|
||||||
nhlog::ui()->debug( |
|
||||||
"WebRTC: call id: {} - hanging up ({})", callid_, callHangUpReasonString(reason)); |
|
||||||
emit newMessage(roomid_, CallHangUp{callid_, 0, reason}); |
|
||||||
endCall(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
bool |
|
||||||
CallManager::onActiveCall() const |
|
||||||
{ |
|
||||||
return session_.state() != webrtc::State::DISCONNECTED; |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::syncEvent(const mtx::events::collections::TimelineEvents &event) |
|
||||||
{ |
|
||||||
#ifdef GSTREAMER_AVAILABLE |
|
||||||
if (handleEvent_<CallInvite>(event) || handleEvent_<CallCandidates>(event) || |
|
||||||
handleEvent_<CallAnswer>(event) || handleEvent_<CallHangUp>(event)) |
|
||||||
return; |
|
||||||
#else |
|
||||||
(void)event; |
|
||||||
#endif |
|
||||||
} |
|
||||||
|
|
||||||
template<typename T> |
|
||||||
bool |
|
||||||
CallManager::handleEvent_(const mtx::events::collections::TimelineEvents &event) |
|
||||||
{ |
|
||||||
if (std::holds_alternative<RoomEvent<T>>(event)) { |
|
||||||
handleEvent(std::get<RoomEvent<T>>(event)); |
|
||||||
return true; |
|
||||||
} |
|
||||||
return false; |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::handleEvent(const RoomEvent<CallInvite> &callInviteEvent) |
|
||||||
{ |
|
||||||
const char video[] = "m=video"; |
|
||||||
const std::string &sdp = callInviteEvent.content.sdp; |
|
||||||
bool isVideo = std::search(sdp.cbegin(), |
|
||||||
sdp.cend(), |
|
||||||
std::cbegin(video), |
|
||||||
std::cend(video) - 1, |
|
||||||
[](unsigned char c1, unsigned char c2) { |
|
||||||
return std::tolower(c1) == std::tolower(c2); |
|
||||||
}) != sdp.cend(); |
|
||||||
|
|
||||||
nhlog::ui()->debug("WebRTC: call id: {} - incoming {} CallInvite from {}", |
|
||||||
callInviteEvent.content.call_id, |
|
||||||
(isVideo ? "video" : "voice"), |
|
||||||
callInviteEvent.sender); |
|
||||||
|
|
||||||
if (callInviteEvent.content.call_id.empty()) |
|
||||||
return; |
|
||||||
|
|
||||||
auto roomInfo = cache::singleRoomInfo(callInviteEvent.room_id); |
|
||||||
if (onActiveCall() || roomInfo.member_count != 2 || isVideo) { |
|
||||||
emit newMessage(QString::fromStdString(callInviteEvent.room_id), |
|
||||||
CallHangUp{callInviteEvent.content.call_id, |
|
||||||
0, |
|
||||||
CallHangUp::Reason::InviteTimeOut}); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
playRingtone("qrc:/media/media/ring.ogg", true); |
|
||||||
roomid_ = QString::fromStdString(callInviteEvent.room_id); |
|
||||||
callid_ = callInviteEvent.content.call_id; |
|
||||||
remoteICECandidates_.clear(); |
|
||||||
|
|
||||||
std::vector<RoomMember> members(cache::getMembers(callInviteEvent.room_id)); |
|
||||||
const RoomMember &caller = |
|
||||||
members.front().user_id == utils::localUser() ? members.back() : members.front(); |
|
||||||
callPartyName_ = caller.display_name.isEmpty() ? caller.user_id : caller.display_name; |
|
||||||
callPartyAvatarUrl_ = QString::fromStdString(roomInfo.avatar_url); |
|
||||||
emit newCallParty(); |
|
||||||
auto dialog = new dialogs::AcceptCall(caller.user_id, |
|
||||||
caller.display_name, |
|
||||||
QString::fromStdString(roomInfo.name), |
|
||||||
QString::fromStdString(roomInfo.avatar_url), |
|
||||||
settings_, |
|
||||||
MainWindow::instance()); |
|
||||||
connect(dialog, &dialogs::AcceptCall::accept, this, [this, callInviteEvent]() { |
|
||||||
MainWindow::instance()->hideOverlay(); |
|
||||||
answerInvite(callInviteEvent.content); |
|
||||||
}); |
|
||||||
connect(dialog, &dialogs::AcceptCall::reject, this, [this]() { |
|
||||||
MainWindow::instance()->hideOverlay(); |
|
||||||
hangUp(); |
|
||||||
}); |
|
||||||
MainWindow::instance()->showSolidOverlayModal(dialog); |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::answerInvite(const CallInvite &invite) |
|
||||||
{ |
|
||||||
stopRingtone(); |
|
||||||
std::string errorMessage; |
|
||||||
if (!session_.init(&errorMessage)) { |
|
||||||
emit ChatPage::instance()->showNotification(QString::fromStdString(errorMessage)); |
|
||||||
hangUp(); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
session_.setStunServer(settings_->useStunServer() ? STUN_SERVER : ""); |
|
||||||
session_.setTurnServers(turnURIs_); |
|
||||||
|
|
||||||
if (!session_.acceptOffer(invite.sdp)) { |
|
||||||
emit ChatPage::instance()->showNotification("Problem setting up call."); |
|
||||||
hangUp(); |
|
||||||
return; |
|
||||||
} |
|
||||||
session_.acceptICECandidates(remoteICECandidates_); |
|
||||||
remoteICECandidates_.clear(); |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::handleEvent(const RoomEvent<CallCandidates> &callCandidatesEvent) |
|
||||||
{ |
|
||||||
if (callCandidatesEvent.sender == utils::localUser().toStdString()) |
|
||||||
return; |
|
||||||
|
|
||||||
nhlog::ui()->debug("WebRTC: call id: {} - incoming CallCandidates from {}", |
|
||||||
callCandidatesEvent.content.call_id, |
|
||||||
callCandidatesEvent.sender); |
|
||||||
|
|
||||||
if (callid_ == callCandidatesEvent.content.call_id) { |
|
||||||
if (onActiveCall()) |
|
||||||
session_.acceptICECandidates(callCandidatesEvent.content.candidates); |
|
||||||
else { |
|
||||||
// CallInvite has been received and we're awaiting localUser to accept or
|
|
||||||
// reject the call
|
|
||||||
for (const auto &c : callCandidatesEvent.content.candidates) |
|
||||||
remoteICECandidates_.push_back(c); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::handleEvent(const RoomEvent<CallAnswer> &callAnswerEvent) |
|
||||||
{ |
|
||||||
nhlog::ui()->debug("WebRTC: call id: {} - incoming CallAnswer from {}", |
|
||||||
callAnswerEvent.content.call_id, |
|
||||||
callAnswerEvent.sender); |
|
||||||
|
|
||||||
if (!onActiveCall() && callAnswerEvent.sender == utils::localUser().toStdString() && |
|
||||||
callid_ == callAnswerEvent.content.call_id) { |
|
||||||
emit ChatPage::instance()->showNotification("Call answered on another device."); |
|
||||||
stopRingtone(); |
|
||||||
MainWindow::instance()->hideOverlay(); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
if (onActiveCall() && callid_ == callAnswerEvent.content.call_id) { |
|
||||||
stopRingtone(); |
|
||||||
if (!session_.acceptAnswer(callAnswerEvent.content.sdp)) { |
|
||||||
emit ChatPage::instance()->showNotification("Problem setting up call."); |
|
||||||
hangUp(); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::handleEvent(const RoomEvent<CallHangUp> &callHangUpEvent) |
|
||||||
{ |
|
||||||
nhlog::ui()->debug("WebRTC: call id: {} - incoming CallHangUp ({}) from {}", |
|
||||||
callHangUpEvent.content.call_id, |
|
||||||
callHangUpReasonString(callHangUpEvent.content.reason), |
|
||||||
callHangUpEvent.sender); |
|
||||||
|
|
||||||
if (callid_ == callHangUpEvent.content.call_id) { |
|
||||||
MainWindow::instance()->hideOverlay(); |
|
||||||
endCall(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::generateCallID() |
|
||||||
{ |
|
||||||
using namespace std::chrono; |
|
||||||
uint64_t ms = duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count(); |
|
||||||
callid_ = "c" + std::to_string(ms); |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::clear() |
|
||||||
{ |
|
||||||
roomid_.clear(); |
|
||||||
callPartyName_.clear(); |
|
||||||
callPartyAvatarUrl_.clear(); |
|
||||||
callid_.clear(); |
|
||||||
remoteICECandidates_.clear(); |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::endCall() |
|
||||||
{ |
|
||||||
stopRingtone(); |
|
||||||
clear(); |
|
||||||
session_.end(); |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::refreshTurnServer() |
|
||||||
{ |
|
||||||
turnURIs_.clear(); |
|
||||||
turnServerTimer_.start(2000); |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::retrieveTurnServer() |
|
||||||
{ |
|
||||||
http::client()->get_turn_server( |
|
||||||
[this](const mtx::responses::TurnServer &res, mtx::http::RequestErr err) { |
|
||||||
if (err) { |
|
||||||
turnServerTimer_.setInterval(5000); |
|
||||||
return; |
|
||||||
} |
|
||||||
emit turnServerRetrieved(res); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::playRingtone(const QString &ringtone, bool repeat) |
|
||||||
{ |
|
||||||
static QMediaPlaylist playlist; |
|
||||||
playlist.clear(); |
|
||||||
playlist.setPlaybackMode(repeat ? QMediaPlaylist::CurrentItemInLoop |
|
||||||
: QMediaPlaylist::CurrentItemOnce); |
|
||||||
playlist.addMedia(QUrl(ringtone)); |
|
||||||
player_.setVolume(100); |
|
||||||
player_.setPlaylist(&playlist); |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
CallManager::stopRingtone() |
|
||||||
{ |
|
||||||
player_.setPlaylist(nullptr); |
|
||||||
} |
|
||||||
|
|
||||||
namespace { |
|
||||||
std::vector<std::string> |
|
||||||
getTurnURIs(const mtx::responses::TurnServer &turnServer) |
|
||||||
{ |
|
||||||
// gstreamer expects: turn(s)://username:password@host:port?transport=udp(tcp)
|
|
||||||
// where username and password are percent-encoded
|
|
||||||
std::vector<std::string> ret; |
|
||||||
for (const auto &uri : turnServer.uris) { |
|
||||||
if (auto c = uri.find(':'); c == std::string::npos) { |
|
||||||
nhlog::ui()->error("Invalid TURN server uri: {}", uri); |
|
||||||
continue; |
|
||||||
} else { |
|
||||||
std::string scheme = std::string(uri, 0, c); |
|
||||||
if (scheme != "turn" && scheme != "turns") { |
|
||||||
nhlog::ui()->error("Invalid TURN server uri: {}", uri); |
|
||||||
continue; |
|
||||||
} |
|
||||||
|
|
||||||
QString encodedUri = |
|
||||||
QString::fromStdString(scheme) + "://" + |
|
||||||
QUrl::toPercentEncoding(QString::fromStdString(turnServer.username)) + |
|
||||||
":" + |
|
||||||
QUrl::toPercentEncoding(QString::fromStdString(turnServer.password)) + |
|
||||||
"@" + QString::fromStdString(std::string(uri, ++c)); |
|
||||||
ret.push_back(encodedUri.toStdString()); |
|
||||||
} |
|
||||||
} |
|
||||||
return ret; |
|
||||||
} |
|
||||||
} |
|
@ -1,76 +0,0 @@ |
|||||||
#pragma once |
|
||||||
|
|
||||||
#include <string> |
|
||||||
#include <vector> |
|
||||||
|
|
||||||
#include <QMediaPlayer> |
|
||||||
#include <QObject> |
|
||||||
#include <QSharedPointer> |
|
||||||
#include <QString> |
|
||||||
#include <QTimer> |
|
||||||
|
|
||||||
#include "mtx/events/collections.hpp" |
|
||||||
#include "mtx/events/voip.hpp" |
|
||||||
|
|
||||||
namespace mtx::responses { |
|
||||||
struct TurnServer; |
|
||||||
} |
|
||||||
|
|
||||||
class UserSettings; |
|
||||||
class WebRTCSession; |
|
||||||
|
|
||||||
class CallManager : public QObject |
|
||||||
{ |
|
||||||
Q_OBJECT |
|
||||||
|
|
||||||
public: |
|
||||||
CallManager(QSharedPointer<UserSettings>); |
|
||||||
|
|
||||||
void sendInvite(const QString &roomid); |
|
||||||
void hangUp( |
|
||||||
mtx::events::msg::CallHangUp::Reason = mtx::events::msg::CallHangUp::Reason::User); |
|
||||||
bool onActiveCall() const; |
|
||||||
QString callPartyName() const { return callPartyName_; } |
|
||||||
QString callPartyAvatarUrl() const { return callPartyAvatarUrl_; } |
|
||||||
void refreshTurnServer(); |
|
||||||
|
|
||||||
public slots: |
|
||||||
void syncEvent(const mtx::events::collections::TimelineEvents &event); |
|
||||||
|
|
||||||
signals: |
|
||||||
void newMessage(const QString &roomid, const mtx::events::msg::CallInvite &); |
|
||||||
void newMessage(const QString &roomid, const mtx::events::msg::CallCandidates &); |
|
||||||
void newMessage(const QString &roomid, const mtx::events::msg::CallAnswer &); |
|
||||||
void newMessage(const QString &roomid, const mtx::events::msg::CallHangUp &); |
|
||||||
void newCallParty(); |
|
||||||
void turnServerRetrieved(const mtx::responses::TurnServer &); |
|
||||||
|
|
||||||
private slots: |
|
||||||
void retrieveTurnServer(); |
|
||||||
|
|
||||||
private: |
|
||||||
WebRTCSession &session_; |
|
||||||
QString roomid_; |
|
||||||
QString callPartyName_; |
|
||||||
QString callPartyAvatarUrl_; |
|
||||||
std::string callid_; |
|
||||||
const uint32_t timeoutms_ = 120000; |
|
||||||
std::vector<mtx::events::msg::CallCandidates::Candidate> remoteICECandidates_; |
|
||||||
std::vector<std::string> turnURIs_; |
|
||||||
QTimer turnServerTimer_; |
|
||||||
QSharedPointer<UserSettings> settings_; |
|
||||||
QMediaPlayer player_; |
|
||||||
|
|
||||||
template<typename T> |
|
||||||
bool handleEvent_(const mtx::events::collections::TimelineEvents &event); |
|
||||||
void handleEvent(const mtx::events::RoomEvent<mtx::events::msg::CallInvite> &); |
|
||||||
void handleEvent(const mtx::events::RoomEvent<mtx::events::msg::CallCandidates> &); |
|
||||||
void handleEvent(const mtx::events::RoomEvent<mtx::events::msg::CallAnswer> &); |
|
||||||
void handleEvent(const mtx::events::RoomEvent<mtx::events::msg::CallHangUp> &); |
|
||||||
void answerInvite(const mtx::events::msg::CallInvite &); |
|
||||||
void generateCallID(); |
|
||||||
void clear(); |
|
||||||
void endCall(); |
|
||||||
void playRingtone(const QString &ringtone, bool repeat); |
|
||||||
void stopRingtone(); |
|
||||||
}; |
|
@ -1,20 +0,0 @@ |
|||||||
#pragma once |
|
||||||
|
|
||||||
// Class for showing a limited amount of completions at a time
|
|
||||||
|
|
||||||
#include <QSortFilterProxyModel> |
|
||||||
|
|
||||||
class CompletionModel : public QSortFilterProxyModel |
|
||||||
{ |
|
||||||
public: |
|
||||||
CompletionModel(QAbstractItemModel *model, QObject *parent = nullptr) |
|
||||||
: QSortFilterProxyModel(parent) |
|
||||||
{ |
|
||||||
setSourceModel(model); |
|
||||||
} |
|
||||||
int rowCount(const QModelIndex &parent) const override |
|
||||||
{ |
|
||||||
auto row_count = QSortFilterProxyModel::rowCount(parent); |
|
||||||
return (row_count < 7) ? row_count : 7; |
|
||||||
} |
|
||||||
}; |
|
@ -1,794 +0,0 @@ |
|||||||
#include "DeviceVerificationFlow.h" |
|
||||||
|
|
||||||
#include "Cache.h" |
|
||||||
#include "ChatPage.h" |
|
||||||
#include "Logging.h" |
|
||||||
#include "timeline/TimelineModel.h" |
|
||||||
|
|
||||||
#include <QDateTime> |
|
||||||
#include <QTimer> |
|
||||||
#include <iostream> |
|
||||||
|
|
||||||
static constexpr int TIMEOUT = 2 * 60 * 1000; // 2 minutes
|
|
||||||
|
|
||||||
namespace msgs = mtx::events::msg; |
|
||||||
|
|
||||||
static mtx::events::msg::KeyVerificationMac |
|
||||||
key_verification_mac(mtx::crypto::SAS *sas, |
|
||||||
mtx::identifiers::User sender, |
|
||||||
const std::string &senderDevice, |
|
||||||
mtx::identifiers::User receiver, |
|
||||||
const std::string &receiverDevice, |
|
||||||
const std::string &transactionId, |
|
||||||
std::map<std::string, std::string> keys); |
|
||||||
|
|
||||||
DeviceVerificationFlow::DeviceVerificationFlow(QObject *, |
|
||||||
DeviceVerificationFlow::Type flow_type, |
|
||||||
TimelineModel *model, |
|
||||||
QString userID, |
|
||||||
QString deviceId_) |
|
||||||
: sender(false) |
|
||||||
, type(flow_type) |
|
||||||
, deviceId(deviceId_) |
|
||||||
, model_(model) |
|
||||||
{ |
|
||||||
timeout = new QTimer(this); |
|
||||||
timeout->setSingleShot(true); |
|
||||||
this->sas = olm::client()->sas_init(); |
|
||||||
this->isMacVerified = false; |
|
||||||
|
|
||||||
auto user_id = userID.toStdString(); |
|
||||||
this->toClient = mtx::identifiers::parse<mtx::identifiers::User>(user_id); |
|
||||||
ChatPage::instance()->query_keys( |
|
||||||
user_id, [user_id, this](const UserKeyCache &res, mtx::http::RequestErr err) { |
|
||||||
if (err) { |
|
||||||
nhlog::net()->warn("failed to query device keys: {},{}", |
|
||||||
err->matrix_error.errcode, |
|
||||||
static_cast<int>(err->status_code)); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
if (!this->deviceId.isEmpty() && |
|
||||||
(res.device_keys.find(deviceId.toStdString()) == res.device_keys.end())) { |
|
||||||
nhlog::net()->warn("no devices retrieved {}", user_id); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
this->their_keys = res; |
|
||||||
}); |
|
||||||
|
|
||||||
ChatPage::instance()->query_keys( |
|
||||||
http::client()->user_id().to_string(), |
|
||||||
[this](const UserKeyCache &res, mtx::http::RequestErr err) { |
|
||||||
if (err) { |
|
||||||
nhlog::net()->warn("failed to query device keys: {},{}", |
|
||||||
err->matrix_error.errcode, |
|
||||||
static_cast<int>(err->status_code)); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
if (res.master_keys.keys.empty()) |
|
||||||
return; |
|
||||||
|
|
||||||
if (auto status = |
|
||||||
cache::verificationStatus(http::client()->user_id().to_string()); |
|
||||||
status && status->user_verified) |
|
||||||
this->our_trusted_master_key = res.master_keys.keys.begin()->second; |
|
||||||
}); |
|
||||||
|
|
||||||
if (model) { |
|
||||||
connect(this->model_, |
|
||||||
&TimelineModel::updateFlowEventId, |
|
||||||
this, |
|
||||||
[this](std::string event_id_) { |
|
||||||
this->relation.rel_type = mtx::common::RelationType::Reference; |
|
||||||
this->relation.event_id = event_id_; |
|
||||||
this->transaction_id = event_id_; |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
connect(timeout, &QTimer::timeout, this, [this]() { |
|
||||||
if (state_ != Success && state_ != Failed) |
|
||||||
this->cancelVerification(DeviceVerificationFlow::Error::Timeout); |
|
||||||
}); |
|
||||||
|
|
||||||
connect(ChatPage::instance(), |
|
||||||
&ChatPage::receivedDeviceVerificationStart, |
|
||||||
this, |
|
||||||
&DeviceVerificationFlow::handleStartMessage); |
|
||||||
connect(ChatPage::instance(), |
|
||||||
&ChatPage::receivedDeviceVerificationAccept, |
|
||||||
this, |
|
||||||
[this](const mtx::events::msg::KeyVerificationAccept &msg) { |
|
||||||
if (msg.transaction_id.has_value()) { |
|
||||||
if (msg.transaction_id.value() != this->transaction_id) |
|
||||||
return; |
|
||||||
} else if (msg.relates_to.has_value()) { |
|
||||||
if (msg.relates_to.value().event_id != this->relation.event_id) |
|
||||||
return; |
|
||||||
} |
|
||||||
if ((msg.key_agreement_protocol == "curve25519-hkdf-sha256") && |
|
||||||
(msg.hash == "sha256") && |
|
||||||
(msg.message_authentication_code == "hkdf-hmac-sha256")) { |
|
||||||
this->commitment = msg.commitment; |
|
||||||
if (std::find(msg.short_authentication_string.begin(), |
|
||||||
msg.short_authentication_string.end(), |
|
||||||
mtx::events::msg::SASMethods::Emoji) != |
|
||||||
msg.short_authentication_string.end()) { |
|
||||||
this->method = mtx::events::msg::SASMethods::Emoji; |
|
||||||
} else { |
|
||||||
this->method = mtx::events::msg::SASMethods::Decimal; |
|
||||||
} |
|
||||||
this->mac_method = msg.message_authentication_code; |
|
||||||
this->sendVerificationKey(); |
|
||||||
} else { |
|
||||||
this->cancelVerification( |
|
||||||
DeviceVerificationFlow::Error::UnknownMethod); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
connect(ChatPage::instance(), |
|
||||||
&ChatPage::receivedDeviceVerificationCancel, |
|
||||||
this, |
|
||||||
[this](const mtx::events::msg::KeyVerificationCancel &msg) { |
|
||||||
if (msg.transaction_id.has_value()) { |
|
||||||
if (msg.transaction_id.value() != this->transaction_id) |
|
||||||
return; |
|
||||||
} else if (msg.relates_to.has_value()) { |
|
||||||
if (msg.relates_to.value().event_id != this->relation.event_id) |
|
||||||
return; |
|
||||||
} |
|
||||||
error_ = User; |
|
||||||
emit errorChanged(); |
|
||||||
setState(Failed); |
|
||||||
}); |
|
||||||
|
|
||||||
connect(ChatPage::instance(), |
|
||||||
&ChatPage::receivedDeviceVerificationKey, |
|
||||||
this, |
|
||||||
[this](const mtx::events::msg::KeyVerificationKey &msg) { |
|
||||||
if (msg.transaction_id.has_value()) { |
|
||||||
if (msg.transaction_id.value() != this->transaction_id) |
|
||||||
return; |
|
||||||
} else if (msg.relates_to.has_value()) { |
|
||||||
if (msg.relates_to.value().event_id != this->relation.event_id) |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
if (sender) { |
|
||||||
if (state_ != WaitingForOtherToAccept) { |
|
||||||
this->cancelVerification(OutOfOrder); |
|
||||||
return; |
|
||||||
} |
|
||||||
} else { |
|
||||||
if (state_ != WaitingForKeys) { |
|
||||||
this->cancelVerification(OutOfOrder); |
|
||||||
return; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
this->sas->set_their_key(msg.key); |
|
||||||
std::string info; |
|
||||||
if (this->sender == true) { |
|
||||||
info = "MATRIX_KEY_VERIFICATION_SAS|" + |
|
||||||
http::client()->user_id().to_string() + "|" + |
|
||||||
http::client()->device_id() + "|" + this->sas->public_key() + |
|
||||||
"|" + this->toClient.to_string() + "|" + |
|
||||||
this->deviceId.toStdString() + "|" + msg.key + "|" + |
|
||||||
this->transaction_id; |
|
||||||
} else { |
|
||||||
info = "MATRIX_KEY_VERIFICATION_SAS|" + this->toClient.to_string() + |
|
||||||
"|" + this->deviceId.toStdString() + "|" + msg.key + "|" + |
|
||||||
http::client()->user_id().to_string() + "|" + |
|
||||||
http::client()->device_id() + "|" + this->sas->public_key() + |
|
||||||
"|" + this->transaction_id; |
|
||||||
} |
|
||||||
|
|
||||||
nhlog::ui()->info("Info is: '{}'", info); |
|
||||||
|
|
||||||
if (this->sender == false) { |
|
||||||
this->sendVerificationKey(); |
|
||||||
} else { |
|
||||||
if (this->commitment != |
|
||||||
mtx::crypto::bin2base64_unpadded( |
|
||||||
mtx::crypto::sha256(msg.key + this->canonical_json.dump()))) { |
|
||||||
this->cancelVerification( |
|
||||||
DeviceVerificationFlow::Error::MismatchedCommitment); |
|
||||||
return; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (this->method == mtx::events::msg::SASMethods::Emoji) { |
|
||||||
this->sasList = this->sas->generate_bytes_emoji(info); |
|
||||||
setState(CompareEmoji); |
|
||||||
} else if (this->method == mtx::events::msg::SASMethods::Decimal) { |
|
||||||
this->sasList = this->sas->generate_bytes_decimal(info); |
|
||||||
setState(CompareNumber); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
connect( |
|
||||||
ChatPage::instance(), |
|
||||||
&ChatPage::receivedDeviceVerificationMac, |
|
||||||
this, |
|
||||||
[this](const mtx::events::msg::KeyVerificationMac &msg) { |
|
||||||
if (msg.transaction_id.has_value()) { |
|
||||||
if (msg.transaction_id.value() != this->transaction_id) |
|
||||||
return; |
|
||||||
} else if (msg.relates_to.has_value()) { |
|
||||||
if (msg.relates_to.value().event_id != this->relation.event_id) |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
std::map<std::string, std::string> key_list; |
|
||||||
std::string key_string; |
|
||||||
for (const auto &mac : msg.mac) { |
|
||||||
for (const auto &[deviceid, key] : their_keys.device_keys) { |
|
||||||
(void)deviceid; |
|
||||||
if (key.keys.count(mac.first)) |
|
||||||
key_list[mac.first] = key.keys.at(mac.first); |
|
||||||
} |
|
||||||
|
|
||||||
if (their_keys.master_keys.keys.count(mac.first)) |
|
||||||
key_list[mac.first] = their_keys.master_keys.keys[mac.first]; |
|
||||||
if (their_keys.user_signing_keys.keys.count(mac.first)) |
|
||||||
key_list[mac.first] = |
|
||||||
their_keys.user_signing_keys.keys[mac.first]; |
|
||||||
if (their_keys.self_signing_keys.keys.count(mac.first)) |
|
||||||
key_list[mac.first] = |
|
||||||
their_keys.self_signing_keys.keys[mac.first]; |
|
||||||
} |
|
||||||
auto macs = key_verification_mac(sas.get(), |
|
||||||
toClient, |
|
||||||
this->deviceId.toStdString(), |
|
||||||
http::client()->user_id(), |
|
||||||
http::client()->device_id(), |
|
||||||
this->transaction_id, |
|
||||||
key_list); |
|
||||||
|
|
||||||
for (const auto &[key, mac] : macs.mac) { |
|
||||||
if (mac != msg.mac.at(key)) { |
|
||||||
this->cancelVerification( |
|
||||||
DeviceVerificationFlow::Error::KeyMismatch); |
|
||||||
return; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (msg.keys == macs.keys) { |
|
||||||
mtx::requests::KeySignaturesUpload req; |
|
||||||
if (utils::localUser().toStdString() == this->toClient.to_string()) { |
|
||||||
// self verification, sign master key with device key, if we
|
|
||||||
// verified it
|
|
||||||
for (const auto &mac : msg.mac) { |
|
||||||
if (their_keys.master_keys.keys.count(mac.first)) { |
|
||||||
json j = their_keys.master_keys; |
|
||||||
j.erase("signatures"); |
|
||||||
j.erase("unsigned"); |
|
||||||
mtx::crypto::CrossSigningKeys master_key = j; |
|
||||||
master_key |
|
||||||
.signatures[utils::localUser().toStdString()] |
|
||||||
["ed25519:" + |
|
||||||
http::client()->device_id()] = |
|
||||||
olm::client()->sign_message(j.dump()); |
|
||||||
req.signatures[utils::localUser().toStdString()] |
|
||||||
[master_key.keys.at(mac.first)] = |
|
||||||
master_key; |
|
||||||
} |
|
||||||
} |
|
||||||
// TODO(Nico): Sign their device key with self signing key
|
|
||||||
} else { |
|
||||||
// TODO(Nico): Sign their master key with user signing key
|
|
||||||
} |
|
||||||
|
|
||||||
if (!req.signatures.empty()) { |
|
||||||
http::client()->keys_signatures_upload( |
|
||||||
req, |
|
||||||
[](const mtx::responses::KeySignaturesUpload &res, |
|
||||||
mtx::http::RequestErr err) { |
|
||||||
if (err) { |
|
||||||
nhlog::net()->error( |
|
||||||
"failed to upload signatures: {},{}", |
|
||||||
err->matrix_error.errcode, |
|
||||||
static_cast<int>(err->status_code)); |
|
||||||
} |
|
||||||
|
|
||||||
for (const auto &[user_id, tmp] : res.errors) |
|
||||||
for (const auto &[key_id, e] : tmp) |
|
||||||
nhlog::net()->error( |
|
||||||
"signature error for user {} and key " |
|
||||||
"id {}: {}, {}", |
|
||||||
user_id, |
|
||||||
key_id, |
|
||||||
e.errcode, |
|
||||||
e.error); |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
this->isMacVerified = true; |
|
||||||
this->acceptDevice(); |
|
||||||
} else { |
|
||||||
this->cancelVerification(DeviceVerificationFlow::Error::KeyMismatch); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
connect(ChatPage::instance(), |
|
||||||
&ChatPage::receivedDeviceVerificationReady, |
|
||||||
this, |
|
||||||
[this](const mtx::events::msg::KeyVerificationReady &msg) { |
|
||||||
if (!sender) { |
|
||||||
if (msg.from_device != http::client()->device_id()) { |
|
||||||
error_ = User; |
|
||||||
emit errorChanged(); |
|
||||||
setState(Failed); |
|
||||||
} |
|
||||||
|
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
if (msg.transaction_id.has_value()) { |
|
||||||
if (msg.transaction_id.value() != this->transaction_id) |
|
||||||
return; |
|
||||||
} else if ((msg.relates_to.has_value() && sender)) { |
|
||||||
if (msg.relates_to.value().event_id != this->relation.event_id) |
|
||||||
return; |
|
||||||
else { |
|
||||||
this->deviceId = QString::fromStdString(msg.from_device); |
|
||||||
} |
|
||||||
} |
|
||||||
this->startVerificationRequest(); |
|
||||||
}); |
|
||||||
|
|
||||||
connect(ChatPage::instance(), |
|
||||||
&ChatPage::receivedDeviceVerificationDone, |
|
||||||
this, |
|
||||||
[this](const mtx::events::msg::KeyVerificationDone &msg) { |
|
||||||
if (msg.transaction_id.has_value()) { |
|
||||||
if (msg.transaction_id.value() != this->transaction_id) |
|
||||||
return; |
|
||||||
} else if (msg.relates_to.has_value()) { |
|
||||||
if (msg.relates_to.value().event_id != this->relation.event_id) |
|
||||||
return; |
|
||||||
} |
|
||||||
nhlog::ui()->info("Flow done on other side"); |
|
||||||
}); |
|
||||||
|
|
||||||
timeout->start(TIMEOUT); |
|
||||||
} |
|
||||||
|
|
||||||
QString |
|
||||||
DeviceVerificationFlow::state() |
|
||||||
{ |
|
||||||
switch (state_) { |
|
||||||
case PromptStartVerification: |
|
||||||
return "PromptStartVerification"; |
|
||||||
case CompareEmoji: |
|
||||||
return "CompareEmoji"; |
|
||||||
case CompareNumber: |
|
||||||
return "CompareNumber"; |
|
||||||
case WaitingForKeys: |
|
||||||
return "WaitingForKeys"; |
|
||||||
case WaitingForOtherToAccept: |
|
||||||
return "WaitingForOtherToAccept"; |
|
||||||
case WaitingForMac: |
|
||||||
return "WaitingForMac"; |
|
||||||
case Success: |
|
||||||
return "Success"; |
|
||||||
case Failed: |
|
||||||
return "Failed"; |
|
||||||
default: |
|
||||||
return ""; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::next() |
|
||||||
{ |
|
||||||
if (sender) { |
|
||||||
switch (state_) { |
|
||||||
case PromptStartVerification: |
|
||||||
sendVerificationRequest(); |
|
||||||
break; |
|
||||||
case CompareEmoji: |
|
||||||
case CompareNumber: |
|
||||||
sendVerificationMac(); |
|
||||||
break; |
|
||||||
case WaitingForKeys: |
|
||||||
case WaitingForOtherToAccept: |
|
||||||
case WaitingForMac: |
|
||||||
case Success: |
|
||||||
case Failed: |
|
||||||
nhlog::db()->error("verification: Invalid state transition!"); |
|
||||||
break; |
|
||||||
} |
|
||||||
} else { |
|
||||||
switch (state_) { |
|
||||||
case PromptStartVerification: |
|
||||||
if (canonical_json.is_null()) |
|
||||||
sendVerificationReady(); |
|
||||||
else // legacy path without request and ready
|
|
||||||
acceptVerificationRequest(); |
|
||||||
break; |
|
||||||
case CompareEmoji: |
|
||||||
[[fallthrough]]; |
|
||||||
case CompareNumber: |
|
||||||
sendVerificationMac(); |
|
||||||
break; |
|
||||||
case WaitingForKeys: |
|
||||||
case WaitingForOtherToAccept: |
|
||||||
case WaitingForMac: |
|
||||||
case Success: |
|
||||||
case Failed: |
|
||||||
nhlog::db()->error("verification: Invalid state transition!"); |
|
||||||
break; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
QString |
|
||||||
DeviceVerificationFlow::getUserId() |
|
||||||
{ |
|
||||||
return QString::fromStdString(this->toClient.to_string()); |
|
||||||
} |
|
||||||
|
|
||||||
QString |
|
||||||
DeviceVerificationFlow::getDeviceId() |
|
||||||
{ |
|
||||||
return this->deviceId; |
|
||||||
} |
|
||||||
|
|
||||||
bool |
|
||||||
DeviceVerificationFlow::getSender() |
|
||||||
{ |
|
||||||
return this->sender; |
|
||||||
} |
|
||||||
|
|
||||||
std::vector<int> |
|
||||||
DeviceVerificationFlow::getSasList() |
|
||||||
{ |
|
||||||
return this->sasList; |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::setEventId(std::string event_id_) |
|
||||||
{ |
|
||||||
this->relation.rel_type = mtx::common::RelationType::Reference; |
|
||||||
this->relation.event_id = event_id_; |
|
||||||
this->transaction_id = event_id_; |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::handleStartMessage(const mtx::events::msg::KeyVerificationStart &msg, |
|
||||||
std::string) |
|
||||||
{ |
|
||||||
if (msg.transaction_id.has_value()) { |
|
||||||
if (msg.transaction_id.value() != this->transaction_id) |
|
||||||
return; |
|
||||||
} else if (msg.relates_to.has_value()) { |
|
||||||
if (msg.relates_to.value().event_id != this->relation.event_id) |
|
||||||
return; |
|
||||||
} |
|
||||||
if ((std::find(msg.key_agreement_protocols.begin(), |
|
||||||
msg.key_agreement_protocols.end(), |
|
||||||
"curve25519-hkdf-sha256") != msg.key_agreement_protocols.end()) && |
|
||||||
(std::find(msg.hashes.begin(), msg.hashes.end(), "sha256") != msg.hashes.end()) && |
|
||||||
(std::find(msg.message_authentication_codes.begin(), |
|
||||||
msg.message_authentication_codes.end(), |
|
||||||
"hkdf-hmac-sha256") != msg.message_authentication_codes.end())) { |
|
||||||
if (std::find(msg.short_authentication_string.begin(), |
|
||||||
msg.short_authentication_string.end(), |
|
||||||
mtx::events::msg::SASMethods::Emoji) != |
|
||||||
msg.short_authentication_string.end()) { |
|
||||||
this->method = mtx::events::msg::SASMethods::Emoji; |
|
||||||
} else if (std::find(msg.short_authentication_string.begin(), |
|
||||||
msg.short_authentication_string.end(), |
|
||||||
mtx::events::msg::SASMethods::Decimal) != |
|
||||||
msg.short_authentication_string.end()) { |
|
||||||
this->method = mtx::events::msg::SASMethods::Decimal; |
|
||||||
} else { |
|
||||||
this->cancelVerification(DeviceVerificationFlow::Error::UnknownMethod); |
|
||||||
return; |
|
||||||
} |
|
||||||
if (!sender) |
|
||||||
this->canonical_json = nlohmann::json(msg); |
|
||||||
else { |
|
||||||
if (utils::localUser().toStdString() < this->toClient.to_string()) { |
|
||||||
this->canonical_json = nlohmann::json(msg); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (state_ != PromptStartVerification) |
|
||||||
this->acceptVerificationRequest(); |
|
||||||
} else { |
|
||||||
this->cancelVerification(DeviceVerificationFlow::Error::UnknownMethod); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
//! accepts a verification
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::acceptVerificationRequest() |
|
||||||
{ |
|
||||||
mtx::events::msg::KeyVerificationAccept req; |
|
||||||
|
|
||||||
req.method = mtx::events::msg::VerificationMethods::SASv1; |
|
||||||
req.key_agreement_protocol = "curve25519-hkdf-sha256"; |
|
||||||
req.hash = "sha256"; |
|
||||||
req.message_authentication_code = "hkdf-hmac-sha256"; |
|
||||||
if (this->method == mtx::events::msg::SASMethods::Emoji) |
|
||||||
req.short_authentication_string = {mtx::events::msg::SASMethods::Emoji}; |
|
||||||
else if (this->method == mtx::events::msg::SASMethods::Decimal) |
|
||||||
req.short_authentication_string = {mtx::events::msg::SASMethods::Decimal}; |
|
||||||
req.commitment = mtx::crypto::bin2base64_unpadded( |
|
||||||
mtx::crypto::sha256(this->sas->public_key() + this->canonical_json.dump())); |
|
||||||
|
|
||||||
send(req); |
|
||||||
setState(WaitingForKeys); |
|
||||||
} |
|
||||||
//! responds verification request
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::sendVerificationReady() |
|
||||||
{ |
|
||||||
mtx::events::msg::KeyVerificationReady req; |
|
||||||
|
|
||||||
req.from_device = http::client()->device_id(); |
|
||||||
req.methods = {mtx::events::msg::VerificationMethods::SASv1}; |
|
||||||
|
|
||||||
send(req); |
|
||||||
setState(WaitingForKeys); |
|
||||||
} |
|
||||||
//! accepts a verification
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::sendVerificationDone() |
|
||||||
{ |
|
||||||
mtx::events::msg::KeyVerificationDone req; |
|
||||||
|
|
||||||
send(req); |
|
||||||
} |
|
||||||
//! starts the verification flow
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::startVerificationRequest() |
|
||||||
{ |
|
||||||
mtx::events::msg::KeyVerificationStart req; |
|
||||||
|
|
||||||
req.from_device = http::client()->device_id(); |
|
||||||
req.method = mtx::events::msg::VerificationMethods::SASv1; |
|
||||||
req.key_agreement_protocols = {"curve25519-hkdf-sha256"}; |
|
||||||
req.hashes = {"sha256"}; |
|
||||||
req.message_authentication_codes = {"hkdf-hmac-sha256"}; |
|
||||||
req.short_authentication_string = {mtx::events::msg::SASMethods::Decimal, |
|
||||||
mtx::events::msg::SASMethods::Emoji}; |
|
||||||
|
|
||||||
if (this->type == DeviceVerificationFlow::Type::ToDevice) { |
|
||||||
mtx::requests::ToDeviceMessages<mtx::events::msg::KeyVerificationStart> body; |
|
||||||
req.transaction_id = this->transaction_id; |
|
||||||
this->canonical_json = nlohmann::json(req); |
|
||||||
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) { |
|
||||||
req.relates_to = this->relation; |
|
||||||
this->canonical_json = nlohmann::json(req); |
|
||||||
} |
|
||||||
send(req); |
|
||||||
setState(WaitingForOtherToAccept); |
|
||||||
} |
|
||||||
//! sends a verification request
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::sendVerificationRequest() |
|
||||||
{ |
|
||||||
mtx::events::msg::KeyVerificationRequest req; |
|
||||||
|
|
||||||
req.from_device = http::client()->device_id(); |
|
||||||
req.methods = {mtx::events::msg::VerificationMethods::SASv1}; |
|
||||||
|
|
||||||
if (this->type == DeviceVerificationFlow::Type::ToDevice) { |
|
||||||
QDateTime currentTime = QDateTime::currentDateTimeUtc(); |
|
||||||
|
|
||||||
req.timestamp = (uint64_t)currentTime.toMSecsSinceEpoch(); |
|
||||||
|
|
||||||
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) { |
|
||||||
req.to = this->toClient.to_string(); |
|
||||||
req.msgtype = "m.key.verification.request"; |
|
||||||
req.body = "User is requesting to verify keys with you. However, your client does " |
|
||||||
"not support this method, so you will need to use the legacy method of " |
|
||||||
"key verification."; |
|
||||||
} |
|
||||||
|
|
||||||
send(req); |
|
||||||
setState(WaitingForOtherToAccept); |
|
||||||
} |
|
||||||
//! cancels a verification flow
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::cancelVerification(DeviceVerificationFlow::Error error_code) |
|
||||||
{ |
|
||||||
mtx::events::msg::KeyVerificationCancel req; |
|
||||||
|
|
||||||
if (error_code == DeviceVerificationFlow::Error::UnknownMethod) { |
|
||||||
req.code = "m.unknown_method"; |
|
||||||
req.reason = "unknown method received"; |
|
||||||
} else if (error_code == DeviceVerificationFlow::Error::MismatchedCommitment) { |
|
||||||
req.code = "m.mismatched_commitment"; |
|
||||||
req.reason = "commitment didn't match"; |
|
||||||
} else if (error_code == DeviceVerificationFlow::Error::MismatchedSAS) { |
|
||||||
req.code = "m.mismatched_sas"; |
|
||||||
req.reason = "sas didn't match"; |
|
||||||
} else if (error_code == DeviceVerificationFlow::Error::KeyMismatch) { |
|
||||||
req.code = "m.key_match"; |
|
||||||
req.reason = "keys did not match"; |
|
||||||
} else if (error_code == DeviceVerificationFlow::Error::Timeout) { |
|
||||||
req.code = "m.timeout"; |
|
||||||
req.reason = "timed out"; |
|
||||||
} else if (error_code == DeviceVerificationFlow::Error::User) { |
|
||||||
req.code = "m.user"; |
|
||||||
req.reason = "user cancelled the verification"; |
|
||||||
} else if (error_code == DeviceVerificationFlow::Error::OutOfOrder) { |
|
||||||
req.code = "m.unexpected_message"; |
|
||||||
req.reason = "received messages out of order"; |
|
||||||
} |
|
||||||
|
|
||||||
this->error_ = error_code; |
|
||||||
emit errorChanged(); |
|
||||||
this->setState(Failed); |
|
||||||
|
|
||||||
send(req); |
|
||||||
} |
|
||||||
//! sends the verification key
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::sendVerificationKey() |
|
||||||
{ |
|
||||||
mtx::events::msg::KeyVerificationKey req; |
|
||||||
|
|
||||||
req.key = this->sas->public_key(); |
|
||||||
|
|
||||||
send(req); |
|
||||||
} |
|
||||||
|
|
||||||
mtx::events::msg::KeyVerificationMac |
|
||||||
key_verification_mac(mtx::crypto::SAS *sas, |
|
||||||
mtx::identifiers::User sender, |
|
||||||
const std::string &senderDevice, |
|
||||||
mtx::identifiers::User receiver, |
|
||||||
const std::string &receiverDevice, |
|
||||||
const std::string &transactionId, |
|
||||||
std::map<std::string, std::string> keys) |
|
||||||
{ |
|
||||||
mtx::events::msg::KeyVerificationMac req; |
|
||||||
|
|
||||||
std::string info = "MATRIX_KEY_VERIFICATION_MAC" + sender.to_string() + senderDevice + |
|
||||||
receiver.to_string() + receiverDevice + transactionId; |
|
||||||
|
|
||||||
std::string key_list; |
|
||||||
bool first = true; |
|
||||||
for (const auto &[key_id, key] : keys) { |
|
||||||
req.mac[key_id] = sas->calculate_mac(key, info + key_id); |
|
||||||
|
|
||||||
if (!first) |
|
||||||
key_list += ","; |
|
||||||
key_list += key_id; |
|
||||||
first = false; |
|
||||||
} |
|
||||||
|
|
||||||
req.keys = sas->calculate_mac(key_list, info + "KEY_IDS"); |
|
||||||
|
|
||||||
return req; |
|
||||||
} |
|
||||||
|
|
||||||
//! sends the mac of the keys
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::sendVerificationMac() |
|
||||||
{ |
|
||||||
std::map<std::string, std::string> key_list; |
|
||||||
key_list["ed25519:" + http::client()->device_id()] = olm::client()->identity_keys().ed25519; |
|
||||||
|
|
||||||
// send our master key, if we trust it
|
|
||||||
if (!this->our_trusted_master_key.empty()) |
|
||||||
key_list["ed25519:" + our_trusted_master_key] = our_trusted_master_key; |
|
||||||
|
|
||||||
mtx::events::msg::KeyVerificationMac req = |
|
||||||
key_verification_mac(sas.get(), |
|
||||||
http::client()->user_id(), |
|
||||||
http::client()->device_id(), |
|
||||||
this->toClient, |
|
||||||
this->deviceId.toStdString(), |
|
||||||
this->transaction_id, |
|
||||||
key_list); |
|
||||||
|
|
||||||
send(req); |
|
||||||
|
|
||||||
setState(WaitingForMac); |
|
||||||
acceptDevice(); |
|
||||||
} |
|
||||||
//! Completes the verification flow
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::acceptDevice() |
|
||||||
{ |
|
||||||
if (!isMacVerified) { |
|
||||||
setState(WaitingForMac); |
|
||||||
} else if (state_ == WaitingForMac) { |
|
||||||
cache::markDeviceVerified(this->toClient.to_string(), this->deviceId.toStdString()); |
|
||||||
this->sendVerificationDone(); |
|
||||||
setState(Success); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void |
|
||||||
DeviceVerificationFlow::unverify() |
|
||||||
{ |
|
||||||
cache::markDeviceUnverified(this->toClient.to_string(), this->deviceId.toStdString()); |
|
||||||
|
|
||||||
emit refreshProfile(); |
|
||||||
} |
|
||||||
|
|
||||||
QSharedPointer<DeviceVerificationFlow> |
|
||||||
DeviceVerificationFlow::NewInRoomVerification(QObject *parent_, |
|
||||||
TimelineModel *timelineModel_, |
|
||||||
const mtx::events::msg::KeyVerificationRequest &msg, |
|
||||||
QString other_user_, |
|
||||||
QString event_id_) |
|
||||||
{ |
|
||||||
QSharedPointer<DeviceVerificationFlow> flow( |
|
||||||
new DeviceVerificationFlow(parent_, |
|
||||||
Type::RoomMsg, |
|
||||||
timelineModel_, |
|
||||||
other_user_, |
|
||||||
QString::fromStdString(msg.from_device))); |
|
||||||
|
|
||||||
flow->setEventId(event_id_.toStdString()); |
|
||||||
|
|
||||||
if (std::find(msg.methods.begin(), |
|
||||||
msg.methods.end(), |
|
||||||
mtx::events::msg::VerificationMethods::SASv1) == msg.methods.end()) { |
|
||||||
flow->cancelVerification(UnknownMethod); |
|
||||||
} |
|
||||||
|
|
||||||
return flow; |
|
||||||
} |
|
||||||
QSharedPointer<DeviceVerificationFlow> |
|
||||||
DeviceVerificationFlow::NewToDeviceVerification(QObject *parent_, |
|
||||||
const mtx::events::msg::KeyVerificationRequest &msg, |
|
||||||
QString other_user_, |
|
||||||
QString txn_id_) |
|
||||||
{ |
|
||||||
QSharedPointer<DeviceVerificationFlow> flow(new DeviceVerificationFlow( |
|
||||||
parent_, Type::ToDevice, nullptr, other_user_, QString::fromStdString(msg.from_device))); |
|
||||||
flow->transaction_id = txn_id_.toStdString(); |
|
||||||
|
|
||||||
if (std::find(msg.methods.begin(), |
|
||||||
msg.methods.end(), |
|
||||||
mtx::events::msg::VerificationMethods::SASv1) == msg.methods.end()) { |
|
||||||
flow->cancelVerification(UnknownMethod); |
|
||||||
} |
|
||||||
|
|
||||||
return flow; |
|
||||||
} |
|
||||||
QSharedPointer<DeviceVerificationFlow> |
|
||||||
DeviceVerificationFlow::NewToDeviceVerification(QObject *parent_, |
|
||||||
const mtx::events::msg::KeyVerificationStart &msg, |
|
||||||
QString other_user_, |
|
||||||
QString txn_id_) |
|
||||||
{ |
|
||||||
QSharedPointer<DeviceVerificationFlow> flow(new DeviceVerificationFlow( |
|
||||||
parent_, Type::ToDevice, nullptr, other_user_, QString::fromStdString(msg.from_device))); |
|
||||||
flow->transaction_id = txn_id_.toStdString(); |
|
||||||
|
|
||||||
flow->handleStartMessage(msg, ""); |
|
||||||
|
|
||||||
return flow; |
|
||||||
} |
|
||||||
QSharedPointer<DeviceVerificationFlow> |
|
||||||
DeviceVerificationFlow::InitiateUserVerification(QObject *parent_, |
|
||||||
TimelineModel *timelineModel_, |
|
||||||
QString userid) |
|
||||||
{ |
|
||||||
QSharedPointer<DeviceVerificationFlow> flow( |
|
||||||
new DeviceVerificationFlow(parent_, Type::RoomMsg, timelineModel_, userid, "")); |
|
||||||
flow->sender = true; |
|
||||||
return flow; |
|
||||||
} |
|
||||||
QSharedPointer<DeviceVerificationFlow> |
|
||||||
DeviceVerificationFlow::InitiateDeviceVerification(QObject *parent_, QString userid, QString device) |
|
||||||
{ |
|
||||||
QSharedPointer<DeviceVerificationFlow> flow( |
|
||||||
new DeviceVerificationFlow(parent_, Type::ToDevice, nullptr, userid, device)); |
|
||||||
|
|
||||||
flow->sender = true; |
|
||||||
flow->transaction_id = http::client()->generate_txn_id(); |
|
||||||
|
|
||||||
return flow; |
|
||||||
} |
|
@ -1,235 +0,0 @@ |
|||||||
#pragma once |
|
||||||
|
|
||||||
#include <QObject> |
|
||||||
|
|
||||||
#include <mtx/responses/crypto.hpp> |
|
||||||
|
|
||||||
#include "CacheCryptoStructs.h" |
|
||||||
#include "Logging.h" |
|
||||||
#include "MatrixClient.h" |
|
||||||
#include "Olm.h" |
|
||||||
#include "timeline/TimelineModel.h" |
|
||||||
|
|
||||||
class QTimer; |
|
||||||
|
|
||||||
using sas_ptr = std::unique_ptr<mtx::crypto::SAS>; |
|
||||||
|
|
||||||
// clang-format off
|
|
||||||
/*
|
|
||||||
* Stolen from fluffy chat :D |
|
||||||
* |
|
||||||
* State | +-------------+ +-----------+ | |
|
||||||
* | | AliceDevice | | BobDevice | | |
|
||||||
* | | (sender) | | | | |
|
||||||
* | +-------------+ +-----------+ | |
|
||||||
* promptStartVerify | | | | |
|
||||||
* | o | (m.key.verification.request) | | |
|
||||||
* | p |-------------------------------->| (ASK FOR VERIFICATION REQUEST) | |
|
||||||
* waitForOtherAccept | t | | | promptStartVerify |
|
||||||
* && | i | (m.key.verification.ready) | | |
|
||||||
* no commitment | o |<--------------------------------| | |
|
||||||
* && | n | | | |
|
||||||
* no canonical_json | a | (m.key.verification.start) | | waitingForKeys |
|
||||||
* | l |<--------------------------------| Not sending to prevent the glare resolve| && no commitment |
|
||||||
* | | | | && no canonical_json |
|
||||||
* | | m.key.verification.start | | |
|
||||||
* waitForOtherAccept | |-------------------------------->| (IF NOT ALREADY ASKED, | |
|
||||||
* && | | | ASK FOR VERIFICATION REQUEST) | promptStartVerify, if not accepted |
|
||||||
* canonical_json | | m.key.verification.accept | | |
|
||||||
* | |<--------------------------------| | |
|
||||||
* waitForOtherAccept | | | | waitingForKeys |
|
||||||
* && | | m.key.verification.key | | && canonical_json |
|
||||||
* commitment | |-------------------------------->| | && commitment |
|
||||||
* | | | | |
|
||||||
* | | m.key.verification.key | | |
|
||||||
* | |<--------------------------------| | |
|
||||||
* compareEmoji/Number| | | | compareEmoji/Number |
|
||||||
* | | COMPARE EMOJI / NUMBERS | | |
|
||||||
* | | | | |
|
||||||
* waitingForMac | | m.key.verification.mac | | waitingForMac |
|
||||||
* | success |<------------------------------->| success | |
|
||||||
* | | | | |
|
||||||
* success/fail | | m.key.verification.done | | success/fail |
|
||||||
* | |<------------------------------->| | |
|
||||||
*/ |
|
||||||
// clang-format on
|
|
||||||
class DeviceVerificationFlow : public QObject |
|
||||||
{ |
|
||||||
Q_OBJECT |
|
||||||
Q_PROPERTY(QString state READ state NOTIFY stateChanged) |
|
||||||
Q_PROPERTY(Error error READ error NOTIFY errorChanged) |
|
||||||
Q_PROPERTY(QString userId READ getUserId CONSTANT) |
|
||||||
Q_PROPERTY(QString deviceId READ getDeviceId CONSTANT) |
|
||||||
Q_PROPERTY(bool sender READ getSender CONSTANT) |
|
||||||
Q_PROPERTY(std::vector<int> sasList READ getSasList CONSTANT) |
|
||||||
|
|
||||||
public: |
|
||||||
enum State |
|
||||||
{ |
|
||||||
PromptStartVerification, |
|
||||||
WaitingForOtherToAccept, |
|
||||||
WaitingForKeys, |
|
||||||
CompareEmoji, |
|
||||||
CompareNumber, |
|
||||||
WaitingForMac, |
|
||||||
Success, |
|
||||||
Failed, |
|
||||||
}; |
|
||||||
Q_ENUM(State) |
|
||||||
|
|
||||||
enum Type |
|
||||||
{ |
|
||||||
ToDevice, |
|
||||||
RoomMsg |
|
||||||
}; |
|
||||||
|
|
||||||
enum Error |
|
||||||
{ |
|
||||||
UnknownMethod, |
|
||||||
MismatchedCommitment, |
|
||||||
MismatchedSAS, |
|
||||||
KeyMismatch, |
|
||||||
Timeout, |
|
||||||
User, |
|
||||||
OutOfOrder, |
|
||||||
}; |
|
||||||
Q_ENUM(Error) |
|
||||||
|
|
||||||
static QSharedPointer<DeviceVerificationFlow> NewInRoomVerification( |
|
||||||
QObject *parent_, |
|
||||||
TimelineModel *timelineModel_, |
|
||||||
const mtx::events::msg::KeyVerificationRequest &msg, |
|
||||||
QString other_user_, |
|
||||||
QString event_id_); |
|
||||||
static QSharedPointer<DeviceVerificationFlow> NewToDeviceVerification( |
|
||||||
QObject *parent_, |
|
||||||
const mtx::events::msg::KeyVerificationRequest &msg, |
|
||||||
QString other_user_, |
|
||||||
QString txn_id_); |
|
||||||
static QSharedPointer<DeviceVerificationFlow> NewToDeviceVerification( |
|
||||||
QObject *parent_, |
|
||||||
const mtx::events::msg::KeyVerificationStart &msg, |
|
||||||
QString other_user_, |
|
||||||
QString txn_id_); |
|
||||||
static QSharedPointer<DeviceVerificationFlow> |
|
||||||
InitiateUserVerification(QObject *parent_, TimelineModel *timelineModel_, QString userid); |
|
||||||
static QSharedPointer<DeviceVerificationFlow> InitiateDeviceVerification(QObject *parent, |
|
||||||
QString userid, |
|
||||||
QString device); |
|
||||||
|
|
||||||
// getters
|
|
||||||
QString state(); |
|
||||||
Error error() { return error_; } |
|
||||||
QString getUserId(); |
|
||||||
QString getDeviceId(); |
|
||||||
bool getSender(); |
|
||||||
std::vector<int> getSasList(); |
|
||||||
QString transactionId() { return QString::fromStdString(this->transaction_id); } |
|
||||||
// setters
|
|
||||||
void setDeviceId(QString deviceID); |
|
||||||
void setEventId(std::string event_id); |
|
||||||
|
|
||||||
void callback_fn(const UserKeyCache &res, mtx::http::RequestErr err, std::string user_id); |
|
||||||
|
|
||||||
public slots: |
|
||||||
//! unverifies a device
|
|
||||||
void unverify(); |
|
||||||
//! Continues the flow
|
|
||||||
void next(); |
|
||||||
//! Cancel the flow
|
|
||||||
void cancel() { cancelVerification(User); } |
|
||||||
|
|
||||||
signals: |
|
||||||
void refreshProfile(); |
|
||||||
void stateChanged(); |
|
||||||
void errorChanged(); |
|
||||||
|
|
||||||
private: |
|
||||||
DeviceVerificationFlow(QObject *, |
|
||||||
DeviceVerificationFlow::Type flow_type, |
|
||||||
TimelineModel *model, |
|
||||||
QString userID, |
|
||||||
QString deviceId_); |
|
||||||
void setState(State state) |
|
||||||
{ |
|
||||||
if (state != state_) { |
|
||||||
state_ = state; |
|
||||||
emit stateChanged(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
void handleStartMessage(const mtx::events::msg::KeyVerificationStart &msg, std::string); |
|
||||||
//! sends a verification request
|
|
||||||
void sendVerificationRequest(); |
|
||||||
//! accepts a verification request
|
|
||||||
void sendVerificationReady(); |
|
||||||
//! completes the verification flow();
|
|
||||||
void sendVerificationDone(); |
|
||||||
//! accepts a verification
|
|
||||||
void acceptVerificationRequest(); |
|
||||||
//! starts the verification flow
|
|
||||||
void startVerificationRequest(); |
|
||||||
//! cancels a verification flow
|
|
||||||
void cancelVerification(DeviceVerificationFlow::Error error_code); |
|
||||||
//! sends the verification key
|
|
||||||
void sendVerificationKey(); |
|
||||||
//! sends the mac of the keys
|
|
||||||
void sendVerificationMac(); |
|
||||||
//! Completes the verification flow
|
|
||||||
void acceptDevice(); |
|
||||||
|
|
||||||
std::string transaction_id; |
|
||||||
|
|
||||||
bool sender; |
|
||||||
Type type; |
|
||||||
mtx::identifiers::User toClient; |
|
||||||
QString deviceId; |
|
||||||
|
|
||||||
// public part of our master key, when trusted or empty
|
|
||||||
std::string our_trusted_master_key; |
|
||||||
|
|
||||||
mtx::events::msg::SASMethods method = mtx::events::msg::SASMethods::Emoji; |
|
||||||
QTimer *timeout = nullptr; |
|
||||||
sas_ptr sas; |
|
||||||
std::string mac_method; |
|
||||||
std::string commitment; |
|
||||||
nlohmann::json canonical_json; |
|
||||||
|
|
||||||
std::vector<int> sasList; |
|
||||||
UserKeyCache their_keys; |
|
||||||
TimelineModel *model_; |
|
||||||
mtx::common::RelatesTo relation; |
|
||||||
|
|
||||||
State state_ = PromptStartVerification; |
|
||||||
Error error_ = UnknownMethod; |
|
||||||
|
|
||||||
bool isMacVerified = false; |
|
||||||
|
|
||||||
template<typename T> |
|
||||||
void send(T msg) |
|
||||||
{ |
|
||||||
if (this->type == DeviceVerificationFlow::Type::ToDevice) { |
|
||||||
mtx::requests::ToDeviceMessages<T> body; |
|
||||||
msg.transaction_id = this->transaction_id; |
|
||||||
body[this->toClient][deviceId.toStdString()] = msg; |
|
||||||
|
|
||||||
http::client()->send_to_device<T>( |
|
||||||
this->transaction_id, body, [](mtx::http::RequestErr err) { |
|
||||||
if (err) |
|
||||||
nhlog::net()->warn( |
|
||||||
"failed to send verification to_device message: {} {}", |
|
||||||
err->matrix_error.error, |
|
||||||
static_cast<int>(err->status_code)); |
|
||||||
}); |
|
||||||
} else if (this->type == DeviceVerificationFlow::Type::RoomMsg && model_) { |
|
||||||
if constexpr (!std::is_same_v<T, mtx::events::msg::KeyVerificationRequest>) |
|
||||||
msg.relates_to = this->relation; |
|
||||||
(model_)->sendMessageEvent(msg, mtx::events::to_device_content_to_type<T>); |
|
||||||
} |
|
||||||
|
|
||||||
nhlog::net()->debug( |
|
||||||
"Sent verification step: {} in state: {}", |
|
||||||
mtx::events::to_string(mtx::events::to_device_content_to_type<T>), |
|
||||||
state().toStdString()); |
|
||||||
} |
|
||||||
}; |
|