Rename some filers and folder for clarity

This commit is contained in:
miruka
2019-12-18 04:44:19 -04:00
parent 127f724357
commit 2bdf21d528
137 changed files with 6 additions and 6 deletions

86
src/gui/Base/HAvatar.qml Normal file
View File

@@ -0,0 +1,86 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import "../Base"
Rectangle {
id: avatar
implicitWidth: theme.controls.avatar.size
implicitHeight: theme.controls.avatar.size
color: avatarImage.visible ? "transparent" : utils.hsluv(
name ? utils.hueFrom(name) : 0,
name ? theme.controls.avatar.background.saturation : 0,
theme.controls.avatar.background.lightness,
theme.controls.avatar.background.opacity
)
property string name
property alias mxc: avatarImage.mxc
property alias toolTipMxc: avatarToolTipImage.mxc
property alias sourceOverride: avatarImage.sourceOverride
property alias toolTipSourceOverride: avatarToolTipImage.sourceOverride
property alias fillMode: avatarImage.fillMode
property alias animate: avatarImage.animate
readonly property alias hovered: hoverHandler.hovered
HLabel {
z: 1
anchors.centerIn: parent
visible: ! avatarImage.visible
text: name ? name.charAt(0) : "?"
font.pixelSize: parent.height / 1.4
color: utils.hsluv(
name ? utils.hueFrom(name) : 0,
name ? theme.controls.avatar.letter.saturation : 0,
theme.controls.avatar.letter.lightness,
theme.controls.avatar.letter.opacity
)
}
HMxcImage {
id: avatarImage
anchors.fill: parent
showProgressBar: false
visible: Boolean(sourceOverride || mxc)
z: 2
sourceSize.width: parent.width
sourceSize.height: parent.height
fillMode: Image.PreserveAspectCrop
animate: false
HoverHandler { id: hoverHandler }
HToolTip {
id: avatarToolTip
visible: ! avatarImage.broken &&
avatarImage.status !== Image.Error &&
avatarImage.width < dimension * 0.75 &&
(toolTipSourceOverride || toolTipMxc) &&
hoverHandler.hovered
delay: 1000
backgroundColor: theme.controls.avatar.hoveredImage.background
readonly property int dimension: Math.min(
mainUI.width / 1.25,
mainUI.height / 1.25,
theme.controls.avatar.hoveredImage.size +
background.border.width * 2,
)
contentItem: HMxcImage {
id: avatarToolTipImage
fillMode: Image.PreserveAspectCrop
mxc: avatarImage.mxc
sourceSize.width: avatarToolTip.dimension
sourceSize.height: avatarToolTip.dimension
width: avatarToolTip.dimension
height: avatarToolTip.dimension
}
}
}
}

125
src/gui/Base/HBox.qml Normal file
View File

@@ -0,0 +1,125 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
Rectangle {
id: box
color: theme.controls.box.background
implicitWidth: theme.controls.box.defaultWidth
implicitHeight: childrenRect.height
Keys.onReturnPressed: if (clickButtonOnEnter) enterClickButton()
Keys.onEnterPressed: Keys.onReturnPressed(event)
property alias buttonModel: buttonRepeater.model
property var buttonCallbacks: []
property string focusButton: ""
property string clickButtonOnEnter: ""
property bool fillAvailableHeight: false
property HButton firstButton: null
default property alias body: interfaceBody.data
function enterClickButton() {
for (let i = 0; i < buttonModel.length; i++) {
let btn = buttonRepeater.itemAt(i)
if (btn.enabled && btn.name === clickButtonOnEnter) btn.clicked()
}
}
HNumberAnimation on scale {
running: true
from: 0
to: 1
overshoot: 3
}
HColumnLayout {
id: mainColumn
width: parent.width
Binding on height {
value: box.height
when: box.fillAvailableHeight
}
HColumnLayout {
id: interfaceBody
spacing: theme.spacing * 1.5
Layout.margins: spacing
}
HGridLayout {
id: buttonGrid
visible: buttonModel.length > 0
flow: width >= buttonRepeater.childrenImplicitWidth ?
GridLayout.LeftToRight : GridLayout.TopToBottom
HRepeater {
id: buttonRepeater
model: []
onItemAdded:
if (index === 0) firstButton = buttonRepeater.itemAt(0)
onItemRemoved:
if (index === 0) firstButton = null
HButton {
id: button
text: modelData.text
icon.name: modelData.iconName || ""
icon.color: modelData.iconColor || (
name === "ok" || name === "apply" || name === "retry" ?
theme.colors.positiveBackground :
name === "cancel" ?
theme.colors.negativeBackground :
theme.icons.colorize
)
enabled:
modelData.enabled === undefined ?
true : modelData.enabled
loading: modelData.loading || false
disableWhileLoading:
modelData.disableWhileLoading === undefined ?
true : modelData.disableWhileLoading
onClicked: buttonCallbacks[name](button)
Keys.onLeftPressed: previous.forceActiveFocus()
Keys.onUpPressed: previous.forceActiveFocus()
Keys.onRightPressed: next.forceActiveFocus()
Keys.onDownPressed: next.forceActiveFocus()
Keys.onReturnPressed: if (button.enabled) button.clicked()
Keys.onEnterPressed: Keys.onReturnPressed(event)
Component.onCompleted:
if (name === focusButton) forceActiveFocus()
Layout.fillWidth: true
Layout.preferredHeight: theme.baseElementsHeight
property string name: modelData.name
property Item next: buttonRepeater.itemAt(
utils.numberWrapAt(index + 1, buttonRepeater.count),
)
property Item previous: buttonRepeater.itemAt(
utils.numberWrapAt(index - 1, buttonRepeater.count),
)
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Shapes 1.12
HCircleProgressBar {
indeterminate: true
dialWidth: 2
}

63
src/gui/Base/HButton.qml Normal file
View File

@@ -0,0 +1,63 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
Button {
id: button
enabled: ! button.loading
spacing: theme.spacing
topPadding: padded ? spacing / (circle ? 1.75 : 1.5) : 0
bottomPadding: topPadding
leftPadding: padded ? spacing / (circle ? 1.5 : 1) : 0
rightPadding: leftPadding
iconItem.svgName: loading ? "hourglass" : icon.name
icon.color: theme.icons.colorize
// Must be explicitely set to display correctly on KDE
implicitWidth: Math.max(
implicitBackgroundWidth + leftInset + rightInset,
implicitContentWidth + leftPadding + rightPadding
)
implicitHeight: Math.max(
implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding
)
// Prevent button from gaining focus and being highlighted on click
focusPolicy: Qt.TabFocus
background: HButtonBackground {
button: button
buttonTheme: theme.controls.button
radius: circle ? height : 0
color: backgroundColor
}
contentItem: HButtonContent {
id: contentItem
button: button
buttonTheme: theme.controls.button
}
readonly property alias iconItem: contentItem.icon
readonly property alias label: contentItem.label
property color backgroundColor: theme.controls.button.background
property bool disableWhileLoading: true
property bool loading: false
property bool circle: false
property bool padded: true
property HToolTip toolTip: HToolTip {
id: toolTip
visible: text && hovered
}
Binding on enabled {
when: disableWhileLoading && button.loading
value: false
}
}

View File

@@ -0,0 +1,31 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
Rectangle {
color: buttonTheme.background
opacity: loading ? theme.loadingElementsOpacity :
enabled ? 1 : theme.disabledElementsOpacity
property AbstractButton button
property QtObject buttonTheme
Behavior on opacity { HNumberAnimation {} }
Rectangle {
anchors.fill: parent
radius: parent.radius
color: button.checked ? buttonTheme.checkedOverlay :
button.enabled && button.pressed ? buttonTheme.pressedOverlay :
(button.enabled && button.hovered) || button.activeFocus ?
buttonTheme.hoveredOverlay :
"transparent"
Behavior on color { HColorAnimation { factor: 0.5 } }
}
}

View File

@@ -0,0 +1,91 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
HRowLayout {
id: buttonContent
spacing: button.spacing
opacity: loading ? theme.loadingElementsOpacity :
enabled ? 1 : theme.disabledElementsOpacity
property AbstractButton button
property QtObject buttonTheme
readonly property alias icon: icon
readonly property alias label: label
Behavior on opacity { HNumberAnimation {} }
HIcon {
property bool loading: button.loading || false
id: icon
svgName: button.icon.name
colorize: button.icon.color
cache: button.icon.cache
onLoadingChanged: if (! loading) resetAnimations.start()
Layout.fillHeight: true
Layout.alignment: Qt.AlignCenter
ParallelAnimation {
id: resetAnimations
HNumberAnimation { target: icon; property: "opacity"; to: 1 }
HNumberAnimation { target: icon; property: "rotation"; to: 0 }
}
HNumberAnimation on opacity {
id: blink
from: 1
to: 0.5
factor: 2
running: button.loading || false
onFinished: { [from, to] = [to, from]; start() }
}
SequentialAnimation {
running: button.loading || false
loops: Animation.Infinite
HPauseAnimation { factor: blink.factor * 8 }
HNumberAnimation {
id: rotation1
target: icon
property: "rotation"
from: 0
to: 180
factor: blink.factor
}
HPauseAnimation { factor: blink.factor * 8 }
HNumberAnimation {
target: rotation1.target
property: rotation1.property
from: rotation1.to
to: 360
factor: rotation1.factor
}
}
}
HLabel {
id: label
text: button.text
visible: Boolean(text)
color: buttonTheme.text
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
Layout.fillWidth: true
Layout.fillHeight: true
}
}

View File

@@ -0,0 +1,90 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
CheckBox {
id: box
checked: defaultChecked
spacing: theme.spacing
padding: 0
indicator: Rectangle {
opacity: box.enabled ? 1 : theme.disabledElementsOpacity + 0.2
implicitWidth: theme.controls.checkBox.boxSize
implicitHeight: implicitWidth
x: box.leftPadding
y: box.topPadding + box.availableHeight / 2 - height / 2
radius: theme.radius / 1.5
color: theme.controls.checkBox.boxBackground
border.color:
box.enabled && box.pressed ?
theme.controls.checkBox.boxPressedBorder :
(box.enabled && box.hovered) || box.activeFocus ?
theme.controls.checkBox.boxHoveredBorder :
theme.controls.checkBox.boxBorder
Behavior on border.color { HColorAnimation { factor: 0.5 } }
HIcon {
anchors.centerIn: parent
dimension: parent.width - 2
svgName: "check-mark"
colorize: theme.controls.checkBox.checkIconColorize
scale: box.checked ? 1 : 0
Behavior on scale {
HNumberAnimation {
overshoot: 4
easing.type: Easing.InOutBack
}
}
}
}
contentItem: HColumnLayout {
opacity: box.enabled ? 1 : theme.disabledElementsOpacity
HLabel {
id: mainText
text: box.text
color: theme.controls.checkBox.text
// Set a width on CheckBox for wrapping to work,
// e.g. by using Layout.fillWidth
wrapMode: Text.Wrap
leftPadding: box.indicator.width + box.spacing
verticalAlignment: Text.AlignVCenter
Layout.fillWidth: true
}
HLabel {
id: subtitleText
visible: Boolean(text)
color: theme.controls.checkBox.subtitle
font.pixelSize: theme.fontSize.small
wrapMode: mainText.wrapMode
leftPadding: mainText.leftPadding
verticalAlignment: mainText.verticalAlignment
Layout.fillWidth: true
}
}
property alias mainText: mainText
property alias subtitle: subtitleText
property bool defaultChecked: false
readonly property bool changed: checked !== defaultChecked
function reset() { checked = defaultChecked }
Behavior on opacity { HNumberAnimation { factor: 2 } }
}

View File

@@ -0,0 +1,48 @@
import QtQuick 2.12
import RadialBar 1.0
RadialBar {
id: bar
implicitWidth: 96 * (theme ? theme.uiScale : 1)
implicitHeight: implicitWidth
foregroundColor: theme.controls.circleProgressBar.background
progressColor: theme.controls.circleProgressBar.foreground
dialWidth: theme.controls.circleProgressBar.thickness
startAngle: 0
spanAngle: 360
from: 0
to: 1
value: 0
showText: true
textFont.pixelSize: theme ? theme.fontSize.big : 22
textColor: theme ? theme.controls.circleProgressBar.text : "white"
property alias from: bar.minValue
property alias to: bar.maxValue
property bool indeterminate: false
property real indeterminateSpan:
theme.controls.circleProgressBar.indeterminateSpan
Binding on value {
value: bar.to * bar.indeterminateSpan
when: bar.indeterminate
}
Binding on showText {
value: false
when: bar.indeterminate
}
HNumberAnimation on rotation {
running: bar.indeterminate
from: 0
to: 360
loops: Animation.Infinite
duration: theme ? (theme.animationDuration * 6) : 600
}
}

View File

@@ -0,0 +1,6 @@
import QtQuick 2.12
ColorAnimation {
property real factor: 1.0
duration: theme.animationDuration * factor
}

View File

@@ -0,0 +1,6 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
ColumnLayout {
spacing: 0
}

123
src/gui/Base/HDrawer.qml Normal file
View File

@@ -0,0 +1,123 @@
import QtQuick 2.13
import QtQuick.Controls 2.13
Drawer {
id: drawer
implicitWidth: horizontal ? calculatedSize * theme.uiScale : parent.width
implicitHeight: vertical ? calculatedSize * theme.uiScale : parent.height
// Prevents this: open a popup, make the window small enough for the
// drawer to collapse, then make it big again → popup is now behind drawer
z: -1
topPadding: 0
bottomPadding: 0
leftPadding: 0
rightPadding: 0
// FIXME: https://bugreports.qt.io/browse/QTBUG-59141
// dragMargin: parent.width / 2
interactive: collapse
position: 1
visible: ! collapse
modal: false
closePolicy: Popup.CloseOnEscape
background: Rectangle { id: bg; color: theme.colors.strongBackground }
property string saveName: ""
property var saveId: "ALL"
property var saveProperties: ["preferredSize"]
//
property alias color: bg.color
property int defaultSize: 300
property int preferredSize:
window.getState(this, "preferredSize", defaultSize)
property int minimumSize: resizeAreaSize
property int maximumSize:
horizontal ?
referenceSizeParent.width - theme.minimumSupportedWidth :
referenceSizeParent.height - theme.minimumSupportedHeight
//
property Item referenceSizeParent: parent
property bool collapse:
(horizontal ? window.width : window.height) < 400 * theme.uiScale
property int peekSizeWhileCollapsed:
horizontal ? referenceSizeParent.width : referenceSizeParent.height
property int resizeAreaSize: theme.spacing / 2
readonly property int calculatedSize:
collapse ?
peekSizeWhileCollapsed :
Math.max(minimumSize, Math.min(preferredSize, maximumSize))
//
readonly property int visibleSize: visible ? width * position : 0
readonly property bool horizontal:
edge === Qt.LeftEdge || edge === Qt.RightEdge
readonly property bool vertical: ! horizontal
Behavior on width {
enabled: horizontal && ! resizeMouseHandler.drag.active
NumberAnimation { duration: 100 }
}
Behavior on height {
enabled: vertical && ! resizeMouseHandler.drag.active
NumberAnimation { duration: 100 }
}
Item {
id: resizeArea
x: vertical || drawer.edge === Qt.RightEdge ? 0 : drawer.width-width
y: horizontal || drawer.edge !== Qt.TopEdge ? 0 : drawer.height-height
width: horizontal ? resizeAreaSize * theme.uiScale : parent.width
height: vertical ? resizeAreaSize * theme.uiScale : parent.height
z: 999
MouseArea {
id: resizeMouseHandler
anchors.fill: parent
enabled: ! drawer.collapse
acceptedButtons: Qt.LeftButton
preventStealing: true
hoverEnabled: true
cursorShape:
containsMouse || drag.active ?
(horizontal ? Qt.SizeHorCursor : Qt.SizeVerCursor) :
Qt.ArrowCursor
onMouseXChanged:
if (horizontal && pressed) {
drawer.preferredSize =
drawer.calculatedSize +
(drawer.edge === Qt.RightEdge ? -mouseX : mouseX)
}
onMouseYChanged:
if (vertical && pressed) {
drawer.preferredSize =
drawer.calculatedSize +
(drawer.edge === Qt.BottomEdge ? -mouseY : mouseY)
}
onReleased: window.saveState(drawer)
}
}
}

View File

@@ -0,0 +1,7 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
Flickable {
interactive: contentWidth > width || contentHeight > height
ScrollBar.vertical: ScrollBar {}
}

36
src/gui/Base/HFlow.qml Normal file
View File

@@ -0,0 +1,36 @@
import QtQuick 2.12
Flow {
populate: Transition {
id: addTrans
SequentialAnimation {
PropertyAction { property: "opacity"; value: 0 }
PauseAnimation {
duration:
addTrans.ViewTransition.index * theme.animationDuration / 2
}
ParallelAnimation {
HNumberAnimation { property: "opacity"; to: 1 }
HNumberAnimation { properties: "x,y"; from: 0 }
}
}
}
add: Transition {
ParallelAnimation {
HNumberAnimation { property: "opacity"; to: 1 }
HNumberAnimation { properties: "x,y"; from: 0 }
}
}
move: Transition {
ParallelAnimation {
// Ensure opacity goes to 1 if add transition is interrupted
HNumberAnimation { property: "opacity"; to: 1 }
HNumberAnimation { properties: "x,y" }
}
}
}

View File

@@ -0,0 +1,7 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
GridLayout {
rowSpacing: 0
columnSpacing: 0
}

35
src/gui/Base/HIcon.qml Normal file
View File

@@ -0,0 +1,35 @@
import QtQuick 2.12
import QtGraphicalEffects 1.12
Image {
id: icon
cache: true
asynchronous: true
fillMode: Image.PreserveAspectFit
visible: Boolean(svgName)
source: svgName ? `../../icons/${iconPack}/${svgName}.svg` : ""
sourceSize.width: svgName ? dimension : 0
sourceSize.height: svgName ? dimension : 0
property string svgName: ""
property bool small: false
property int dimension:
theme ?
(small ? theme.icons.smallDimension : theme.icons.dimension) :
(small ? 16 : 22)
property color colorize: theme.icons.colorize
property string iconPack: theme ? theme.icons.preferredPack : "thin"
layer.enabled: ! Qt.colorEqual(colorize, "transparent")
layer.effect: ColorOverlay {
color: icon.colorize
cached: icon.cache
Behavior on color { HColorAnimation {} }
}
}

90
src/gui/Base/HImage.qml Normal file
View File

@@ -0,0 +1,90 @@
import QtQuick 2.12
Image {
id: image
autoTransform: true
asynchronous: true
fillMode: Image.PreserveAspectFit
cache: ! (animate && animated) &&
(sourceSize.width + sourceSize.height) <= 512
property bool broken: false
property bool animate: true
property bool animated: utils.urlExtension(image.source) === "gif"
property alias showProgressBar: progressBarLoader.active
property bool inderterminateProgressBar: false
Component {
id: animatedImageComponent
AnimatedImage {
id: animatedImage
source: image.source
autoTransform: image.autoTransform
asynchronous: image.asynchronous
fillMode: image.fillMode
mirror: image.mirror
mipmap: image.mipmap
smooth: image.smooth
horizontalAlignment: image.horizontalAlignment
verticalAlignment: image.verticalAlignment
// Online GIFs won't be able to loop if cache is set to false,
// but caching GIFs is expansive.
cache: ! Qt.resolvedUrl(source).startsWith("file://")
paused: ! visible || window.hidden || userPaused
property bool userPaused: ! window.settings.media.autoPlayGIF
TapHandler {
onTapped: parent.userPaused = ! parent.userPaused
}
HIcon {
anchors.centerIn: parent
svgName: "play-overlay"
colorize: "transparent"
dimension: Math.min(
parent.width - theme.spacing * 2,
parent.height - theme.spacing * 2,
theme.controls.image.maxPauseIndicatorSize,
)
scale: parent.status === Image.Ready && parent.paused ? 1 : 0
Behavior on scale { HNumberAnimation { overshoot: 4 } }
}
}
}
HLoader {
anchors.fill: parent
sourceComponent: animate && animated ? animatedImageComponent : null
}
HLoader {
id: progressBarLoader
anchors.centerIn: parent
width: Math.min(parent.width, parent.height) * 0.5
height: width
active: image.status === Image.Loading
sourceComponent: HCircleProgressBar {
id: progressBar
value: image.progress
text: Math.round(value * 100) + "%"
Behavior on value { HNumberAnimation { factor: 2 } }
}
}
HIcon {
anchors.centerIn: parent
visible: broken || image.status === Image.Error
svgName: "broken-image"
colorize: theme.colors.negativeBackground
}
}

14
src/gui/Base/HLabel.qml Normal file
View File

@@ -0,0 +1,14 @@
import QtQuick.Controls 2.12
import QtQuick 2.12
Label {
font.family: theme.fontFamily.sans
font.pixelSize: theme.fontSize.normal
font.pointSize: -1
textFormat: Label.PlainText
color: theme.colors.text
linkColor: theme.colors.link
maximumLineCount: elide === Label.ElideNone ? Number.MAX_VALUE : 1
}

View File

@@ -0,0 +1,19 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
Column {
spacing: theme.spacing / 2
property alias label: fieldLabel
property alias field: textField
HLabel {
id: fieldLabel
}
HTextField {
id: textField
radius: 2
width: parent.width
}
}

View File

@@ -0,0 +1,28 @@
import QtQuick 2.12
import QSyncable 1.0
JsonListModel {
id: model
source: []
Component.onCompleted: if (! keyField) { throw "keyField not set" }
function toObject(itemList=listModel) {
let objList = []
for (let item of itemList) {
let obj = JSON.parse(JSON.stringify(item))
for (let role in obj) {
if (obj[role]["objectName"] !== undefined) {
obj[role] = toObject(item[role])
}
}
objList.push(obj)
}
return objList
}
function toJson() {
return JSON.stringify(toObject(), null, 4)
}
}

View File

@@ -0,0 +1,90 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
ListView {
id: listView
interactive: allowDragging
currentIndex: -1
keyNavigationWraps: true
highlightMoveDuration: theme.animationDuration
// Keep highlighted delegate at the center
highlightRangeMode: ListView.ApplyRange
preferredHighlightBegin: height / 2 - currentItemHeight
preferredHighlightEnd: height / 2 + currentItemHeight
maximumFlickVelocity: 4000
highlight: Rectangle {
color: theme.controls.listView.highlight
}
ScrollBar.vertical: ScrollBar {
visible: listView.interactive || ! listView.allowDragging
}
// Make sure to handle when a previous transition gets interrupted
add: Transition {
ParallelAnimation {
// ScriptAction { script: print("add") }
HNumberAnimation { property: "opacity"; from: 0; to: 1 }
HNumberAnimation { property: "scale"; from: 0; to: 1 }
}
}
move: Transition {
ParallelAnimation {
// ScriptAction { script: print("move") }
HNumberAnimation { property: "opacity"; to: 1 }
HNumberAnimation { property: "scale"; to: 1 }
HNumberAnimation { properties: "x,y" }
}
}
remove: Transition {
ParallelAnimation {
// ScriptAction { script: print("remove") }
HNumberAnimation { property: "opacity"; to: 0 }
HNumberAnimation { property: "scale"; to: 0 }
}
}
// displaced: move
displaced: Transition {
ParallelAnimation {
// ScriptAction { script: print("displaced") }
HNumberAnimation { property: "opacity"; to: 1 }
HNumberAnimation { property: "scale"; to: 1 }
HNumberAnimation { properties: "x,y" }
}
}
property bool allowDragging: true
property alias cursorShape: mouseArea.cursorShape
readonly property int currentItemHeight:
currentItem ? currentItem.height : 0
Connections {
target: listView
enabled: ! listView.allowDragging
// interactive gets temporarily set to true below to allow wheel scroll
onDraggingChanged: listView.interactive = false
}
MouseArea {
id: mouseArea
anchors.fill: parent
enabled: ! parent.allowDragging || cursorShape !== Qt.ArrowCursor
acceptedButtons: Qt.NoButton
onWheel: {
// Allow wheel usage, will be back to false on any drag attempt
parent.interactive = true
wheel.accepted = false
}
}
}

7
src/gui/Base/HLoader.qml Normal file
View File

@@ -0,0 +1,7 @@
import QtQuick 2.12
Loader {
id: loader
asynchronous: true
visible: status === Loader.Ready
}

31
src/gui/Base/HMenu.qml Normal file
View File

@@ -0,0 +1,31 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
Menu {
id: menu
padding: theme.controls.menu.borderWidth
implicitWidth: {
let result = 0
for (let i = 0; i < count; ++i) {
let item = itemAt(i)
if (! item.visible) continue
result = Math.max(item.implicitWidth, result)
}
return Math.min(result + menu.padding * 2, window.width)
}
background: Rectangle {
color: theme.controls.menu.background
border.color: theme.controls.menu.border
border.width: theme.controls.menu.borderWidth
}
onAboutToShow: previouslyFocused = window.activeFocusItem
onClosed: if (previouslyFocused) previouslyFocused.forceActiveFocus()
property var previouslyFocused: null
}

View File

@@ -0,0 +1,29 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
MenuItem {
id: menuItem
spacing: theme.spacing
leftPadding: spacing
rightPadding: leftPadding
topPadding: spacing / 1.75
bottomPadding: topPadding
height: visible ? implicitHeight : 0
readonly property alias iconItem: contentItem.icon
readonly property alias label: contentItem.label
background: HButtonBackground {
button: menuItem
buttonTheme: theme.controls.menuItem
}
contentItem: HButtonContent {
id: contentItem
button: menuItem
buttonTheme: theme.controls.menuItem
label.horizontalAlignment: Label.AlignLeft
}
}

View File

@@ -0,0 +1,56 @@
import QtQuick 2.12
HImage {
id: image
inderterminateProgressBar: isMxc
source: sourceOverride || (show ? cachedPath : "")
onWidthChanged: Qt.callLater(update)
onHeightChanged: Qt.callLater(update)
onVisibleChanged: Qt.callLater(update)
onMxcChanged: Qt.callLater(update)
property string mxc
property string sourceOverride: ""
property bool thumbnail: true
property var cryptDict: ({})
property bool show: false
property string cachedPath: ""
readonly property bool isMxc: mxc.startsWith("mxc://")
function update() {
let w = sourceSize.width || width
let h = sourceSize.height || height
if (! image.mxc || w < 1 || h < 1 ) {
show = false
return
}
if (! image) return // if it was destroyed
if (! isMxc) {
if (source !== mxc) source = mxc
show = image.visible
return
}
let method = image.thumbnail ? "get_thumbnail" : "get_media"
let args = image.thumbnail ?
[image.mxc, w, h, cryptDict] : [image.mxc, cryptDict]
py.callCoro("media_cache." + method, args, path => {
if (! image) return
if (image.cachedPath !== path) image.cachedPath = path
image.broken = false
image.show = image.visible
}, () => {
image.broken = true
},
)
}
}

View File

@@ -0,0 +1,33 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
HRowLayout {
property alias label: noticeLabel
property alias text: noticeLabel.text
property alias color: noticeLabel.color
property alias font: noticeLabel.font
property alias backgroundColor: noticeLabelBackground.color
property alias radius: noticeLabelBackground.radius
HLabel {
id: noticeLabel
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
padding: theme.spacing / 2
leftPadding: theme.spacing
rightPadding: leftPadding
opacity: width > 16 * theme.uiScale ? 1 : 0
background: Rectangle {
id: noticeLabelBackground
color: theme.controls.box.background
radius: theme.controls.box.radius
}
Layout.alignment: Qt.AlignCenter
Layout.preferredWidth: implicitWidth
Layout.maximumWidth: parent.width
}
}

View File

@@ -0,0 +1,10 @@
import QtQuick 2.12
NumberAnimation {
property real factor: 1.0
property real overshoot: 1.0
duration: theme.animationDuration * Math.max(overshoot / 1.7, 1.0) * factor
easing.type: overshoot > 1 ? Easing.OutBack : Easing.Linear
easing.overshoot: overshoot
}

73
src/gui/Base/HPage.qml Normal file
View File

@@ -0,0 +1,73 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../MainPane"
Page {
id: innerPage
default property alias columnData: contentColumn.data
property alias flickable: innerFlickable
property alias headerLabel: innerHeaderLabel
property var hideHeaderUnderHeight: null
property int currentSpacing:
Math.min(theme.spacing * width / 400, theme.spacing)
property bool becomeKeyboardFlickableTarget: true
background: null
header: Rectangle {
implicitWidth: parent ? parent.width : 0
color: theme.controls.header.background
height: innerHeaderLabel.text && (
! hideHeaderUnderHeight ||
window.height >=
hideHeaderUnderHeight +
theme.baseElementsHeight +
currentSpacing * 2
) ? theme.baseElementsHeight : 0
Behavior on height { HNumberAnimation {} }
visible: height > 0
HLabel {
id: innerHeaderLabel
anchors.fill: parent
textFormat: Text.StyledText
font.pixelSize: theme.fontSize.big
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
leftPadding: currentSpacing
rightPadding: leftPadding
}
}
leftPadding: currentSpacing < theme.spacing ? 0 : currentSpacing
rightPadding: leftPadding
Behavior on leftPadding { HNumberAnimation {} }
HFlickable {
id: innerFlickable
anchors.fill: parent
clip: true
contentWidth: parent.width
contentHeight: contentColumn.childrenRect.height
Component.onCompleted:
if (becomeKeyboardFlickableTarget) shortcuts.flickTarget = this
HColumnLayout {
id: contentColumn
width: innerFlickable.width
height: innerFlickable.height
}
}
}

View File

@@ -0,0 +1,7 @@
import QtQuick 2.12
PauseAnimation {
property real factor: 1.0
duration: theme.animationDuration * factor
}

35
src/gui/Base/HPopup.qml Normal file
View File

@@ -0,0 +1,35 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
Popup {
id: popup
anchors.centerIn: Overlay.overlay
modal: true
focus: true
padding: 0
margins: theme.spacing
enter: Transition {
HNumberAnimation { property: "scale"; from: 0; to: 1; overshoot: 4 }
}
exit: Transition {
HNumberAnimation { property: "scale"; to: 0 }
}
background: Rectangle {
color: theme.controls.popup.background
}
onAboutToShow: previouslyFocused = window.activeFocusItem
onClosed: if (previouslyFocused) previouslyFocused.forceActiveFocus()
property var previouslyFocused: null
readonly property int maximumPreferredWidth:
window.width - leftMargin - rightMargin - leftInset - rightInset
readonly property int maximumPreferredHeight:
window.height - topMargin - bottomMargin - topInset - bottomInset
}

View File

@@ -0,0 +1,44 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
ProgressBar {
id: bar
property color backgroundColor: theme.controls.progressBar.background
property color foregroundColor: theme.controls.progressBar.foreground
background: Rectangle {
implicitWidth: 200
implicitHeight: theme.controls.progressBar.height
color: backgroundColor
}
contentItem: Item {
implicitWidth: 200
implicitHeight: theme.controls.progressBar.height
Rectangle {
id: indicator
width: bar.indeterminate ?
parent.width / 8 : bar.visualPosition * parent.width
height: parent.height
color: foregroundColor
Behavior on color { HColorAnimation {} }
HNumberAnimation on x {
running: bar.visible && bar.indeterminate
duration: theme.animationDuration * 8
from: 0
to: bar.width - indicator.width
onStopped: if (bar.indeterminate) {
[from, to] = [to, from];
start()
} else {
indicator.x = 0
}
}
}
}
}

View File

@@ -0,0 +1,29 @@
import QtQuick.Controls 2.12
import QtQuick 2.12
Repeater {
id: repeater
readonly property int childrenImplicitWidth: {
let total = 0
for (let i = 0; i < repeater.count; i++) {
let item = repeater.itemAt(i)
if (item && item.implicitWidth) total += item.implicitWidth
}
return total
}
readonly property int childrenWidth: {
let total = 0
for (let i = 0; i < repeater.count; i++) {
let item = repeater.itemAt(i)
if (item && item.width) total += item.width
}
return total
}
}

View File

@@ -0,0 +1,14 @@
import QtQuick 2.12
HLabel {
// https://blog.shantanu.io/2015/02/15/creating-working-hyperlinks-in-qtquick-text/
id: label
textFormat: Text.RichText
onLinkActivated: Qt.openUrlExternally(link)
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
}
}

View File

@@ -0,0 +1,10 @@
import QtQuick 2.12
HAvatar {
name: displayName[0] === "#" && displayName.length > 1 ?
displayName.substring(1) :
displayName
property string displayName
}

View File

@@ -0,0 +1,6 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
RowLayout {
spacing: 0
}

View File

@@ -0,0 +1,108 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
ScrollView {
id: scrollView
opacity: enabled ? 1 : theme.disabledElementsOpacity
clip: true
ScrollBar.vertical.visible: contentHeight > height
// Set it only on component creation to avoid binding loops
Component.onCompleted: if (! text) {
text = window.getState(this, "text", "")
textArea.cursorPosition = text.length
}
onTextChanged: window.saveState(this)
default property alias textAreaData: textArea.data
property string saveName: ""
property var saveId: "ALL"
property var saveProperties: ["text"]
property alias backgroundColor: textAreaBackground.color
property alias placeholderText: textArea.placeholderText
property alias placeholderTextColor: textArea.placeholderTextColor
property alias area: textArea
property alias text: textArea.text
property var focusItemOnTab: null
property var disabledText: null
property string defaultText: ""
readonly property bool changed: text !== defaultText
function reset() { area.clear(); text = defaultText }
Behavior on opacity { HNumberAnimation {} }
TextArea {
id: textArea
text: defaultText
enabled: parent.enabled
leftPadding: theme.spacing
rightPadding: leftPadding
topPadding: theme.spacing / 1.5
bottomPadding: topPadding
readOnly: ! visible
selectByMouse: true
wrapMode: TextEdit.Wrap
font.family: theme.fontFamily.sans
font.pixelSize: theme.fontSize.normal
font.pointSize: -1
placeholderTextColor: theme.controls.textArea.placeholderText
color: theme.controls.textArea.text
background: Rectangle {
id: textAreaBackground
color: theme.controls.textArea.background
}
Keys.onPressed: if (
event.modifiers & Qt.AltModifier ||
event.modifiers & Qt.MetaModifier
) event.accepted = true
KeyNavigation.priority: KeyNavigation.BeforeItem
KeyNavigation.tab: focusItemOnTab
Binding on color {
value: "transparent"
when: disabledText !== null && ! textArea.enabled
}
Binding on placeholderTextColor {
value: "transparent"
when: disabledText !== null && ! textArea.enabled
}
Behavior on color { HColorAnimation {} }
Behavior on placeholderTextColor { HColorAnimation {} }
HLabel {
anchors.fill: parent
visible: opacity > 0
opacity: disabledText !== null && parent.enabled ? 0 : 1
text: disabledText || ""
leftPadding: parent.leftPadding
rightPadding: parent.rightPadding
topPadding: parent.topPadding
bottomPadding: parent.bottomPadding
wrapMode: parent.wrapMode
font.family: parent.font.family
font.pixelSize: parent.font.pixelSize
Behavior on opacity { HNumberAnimation {} }
}
}
}

View File

@@ -0,0 +1,140 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
TextEdit {
id: label
font.family: theme.fontFamily.sans
font.pixelSize: theme.fontSize.normal
color: theme.colors.text
textFormat: Label.PlainText
tabStopDistance: 4 * 4 // 4 spaces
readOnly: true
persistentSelection: true
activeFocusOnPress: false
focus: false
onLinkActivated: Qt.openUrlExternally(link)
Component.onCompleted: updateSelection()
// If index is a whole number, the label will get two \n before itself
// in container.joinedSelection. If it's a decimal number, if gets one \n.
property real index
property HSelectableLabelContainer container
property bool selectable: true
function updateSelection() {
if (! selectable && label.selectedText) {
label.deselect()
updateContainerSelectedTexts()
return
}
if (! selectable) return
if (! container.reversed &&
container.selectionStart <= container.selectionEnd ||
container.reversed &&
container.selectionStart > container.selectionEnd)
{
var first = container.selectionStart
var firstPos = container.selectionStartPosition
var last = container.selectionEnd
var lastPos = container.selectionEndPosition
} else {
var first = container.selectionEnd
var firstPos = container.selectionEndPosition
var last = container.selectionStart
var lastPos = container.selectionStartPosition
}
if (first === index && last === index) {
select(
label.positionAt(firstPos.x, firstPos.y),
label.positionAt(lastPos.x, lastPos.y),
)
} else if ((! container.reversed && first < index && index < last) ||
(container.reversed && first > index && index > last))
{
label.selectAll()
} else if (first === index) {
label.select(positionAt(firstPos.x, firstPos.y), length)
} else if (last === index) {
label.select(0, positionAt(lastPos.x, lastPos.y))
} else {
label.deselect()
}
updateContainerSelectedTexts()
}
function updateContainerSelectedTexts() {
container.selectedTexts[index] = selectedText
container.selectedTextsChanged()
}
function selectWordAt(position) {
container.clearSelection()
label.cursorPosition = positionAt(position.x, position.y)
label.selectWord()
updateContainerSelectedTexts()
}
function selectAllText() {
container.clearSelection()
label.selectAll()
updateContainerSelectedTexts()
}
Connections {
target: container
onSelectionInfoChanged: updateSelection()
onDeselectAll: deselect()
}
DropArea {
anchors.fill: parent
onPositionChanged: {
if (! container.selecting) {
container.clearSelection()
container.selectionStart = index
container.selectionStartPosition = Qt.point(drag.x, drag.y)
container.selecting = true
} else {
container.selectionEnd = index
container.selectionEndPosition = Qt.point(drag.x, drag.y)
}
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: {
tapCount === 2 ? selectWordAt(eventPoint.position) :
tapCount === 3 ? selectAllText() :
container.clearSelection()
}
}
PointHandler {
onActiveChanged:
active ? container.dragStarted() : container.dragStopped()
onPointChanged: container.dragPointChanged(point)
}
MouseArea {
anchors.fill: label
acceptedButtons: Qt.NoButton
cursorShape: label.hoveredLink ? Qt.PointingHandCursor : Qt.IBeamCursor
}
}

View File

@@ -0,0 +1,89 @@
import QtQuick 2.12
FocusScope {
signal deselectAll()
signal dragStarted()
signal dragStopped()
signal dragPointChanged(var eventPoint)
property bool reversed: false
property bool selecting: false
property real selectionStart: -1
property real selectionEnd: -1
property point selectionStartPosition: Qt.point(-1, -1)
property point selectionEndPosition: Qt.point(-1, -1)
property var selectedTexts: ({})
readonly property var selectionInfo: [
selectionStart, selectionStartPosition,
selectionEnd, selectionEndPosition,
]
readonly property string joinedSelection: {
let toCopy = []
for (let key of Object.keys(selectedTexts).sort()) {
if (! selectedTexts[key]) continue
// For some dumb reason, Object.keys convert the floats to strings
toCopy.push(Number.isInteger(parseFloat(key)) ? "\n\n" : "\n")
toCopy.push(selectedTexts[key])
}
if (reversed) toCopy.reverse()
return toCopy.join("").trim()
}
onJoinedSelectionChanged:
if (joinedSelection) Clipboard.selection = joinedSelection
onDragStarted: {
draggedItem.Drag.active = true
}
onDragStopped: {
draggedItem.Drag.drop()
draggedItem.Drag.active = false
selecting = false
}
onDragPointChanged: {
let pos = mapFromItem(
mainUI, eventPoint.scenePosition.x, eventPoint.scenePosition.y,
)
draggedItem.x = pos.x
draggedItem.y = pos.y
}
function clearSelection() {
selecting = false
selectionStart = -1
selectionEnd = -1
selectionStartPosition = Qt.point(-1, -1)
selectionEndPosition = Qt.point(-1, -1)
deselectAll()
}
// PointHandler and TapHandler won't activate if the press occurs inside
// a label child, so we need a Point/TapHandler inside them too.
PointHandler {
// We don't use a DragHandler because they have an unchangable minimum
// drag distance before they activate.
id: pointHandler
onActiveChanged: active ? dragStarted() : dragStopped()
onPointChanged: dragPointChanged(point)
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: clearSelection()
}
// This item will trigger the children labels's DropAreas
Item { id: draggedItem }
}

View File

@@ -0,0 +1,5 @@
import QtQuick 2.12
Shortcut {
context: Qt.ApplicationShortcut
}

72
src/gui/Base/HSlider.qml Normal file
View File

@@ -0,0 +1,72 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
Slider {
id: slider
leftPadding: 0
rightPadding: leftPadding
topPadding: 0
bottomPadding: topPadding
property bool enableRadius: true
property bool fullHeight: false
property color backgroundColor: theme.controls.slider.background
property color foregroundColor: theme.controls.slider.foreground
property alias toolTip: toolTip
property alias mouseArea: mouseArea
background: Rectangle {
color: backgroundColor
x: slider.leftPadding
y: slider.topPadding + slider.availableHeight / 2 - height / 2
implicitWidth: 200
implicitHeight: theme.controls.slider.height
width: slider.availableWidth
height: fullHeight ? slider.height : implicitHeight
radius: enableRadius ? theme.controls.slider.radius : 0
Rectangle {
width: slider.visualPosition * parent.width
height: parent.height
color: foregroundColor
radius: parent.radius
}
}
handle: Rectangle {
x: slider.leftPadding + slider.visualPosition *
(slider.availableWidth - width)
y: slider.topPadding + slider.availableHeight / 2 - height / 2
implicitWidth: theme.controls.slider.handle.size
implicitHeight: implicitWidth
radius: implicitWidth / 2
color: slider.pressed ?
theme.controls.slider.handle.pressedInside :
theme.controls.slider.handle.inside
border.color: slider.pressed ?
theme.controls.slider.handle.pressedBorder :
theme.controls.slider.handle.border
Behavior on color { HColorAnimation {} }
Behavior on border.color { HColorAnimation {} }
}
HToolTip {
id: toolTip
parent: slider.handle
visible: slider.pressed && text
delay: 0
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: slider.hovered ? Qt.PointingHandCursor : Qt.ArrowCursor
}
}

7
src/gui/Base/HSpacer.qml Normal file
View File

@@ -0,0 +1,7 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
Item {
Layout.fillWidth: true
Layout.fillHeight: true
}

View File

@@ -0,0 +1,27 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
SwipeView {
Component.onCompleted: if (! changed) {
if (becomeKeyboardTabsTarget) shortcuts.tabsTarget = this
setCurrentIndex(window.getState(this, "currentIndex", defaultIndex))
saveEnabled = true
}
onCurrentIndexChanged: if (saveEnabled) window.saveState(this)
property string saveName: ""
property var saveId: "ALL"
property var saveProperties: ["currentIndex"]
// Prevent onCurrentIndexChanged from running before Component.onCompleted
property bool saveEnabled: false
property bool becomeKeyboardTabsTarget: true
property int defaultIndex: 0
property bool changed: currentIndex !== defaultIndex
function reset() { setCurrentIndex(defaultIndex) }
}

7
src/gui/Base/HTabBar.qml Normal file
View File

@@ -0,0 +1,7 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
TabBar {
spacing: 0
position: TabBar.Header
}

View File

@@ -0,0 +1,56 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
TabButton {
id: button
spacing: theme.spacing
topPadding: spacing / 1.5
bottomPadding: topPadding
leftPadding: spacing
rightPadding: leftPadding
iconItem.svgName: loading ? "hourglass" : icon.name
icon.color: theme.icons.colorize
implicitWidth: Math.max(
implicitBackgroundWidth + leftInset + rightInset,
// FIXME: why is *2 needed to not get ellided text in AddAccount page?
implicitContentWidth + leftPadding * 2 + rightPadding * 2,
)
implicitHeight: Math.max(
implicitBackgroundHeight + topInset + bottomInset,
implicitContentHeight + topPadding + bottomPadding,
)
// Prevent button from gaining focus and being highlighted on click
focusPolicy: Qt.TabFocus
readonly property alias iconItem: contentItem.icon
readonly property alias label: contentItem.label
property color backgroundColor:
TabBar.index % 2 === 0 ?
theme.controls.tab.background : theme.controls.tab.alternateBackground
property bool loading: false
property HToolTip toolTip: HToolTip {
id: toolTip
visible: text && hovered
}
background: HButtonBackground {
button: button
buttonTheme: theme.controls.tab
color: backgroundColor
}
contentItem: HButtonContent {
id: contentItem
button: button
buttonTheme: theme.controls.tab
}
}

View File

@@ -0,0 +1,38 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
HColumnLayout {
Layout.alignment: Qt.AlignCenter
Layout.fillWidth: false
Layout.fillHeight: false
Layout.maximumWidth: parent.width
property alias tabIndex: tabBar.currentIndex
property alias tabModel: tabRepeater.model
default property alias data: swipeView.contentData
HTabBar {
id: tabBar
Component.onCompleted: shortcuts.tabsTarget = this
Layout.fillWidth: true
Repeater {
id: tabRepeater
HTabButton { text: modelData }
}
}
SwipeView {
id: swipeView
clip: true
currentIndex: tabBar.currentIndex
interactive: false
Layout.fillWidth: true
Behavior on implicitWidth { HNumberAnimation {} }
Behavior on implicitHeight { HNumberAnimation {} }
}
}

104
src/gui/Base/HTextField.qml Normal file
View File

@@ -0,0 +1,104 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
TextField {
id: field
text: defaultText
opacity: enabled ? 1 : theme.disabledElementsOpacity
selectByMouse: true
leftPadding: theme.spacing
rightPadding: leftPadding
topPadding: theme.spacing / 1.5
bottomPadding: topPadding
font.family: theme.fontFamily.sans
font.pixelSize: theme.fontSize.normal
font.pointSize: -1
placeholderTextColor: theme.controls.textField.placeholderText
color: activeFocus ?
theme.controls.textField.focusedText :
theme.controls.textField.text
background: Rectangle {
id: textFieldBackground
color: field.activeFocus ? focusedBackgroundColor : backgroundColor
border.color: error ? errorBorder :
field.activeFocus ? focusedBorderColor : borderColor
border.width: bordered ? theme.controls.textField.borderWidth : 0
Behavior on color { HColorAnimation { factor: 0.25 } }
Behavior on border.color { HColorAnimation { factor: 0.25 } }
}
// Set it only on component creation to avoid binding loops
Component.onCompleted: if (! text) {
text = window.getState(this, "text", "")
cursorPosition = text.length
}
onTextChanged: window.saveState(this)
Keys.onPressed: if (
event.modifiers & Qt.AltModifier ||
event.modifiers & Qt.MetaModifier
) event.accepted = true // XXX Still needed?
property string saveName: ""
property var saveId: "ALL"
property var saveProperties: ["text"]
property bool error: false
property alias radius: textFieldBackground.radius
property bool bordered: true
property color backgroundColor: theme.controls.textField.background
property color borderColor: theme.controls.textField.border
property color errorBorder: theme.controls.textField.errorBorder
property color focusedBackgroundColor:
theme.controls.textField.focusedBackground
property color focusedBorderColor: theme.controls.textField.focusedBorder
property var disabledText: null
property string defaultText: ""
readonly property bool changed: text !== defaultText
function reset() { clear(); text = defaultText }
Binding on color {
value: "transparent"
when: disabledText !== null && ! field.enabled
}
Binding on placeholderTextColor {
value: "transparent"
when: disabledText !== null && ! field.enabled
}
Behavior on opacity { HNumberAnimation {} }
Behavior on color { HColorAnimation {} }
Behavior on placeholderTextColor { HColorAnimation {} }
HLabel {
anchors.fill: parent
visible: opacity > 0
opacity: disabledText !== null && parent.enabled ? 0 : 1
text: disabledText || ""
leftPadding: parent.leftPadding
rightPadding: parent.rightPadding
topPadding: parent.topPadding
bottomPadding: parent.bottomPadding
wrapMode: parent.wrapMode
font.family: parent.font.family
font.pixelSize: parent.font.pixelSize
Behavior on opacity { HNumberAnimation {} }
}
}

110
src/gui/Base/HTile.qml Normal file
View File

@@ -0,0 +1,110 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
HButton {
id: tile
signal leftClicked()
signal rightClicked()
default property alias additionalData: contentItem.data
property real contentOpacity: 1
readonly property alias title: title
readonly property alias additionalInfo: additionalInfo
readonly property alias rightInfo: rightInfo
readonly property alias subtitle: subtitle
property alias contextMenu: contextMenuLoader.sourceComponent
property Component image
contentItem: HRowLayout {
id: contentItem
spacing: tile.spacing
opacity: tile.contentOpacity
HLoader {
sourceComponent: image
}
HColumnLayout {
Layout.fillWidth: true
HRowLayout {
spacing: tile.spacing
HLabel {
id: title
text: "Missing title"
elide: Text.ElideRight
verticalAlignment: Qt.AlignVCenter
Layout.fillWidth: true
Layout.fillHeight: true
}
HRowLayout {
id: additionalInfo
visible: visibleChildren.length > 0
}
HLabel {
id: rightInfo
font.pixelSize: theme.fontSize.small
color: theme.colors.halfDimText
visible: Layout.maximumWidth > 0
Layout.fillHeight: true
Layout.maximumWidth:
text && tile.width >= 160 * theme.uiScale ?
implicitWidth : 0
Behavior on Layout.maximumWidth { HNumberAnimation {} }
}
}
HRichLabel {
id: subtitle
textFormat: Text.StyledText
font.pixelSize: theme.fontSize.small
elide: Text.ElideRight
color: theme.colors.dimText
visible: Layout.maximumHeight > 0
Layout.maximumHeight: text ? implicitWidth : 0
Layout.fillWidth: true
Layout.fillHeight: true
Behavior on Layout.maximumHeight { HNumberAnimation {} }
}
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: leftClicked()
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: {
rightClicked()
if (contextMenu) contextMenuLoader.active = true
}
}
Connections {
enabled: contextMenuLoader.status === Loader.Ready
target: contextMenuLoader.item
onClosed: contextMenuLoader.active = false
}
HLoader {
id: contextMenuLoader
active: false
onLoaded: item.popup()
}
}

View File

@@ -0,0 +1,34 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
HTile {
id: tile
onActivated: view.currentIndex = model.index
onLeftClicked: {
view.highlightRangeMode = ListView.NoHighlightRange
view.highlightMoveDuration = 0
activated()
view.highlightRangeMode = ListView.ApplyRange
view.highlightMoveDuration = theme.animationDuration
}
signal activated()
property HListView view: ListView.view
property bool shouldBeCurrent: false
readonly property QtObject delegateModel: model
readonly property alias setCurrentTimer: setCurrentTimer
Timer {
id: setCurrentTimer
interval: 100
repeat: true
running: true
// Component.onCompleted won't work for this
onTriggered: if (shouldBeCurrent) view.currentIndex = model.index
}
}

58
src/gui/Base/HToolTip.qml Normal file
View File

@@ -0,0 +1,58 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
ToolTip {
id: toolTip
delay: theme.controls.toolTip.delay
padding: background.border.width
property alias label: label
property alias backgroundColor: background.color
readonly property bool hideNow: ! window.hovered
onHideNowChanged: if (visible && hideNow) toolTip.hide()
background: Rectangle {
id: background
color: theme.controls.toolTip.background
border.color: theme.controls.toolTip.border
border.width: theme.controls.toolTip.borderWidth
}
contentItem: HRowLayout {
HLabel {
id: label
color: theme.controls.toolTip.text
text: toolTip.text
wrapMode: Text.Wrap
leftPadding: theme.spacing / 1.5
rightPadding: leftPadding
topPadding: theme.spacing / 2
bottomPadding: topPadding
Layout.maximumWidth: Math.min(
window.width / 1.25, theme.fontSize.normal * 0.5 * 75,
)
}
}
enter: Transition {
HNumberAnimation { property: "opacity"; from: 0.0; to: 1.0 }
}
exit: Transition {
HNumberAnimation { property: "opacity"; to: 0.0 }
}
TapHandler {
onTapped: toolTip.hide()
}
HoverHandler {
onHoveredChanged: if (! hovered) toolTip.hide()
}
}

View File

@@ -0,0 +1,51 @@
import QtQuick 2.12
HAvatar {
name: displayName || userId.substring(1) // no leading @
property string userId
property string displayName
property int powerLevel: 0
property bool shiftMembershipIconPosition: true
property bool invited: false
readonly property bool admin: powerLevel >= 100
readonly property bool moderator: powerLevel >= 50 && ! admin
HLoader {
active: admin || moderator || invited
anchors.top: parent.top
anchors.left: parent.left
anchors.topMargin: shiftMembershipIconPosition ? -16 / 2 : 0
anchors.leftMargin: anchors.topMargin
z: 100
Behavior on anchors.topMargin { HNumberAnimation {} }
sourceComponent: HIcon {
small: true
svgName:
invited ? "user-invited" :
admin ? "user-power-100" :
"user-power-50"
colorize:
invited ? theme.chat.roomPane.member.invitedIcon :
admin ? theme.chat.roomPane.member.adminIcon :
theme.chat.roomPane.member.moderatorIcon
HoverHandler { id: membershipIcon }
HToolTip {
visible: membershipIcon.hovered
text:
invited ? qsTr("Invited") :
admin ? qsTr("Admin (%1 power)").arg(powerLevel) :
qsTr("Moderator (%1 power)").arg(powerLevel)
}
}
}
}

View File

@@ -0,0 +1,23 @@
import QtQuick 2.12
import QtAV 1.7
OSD {
id: osd
audioOnly: true
media: audioPlayer
implicitWidth: osd.width
implicitHeight: osd.height
property alias source: audioPlayer.source
MediaPlayer {
id: audioPlayer
autoLoad: window.settings.media.autoLoad
autoPlay: window.settings.media.autoPlay
volume: window.settings.media.defaultVolume / 100
muted: window.settings.media.startMuted
}
}

View File

@@ -0,0 +1,273 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtAV 1.7
import "../../Base"
HColumnLayout {
id: osd
visible: osdScaleTransform.yScale > 0
transform: Scale {
id: osdScaleTransform
yScale: audioOnly ||
osdHover.hovered ||
media.playbackState !== MediaPlayer.PlayingState ||
osd.showup ?
1 : 0
origin.y: osd.height
Behavior on yScale { HNumberAnimation {} }
}
property QtObject media: parent // QtAV.Video or QtAV.MediaPlayer
property bool audioOnly: false
property bool showup: false
property bool fullScreen: false
property real savedAspectRatio: 16 / 9
property int savedDuration: 0
readonly property real aspectRatio: media.sourceAspectRatio || 0
readonly property int duration: media.duration
readonly property int boundPosition:
savedDuration ?
Math.min(media.position, savedDuration) : media.position
onShowupChanged: if (showup) osdHideTimer.restart()
onDurationChanged: if (duration) savedDuration = duration
onAspectRatioChanged: if (aspectRatio) savedAspectRatio = aspectRatio
function togglePlay() {
media.playbackState === MediaPlayer.PlayingState ?
media.pause() : media.play()
}
function seekToPosition(pos) { // pos: 0.0 to 1.0
if (media.playbackState === MediaPlayer.StoppedState) media.play()
if (media.seekable) media.seek(pos * (savedDuration || boundPosition))
}
HoverHandler { id: osdHover }
Timer {
id: osdHideTimer
interval: window.settings.media.autoHideOSDAfterMsec
onTriggered: osd.showup = false
}
HSlider {
id: timeSlider
topPadding: 5
z: 1
to: savedDuration || boundPosition
backgroundColor: theme.mediaPlayer.progress.background
enableRadius: false
fullHeight: true
mouseArea.hoverEnabled: true
onMoved: seekToPosition(timeSlider.position)
Layout.fillWidth: true
Layout.preferredHeight: theme.mediaPlayer.progress.height
HToolTip {
id: previewToolTip
x: timeSlider.mouseArea.mouseX - width / 2
visible: ! audioOnly &&
preview.implicitWidth >=
previewLabel.implicitWidth + previewLabel.padding &&
preview.implicitHeight >=
previewLabel.implicitHeight + previewLabel.padding &&
! timeSlider.pressed && timeSlider.mouseArea.containsMouse
readonly property int wantTimestamp:
visible ?
savedDuration *
(timeSlider.mouseArea.mouseX / timeSlider.mouseArea.width) :
-1
Timer {
interval: 300
running: previewToolTip.visible
repeat: true
triggeredOnStart: true
onTriggered: preview.timestamp = previewToolTip.wantTimestamp
}
contentItem: VideoPreview {
id: preview
implicitHeight: Math.min(
theme.mediaPlayer.hoverPreview.maxHeight,
media.height - osd.height - theme.spacing
)
implicitWidth: Math.min(
implicitHeight * savedAspectRatio,
media.width - theme.spacing,
)
file: media.source
HLabel {
id: previewLabel
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.margins: padding / 4
text: utils.formatDuration(previewToolTip.wantTimestamp)
padding: theme.spacing / 2
opacity: previewToolTip.wantTimestamp === -1 ? 0 : 1
background: Rectangle {
color: theme.mediaPlayer.controls.background
radius: theme.radius
}
}
}
}
Binding on value {
value: boundPosition
when: ! timeSlider.pressed
}
}
Rectangle {
color: theme.mediaPlayer.controls.background
Layout.fillWidth: true
Layout.preferredHeight: childrenRect.height
HRowLayout {
width: parent.width
OSDButton {
readonly property string mode:
media.playbackState === MediaPlayer.StoppedState &&
savedDuration &&
boundPosition >= savedDuration - 500 ?
"restart" :
media.playbackState === MediaPlayer.PlayingState ? "pause" :
"play"
icon.name: "player-" + mode
toolTip.text: qsTr(
mode === "play" ? "Play" :
mode === "pause" ? "Pause" :
"Restart"
)
onClicked: togglePlay()
}
// OSDButton {
// icon.name: "player-loop"
// visible: false
// }
OSDButton {
id: volumeButton
icon.name: "player-volume-" + (
media.muted ? "mute" : media.volume > 0.5 ? "high" : "low"
)
text: media.muted ? "" : Math.round(media.volume * 100)
toolTip.text: media.muted ? qsTr("Unmute") : qsTr("Mute")
onClicked: media.muted = ! media.muted
}
HSlider {
value: media.volume
onMoved: media.volume = value
visible: Layout.preferredWidth > 0
Layout.preferredWidth:
! media.muted &&
(hovered || pressed || volumeButton.hovered) ?
theme.mediaPlayer.controls.volumeSliderWidth : 0
Layout.fillHeight: true
Behavior on Layout.preferredWidth { HNumberAnimation {} }
}
OSDButton {
id: speedButton
icon.name: "player-speed"
text: qsTr("%1x").arg(utils.round(media.playbackRate))
toolTip.text: qsTr("Reset speed")
onClicked: media.playbackRate = 1
}
HSlider {
id: speedSlider
from: 0.2
to: 4
value: media.playbackRate
stepSize: 0.2
snapMode: HSlider.SnapAlways
onMoved: media.playbackRate = value
visible: Layout.preferredWidth > 0
Layout.preferredWidth:
(hovered || pressed || speedButton.hovered) ?
theme.mediaPlayer.controls.speedSliderWidth : 0
Layout.fillHeight: true
Behavior on Layout.preferredWidth { HNumberAnimation {} }
}
OSDLabel {
text: boundPosition && savedDuration ?
qsTr("%1 / %2")
.arg(utils.formatDuration(boundPosition))
.arg(utils.formatDuration(savedDuration)) :
boundPosition || savedDuration ?
utils.formatDuration(boundPosition || savedDuration) :
""
}
HSpacer {}
OSDLabel {
text: boundPosition && savedDuration ?
qsTr("-%1").arg(
utils.formatDuration(savedDuration - boundPosition)
) : ""
}
// OSDButton {
// icon.name: "player-track-video"
// }
// OSDButton {
// icon.name: "player-track-audio"
// }
// OSDButton {
// icon.name: "player-track-subtitle"
// }
OSDButton {
icon.name: "download"
toolTip.text: qsTr("Download")
onClicked: Qt.openUrlExternally(media.source)
}
OSDButton {
id: fullScreenButton
visible: ! audioOnly
icon.name: "player-fullscreen" + (fullScreen ? "-exit" : "")
toolTip.text: fullScreen ?
qsTr("Exit fullscreen") : qsTr("Fullscreen")
onClicked: fullScreen = ! fullScreen
}
}
}
}

View File

@@ -0,0 +1,8 @@
import QtQuick 2.12
import "../../Base"
HButton {
backgroundColor: "transparent"
iconItem.dimension: theme.mediaPlayer.controls.iconSize
}

View File

@@ -0,0 +1,9 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HLabel {
Layout.leftMargin: theme.spacing / 2
Layout.rightMargin: Layout.leftMargin
}

View File

@@ -0,0 +1,68 @@
import QtQuick 2.12
import QtQuick.Window 2.12
import QtAV 1.7
Video {
id: video
autoLoad: window.settings.media.autoLoad
autoPlay: window.settings.media.autoPlay
volume: window.settings.media.defaultVolume / 100
muted: window.settings.media.startMuted
implicitWidth: fullScreen ? window.width : 640
implicitHeight: fullScreen ? window.height : (width / osd.savedAspectRatio)
property bool hovered: false
property alias fullScreen: osd.fullScreen
property int oldVisibility: Window.Windowed
property QtObject oldParent: video.parent
onFullScreenChanged: {
if (fullScreen) {
oldVisibility = window.visibility
window.visibility = Window.FullScreen
oldParent = video.parent
video.parent = mainUI.fullScreenPopup.contentItem
mainUI.fullScreenPopup.open()
} else {
window.visibility = oldVisibility
mainUI.fullScreenPopup.close()
video.parent = oldParent
}
}
Connections {
target: mainUI.fullScreenPopup
onClosed: fullScreen = false
}
TapHandler {
onTapped: osd.togglePlay()
onDoubleTapped: video.fullScreen = ! video.fullScreen
}
MouseArea {
width: parent.width
height: parent.height - (osd.visible ? osd.height : 0)
acceptedButtons: Qt.NoButton
hoverEnabled: true
propagateComposedEvents: true
onContainsMouseChanged: video.hovered = containsMouse
onMouseXChanged: osd.showup = true
onMouseYChanged: osd.showup = true
}
OSD {
id: osd
width: parent.width
anchors.bottom: parent.bottom
}
}

View File

@@ -0,0 +1,93 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
Rectangle {
id: banner
implicitHeight: childrenRect.height
color: theme.controls.box.background
property alias avatar: bannerAvatar
property alias icon: bannerIcon
property alias labelText: bannerLabel.text
property alias buttonModel: bannerRepeater.model
property var buttonCallbacks: []
HGridLayout {
id: bannerGrid
width: parent.width
flow: bannerAvatarWrapper.width +
bannerIcon.width +
bannerLabel.implicitWidth +
bannerButtons.width >
parent.width ?
GridLayout.TopToBottom : GridLayout.LeftToRight
HRowLayout {
id: bannerRow
Rectangle {
id: bannerAvatarWrapper
color: "black"
Layout.preferredWidth: bannerAvatar.width
Layout.minimumHeight: bannerAvatar.height
Layout.preferredHeight: bannerLabel.height
HUserAvatar {
id: bannerAvatar
anchors.centerIn: parent
}
}
HIcon {
id: bannerIcon
visible: Boolean(svgName)
Layout.leftMargin: theme.spacing / 2
}
HLabel {
id: bannerLabel
textFormat: Text.StyledText
wrapMode: Text.Wrap
Layout.fillWidth: true
Layout.leftMargin: bannerIcon.Layout.leftMargin
Layout.rightMargin: Layout.leftMargin
}
HSpacer {}
}
HRowLayout {
HRowLayout {
id: bannerButtons
Repeater {
id: bannerRepeater
model: []
HButton {
id: button
text: modelData.text
icon.name: modelData.iconName
icon.color: modelData.iconColor || theme.icons.colorize
onClicked: buttonCallbacks[modelData.name](button)
Layout.preferredHeight: theme.baseElementsHeight
}
}
}
Rectangle {
id: buttonsRightPadding
color: theme.controls.button.background
visible: bannerGrid.flow === GridLayout.TopToBottom
Layout.fillWidth: true
Layout.fillHeight: true
}
}
}
}

View File

@@ -0,0 +1,51 @@
import QtQuick 2.12
import "../../Base"
Banner {
property string inviterId: chat.roomInfo.inviter
property string inviterName: chat.roomInfo.inviter_name
property string inviterAvatar: chat.roomInfo.inviter_avatar
color: theme.chat.inviteBanner.background
avatar.userId: inviterId
avatar.displayName: inviterName
avatar.mxc: inviterAvatar
labelText: qsTr("%1 invited you to this room").arg(
utils.coloredNameHtml(inviterName, inviterId)
)
buttonModel: [
{
name: "accept",
text: qsTr("Join"),
iconName: "invite-accept",
iconColor: theme.colors.positiveBackground
},
{
name: "decline",
text: qsTr("Decline"),
iconName: "invite-decline",
iconColor: theme.colors.negativeBackground
}
]
buttonCallbacks: ({
accept: button => {
button.loading = true
py.callClientCoro(
chat.userId, "join", [chat.roomId], () => {
button.loading = false
})
},
decline: button => {
button.loading = true
py.callClientCoro(
chat.userId, "room_leave", [chat.roomId], () => {
button.loading = false
})
}
})
}

View File

@@ -0,0 +1,39 @@
import QtQuick 2.12
import "../../Base"
Banner {
color: theme.chat.leftBanner.background
// TODO: avatar func auto
avatar.userId: chat.userId
avatar.displayName: chat.userInfo.display_name
avatar.mxc: chat.userInfo.avatar_url
labelText: qsTr("You are not part of this room anymore")
buttonModel: [
{
name: "forget",
text: qsTr("Forget"),
iconName: "room-forget",
iconColor: theme.colors.negativeBackground
}
]
buttonCallbacks: ({
forget: button => {
utils.makePopup(
"Popups/ForgetRoomPopup.qml",
mainUI, // Must not be destroyed with chat
{
userId: chat.userId,
roomId: chat.roomId,
roomName: chat.roomInfo.display_name,
},
obj => {
obj.onOk.connect(() => { button.loading = true })
},
false,
)
}
})
}

View File

@@ -0,0 +1,24 @@
import QtQuick 2.12
import "../../Base"
Banner {
color: theme.chat.unknownDevices.background
avatar.visible: false
icon.svgName: "unknown-devices-warning"
labelText: qsTr("Unknown devices are present in this encrypted room")
buttonModel: [
{
name: "inspect",
text: qsTr("Inspect"),
iconName: "unknown-devices-inspect",
}
]
buttonCallbacks: ({
inspect: button => {
print("show")
}
})
}

59
src/gui/Chat/Chat.qml Normal file
View File

@@ -0,0 +1,59 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
import "RoomPane"
Item {
id: chat
onFocusChanged: if (focus && loader.item) loader.item.composer.takeFocus()
property string userId: ""
property string roomId: ""
property bool loadingMessages: false
property bool ready: userInfo !== "waiting" && roomInfo !== "waiting"
readonly property var userInfo:
utils.getItem(modelSources["Account"] || [], "user_id", userId) ||
"waiting"
readonly property var roomInfo: utils.getItem(
modelSources[["Room", userId]] || [], "room_id", roomId
) || "waiting"
readonly property alias loader: loader
readonly property alias roomPane: roomPaneLoader.item
HLoader {
id: loader
anchors.rightMargin: ready ? roomPane.visibleSize : 0
anchors.fill: parent
visible:
ready ? ! roomPane.hidden || anchors.rightMargin < width : true
onLoaded: if (chat.focus) item.composer.takeFocus()
source: ready ? "ChatPage.qml" : ""
HLoader {
anchors.centerIn: parent
width: 96 * theme.uiScale
height: width
source: opacity > 0 ? "../Base/HBusyIndicator.qml" : ""
opacity: ready ? 0 : 1
Behavior on opacity { HNumberAnimation { factor: 2 } }
}
}
HLoader {
id: roomPaneLoader
active: ready
sourceComponent: RoomPane {
id: roomPane
referenceSizeParent: chat
}
}
}

70
src/gui/Chat/ChatPage.qml Normal file
View File

@@ -0,0 +1,70 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
import "Banners"
import "Timeline"
import "FileTransfer"
HPage {
id: chatPage
leftPadding: 0
rightPadding: 0
// The target will be our EventList, not the page itself
becomeKeyboardFlickableTarget: false
readonly property alias composer: composer
RoomHeader {
Layout.fillWidth: true
}
LoadingRoomProgressBar {
Layout.fillWidth: true
}
EventList {
id: eventList
// Avoid a certain binding loop
Layout.minimumWidth: theme.minimumSupportedWidth
Layout.fillWidth: true
Layout.fillHeight: true
}
TypingMembersBar {
Layout.fillWidth: true
}
TransferList {
Layout.fillWidth: true
Layout.minimumHeight: implicitHeight
Layout.preferredHeight: implicitHeight * transferCount
Layout.maximumHeight: chatPage.height / 6
Behavior on Layout.preferredHeight { HNumberAnimation {} }
}
InviteBanner {
id: inviteBanner
visible: ! chat.roomInfo.left && inviterId
inviterId: chat.roomInfo.inviter_id
Layout.fillWidth: true
}
LeftBanner {
id: leftBanner
visible: chat.roomInfo.left
Layout.fillWidth: true
}
Composer {
id: composer
visible: ! chat.roomInfo.left &&
! chat.roomInfo.inviter_id
}
}

230
src/gui/Chat/Composer.qml Normal file
View File

@@ -0,0 +1,230 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
import "../Dialogs"
Rectangle {
property string indent: " "
property var aliases: window.settings.writeAliases
property string toSend: ""
property string writingUserId: chat.userId
readonly property var writingUserInfo:
utils.getItem(modelSources["Account"] || [], "user_id", writingUserId)
property bool textChangedSinceLostFocus: false
property alias textArea: areaScrollView.area
readonly property int cursorPosition:
textArea.cursorPosition
readonly property int cursorY:
textArea.text.substring(0, cursorPosition).split("\n").length - 1
readonly property int cursorX:
cursorPosition - lines.slice(0, cursorY).join("").length - cursorY
readonly property var lines: textArea.text.split("\n")
readonly property string lineText: lines[cursorY] || ""
readonly property string lineTextUntilCursor:
lineText.substring(0, cursorX)
readonly property int deleteCharsOnBackspace:
lineTextUntilCursor.match(/^ +$/) ?
lineTextUntilCursor.match(/ {1,4}/g).slice(-1)[0].length :
1
function takeFocus() { areaScrollView.forceActiveFocus() }
// property var pr: lineTextUntilCursor
// onPrChanged: print(
// "y", cursorY, "x", cursorX,
// "ltuc <" + lineTextUntilCursor + ">", "dob",
// deleteCharsOnBackspace, "m", lineTextUntilCursor.match(/^ +$/))
id: composer
Layout.fillWidth: true
Layout.minimumHeight: theme.baseElementsHeight
Layout.preferredHeight: areaScrollView.implicitHeight
Layout.maximumHeight: pageLoader.height / 2
color: theme.chat.composer.background
HRowLayout {
anchors.fill: parent
HUserAvatar {
id: avatar
userId: writingUserId
displayName: writingUserInfo.display_name
mxc: writingUserInfo.avatar_url
}
HScrollableTextArea {
id: areaScrollView
saveName: "composer"
saveId: [chat.roomId, writingUserId]
enabled: chat.roomInfo.can_send_messages
disabledText:
qsTr("You do not have permission to post in this room")
placeholderText: qsTr("Type a message...")
backgroundColor: "transparent"
area.tabStopDistance: 4 * 4 // 4 spaces
area.focus: true
Layout.fillHeight: true
Layout.fillWidth: true
function setTyping(typing) {
py.callClientCoro(
writingUserId,
"room_typing",
[chat.roomId, typing, 5000]
)
}
onTextChanged: {
if (utils.isEmptyObject(aliases)) {
writingUserId = Qt.binding(() => chat.userId)
toSend = text
setTyping(Boolean(text))
textChangedSinceLostFocus = true
return
}
let foundAlias = null
for (let [user, writing_alias] of Object.entries(aliases)) {
if (text.startsWith(writing_alias + " ")) {
writingUserId = user
foundAlias = new RegExp("^" + writing_alias + " ")
break
}
}
if (foundAlias) {
toSend = text.replace(foundAlias, "")
setTyping(Boolean(text))
textChangedSinceLostFocus = true
return
}
writingUserId = Qt.binding(() => chat.userId)
toSend = text
let vals = Object.values(aliases)
let longestAlias =
vals.reduce((a, b) => a.length > b.length ? a: b)
let textNotStartsWithAnyAlias =
! vals.some(a => a.startsWith(text))
let textContainsCharNotInAnyAlias =
vals.every(a => text.split("").some(c => ! a.includes(c)))
// Only set typing when it's sure that the user will not use
// an alias and has written something
if (toSend &&
(text.length > longestAlias.length ||
textNotStartsWithAnyAlias ||
textContainsCharNotInAnyAlias))
{
setTyping(Boolean(text))
textChangedSinceLostFocus = true
}
}
area.onEditingFinished: { // when lost focus
if (text && textChangedSinceLostFocus) {
setTyping(false)
textChangedSinceLostFocus = false
}
}
area.onSelectedTextChanged: if (area.selectedText) {
eventList.selectableLabelContainer.clearSelection()
}
Component.onCompleted: {
area.Keys.onReturnPressed.connect(ev => {
ev.accepted = true
if (ev.modifiers & Qt.ShiftModifier ||
ev.modifiers & Qt.ControlModifier ||
ev.modifiers & Qt.AltModifier)
{
let indents = 0
let parts = lineText.split(indent)
for (const [i, part] of parts.entries()) {
if (i === parts.length - 1 || part) { break }
indents += 1
}
let add = indent.repeat(indents)
textArea.insert(cursorPosition, "\n" + add)
return
}
if (textArea.text === "") { return }
let args = [chat.roomId, toSend]
py.callClientCoro(writingUserId, "send_text", args)
area.clear()
})
area.Keys.onEnterPressed.connect(area.Keys.onReturnPressed)
area.Keys.onTabPressed.connect(ev => {
ev.accepted = true
textArea.insert(cursorPosition, indent)
})
area.Keys.onPressed.connect(ev => {
if (ev.matches(StandardKey.Copy) &&
eventList.selectableLabelContainer.joinedSelection
) {
ev.accepted = true
Clipboard.text =
eventList.selectableLabelContainer.joinedSelection
return
}
if (ev.modifiers === Qt.NoModifier &&
ev.key === Qt.Key_Backspace &&
! textArea.selectedText)
{
ev.accepted = true
textArea.remove(
cursorPosition - deleteCharsOnBackspace,
cursorPosition
)
}
})
}
}
HButton {
enabled: chat.roomInfo.can_send_messages
icon.name: "upload-file"
backgroundColor: theme.chat.composer.uploadButton.background
toolTip.text: qsTr("Send files")
onClicked: sendFilePicker.dialog.open()
Layout.fillHeight: true
SendFilePicker {
id: sendFilePicker
userId: chat.userId
roomId: chat.roomId
}
}
}
}

View File

@@ -0,0 +1,192 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HColumnLayout {
id: transfer
property bool paused: false
property int msLeft: model.time_left || 0
property int uploaded: model.uploaded
readonly property int speed: model.speed
readonly property int totalSize: model.total_size
readonly property string status: model.status
function cancel() {
// Python might take a sec to cancel, but we want
// immediate visual feedback
transfer.height = 0
// Python will delete this model item on cancel
py.call(py.getattr(model.task, "cancel"))
}
function pause() {
transfer.paused = ! transfer.paused
py.setattr(model.monitor, "pause", transfer.paused)
}
Behavior on height { HNumberAnimation {} }
HRowLayout {
HIcon {
svgName: "uploading"
colorize:
transfer.status === "Error" ? theme.colors.negativeBackground :
transfer.paused ? theme.colors.middleBackground :
theme.icons.colorize
Layout.preferredWidth: theme.baseElementsHeight
}
HLabel {
id: statusLabel
elide: expand ? Text.ElideNone : Text.ElideRight
wrapMode: expand ? Text.Wrap : Text.NoWrap
text:
status === "Uploading" ? fileName :
status === "Caching" ?
qsTr("Caching %1...").arg(fileName) :
model.error === "MatrixForbidden" ?
qsTr("Forbidden file type or quota exceeded: %1")
.arg(fileName) :
model.error === "MatrixTooLarge" ?
qsTr("Too large for this server: %1").arg(fileName) :
model.error === "IsADirectoryError" ?
qsTr("Can't upload folders, need a file: %1").arg(filePath) :
model.error === "FileNotFoundError" ?
qsTr("Non-existant file: %1").arg(filePath) :
model.error === "PermissionError" ?
qsTr("No permission to read this file: %1").arg(filePath) :
qsTr("Unknown error for %1: %2 - %3")
.arg(filePath).arg(model.error).arg(model.error_args)
topPadding: theme.spacing / 2
bottomPadding: topPadding
leftPadding: theme.spacing / 1.5
rightPadding: leftPadding
Layout.fillWidth: true
property bool expand: status === "Error"
readonly property string fileName:
model.filepath.split("/").slice(-1)[0]
readonly property string filePath:
model.filepath.replace(/^file:\/\//, "")
HoverHandler { id: statusLabelHover }
HToolTip {
text: parent.truncated ? parent.text : ""
visible: text && statusLabelHover.hovered
}
}
HSpacer {}
Repeater {
model: [
msLeft ? qsTr("-%1").arg(utils.formatDuration(msLeft)) : "",
speed ? qsTr("%1/s").arg(CppUtils.formattedBytes(speed)) : "",
qsTr("%1/%2").arg(CppUtils.formattedBytes(uploaded))
.arg(CppUtils.formattedBytes(totalSize)),
]
HLabel {
text: modelData
visible: text && Layout.preferredWidth > 0
leftPadding: theme.spacing / 1.5
rightPadding: leftPadding
Layout.preferredWidth:
status === "Uploading" ? implicitWidth : 0
Behavior on Layout.preferredWidth { HNumberAnimation {} }
}
}
HButton {
visible: Layout.preferredWidth > 0
padded: false
icon.name: transfer.paused ?
"upload-resume" : "upload-pause"
icon.color: transfer.paused ?
theme.colors.positiveBackground :
theme.colors.middleBackground
toolTip.text: transfer.paused ?
qsTr("Resume") : qsTr("Pause")
onClicked: transfer.pause()
Layout.preferredWidth:
status === "Uploading" ?
theme.baseElementsHeight : 0
Layout.fillHeight: true
Behavior on Layout.preferredWidth { HNumberAnimation {} }
}
HButton {
icon.name: "upload-cancel"
icon.color: theme.colors.negativeBackground
padded: false
onClicked: transfer.cancel()
Layout.preferredWidth: theme.baseElementsHeight
Layout.fillHeight: true
}
TapHandler {
onTapped: {
if (status === "Error") { transfer.cancel() }
else { statusLabel.expand = ! statusLabel.expand }
}
}
}
HProgressBar {
id: progressBar
visible: Layout.maximumHeight !== 0
indeterminate: status !== "Uploading"
value: uploaded
to: totalSize
// TODO: bake this in hprogressbar
foregroundColor:
status === "Error" ?
theme.controls.progressBar.errorForeground :
transfer.paused ?
theme.controls.progressBar.pausedForeground :
theme.controls.progressBar.foreground
Layout.fillWidth: true
Layout.maximumHeight:
status === "Error" && indeterminate ? 0 : -1
Behavior on value { HNumberAnimation { duration: 1200 } }
Behavior on Layout.maximumHeight { HNumberAnimation {} }
}
}

View File

@@ -0,0 +1,33 @@
import QtQuick 2.12
import "../../Base"
Rectangle {
implicitWidth: 800
implicitHeight: firstDelegate ? firstDelegate.height : 0
color: theme.chat.fileTransfer.background
opacity: implicitHeight ? 1 : 0
clip: true
property int delegateHeight: 0
readonly property var firstDelegate:
transferList.contentItem.visibleChildren[0]
readonly property alias transferCount: transferList.count
Behavior on implicitHeight { HNumberAnimation {} }
HListView {
id: transferList
anchors.fill: parent
model: HListModel {
keyField: "uuid"
source: modelSources[["Upload", chat.roomId]] || []
}
delegate: Transfer { width: transferList.width }
}
}

View File

@@ -0,0 +1,11 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
HProgressBar {
indeterminate: true
height: chat.loadingMessages ? implicitHeight : 0
visible: height > 0
Behavior on height { HNumberAnimation {} }
}

View File

@@ -0,0 +1,69 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
Rectangle {
implicitHeight: theme.baseElementsHeight
color: theme.chat.roomHeader.background
HRowLayout {
id: row
anchors.fill: parent
HRoomAvatar {
id: avatar
displayName: chat.roomInfo.display_name
mxc: chat.roomInfo.avatar_url
Layout.alignment: Qt.AlignTop
}
HLabel {
id: nameLabel
text: chat.roomInfo.display_name || qsTr("Empty room")
font.pixelSize: theme.fontSize.big
color: theme.chat.roomHeader.name
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
leftPadding: theme.spacing
rightPadding: leftPadding
Layout.preferredWidth: Math.min(
implicitWidth, row.width - row.spacing - avatar.width
)
Layout.fillHeight: true
HoverHandler { id: nameHover }
}
HRichLabel {
id: topicLabel
text: chat.roomInfo.topic
textFormat: Text.StyledText
font.pixelSize: theme.fontSize.small
color: theme.chat.roomHeader.topic
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
rightPadding: nameLabel.rightPadding
Layout.fillWidth: true
Layout.fillHeight: true
HoverHandler { id: topicHover }
}
HToolTip {
visible: text && (nameHover.hovered || topicHover.hovered)
label.textFormat: Text.StyledText
text: name && topic ? (`${name}<br>${topic}`) : (name || topic)
readonly property string name:
nameLabel.truncated ?
(`<b>${chat.roomInfo.display_name}</b>`) : ""
readonly property string topic:
topicLabel.truncated ? chat.roomInfo.topic : ""
}
}
}

View File

@@ -0,0 +1,39 @@
import QtQuick 2.12
import "../../Base"
HTileDelegate {
id: memberDelegate
backgroundColor: theme.chat.roomPane.member.background
contentOpacity:
model.invited ? theme.chat.roomPane.member.invitedOpacity : 1
image: HUserAvatar {
userId: model.user_id
displayName: model.display_name
mxc: model.avatar_url
powerLevel: model.power_level
shiftMembershipIconPosition: ! roomPane.collapsed
invited: model.invited
}
title.text: model.display_name || model.user_id
title.color:
memberDelegate.hovered ?
utils.nameColor(model.display_name || model.user_id.substring(1)) :
theme.chat.roomPane.member.name
subtitle.text: model.display_name ? model.user_id : ""
subtitle.color: theme.chat.roomPane.member.subtitle
contextMenu: HMenu {
HMenuItem {
icon.name: "copy-user-id"
text: qsTr("Copy user ID")
onTriggered: Clipboard.text = model.user_id
}
}
Behavior on title.color { HColorAnimation {} }
Behavior on contentOpacity { HNumberAnimation {} }
}

View File

@@ -0,0 +1,95 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HColumnLayout {
HListView {
id: memberList
clip: true
Layout.fillWidth: true
Layout.fillHeight: true
readonly property var originSource:
modelSources[["Member", chat.userId, chat.roomId]] || []
onOriginSourceChanged: filterLimiter.restart()
function filterSource() {
model.source =
utils.filterModelSource(originSource, filterField.text)
}
model: HListModel {
keyField: "user_id"
source: memberList.originSource
}
delegate: MemberDelegate {
width: memberList.width
}
Timer {
id: filterLimiter
interval: 16
onTriggered: memberList.filterSource()
}
}
HRowLayout {
Layout.minimumHeight: theme.baseElementsHeight
Layout.maximumHeight: Layout.minimumHeight
HTextField {
id: filterField
saveName: "memberFilterField"
saveId: chat.roomId
placeholderText: qsTr("Filter members")
backgroundColor: theme.chat.roomPane.filterMembers.background
bordered: false
opacity: width >= 16 * theme.uiScale ? 1 : 0
onTextChanged: filterLimiter.restart()
Layout.fillWidth: true
Layout.fillHeight: true
Behavior on opacity { HNumberAnimation {} }
}
HButton {
id: inviteButton
icon.name: "room-send-invite"
backgroundColor: theme.chat.roomPane.inviteButton.background
enabled: chat.roomInfo.can_invite
toolTip.text:
enabled ?
qsTr("Invite members to this room") :
qsTr("No permission to invite members in this room")
topPadding: 0 // XXX
bottomPadding: 0
onClicked: utils.makePopup(
"Popups/InviteToRoomPopup.qml",
chat,
{
userId: chat.userId,
roomId: chat.roomId,
roomName: chat.roomInfo.display_name,
invitingAllowed: Qt.binding(() => inviteButton.enabled),
},
)
// onEnabledChanged: if (openedPopup && ! enabled)
Layout.fillHeight: true
}
}
}

View File

@@ -0,0 +1,79 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HDrawer {
id: roomPane
saveName: "roomPane"
edge: Qt.RightEdge
defaultSize: buttonRepeater.childrenImplicitWidth
minimumSize:
buttonRepeater.count > 0 ? buttonRepeater.itemAt(0).implicitWidth : 0
background: HColumnLayout{
Rectangle {
color: theme.chat.roomPaneButtons.background
Layout.fillWidth: true
Layout.preferredHeight: theme.baseElementsHeight
}
Rectangle {
color: theme.chat.roomPane.background
Layout.fillWidth: true
Layout.fillHeight: true
}
}
HColumnLayout {
anchors.fill: parent
HFlow {
populate: null
Layout.fillWidth: true
HRepeater {
id: buttonRepeater
model: [
"members", "files", "notifications", "history", "settings"
]
HButton {
height: theme.baseElementsHeight
backgroundColor: "transparent"
icon.name: "room-view-" + modelData
toolTip.text: qsTr(
modelData.charAt(0).toUpperCase() + modelData.slice(1)
)
autoExclusive: true
checked: swipeView.currentIndex === 0 && index === 0 ||
swipeView.currentIndex === 1 && index === 4
enabled: ["members", "settings"].includes(modelData)
onClicked: swipeView.currentIndex = Math.min(index, 1)
}
}
}
HSwipeView {
id: swipeView
clip: true
interactive: ! roomPane.collapsed
saveName: "roomPaneView"
saveId: chat.roomId
Layout.fillWidth: true
Layout.fillHeight: true
MemberView {}
SettingsView { fillAvailableHeight: true }
}
}
}

View File

@@ -0,0 +1,132 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HBox {
color: "transparent"
buttonModel: [
{
name: "apply",
text: qsTr("Save"),
iconName: "apply",
// enabled: anyChange, TODO
enabled: false,
loading: saveFuture !== null,
disableWhileLoading: false,
},
{
name: "cancel",
text: qsTr("Cancel"),
iconName: "cancel",
enabled: anyChange || saveFuture !== null,
},
]
buttonCallbacks: ({
apply: button => {
if (saveFuture) saveFuture.cancel()
// TODO
},
cancel: button => {
if (saveFuture) {
saveFuture.cancel()
saveFuture = null
}
nameField.reset()
topicField.reset()
encryptCheckBox.reset()
requireInviteCheckbox.reset()
forbidGuestsCheckBox.reset()
},
})
property var saveFuture: null
readonly property bool anyChange:
nameField.changed || topicField.changed || encryptCheckBox.changed ||
requireInviteCheckbox.changed || forbidGuestsCheckBox.changed
HRoomAvatar {
id: avatar
displayName: chat.roomInfo.display_name
mxc: chat.roomInfo.avatar_url
// enabled: chat.roomInfo.can_set_avatar # put this in "change avatar"
Layout.fillWidth: true
Layout.preferredHeight: width
Layout.maximumWidth: 256 * theme.uiScale
}
HTextField {
id: nameField
placeholderText: qsTr("Room name")
maximumLength: 255
defaultText: chat.roomInfo.given_name
enabled: chat.roomInfo.can_set_name
Layout.fillWidth: true
}
HScrollableTextArea {
id: topicField
placeholderText: qsTr("Room topic")
defaultText: chat.roomInfo.plain_topic
enabled: chat.roomInfo.can_set_topic
Layout.fillWidth: true
}
HCheckBox {
id: encryptCheckBox
text: qsTr("Encrypt messages")
subtitle.text:
qsTr("Only you and those you trust will be able to read the " +
"conversation") +
`<br><font color="${theme.colors.middleBackground}">` +
(
chat.roomInfo.encrypted ?
qsTr("Cannot be disabled") :
qsTr("Cannot be disabled later!")
) +
"</font>"
subtitle.textFormat: Text.StyledText
defaultChecked: chat.roomInfo.encrypted
enabled: chat.roomInfo.can_set_encryption && ! chat.roomInfo.encrypted
Layout.fillWidth: true
}
HCheckBox {
id: requireInviteCheckbox
text: qsTr("Require being invited")
subtitle.text: qsTr("Users will need an invite to join the room")
defaultChecked: chat.roomInfo.invite_required
enabled: chat.roomInfo.can_set_join_rules
Layout.fillWidth: true
}
HCheckBox {
id: forbidGuestsCheckBox
text: qsTr("Forbid guests")
subtitle.text: qsTr("Users without an account won't be able to join")
defaultChecked: ! chat.roomInfo.guests_allowed
enabled: chat.roomInfo.can_set_guest_access
Layout.fillWidth: true
}
// HCheckBox { TODO
// text: qsTr("Make this room visible in the public room directory")
// checked: chat.roomInfo.published_in_directory
// Layout.fillWidth: true
// }
HSpacer {}
}

View File

@@ -0,0 +1,9 @@
import QtQuick 2.12
import "../../Base"
HNoticePage {
text: model.date.toLocaleDateString()
color: theme.chat.daybreak.text
backgroundColor: theme.chat.daybreak.background
radius: theme.chat.daybreak.radius
}

View File

@@ -0,0 +1,15 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtAV 1.7
import "../../Base"
import "../../Base/MediaPlayer"
AudioPlayer {
id: audio
HoverHandler {
onHoveredChanged:
eventDelegate.hoveredMediaTypeUrl =
hovered ? [EventDelegate.Media.Audio, audio.source] : []
}
}

View File

@@ -0,0 +1,182 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HRowLayout {
id: eventContent
spacing: theme.spacing / 1.25
layoutDirection: onRight ? Qt.RightToLeft: Qt.LeftToRight
readonly property string senderText:
hideNameLine ? "" : (
"<div class='sender'>" +
utils.coloredNameHtml(model.sender_name, model.sender_id) +
"</div>"
)
readonly property string contentText: utils.processedEventText(model)
readonly property string timeText: utils.formatTime(model.date, false)
readonly property string localEchoText:
model.is_local_echo ?
`&nbsp;<font size=${theme.fontSize.small}px></font>` :
""
readonly property bool pureMedia: ! contentText && linksRepeater.count
readonly property string hoveredLink: contentLabel.hoveredLink
readonly property bool hoveredSelectable: contentHover.hovered
readonly property int xOffset:
onRight ?
contentLabel.width - contentLabel.paintedWidth -
contentLabel.leftPadding - contentLabel.rightPadding :
0
// 600px max with a 16px font
readonly property int maxMessageWidth: theme.fontSize.normal * 0.5 * 75
TapHandler {
enabled: debugMode
onDoubleTapped:
utils.debug(eventContent, null, con => { con.runJS("json()") })
}
Item {
id: avatarWrapper
opacity: collapseAvatar ? 0 : 1
visible: ! hideAvatar
Layout.minimumWidth: theme.chat.message.avatarSize
Layout.minimumHeight:
collapseAvatar ? 1 :
smallAvatar ? theme.chat.message.collapsedAvatarSize :
Layout.minimumWidth
Layout.maximumWidth: Layout.minimumWidth
Layout.maximumHeight: Layout.minimumHeight
Layout.alignment: Qt.AlignTop
HUserAvatar {
id: avatar
userId: model.sender_id
displayName: model.sender_name
mxc: model.sender_avatar
width: parent.width
height: collapseAvatar ? 1 : theme.chat.message.avatarSize
}
}
HColumnLayout {
id: contentColumn
Layout.alignment: Qt.AlignVCenter
HSelectableLabel {
id: contentLabel
container: selectableLabelContainer
index: model.index
visible: ! pureMedia
topPadding: theme.spacing / 1.75
bottomPadding: topPadding
leftPadding: eventContent.spacing
rightPadding: leftPadding
color: model.event_type === "RoomMessageNotice" ?
theme.chat.message.noticeBody :
theme.chat.message.body
font.italic: model.event_type === "RoomMessageEmote"
wrapMode: TextEdit.Wrap
textFormat: Text.RichText
text:
// CSS
theme.chat.message.styleInclude +
// Sender name
eventContent.senderText +
// Message body
eventContent.contentText +
// Time
// For some reason, if there's only one space,
// times will be on their own lines most of the time.
" " +
`<font size=${theme.fontSize.small}px ` +
`color=${theme.chat.message.date}>` +
timeText +
"</font>" +
// Local echo icon
(model.is_local_echo ?
`&nbsp;<font size=${theme.fontSize.small}px></font>` : "")
transform: Translate { x: xOffset }
Layout.maximumWidth: eventContent.maxMessageWidth
Layout.fillWidth: true
function selectAllText() {
// Select the message body without the date or name
container.clearSelection()
contentLabel.select(
0,
contentLabel.length -
timeText.length - 1 // - 1: separating space
)
contentLabel.updateContainerSelectedTexts()
}
HoverHandler { id: contentHover }
Rectangle {
width: Math.max(
parent.paintedWidth +
parent.leftPadding + parent.rightPadding,
linksRepeater.childrenWidth +
(pureMedia ? 0 : parent.leftPadding + parent.rightPadding),
)
height: contentColumn.height
z: -1
color: isOwn?
theme.chat.message.ownBackground :
theme.chat.message.background
Rectangle {
visible: model.event_type === "RoomMessageNotice"
width: theme.chat.message.noticeLineWidth
height: parent.height
color: utils.nameColor(
model.sender_name || model.sender_id.substring(1),
)
}
}
}
HRepeater {
id: linksRepeater
model: eventDelegate.currentModel.links
EventMediaLoader {
singleMediaInfo: eventDelegate.currentModel
mediaUrl: modelData
showSender: pureMedia ? senderText : ""
showDate: pureMedia ? timeText : ""
showLocalEcho: pureMedia ? localEchoText : ""
transform: Translate { x: xOffset }
Layout.bottomMargin: pureMedia ? 0 : contentLabel.bottomPadding
Layout.leftMargin: pureMedia ? 0 : contentLabel.leftPadding
Layout.rightMargin: pureMedia ? 0 : contentLabel.rightPadding
Layout.preferredWidth: item ? item.width : -1
Layout.preferredHeight: item ? item.height : -1
}
}
}
HSpacer {}
}

View File

@@ -0,0 +1,181 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HColumnLayout {
id: eventDelegate
width: eventList.width
enum Media { Page, File, Image, Video, Audio }
property var hoveredMediaTypeUrl: []
// Remember timeline goes from newest message at index 0 to oldest
readonly property var previousModel: eventList.model.get(model.index + 1)
readonly property var nextModel: eventList.model.get(model.index - 1)
readonly property QtObject currentModel: model
property bool isOwn: chat.userId === model.sender_id
property bool onRight: eventList.ownEventsOnRight && isOwn
property bool combine: eventList.canCombine(previousModel, model)
property bool talkBreak: eventList.canTalkBreak(previousModel, model)
property bool dayBreak: eventList.canDayBreak(previousModel, model)
readonly property bool smallAvatar:
eventList.canCombine(model, nextModel) &&
(model.event_type === "RoomMessageEmote" ||
! (model.event_type.startsWith("RoomMessage") ||
model.event_type.startsWith("RoomEncrypted")))
readonly property bool collapseAvatar: combine
readonly property bool hideAvatar: onRight
readonly property bool hideNameLine:
model.event_type === "RoomMessageEmote" ||
! (
model.event_type.startsWith("RoomMessage") ||
model.event_type.startsWith("RoomEncrypted")
) ||
onRight ||
combine
readonly property int cursorShape:
eventContent.hoveredLink || hoveredMediaTypeUrl.length > 0 ?
Qt.PointingHandCursor :
eventContent.hoveredSelectable ? Qt.IBeamCursor :
Qt.ArrowCursor
readonly property int separationSpacing:
dayBreak ? theme.spacing * 4 :
talkBreak ? theme.spacing * 6 :
combine ? theme.spacing / 2 :
theme.spacing * 2
// Needed because of eventList's MouseArea which steals the
// HSelectableLabel's MouseArea hover events
onCursorShapeChanged: eventList.cursorShape = cursorShape
function json() {
return JSON.stringify(
{
"model": utils.getItem(
modelSources[[
"Event", chat.userId, chat.roomId
]],
"client_id",
model.client_id
),
"source": py.getattr(model.source, "__dict__"),
},
null, 4)
}
function openContextMenu() {
contextMenu.media = eventDelegate.hoveredMediaTypeUrl
contextMenu.link = eventContent.hoveredLink
contextMenu.popup()
}
Item {
Layout.fillWidth: true
Layout.preferredHeight:
model.event_type === "RoomCreateEvent" ? 0 : separationSpacing
}
Daybreak {
visible: dayBreak
Layout.fillWidth: true
Layout.minimumWidth: parent.width
}
Item {
visible: dayBreak
Layout.fillWidth: true
Layout.preferredHeight: separationSpacing
}
EventContent {
id: eventContent
Layout.fillWidth: true
Behavior on x { HNumberAnimation {} }
}
TapHandler {
acceptedButtons: Qt.RightButton
onTapped: openContextMenu()
}
HMenu {
id: contextMenu
property var media: []
property string link: ""
onClosed: { media = []; link = "" }
HMenuItem {
id: copyMedia
icon.name: "copy-link"
text:
contextMenu.media.length < 1 ? "" :
contextMenu.media[0] === EventDelegate.Media.Page ?
qsTr("Copy page address") :
contextMenu.media[0] === EventDelegate.Media.File ?
qsTr("Copy file address") :
contextMenu.media[0] === EventDelegate.Media.Image ?
qsTr("Copy image address") :
contextMenu.media[0] === EventDelegate.Media.Video ?
qsTr("Copy video address") :
contextMenu.media[0] === EventDelegate.Media.Audio ?
qsTr("Copy audio address") :
qsTr("Copy media address")
visible: Boolean(text)
onTriggered: Clipboard.text = contextMenu.media[1]
}
HMenuItem {
id: copyLink
icon.name: "copy-link"
text: qsTr("Copy link address")
visible: Boolean(contextMenu.link)
onTriggered: Clipboard.text = contextMenu.link
}
HMenuItem {
icon.name: "copy-text"
text: qsTr("Copy text")
visible: enabled || (! copyLink.visible && ! copyMedia.visible)
enabled: Boolean(selectableLabelContainer.joinedSelection)
onTriggered:
Clipboard.text = selectableLabelContainer.joinedSelection
}
HMenuItem {
icon.name: "clear-messages"
text: qsTr("Clear messages")
onTriggered: utils.makePopup(
"Popups/ClearMessagesPopup.qml",
chat,
{userId: chat.userId, roomId: chat.roomId},
)
}
}
}

View File

@@ -0,0 +1,42 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HTile {
id: file
width: Math.min(
eventDelegate.width,
eventContent.maxMessageWidth,
Math.max(theme.chat.message.fileMinWidth, implicitWidth),
)
height: Math.max(theme.chat.message.avatarSize, implicitHeight)
title.text: loader.singleMediaInfo.media_title || qsTr("Untitled file")
title.elide: Text.ElideMiddle
subtitle.text: CppUtils.formattedBytes(loader.singleMediaInfo.media_size)
image: HIcon {
svgName: "download"
}
onLeftClicked: download(Qt.openUrlExternally)
onRightClicked: eventDelegate.openContextMenu()
onHoveredChanged: {
if (! hovered) {
eventDelegate.hoveredMediaTypeUrl = []
return
}
eventDelegate.hoveredMediaTypeUrl = [
EventDelegate.Media.File,
loader.downloadedPath.replace(/^file:\/\//, "") || loader.mediaUrl
]
}
property EventMediaLoader loader
readonly property bool cryptDict: loader.singleMediaInfo.media_crypt_dict
readonly property bool isEncrypted: ! utils.isEmptyObject(cryptDict)
}

View File

@@ -0,0 +1,119 @@
import QtQuick 2.12
import "../../Base"
HMxcImage {
id: image
width: fitSize.width
height: fitSize.height
horizontalAlignment: Image.AlignLeft
animated: loader.singleMediaInfo.media_mime === "image/gif" ||
utils.urlExtension(loader.mediaUrl) === "gif"
thumbnail: ! animated && loader.thumbnailMxc
mxc: thumbnail ?
(loader.thumbnailMxc || loader.mediaUrl) :
(loader.mediaUrl || loader.thumbnailMxc)
cryptDict: thumbnail && loader.thumbnailMxc ?
loader.singleMediaInfo.thumbnail_crypt_dict :
loader.singleMediaInfo.media_crypt_dict
property EventMediaLoader loader
readonly property bool isEncrypted: ! utils.isEmptyObject(cryptDict)
readonly property real maxHeight:
theme.chat.message.thumbnailMaxHeightRatio
readonly property size fitSize: utils.fitSize(
// Minimum display size
theme.chat.message.thumbnailMinSize.width,
theme.chat.message.thumbnailMinSize.height,
// Real size
(
loader.singleMediaInfo.thumbnail_width ||
loader.singleMediaInfo.media_width ||
implicitWidth ||
800
) * theme.uiScale,
(
loader.singleMediaInfo.thumbnail_height ||
loader.singleMediaInfo.media_height ||
implicitHeight ||
600
) * theme.uiScale,
// Maximum display size
Math.min(
eventList.height * maxHeight,
eventContent.maxMessageWidth * Math.min(1, theme.uiScale), // XXX
),
eventList.height * maxHeight,
)
function getOpenUrl(callback) {
if (image.isEncrypted && loader.mediaUrl) {
loader.download(callback)
return
}
if (image.isEncrypted) {
callback(image.cachedPath)
return
}
let toOpen = loader.mediaUrl || loader.thumbnailMxc
let isMxc = toOpen.startsWith("mxc://")
isMxc ?
py.callClientCoro(chat.userId, "mxc_to_http", [toOpen], callback) :
callback(toOpen)
}
TapHandler {
onTapped: if (! image.animated) getOpenUrl(Qt.openUrlExternally)
onDoubleTapped: getOpenUrl(Qt.openUrlExternally)
}
HoverHandler {
id: hover
onHoveredChanged: {
if (! hovered) {
eventDelegate.hoveredMediaTypeUrl = []
return
}
eventDelegate.hoveredMediaTypeUrl = [
EventDelegate.Media.Image,
loader.downloadedPath.replace(/^file:\/\//, "") ||
loader.mediaUrl
]
}
}
EventImageTextBubble {
anchors.left: parent.left
anchors.top: parent.top
text: loader.showSender
textFormat: Text.StyledText
opacity: hover.hovered ? 0 : 1
visible: opacity > 0
Behavior on opacity { HNumberAnimation {} }
}
EventImageTextBubble {
anchors.right: parent.right
anchors.bottom: parent.bottom
text: [loader.showDate, loader.showLocalEcho].join(" ").trim()
textFormat: Text.StyledText
opacity: hover.hovered ? 0 : 1
visible: opacity > 0
Behavior on opacity { HNumberAnimation {} }
}
}

View File

@@ -0,0 +1,24 @@
import QtQuick 2.12
import "../../Base"
HLabel {
id: bubble
anchors.margins: theme.spacing / 4
topPadding: theme.spacing / 2
bottomPadding: topPadding
leftPadding: theme.spacing / 1.5
rightPadding: leftPadding
font.pixelSize: theme.fontSize.small
background: Rectangle {
color: Qt.hsla(0, 0, 0, 0.7)
radius: theme.radius
}
Binding on visible {
value: false
when: ! Boolean(bubble.text)
}
}

View File

@@ -0,0 +1,173 @@
import QtQuick 2.12
import "../../Base"
Rectangle {
property alias selectableLabelContainer: selectableLabelContainer
property alias eventList: eventList
color: theme.chat.eventList.background
HSelectableLabelContainer {
id: selectableLabelContainer
anchors.fill: parent
reversed: eventList.verticalLayoutDirection === ListView.BottomToTop
DragHandler {
target: null
onActiveChanged: if (! active) dragFlicker.speed = 0
onCentroidChanged: {
let left = centroid.pressedButtons & Qt.LeftButton
let vel = centroid.velocity.y
let pos = centroid.position.y
let dist = Math.min(selectableLabelContainer.height / 4, 50)
let boost = 20 * (pos < dist ? -pos : -(height - pos))
dragFlicker.speed =
left && vel && pos < dist ? 1000 + boost :
left && vel && pos > height - dist ? -1000 + -boost :
0
}
}
Timer {
id: dragFlicker
interval: 100
running: speed !== 0
repeat: true
onTriggered: {
if (eventList.verticalOvershoot !== 0) return
if (speed < 0 && eventList.atYEnd) return
if (eventList.atYBeggining) {
if (bouncedStart) { return } else { bouncedStart = true }
}
eventList.flick(0, speed * acceleration)
acceleration = Math.min(8, acceleration * 1.05)
}
onRunningChanged: if (! running) {
acceleration = 1.0
bouncedStart = false
eventList.cancelFlick()
eventList.returnToBounds()
}
property real speed: 0.0
property real acceleration: 1.0
property bool bouncedStart: false
}
HListView {
id: eventList
clip: true
allowDragging: false
anchors.fill: parent
anchors.leftMargin: theme.spacing
anchors.rightMargin: theme.spacing
topMargin: theme.spacing
bottomMargin: theme.spacing
verticalLayoutDirection: ListView.BottomToTop
// Keep x scroll pages cached, to limit images having to be
// reloaded from network.
cacheBuffer: height * 2
onYPosChanged:
if (canLoad && yPos < 0.1) Qt.callLater(loadPastEvents)
// When an invited room becomes joined, we should now be able to
// fetch past events.
onInviterChanged: canLoad = true
Component.onCompleted: shortcuts.flickTarget = eventList
property string inviter: chat.roomInfo.inviter || ""
property real yPos: visibleArea.yPosition
property bool canLoad: true
property bool ownEventsOnRight:
width < theme.chat.eventList.ownEventsOnRightUnderWidth
function canCombine(item, itemAfter) {
if (! item || ! itemAfter) return false
return Boolean(
! canTalkBreak(item, itemAfter) &&
! canDayBreak(item, itemAfter) &&
item.sender_id === itemAfter.sender_id &&
utils.minutesBetween(item.date, itemAfter.date) <= 5
)
}
function canTalkBreak(item, itemAfter) {
if (! item || ! itemAfter) return false
return Boolean(
! canDayBreak(item, itemAfter) &&
utils.minutesBetween(item.date, itemAfter.date) >= 20
)
}
function canDayBreak(item, itemAfter) {
if (itemAfter && itemAfter.event_type === "RoomCreateEvent")
return true
if (! item || ! itemAfter || ! item.date || ! itemAfter.date)
return false
return item.date.getDate() !== itemAfter.date.getDate()
}
function loadPastEvents() {
// try/catch blocks to hide pyotherside error when the
// component is destroyed but func is still running
try {
eventList.canLoad = false
chat.loadingMessages = true
py.callClientCoro(
chat.userId, "load_past_events", [chat.roomId],
moreToLoad => {
try {
eventList.canLoad = moreToLoad
// Call yPosChanged() to run this func again
// if the loaded messages aren't enough to fill
// the screen.
if (moreToLoad) yPosChanged()
chat.loadingMessages = false
} catch (err) {
return
}
}
)
} catch (err) {
return
}
}
model: HListModel {
keyField: "client_id"
source: modelSources[[
"Event", chat.userId, chat.roomId
]] || []
}
delegate: EventDelegate {}
}
}
HNoticePage {
text: qsTr("No messages to show yet")
visible: eventList.model.count < 1
anchors.fill: parent
}
}

View File

@@ -0,0 +1,89 @@
import QtQuick 2.12
import "../../Base"
HLoader {
id: loader
x: eventContent.spacing
onTypeChanged: {
if (type === EventDelegate.Media.Image) {
var file = "EventImage.qml"
} else if (type !== EventDelegate.Media.Page) {
var file = "EventFile.qml"
} else { return }
loader.setSource(file, {loader})
}
property QtObject singleMediaInfo
property string mediaUrl
property string showSender: ""
property string showDate: ""
property string showLocalEcho: ""
property string downloadedPath: ""
readonly property var imageExtensions: [
"bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm",
"tiff", "webp", "svg",
]
readonly property var videoExtensions: [
"3gp", "avi", "flv", "m4p", "m4v", "mkv", "mov", "mp4",
"mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv",
]
readonly property var audioExtensions: [
"pcm", "wav", "raw", "aiff", "flac", "m4a", "tta", "aac", "mp3",
"ogg", "oga", "opus",
]
readonly property int type: {
if (singleMediaInfo.event_type === "RoomAvatarEvent")
return EventDelegate.Media.Image
let mainType = singleMediaInfo.media_mime.split("/")[0].toLowerCase()
if (mainType === "image") return EventDelegate.Media.Image
if (mainType === "video") return EventDelegate.Media.Video
if (mainType === "audio") return EventDelegate.Media.Audio
let fileEvents = ["RoomMessageFile", "RoomEncryptedFile"]
if (fileEvents.includes(singleMediaInfo.event_type))
return EventDelegate.Media.File
// If this is a preview for a link in a normal message
let ext = utils.urlExtension(mediaUrl)
if (imageExtensions.includes(ext)) return EventDelegate.Media.Image
if (videoExtensions.includes(ext)) return EventDelegate.Media.Video
if (audioExtensions.includes(ext)) return EventDelegate.Media.Audio
return EventDelegate.Media.Page
}
readonly property string thumbnailMxc: singleMediaInfo.thumbnail_url
function download(callback) {
if (! loader.mediaUrl.startsWith("mxc://")) {
downloadedPath = loader.mediaUrl
callback(loader.mediaUrl)
return
}
if (! downloadedPath) print("Downloading " + loader.mediaUrl + " ...")
const args = [loader.mediaUrl, loader.singleMediaInfo.media_crypt_dict]
py.callCoro("media_cache.get_media", args, path => {
if (! downloadedPath) print("Done: " + path)
downloadedPath = path
callback(path)
})
}
}

View File

@@ -0,0 +1,13 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import QtAV 1.7
import "../../Base"
import "../../Base/MediaPlayer"
VideoPlayer {
id: video
onHoveredChanged:
eventDelegate.hoveredMediaTypeUrl =
hovered ? [EventDelegate.Media.Video, video.source] : []
}

View File

@@ -0,0 +1,50 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
Rectangle {
id: typingMembersBar
property alias label: typingLabel
color: theme.chat.typingMembers.background
implicitHeight: typingLabel.text ? rowLayout.height : 0
opacity: implicitHeight ? 1 : 0
Behavior on implicitHeight { HNumberAnimation {} }
HRowLayout {
id: rowLayout
spacing: theme.spacing
HIcon {
id: icon
svgName: "typing" // TODO: animate
Layout.fillHeight: true
Layout.leftMargin: rowLayout.spacing / 2
}
HLabel {
id: typingLabel
textFormat: Text.StyledText
elide: Text.ElideRight
text: {
let tm = chat.roomInfo.typing_members
if (tm.length === 0) return ""
if (tm.length === 1) return qsTr("%1 is typing...").arg(tm[0])
return qsTr("%1 and %2 are typing...")
.arg(tm.slice(0, -1).join(", ")).arg(tm.slice(-1)[0])
}
Layout.fillWidth: true
Layout.fillHeight: true
Layout.topMargin: rowLayout.spacing / 4
Layout.bottomMargin: rowLayout.spacing / 4
Layout.leftMargin: rowLayout.spacing / 2
Layout.rightMargin: rowLayout.spacing / 2
}
}
}

193
src/gui/DebugConsole.qml Normal file
View File

@@ -0,0 +1,193 @@
import QtQuick 2.12
import QtQuick.Window 2.12
import QtQuick.Layouts 1.12
import "Base"
HDrawer {
id: debugConsole
objectName: "debugConsole"
edge: Qt.TopEdge
x: horizontal ? 0 : referenceSizeParent.width / 2 - width / 2
y: vertical ? 0 : referenceSizeParent.height / 2 - height / 2
width: horizontal ? calculatedSize : Math.min(window.width, 720)
height: vertical ? calculatedSize : Math.min(window.height, 720)
defaultSize: 400
z: 9999
position: 0
property var previouslyFocused: null
property var target: null
property alias t: debugConsole.target
property var history: window.history.console
property alias his: debugConsole.history
property int historyEntry: -1
property int maxHistoryLength: 4096
property string help: qsTr(
`Javascript debugging console
Useful variables:
window, theme, settings, shortcuts, utils, mainUI, pageLoader
py Python interpreter
this The console itself
t Target item to debug for which this console was opened
his History, list of commands entered
Special commands:
.j OBJECT, .json OBJECT Print OBJECT as human-readable JSON
.t, .top Attach the console to the parent window's top
.b, .bottom Attach the console to the parent window's bottom
.l, .left Attach the console to the parent window's left
.r, .right Attach the console to the parent window's right
.h, .help Show this help`.replace(/^ {8}/gm, "")
)
readonly property alias commandsView: commandsView
Component.onCompleted: {
if (mainUI.shortcuts.debugConsole)
mainUI.shortcuts.debugConsole.destroy()
mainUI.shortcuts.debugConsole = debugConsole
position = 1
commandsView.model.insert(0, {
input: "t = " + String(target),
output: "",
error: false,
})
}
onVisibleChanged: {
if (visible) {
previouslyFocused = window.activeFocusItem
forceActiveFocus()
} else if (previouslyFocused) {
previouslyFocused.forceActiveFocus()
}
}
onHistoryEntryChanged:
inputField.text =
historyEntry === -1 ? "" : history.slice(-historyEntry - 1)[0]
function runJS(input) {
if (history.slice(-1)[0] !== input) {
history.push(input)
while (history.length > maxHistoryLength) history.shift()
window.historyChanged()
}
let output = ""
let error = false
try {
if ([".h", ".help"].includes(input)) {
output = debugConsole.help
} else if ([".t", ".top"].includes(input)) {
debugConsole.edge = Qt.TopEdge
} else if ([".b", ".bottom"].includes(input)) {
debugConsole.edge = Qt.BottomEdge
} else if ([".l", ".left"].includes(input)) {
debugConsole.edge = Qt.LeftEdge
} else if ([".r", ".right"].includes(input)) {
debugConsole.edge = Qt.RightEdge
} else if (input.startsWith(".j ") || input.startsWith(".json ")) {
output = JSON.stringify(eval(input.substring(2)), null, 4)
} else {
let result = eval(input)
output = result instanceof Array ?
"[" + String(result) + "]" : String(result)
}
} catch (err) {
error = true
output = err.toString()
}
commandsView.model.insert(0, { input, output, error })
}
HColumnLayout {
anchors.fill: parent
Keys.onEscapePressed: debugConsole.visible = false
HListView {
id: commandsView
spacing: theme.spacing
topMargin: theme.spacing
bottomMargin: topMargin
leftMargin: theme.spacing
rightMargin: leftMargin
clip: true
verticalLayoutDirection: ListView.BottomToTop
Layout.fillWidth: true
Layout.fillHeight: true
model: ListModel {}
delegate: HColumnLayout {
width: commandsView.width -
commandsView.leftMargin - commandsView.rightMargin
HLabel {
text: "> " + model.input
wrapMode: Text.Wrap
color: theme.chat.message.quote
font.family: theme.fontFamily.mono
visible: Boolean(model.input)
Layout.fillWidth: true
}
HLabel {
text: "" + model.output
wrapMode: Text.Wrap
color: model.error ?
theme.colors.errorText : theme.colors.text
font.family: theme.fontFamily.mono
visible: Boolean(model.output)
Layout.fillWidth: true
}
}
Rectangle {
z: -10
anchors.fill: parent
color: theme.colors.weakBackground
}
}
HTextField {
id: inputField
focus: true
onAccepted: if (text) { runJS(text); text = ""; historyEntry = -1 }
backgroundColor: Qt.hsla(0, 0, 0, 0.85)
bordered: false
placeholderText: qsTr("Javascript debug console - Try .help")
font.family: theme.fontFamily.mono
Keys.onUpPressed:
if (historyEntry + 1 < history.length ) historyEntry += 1
Keys.onDownPressed:
if (historyEntry - 1 >= -1) historyEntry -= 1
Layout.fillWidth: true
}
}
}

View File

@@ -0,0 +1,45 @@
import QtQuick 2.12
import Qt.labs.platform 1.1
import "../Popups"
HFileDialogOpener {
fill: false
dialog.title: qsTr("Save decryption keys file as...")
dialog.fileMode: FileDialog.SaveFile
onFilePicked: {
exportPasswordPopup.file = file
exportPasswordPopup.open()
}
// This is used for the SignOutPopup to know when the export is done
// so it can close
signal done()
property string userId: ""
property bool exporting: false
function exportKeys(file, passphrase) {
exporting = true
let path = file.toString().replace(/^file:\/\//, "")
py.callClientCoro(userId, "export_keys", [path, passphrase], () => {
exporting = false
done()
})
}
PasswordPopup {
id: exportPasswordPopup
details.text: qsTr("Please enter a passphrase to protect this file:")
okText: qsTr("Export")
onAcceptedPasswordChanged: exportKeys(file, acceptedPassword)
property url file: ""
}
}

View File

@@ -0,0 +1,90 @@
import QtQuick 2.12
import Qt.labs.platform 1.1
Item {
id: opener
anchors.fill: fill ? parent : undefined
signal filePicked(string file)
signal filesPicked(var files)
signal cancelled()
property bool fill: true
property alias dialog: fileDialog
property string selectedFile: ""
property string file: ""
property var selectedFiles: []
property var files: []
property string selectSubject:
dialog.fileMode === FileDialog.SaveFile ? qsTr("file") : qsTr("open")
enum FileType { All, Images }
property int fileType: HFileDialogOpener.FileType.All
TapHandler { enabled: fill; onTapped: fileDialog.open() }
FileDialog {
id: fileDialog
property var filters: ({
all: qsTr("All files") + " (*)",
images: qsTr("Image files") +
" (*.jpg *.jpeg *.png *.gif *.bmp *.webp)"
})
nameFilters:
fileType === HFileDialogOpener.FileType.Images ?
[filters.images, filters.all] :
[filters.all]
folder: StandardPaths.writableLocation(
fileType === HFileDialogOpener.FileType.Images ?
StandardPaths.PicturesLocation :
StandardPaths.HomeLocation
)
title: fileMode === FileDialog.OpenFile ?
qsTr("Select a file to open") :
fileMode === FileDialog.OpenFiles ?
qsTr("Select files to open") :
fileMode === FileDialog.SaveFile ?
qsTr("Save as...") :
""
modality: Qt.NonModal
onVisibleChanged: if (visible) {
opener.selectedFile = Qt.binding(() => Qt.resolvedUrl(currentFile))
opener.file = Qt.binding(() => Qt.resolvedUrl(file))
opener.files = Qt.binding(() => Qt.resolvedUrl(files))
opener.selectedFiles =
Qt.binding(() => Qt.resolvedUrl(currentFiles))
}
onAccepted: {
opener.selectedFile = currentFile
opener.selectedFiles = currentFiles
opener.file = file
opener.files = files
opener.filePicked(file)
opener.filesPicked(files)
}
onRejected: {
selectedFile = ""
file = ""
selectedFiles = ""
files = ""
cancelled()
}
}
}

View File

@@ -0,0 +1,59 @@
import QtQuick 2.12
import Qt.labs.platform 1.1
import "../Popups"
HFileDialogOpener {
fill: false
dialog.title: qsTr("Select a decryption keys file to import")
onFilePicked: {
importPasswordPopup.file = file
importPasswordPopup.open()
}
property string userId: ""
property bool importing: false
PasswordPopup {
id: importPasswordPopup
details.text: qsTr(
"Please enter the passphrase that was used to protect this file:"
)
okText: qsTr("Import")
property url file: ""
function verifyPassword(pass, callback) {
importing = true
let path = file.toString().replace(/^file:\/\//, "")
py.callClientCoro(userId, "import_keys", [path, pass], () => {
importing = false
callback(true)
}, (type, args) => {
callback(
type === "EncryptionError" ?
false :
type === "ValueError" ?
qsTr("Invalid file format") :
type === "FileNotFoundError" ?
qsTr("This file doesn't exist") :
type === "IsADirectoryError" ?
qsTr("A folder was given, expecting a file") :
type === "PermissionError" ?
qsTr("No permission to read this file") :
qsTr("Unknown error: %1 - %2").arg(type).arg(args)
)
})
}
}
}

View File

@@ -0,0 +1,29 @@
import QtQuick 2.12
import Qt.labs.platform 1.1
HFileDialogOpener {
fill: false
dialog.title: qsTr("Select a file to send")
dialog.fileMode: FileDialog.OpenFiles
onFilesPicked: {
for (let file of files) {
let path = Qt.resolvedUrl(file).replace(/^file:/, "")
utils.sendFile(userId, roomId, path, () => {
if (destroyWhenDone) destroy()
},
(type, args, error, traceback) => {
console.error(`python:\n${traceback}`)
if (destroyWhenDone) destroy()
})
}
}
onCancelled: if (destroyWhenDone) destroy()
property string userId
property string roomId
property bool destroyWhenDone: false
}

221
src/gui/GlobalShortcuts.qml Normal file
View File

@@ -0,0 +1,221 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import "Base"
Item {
visible: false
// Flickable or ListView that should be affected by scroll shortcuts
property Item flickTarget
// A QQC Container that should be affected by tab navigation shortcuts
property Container tabsTarget
// DebugConsole that should be affected by console shortcuts
property DebugConsole debugConsole
readonly property Item toFlick:
debugConsole && debugConsole.activeFocus ?
debugConsole.commandsView : flickTarget
// App
HShortcut {
enabled: debugMode
sequences: settings.keys.startPythonDebugger
onActivated: py.call("APP.pdb")
}
HShortcut {
enabled: debugMode
sequences: settings.keys.toggleDebugConsole
onActivated: {
if (debugConsole) {
debugConsole.visible = ! debugConsole.visible
} else {
utils.debug(mainUI || window)
}
}
}
HShortcut {
sequences: settings.keys.reloadConfig
onActivated: py.loadSettings(() => { mainUI.pressAnimation.start() })
}
HShortcut {
sequences: settings.keys.zoomIn
onActivated: theme.uiScale += 0.1
}
HShortcut {
sequences: settings.keys.zoomOut
onActivated: theme.uiScale = Math.max(0.1, theme.uiScale - 0.1)
}
HShortcut {
sequences: settings.keys.zoomReset
onActivated: theme.uiScale = 1
}
// Pages
HShortcut {
sequences: settings.keys.goToLastPage
onActivated: mainUI.pageLoader.showPrevious()
}
// Page scrolling
HShortcut {
enabled: toFlick
sequences: settings.keys.scrollUp
onActivated: utils.flickPages(toFlick, -1 / 10)
}
HShortcut {
enabled: toFlick
sequences: settings.keys.scrollDown
onActivated: utils.flickPages(toFlick, 1 / 10)
}
HShortcut {
enabled: toFlick
sequences: settings.keys.scrollPageUp
onActivated: utils.flickPages(toFlick, -1)
}
HShortcut {
enabled: toFlick
sequences: settings.keys.scrollPageDown
onActivated: utils.flickPages(toFlick, 1)
}
HShortcut {
enabled: toFlick
sequences: settings.keys.scrollToTop
onActivated: utils.flickToTop(toFlick)
}
HShortcut {
enabled: toFlick
sequences: settings.keys.scrollToBottom
onActivated: utils.flickToBottom(toFlick)
}
// Tab navigation
HShortcut {
enabled: tabsTarget
sequences: settings.keys.previousTab
onActivated: tabsTarget.setCurrentIndex(
utils.numberWrapAt(tabsTarget.currentIndex - 1, tabsTarget.count),
)
}
HShortcut {
enabled: tabsTarget
sequences: settings.keys.nextTab
onActivated: tabsTarget.setCurrentIndex(
utils.numberWrapAt(tabsTarget.currentIndex + 1, tabsTarget.count),
)
}
// MainPane
HShortcut {
enabled: mainUI.accountsPresent
sequences: settings.keys.focusMainPane
onActivated: mainUI.mainPane.toggleFocus()
context: Qt.ApplicationShortcut
}
HShortcut {
enabled: mainUI.accountsPresent
sequences: settings.keys.clearRoomFilter
onActivated: mainUI.mainPane.toolBar.roomFilter = ""
}
HShortcut {
enabled: mainUI.accountsPresent
sequences: settings.keys.addNewAccount
onActivated: mainUI.mainPane.toolBar.addAccountButton.clicked()
}
HShortcut {
enabled: mainUI.accountsPresent
sequences: settings.keys.addNewChat
onActivated: mainUI.mainPane.mainPaneList.addNewChat()
}
HShortcut {
enabled: mainUI.accountsPresent
sequences: settings.keys.accountSettings
onActivated: mainUI.mainPane.mainPaneList.accountSettings()
}
HShortcut {
enabled: mainUI.accountsPresent
sequences: settings.keys.toggleCollapseAccount
onActivated: mainUI.mainPane.mainPaneList.toggleCollapseAccount()
}
HShortcut {
enabled: mainUI.accountsPresent
sequences: settings.keys.goToPreviousRoom
onActivated: mainUI.mainPane.mainPaneList.previous()
}
HShortcut {
enabled: mainUI.accountsPresent
sequences: settings.keys.goToNextRoom
onActivated: mainUI.mainPane.mainPaneList.next()
}
// Chat
HShortcut {
enabled: window.uiState.page === "Chat/Chat.qml"
sequences: settings.keys.clearRoomMessages
onActivated: utils.makePopup(
"Popups/ClearMessagesPopup.qml",
mainUI,
{
userId: window.uiState.pageProperties.userId,
roomId: window.uiState.pageProperties.roomId,
}
)
}
HShortcut {
enabled: window.uiState.page === "Chat/Chat.qml"
sequences: settings.keys.sendFile
onActivated: utils.makeObject(
"Dialogs/SendFilePicker.qml",
mainUI,
{
userId: window.uiState.pageProperties.userId,
roomId: window.uiState.pageProperties.roomId,
destroyWhenDone: true,
},
picker => { picker.dialog.open() }
)
}
HShortcut {
enabled: window.uiState.page === "Chat/Chat.qml"
sequences: settings.keys.sendFileFromPathInClipboard
onActivated: utils.sendFile(
window.uiState.pageProperties.userId,
window.uiState.pageProperties.roomId,
Clipboard.text.trim(),
)
}
}

26
src/gui/LoadingScreen.qml Normal file
View File

@@ -0,0 +1,26 @@
import QtQuick 2.12
import QtGraphicalEffects 1.12
import "Base"
Item {
LinearGradient {
anchors.fill: parent
start: Qt.point(0, 0)
end: Qt.point(window.width, window.height)
gradient: Gradient {
GradientStop { position: 0.0; color: "#001b20" }
GradientStop { position: 1.0; color: "#3c2f4b" }
}
}
HBusyIndicator {
anchors.centerIn: parent
width: Math.min(160, parent.width - 16, parent.height - 16)
height: width
indeterminateSpan: 0.5
foregroundColor: Qt.hsla(0, 0, 1, 0.15)
progressColor: Qt.hsla(0, 0, 1, 0.4)
}
}

View File

@@ -0,0 +1,122 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
HTileDelegate {
id: accountDelegate
spacing: 0
topPadding: model.index > 0 ? theme.spacing / 2 : 0
bottomPadding: topPadding
backgroundColor: theme.mainPane.account.background
opacity: collapsed && ! forceExpand ?
theme.mainPane.account.collapsedOpacity : 1
shouldBeCurrent:
window.uiState.page === "Pages/AccountSettings/AccountSettings.qml" &&
window.uiState.pageProperties.userId === model.data.user_id
setCurrentTimer.running:
! mainPaneList.activateLimiter.running && ! mainPane.hasFocus
Behavior on opacity { HNumberAnimation {} }
readonly property bool forceExpand: Boolean(mainPaneList.filter)
// Hide harmless error when a filter matches nothing
readonly property bool collapsed: try {
return mainPaneList.collapseAccounts[model.data.user_id] || false
} catch (err) {}
onActivated: pageLoader.showPage(
"AccountSettings/AccountSettings", { "userId": model.data.user_id }
)
function toggleCollapse() {
window.uiState.collapseAccounts[model.data.user_id] = ! collapsed
window.uiStateChanged()
}
image: HUserAvatar {
userId: model.data.user_id
displayName: model.data.display_name
mxc: model.data.avatar_url
}
title.color: theme.mainPane.account.name
title.text: model.data.display_name || model.data.user_id
title.font.pixelSize: theme.fontSize.big
title.leftPadding: theme.spacing
HButton {
id: addChat
iconItem.small: true
icon.name: "add-chat"
backgroundColor: "transparent"
toolTip.text: qsTr("Add new chat")
onClicked: pageLoader.showPage(
"AddChat/AddChat", {userId: model.data.user_id},
)
leftPadding: theme.spacing / 2
rightPadding: leftPadding
visible: opacity > 0
opacity: expand.loading ? 0 : 1
Layout.fillHeight: true
Behavior on opacity { HNumberAnimation {} }
}
HButton {
id: expand
loading: ! model.data.first_sync_done || ! model.data.profile_updated
iconItem.small: true
icon.name: "expand"
backgroundColor: "transparent"
toolTip.text: collapsed ? qsTr("Expand") : qsTr("Collapse")
onClicked: accountDelegate.toggleCollapse()
leftPadding: theme.spacing / 2
rightPadding: leftPadding
visible: opacity > 0
opacity: ! loading && accountDelegate.forceExpand ? 0 : 1
Layout.fillHeight: true
iconItem.transform: Rotation {
origin.x: expand.iconItem.width / 2
origin.y: expand.iconItem.height / 2
angle: expand.loading ? 0 : collapsed ? 180 : 90
Behavior on angle { HNumberAnimation {} }
}
Behavior on opacity { HNumberAnimation {} }
}
contextMenu: HMenu {
HMenuItem {
icon.name: "copy-user-id"
text: qsTr("Copy user ID")
onTriggered: Clipboard.text = model.data.user_id
}
HMenuItem {
icon.name: "sign-out"
icon.color: theme.colors.negativeBackground
text: qsTr("Sign out")
onTriggered: utils.makePopup(
"Popups/SignOutPopup.qml",
window,
{ "userId": model.data.user_id },
)
}
}
}

View File

@@ -0,0 +1,141 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
HListView {
id: mainPaneList
readonly property var originSource: window.mainPaneModelSource
readonly property var collapseAccounts: window.uiState.collapseAccounts
readonly property string filter: toolBar.roomFilter
readonly property alias activateLimiter: activateLimiter
onOriginSourceChanged: filterLimiter.restart()
onFilterChanged: filterLimiter.restart()
onCollapseAccountsChanged: filterLimiter.restart()
function filterSource() {
let show = []
// Hide a harmless error when activating a RoomDelegate
try { window.mainPaneModelSource } catch (err) { return }
for (let i = 0; i < window.mainPaneModelSource.length; i++) {
let item = window.mainPaneModelSource[i]
if (item.type === "Account" ||
(filter ?
utils.filterMatches(filter, item.data.filter_string) :
! window.uiState.collapseAccounts[item.user_id]))
{
if (filter && show.length && item.type === "Account" &&
show[show.length - 1].type === "Account" &&
! utils.filterMatches(
filter, show[show.length - 1].data.filter_string)
) {
// If filter active, current and previous items are
// both accounts and previous account doesn't match filter,
// that means the previous account had no matching rooms.
show.pop()
}
show.push(item)
}
}
let last = show[show.length - 1]
if (show.length && filter && last.type === "Account" &&
! utils.filterMatches(filter, last.data.filter_string))
{
// If filter active, last item is an account and last item
// doesn't match filter, that account had no matching rooms.
show.pop()
}
model.source = show
}
function previous(activate=true) {
decrementCurrentIndex()
if (activate) activateLimiter.restart()
}
function next(activate=true) {
incrementCurrentIndex()
if (activate) activateLimiter.restart()
}
function activate() {
currentItem.item.activated()
}
function accountSettings() {
if (! currentItem) incrementCurrentIndex()
pageLoader.showPage(
"AccountSettings/AccountSettings",
{userId: currentItem.item.delegateModel.user_id},
)
}
function addNewChat() {
if (! currentItem) incrementCurrentIndex()
pageLoader.showPage(
"AddChat/AddChat",
{userId: currentItem.item.delegateModel.user_id},
)
}
function toggleCollapseAccount() {
if (filter) return
if (! currentItem) incrementCurrentIndex()
if (currentItem.item.delegateModel.type === "Account") {
currentItem.item.toggleCollapse()
return
}
for (let i = 0; i < model.source.length; i++) {
let item = model.source[i]
if (item.type === "Account" && item.user_id ==
currentItem.item.delegateModel.user_id)
{
currentIndex = i
currentItem.item.toggleCollapse()
}
}
}
model: HListModel {
keyField: "id"
source: originSource
}
delegate: Loader {
width: mainPaneList.width
Component.onCompleted: setSource(
model.type === "Account" ?
"AccountDelegate.qml" : "RoomDelegate.qml",
{view: mainPaneList}
)
}
Timer {
id: filterLimiter
interval: 16
onTriggered: filterSource()
}
Timer {
id: activateLimiter
interval: 300
onTriggered: activate()
}
}

View File

@@ -0,0 +1,56 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
HDrawer {
id: mainPane
saveName: "mainPane"
color: theme.mainPane.background
minimumSize: theme.controls.avatar.size + theme.spacing * 2
property bool hasFocus: toolBar.filterField.activeFocus
property alias mainPaneList: mainPaneList
property alias toolBar: toolBar
function toggleFocus() {
if (toolBar.filterField.activeFocus) {
pageLoader.takeFocus()
return
}
mainPane.open()
toolBar.filterField.forceActiveFocus()
}
Behavior on opacity { HNumberAnimation {} }
Binding on visible {
value: false
when: ! mainUI.accountsPresent
}
HColumnLayout {
anchors.fill: parent
AccountRoomList {
id: mainPaneList
clip: true
Layout.fillWidth: true
Layout.fillHeight: true
}
MainPaneToolBar {
id: toolBar
mainPaneList: mainPaneList
Layout.fillWidth: true
Layout.fillHeight: false
Layout.preferredHeight: theme.baseElementsHeight
}
}
}

View File

@@ -0,0 +1,58 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
HRowLayout {
id: toolBar
// Hide filter field overflowing for a sec on size changes
clip: true
property AccountRoomList mainPaneList
readonly property alias addAccountButton: addAccountButton
readonly property alias filterField: filterField
property alias roomFilter: filterField.text
HButton {
id: addAccountButton
icon.name: "add-account"
toolTip.text: qsTr("Add another account")
backgroundColor: theme.mainPane.settingsButton.background
onClicked: pageLoader.showPage("AddAccount/AddAccount")
Layout.fillHeight: true
}
HTextField {
id: filterField
saveName: "roomFilterField"
placeholderText: qsTr("Filter rooms")
backgroundColor: theme.mainPane.filterRooms.background
bordered: false
opacity: width >= 16 * theme.uiScale ? 1 : 0
Layout.fillWidth: true
Layout.fillHeight: true
Keys.onUpPressed: mainPaneList.previous(false) // do not activate
Keys.onDownPressed: mainPaneList.next(false)
Keys.onEnterPressed: Keys.onReturnPressed(event)
Keys.onReturnPressed: {
if (event.modifiers & Qt.ShiftModifier) {
mainPaneList.toggleCollapseAccount()
return
}
if (window.settings.clearRoomFilterOnEnter) text = ""
mainPaneList.activate()
}
Keys.onEscapePressed: {
if (window.settings.clearRoomFilterOnEscape) text = ""
mainUI.pageLoader.forceActiveFocus()
}
Behavior on opacity { HNumberAnimation {} }
}
}

View File

@@ -0,0 +1,165 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
HTileDelegate {
id: roomDelegate
spacing: theme.spacing
backgroundColor: theme.mainPane.room.background
opacity: model.data.left ? theme.mainPane.room.leftRoomOpacity : 1
shouldBeCurrent:
window.uiState.page === "Chat/Chat.qml" &&
window.uiState.pageProperties.userId === model.user_id &&
window.uiState.pageProperties.roomId === model.data.room_id
setCurrentTimer.running:
! mainPaneList.activateLimiter.running && ! mainPane.hasFocus
Behavior on opacity { HNumberAnimation {} }
readonly property bool joined: ! invited && ! parted
readonly property bool invited: model.data.inviter_id && ! parted
readonly property bool parted: model.data.left
readonly property var lastEvent: model.data.last_event
onActivated: pageLoader.showRoom(model.user_id, model.data.room_id)
image: HRoomAvatar {
displayName: model.data.display_name
mxc: model.data.avatar_url
}
title.color: theme.mainPane.room.name
title.text: model.data.display_name || qsTr("Empty room")
additionalInfo.children: HIcon {
svgName: "invite-received"
colorize: theme.colors.alertBackground
visible: invited
Layout.maximumWidth: invited ? implicitWidth : 0
Behavior on Layout.maximumWidth { HNumberAnimation {} }
}
rightInfo.color: theme.mainPane.room.lastEventDate
rightInfo.text: {
! lastEvent || ! lastEvent.date ?
"" :
utils.dateIsToday(lastEvent.date) ?
utils.formatTime(lastEvent.date, false) : // no seconds
lastEvent.date.getFullYear() === new Date().getFullYear() ?
Qt.formatDate(lastEvent.date, "d MMM") : // e.g. "5 Dec"
lastEvent.date.getFullYear()
}
subtitle.color: theme.mainPane.room.subtitle
subtitle.font.italic:
Boolean(lastEvent && lastEvent.event_type === "RoomMessageEmote")
subtitle.textFormat: Text.StyledText
subtitle.text: {
if (! lastEvent) return ""
let isEmote = lastEvent.event_type === "RoomMessageEmote"
let isMsg = lastEvent.event_type.startsWith("RoomMessage")
let isUnknownMsg = lastEvent.event_type === "RoomMessageUnknown"
let isCryptMedia = lastEvent.event_type.startsWith("RoomEncrypted")
// If it's a general event
if (isEmote || isUnknownMsg || (! isMsg && ! isCryptMedia)) {
return utils.processedEventText(lastEvent)
}
let text = utils.coloredNameHtml(
lastEvent.sender_name, lastEvent.sender_id
) + ": " + lastEvent.inline_content
return text.replace(
/< *span +class=['"]?quote['"]? *>(.+?)<\/ *span *>/g,
`<font color="${theme.mainPane.room.subtitleQuote}">$1</font>`,
)
}
contextMenu: HMenu {
HMenuItem {
visible: joined
enabled: model.data.can_invite
icon.name: "room-send-invite"
text: qsTr("Invite members")
onTriggered: utils.makePopup(
"Popups/InviteToRoomPopup.qml",
window,
{
userId: model.user_id,
roomId: model.data.room_id,
roomName: model.data.display_name,
invitingAllowed: Qt.binding(() => model.data.can_invite)
}
)
}
HMenuItem {
icon.name: "copy-room-id"
text: qsTr("Copy room ID")
onTriggered: Clipboard.text = model.data.room_id
}
HMenuItem {
visible: invited
icon.name: "invite-accept"
icon.color: theme.colors.positiveBackground
text: qsTr("Accept %1's invite").arg(utils.coloredNameHtml(
model.data.inviter_name, model.data.inviter_id
))
label.textFormat: Text.StyledText
onTriggered: py.callClientCoro(
model.user_id, "join", [model.data.room_id]
)
}
HMenuItem {
visible: invited || joined
icon.name: invited ? "invite-decline" : "room-leave"
icon.color: theme.colors.negativeBackground
text: invited ? qsTr("Decline invite") : qsTr("Leave")
onTriggered: utils.makePopup(
"Popups/LeaveRoomPopup.qml",
window,
{
userId: model.user_id,
roomId: model.data.room_id,
roomName: model.data.display_name,
}
)
}
HMenuItem {
icon.name: "room-forget"
icon.color: theme.colors.negativeBackground
text: qsTr("Forget")
onTriggered: utils.makePopup(
"Popups/ForgetRoomPopup.qml",
window,
{
userId: model.user_id,
roomId: model.data.room_id,
roomName: model.data.display_name,
},
null,
false,
)
}
}
}

View File

@@ -0,0 +1,61 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HPage {
id: accountSettings
property int avatarPreferredSize: 256 * theme.uiScale
property string userId: ""
readonly property bool ready:
accountInfo !== "waiting" && Boolean(accountInfo.profile_updated)
readonly property var accountInfo: utils.getItem(
modelSources["Account"] || [], "user_id", userId
) || "waiting"
property string headerName: ready ? accountInfo.display_name : userId
hideHeaderUnderHeight: avatarPreferredSize
headerLabel.text: qsTr("Account settings for %1").arg(
utils.coloredNameHtml(headerName, userId)
)
HSpacer {}
Repeater {
id: repeater
model: ["Profile.qml", "ImportExportKeys.qml"]
Rectangle {
color: ready ? theme.controls.box.background : "transparent"
Behavior on color { HColorAnimation {} }
Layout.alignment: Qt.AlignCenter
Layout.topMargin: header.visible || index > 0 ? theme.spacing : 0
Layout.bottomMargin:
header.visible || index < repeater.count - 1? theme.spacing : 0
Layout.maximumWidth: Math.min(parent.width, 640)
Layout.preferredWidth:
pageLoader.isWide ? parent.width : avatarPreferredSize
Layout.preferredHeight: childrenRect.height
HLoader {
anchors.centerIn: parent
width: ready ? parent.width : 96
source: ready ?
modelData :
(modelData === "Profile.qml" ?
"../../Base/HBusyIndicator.qml" : "")
}
}
}
HSpacer {}
}

View File

@@ -0,0 +1,46 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HBox {
buttonModel: [
{ name: "export", text: qsTr("Export"), iconName: "export-keys"},
{ name: "import", text: qsTr("Import"), iconName: "import-keys"},
]
buttonCallbacks: ({
export: button => {
utils.makeObject(
"Dialogs/ExportKeys.qml",
accountSettings,
{ userId: accountSettings.userId },
obj => {
button.loading = Qt.binding(() => obj.exporting)
obj.dialog.open()
}
)
},
import: button => {
utils.makeObject(
"Dialogs/ImportKeys.qml",
accountSettings,
{ userId: accountSettings.userId },
obj => { obj.dialog.open() }
)
},
})
HLabel {
wrapMode: Text.Wrap
text: qsTr(
"The decryption keys for messages you received in encrypted " +
"rooms can be exported to a passphrase-protected file.\n\n" +
"You can then import this file on any Matrix account or " +
"client, to be able to decrypt these messages again."
)
Layout.fillWidth: true
}
}

View File

@@ -0,0 +1,219 @@
import QtQuick 2.12
import QtQuick.Controls 2.12
import QtQuick.Layouts 1.12
import "../../Base"
import "../../Dialogs"
HGridLayout {
function applyChanges() {
if (nameField.changed) {
saveButton.nameChangeRunning = true
py.callClientCoro(
userId, "set_displayname", [nameField.field.text], () => {
saveButton.nameChangeRunning = false
accountSettings.headerName =
Qt.binding(() => accountInfo.display_name)
}
)
}
if (aliasField.changed) {
window.settings.writeAliases[userId] = aliasField.field.text
window.settingsChanged()
}
if (avatar.changed) {
saveButton.avatarChangeRunning = true
let path =
Qt.resolvedUrl(avatar.sourceOverride).replace(/^file:/, "")
py.callClientCoro(userId, "set_avatar_from_file", [path], () => {
saveButton.avatarChangeRunning = false
}, (errType, [httpCode]) => {
console.error("Avatar upload failed:", httpCode, errType)
saveButton.avatarChangeRunning = false
})
}
}
function cancelChanges() {
nameField.field.text = accountInfo.display_name
aliasField.field.text = aliasField.currentAlias
fileDialog.selectedFile = ""
fileDialog.file = ""
accountSettings.headerName = Qt.binding(() => accountInfo.display_name)
}
columns: 2
flow: pageLoader.isWide ? GridLayout.LeftToRight : GridLayout.TopToBottom
rowSpacing: currentSpacing
Component.onCompleted: nameField.field.forceActiveFocus()
HUserAvatar {
property bool changed: Boolean(sourceOverride)
id: avatar
userId: accountSettings.userId
displayName: nameField.field.text
mxc: accountInfo.avatar_url
toolTipMxc: ""
sourceOverride: fileDialog.selectedFile || fileDialog.file
Layout.alignment: Qt.AlignHCenter
Layout.preferredWidth: Math.min(flickable.height, avatarPreferredSize)
Layout.preferredHeight: Layout.preferredWidth
Rectangle {
z: 10
visible: opacity > 0
opacity: ! fileDialog.dialog.visible &&
((! avatar.mxc && ! avatar.changed) || avatar.hovered) ?
1 : 0
anchors.fill: parent
color: utils.hsluv(0, 0, 0,
(! avatar.mxc && overlayHover.hovered) ? 0.8 : 0.7
)
Behavior on opacity { HNumberAnimation {} }
Behavior on color { HColorAnimation {} }
HoverHandler { id: overlayHover }
HColumnLayout {
anchors.centerIn: parent
spacing: currentSpacing
width: parent.width
HIcon {
svgName: "upload-avatar"
colorize: (! avatar.mxc && overlayHover.hovered) ?
theme.colors.accentText : theme.icons.colorize
dimension: avatar.width / 3
Layout.alignment: Qt.AlignCenter
}
Item { Layout.preferredHeight: theme.spacing }
HLabel {
text: avatar.mxc ?
qsTr("Change profile picture") :
qsTr("Upload profile picture")
color: (! avatar.mxc && overlayHover.hovered) ?
theme.colors.accentText : theme.colors.brightText
Behavior on color { HColorAnimation {} }
font.pixelSize: theme.fontSize.big *
avatar.height / avatarPreferredSize
wrapMode: Text.WordWrap
horizontalAlignment: Qt.AlignHCenter
Layout.fillWidth: true
}
}
}
HFileDialogOpener {
id: fileDialog
fileType: HFileDialogOpener.FileType.Images
dialog.title: qsTr("Select profile picture for %1")
.arg(accountInfo.display_name)
}
}
HColumnLayout {
id: profileInfo
spacing: theme.spacing
HColumnLayout {
spacing: theme.spacing
Layout.margins: currentSpacing
HLabel {
text: qsTr("User ID:<br>%1")
.arg(utils.coloredNameHtml(userId, userId, userId))
textFormat: Text.StyledText
wrapMode: Text.Wrap
Layout.fillWidth: true
}
HLabeledTextField {
property bool changed: field.text !== accountInfo.display_name
readonly property string fText: field.text
onFTextChanged: accountSettings.headerName = field.text
id: nameField
label.text: qsTr("Display name:")
field.onAccepted: applyChanges()
Component.onCompleted: field.text = accountInfo.display_name
Keys.onEscapePressed: cancelChanges()
Layout.fillWidth: true
Layout.maximumWidth: 480
}
HLabeledTextField {
property string currentAlias:
window.settings.writeAliases[userId] || ""
property bool changed: field.text !== currentAlias
id: aliasField
label.text: qsTr("Write alias:")
field.onAccepted: applyChanges()
Component.onCompleted: field.text = currentAlias
Layout.fillWidth: true
Layout.maximumWidth: 480
Keys.onEscapePressed: cancelChanges()
}
}
HSpacer {}
HRowLayout {
Layout.alignment: Qt.AlignBottom
HButton {
property bool nameChangeRunning: false
property bool avatarChangeRunning: false
id: saveButton
icon.name: "apply"
icon.color: theme.colors.positiveBackground
text: qsTr("Save")
loading: nameChangeRunning || avatarChangeRunning
enabled:
nameField.changed || aliasField.changed || avatar.changed
onClicked: applyChanges()
Layout.fillWidth: true
Layout.alignment: Qt.AlignBottom
}
HButton {
icon.name: "cancel"
icon.color: theme.colors.negativeBackground
text: qsTr("Cancel")
enabled: saveButton.enabled && ! saveButton.loading
onClicked: cancelChanges()
Layout.fillWidth: true
Layout.alignment: Qt.AlignBottom
}
}
}
}

View File

@@ -0,0 +1,15 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HPage {
HTabContainer {
tabModel: [
qsTr("Sign in"), qsTr("Register"), qsTr("Reset"),
]
SignIn { Component.onCompleted: forceActiveFocus() }
Register {}
Reset {}
}
}

View File

@@ -0,0 +1,29 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HBox {
id: signInBox
clickButtonOnEnter: "ok"
buttonModel: [
{ name: "ok", text: qsTr("Register from Riot"), iconName: "register" },
]
buttonCallbacks: ({
ok: button => {
Qt.openUrlExternally("https://riot.im/app/#/register")
}
})
HLabel {
wrapMode: Text.Wrap
text: qsTr(
"Registering is not implemented yet. You can create a new " +
"account from a client that supports it, like Riot."
)
Layout.fillWidth: true
}
}

View File

@@ -0,0 +1,33 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HBox {
id: signInBox
clickButtonOnEnter: "ok"
buttonModel: [
{
name: "ok",
text: qsTr("Reset password from Riot"),
iconName: "reset-password"
},
]
buttonCallbacks: ({
ok: button => {
Qt.openUrlExternally("https://riot.im/app/#/forgot_password")
}
})
HLabel {
wrapMode: Text.Wrap
text: qsTr(
"Account recovery is not implemented yet. You can reset your " +
"password using a client that supports it, like Riot."
)
Layout.fillWidth: true
}
}

View File

@@ -0,0 +1,201 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HBox {
id: signInBox
clickButtonOnEnter: "apply"
onFocusChanged: idField.forceActiveFocus()
buttonModel: [
{
name: "apply",
text: qsTr("Sign in"),
enabled: canSignIn,
iconName: "sign-in",
loading: loginFuture !== null,
disableWhileLoading: false,
},
{ name: "cancel", text: qsTr("Cancel"), iconName: "cancel"},
]
buttonCallbacks: ({
apply: button => {
if (loginFuture) loginFuture.cancel()
signInTimeout.restart()
errorMessage.text = ""
let args = [
idField.text.trim(), passwordField.text,
undefined, serverField.text.trim(),
]
loginFuture = py.callCoro("login_client", args, userId => {
signInTimeout.stop()
errorMessage.text = ""
loginFuture = null
py.callCoro(
rememberAccount.checked ?
"saved_accounts.add": "saved_accounts.delete",
[userId]
)
pageLoader.showPage(
"AccountSettings/AccountSettings", {userId}
)
}, type => {
loginFuture = null
signInTimeout.stop()
let txt = qsTr("Invalid request or login type")
if (type === "MatrixForbidden")
txt = qsTr("Invalid username or password")
if (type === "MatrixUserDeactivated")
txt = qsTr("This account was deactivated")
errorMessage.text = txt
})
},
cancel: button => {
if (! loginFuture) return
signInTimeout.stop()
loginFuture.cancel()
loginFuture = null
}
})
property var loginFuture: null
property string signInWith: "username"
readonly property bool canSignIn:
serverField.text.trim() && idField.text.trim() && passwordField.text &&
! serverField.error
Timer {
id: signInTimeout
interval: 30 * 1000
onTriggered: {
errorMessage.text =
serverField.knownServerChosen ?
qsTr("This server seems unavailable. Verify your inter" +
"net connection or try again in a few minutes.") :
qsTr("This server seems unavailable. Verify the " +
"entered URL, your internet connection or try " +
"again in a few minutes.")
}
}
HRowLayout {
visible: false // TODO
spacing: theme.spacing * 1.25
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: theme.spacing
Layout.bottomMargin: Layout.topMargin
Repeater {
model: ["username", "email", "phone"]
HButton {
icon.name: modelData
circle: true
checked: signInWith === modelData
enabled: modelData === "username"
autoExclusive: true
onClicked: signInWith = modelData
}
}
}
HTextField {
id: idField
placeholderText: qsTr(
signInWith === "email" ? "Email" :
signInWith === "phone" ? "Phone" :
"Username"
)
Layout.fillWidth: true
}
HTextField {
id: passwordField
placeholderText: qsTr("Password")
echoMode: HTextField.Password
Layout.fillWidth: true
}
HTextField {
id: serverField
placeholderText: qsTr("Homeserver URL")
text: "https://matrix.org"
error: ! /.+:\/\/.+/.test(cleanText)
Layout.fillWidth: true
readonly property string cleanText: text.toLowerCase().trim()
// 2019-11-11 https://www.hello-matrix.net/public_servers.php
readonly property var knownServers: [
"https://matrix.org",
"https://chat.weho.st",
"https://tchncs.de",
"https://chat.privacytools.io",
"https://hackerspaces.be",
"https://matrix.allmende.io",
"https://feneas.org",
"https://junta.pl",
"https://perthchat.org",
"https://matrix.tedomum.net",
"https://converser.eu",
"https://ru-matrix.org",
"https://matrix.sibnsk.net",
"https://alternanet.fr",
]
readonly property bool knownServerChosen:
knownServers.includes(cleanText)
}
HCheckBox {
id: rememberAccount
checked: true
text: qsTr("Remember my account")
subtitle.text: qsTr(
"An access token will be stored on this device to " +
"automatically sign you in."
)
Layout.fillWidth: true
}
HLabel {
id: errorMessage
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignHCenter
color: theme.colors.errorText
visible: Layout.maximumHeight > 0
Layout.maximumHeight: text ? implicitHeight : 0
Behavior on Layout.maximumHeight { HNumberAnimation {} }
Layout.fillWidth: true
}
}

View File

@@ -0,0 +1,24 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../../Base"
HPage {
id: addChatPage
property string userId
readonly property var account:
utils.getItem(modelSources["Account"] || [], "user_id", userId)
HTabContainer {
tabModel: [
qsTr("Direct chat"), qsTr("Join room"), qsTr("Create room"),
]
DirectChat { Component.onCompleted: forceActiveFocus() }
JoinRoom {}
CreateRoom {}
}
}

Some files were not shown because too many files have changed in this diff Show More