diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index f03dcbbe..b44e740b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -88,7 +88,7 @@ build-flatpak-amd64:
#image: 'registry.gitlab.gnome.org/gnome/gnome-runtime-images/gnome:master'
tags: [docker]
before_script:
- - apt-get update && apt-get -y install flatpak-builder git python curl
+ - apt-get update && apt-get -y install flatpak-builder git python curl python3-aiohttp python3-tenacity gir1.2-ostree-1.0
- flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- flatpak --noninteractive install --user flathub org.kde.Platform//5.15
- flatpak --noninteractive install --user flathub org.kde.Sdk//5.15
@@ -99,6 +99,7 @@ build-flatpak-amd64:
- flatpak-builder --user --disable-rofiles-fuse --ccache --repo=repo --default-branch=${CI_COMMIT_REF_NAME//\//_} --subject="Build of Nheko ${VERSION} `date`" app ../io.github.NhekoReborn.Nheko.json
- flatpak build-bundle repo nheko-amd64.flatpak io.github.NhekoReborn.Nheko ${CI_COMMIT_REF_NAME//\//_}
after_script:
+ - (cd ./scripts && ./upload-to-flatpak-repo.sh ../build-flatpak/repo) || true
- bash ./.ci/upload-nightly-gitlab.sh build-flatpak/nheko-amd64.flatpak
cache:
key: "$CI_JOB_NAME"
@@ -115,7 +116,7 @@ build-flatpak-arm64:
#image: 'registry.gitlab.gnome.org/gnome/gnome-runtime-images/gnome:master'
tags: [docker-arm64]
before_script:
- - apt-get update && apt-get -y install flatpak-builder git python curl
+ - apt-get update && apt-get -y install flatpak-builder git python curl python3-aiohttp python3-tenacity gir1.2-ostree-1.0
- flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
- flatpak --noninteractive install --user flathub org.kde.Platform//5.15
- flatpak --noninteractive install --user flathub org.kde.Sdk//5.15
@@ -126,6 +127,7 @@ build-flatpak-arm64:
- flatpak-builder --user --disable-rofiles-fuse --ccache --repo=repo --default-branch=${CI_COMMIT_REF_NAME//\//_} --subject="Build of Nheko ${VERSION} `date` for arm64" app ../io.github.NhekoReborn.Nheko.json
- flatpak build-bundle repo nheko-arm64.flatpak io.github.NhekoReborn.Nheko ${CI_COMMIT_REF_NAME//\//_}
after_script:
+ - (cd ./scripts && ./upload-to-flatpak-repo.sh ../build-flatpak/repo) || true
- bash ./.ci/upload-nightly-gitlab.sh build-flatpak/nheko-arm64.flatpak
cache:
key: "$CI_JOB_NAME"
diff --git a/AppImageBuilder.yml b/AppImageBuilder.yml
index 7b263058..fab81da9 100644
--- a/AppImageBuilder.yml
+++ b/AppImageBuilder.yml
@@ -79,6 +79,7 @@ AppDir:
- libxv1
- libxxf86vm1
- libzstd1
+ - qml-module-qt-labs-platform
- qml-module-qtgraphicaleffects
- qml-module-qtmultimedia
- qml-module-qtquick-controls2
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 9c0eb1ab..6e0cf575 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -257,7 +257,6 @@ set(SRC_FILES
src/dialogs/PreviewUploadOverlay.cpp
src/dialogs/ReCaptcha.cpp
src/dialogs/ReadReceipts.cpp
- src/dialogs/RoomSettings.cpp
# Emoji
src/emoji/EmojiModel.cpp
@@ -295,6 +294,7 @@ set(SRC_FILES
src/ui/ThemeManager.cpp
src/ui/ToggleButton.cpp
src/ui/UserProfile.cpp
+ src/ui/RoomSettings.cpp
src/AvatarProvider.cpp
src/BlurhashProvider.cpp
@@ -326,6 +326,7 @@ set(SRC_FILES
src/UserInfoWidget.cpp
src/UserSettingsPage.cpp
src/UsersModel.cpp
+ src/RoomsModel.cpp
src/Utils.cpp
src/WebRTCSession.cpp
src/WelcomePage.cpp
@@ -357,7 +358,7 @@ if(USE_BUNDLED_MTXCLIENT)
FetchContent_Declare(
MatrixClient
GIT_REPOSITORY https://github.com/Nheko-Reborn/mtxclient.git
- GIT_TAG fee5298f068394958c2de935836a2c145f273906
+ GIT_TAG 004d4203ceb441239aafb17e1340cd063139d029
)
set(BUILD_LIB_EXAMPLES OFF CACHE INTERNAL "")
set(BUILD_LIB_TESTS OFF CACHE INTERNAL "")
@@ -474,7 +475,6 @@ qt5_wrap_cpp(MOC_HEADERS
src/dialogs/RawMessage.h
src/dialogs/ReCaptcha.h
src/dialogs/ReadReceipts.h
- src/dialogs/RoomSettings.h
# Emoji
src/emoji/EmojiModel.h
@@ -510,6 +510,7 @@ qt5_wrap_cpp(MOC_HEADERS
src/ui/Theme.h
src/ui/ThemeManager.h
src/ui/UserProfile.h
+ src/ui/RoomSettings.h
src/notifications/Manager.h
@@ -538,6 +539,7 @@ qt5_wrap_cpp(MOC_HEADERS
src/UserInfoWidget.h
src/UserSettingsPage.h
src/UsersModel.h
+ src/RoomsModel.h
src/WebRTCSession.h
src/WelcomePage.h
src/popups/PopupItem.h
diff --git a/README.md b/README.md
index b9690fc5..cfcc6be6 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@ nheko
[![Build status](https://ci.appveyor.com/api/projects/status/07qrqbfylsg4hw2h/branch/master?svg=true)](https://ci.appveyor.com/project/redsky17/nheko/branch/master)
[![Stable Version](https://img.shields.io/badge/download-stable-green.svg)](https://github.com/Nheko-Reborn/nheko/releases/v0.8.1)
[![Nightly](https://img.shields.io/badge/download-nightly-green.svg)](https://matrix-static.neko.dev/room/!TshDrgpBNBDmfDeEGN:neko.dev/)
+
[![#nheko-reborn:matrix.org](https://img.shields.io/matrix/nheko-reborn:matrix.org.svg?label=%23nheko-reborn:matrix.org)](https://matrix.to/#/#nheko-reborn:matrix.org)
[![AUR: nheko](https://img.shields.io/badge/AUR-nheko-blue.svg)](https://aur.archlinux.org/packages/nheko)
@@ -49,7 +50,7 @@ Specifically there is support for:
### Releases
Releases for Linux (AppImage), macOS (disk image) & Windows (x64 installer)
-can be found in the [Github releases](https://github.com/Nheko-Reborn/nheko/releases).
+can be found in the [GitHub releases](https://github.com/Nheko-Reborn/nheko/releases).
### Repositories
@@ -191,7 +192,7 @@ sudo emerge -a ">=dev-qt/qtgui-5.10.0" media-libs/fontconfig dev-libs/qtkeychain
```bash
# Build requirements + qml modules needed at runtime (you may not need all of them, but the following seem to work according to reports):
-sudo apt install g++ cmake zlib1g-dev libssl-dev qt{base,declarative,tools,multimedia,quickcontrols2-}5-dev libqt5svg5-dev libboost-system-dev libboost-thread-dev libboost-iostreams-dev libolm-dev liblmdb++-dev libcmark-dev nlohmann-json3-dev libspdlog-dev libgtest-dev qml-module-qt{gstreamer,multimedia,quick-extras,-labs-settings,graphicaleffects,quick-controls2} qt5keychain-dev
+sudo apt install g++ cmake zlib1g-dev libssl-dev qt{base,declarative,tools,multimedia,quickcontrols2-}5-dev libqt5svg5-dev libboost-system-dev libboost-thread-dev libboost-iostreams-dev libolm-dev liblmdb++-dev libcmark-dev nlohmann-json3-dev libspdlog-dev libgtest-dev qml-module-qt{gstreamer,multimedia,quick-extras,-labs-settings,-labs-platform,graphicaleffects,quick-controls2} qt5keychain-dev
```
This will install all dependencies, except for tweeny (use bundled tweeny)
and mtxclient (needs to be build separately).
@@ -204,7 +205,7 @@ and mtxclient (needs to be build separately).
sudo apt install cmake gcc make automake liblmdb-dev \
qt5-default libssl-dev libqt5multimedia5-plugins libqt5multimediagsttools5 libqt5multimediaquick5 libqt5svg5-dev \
qml-module-qtgstreamer qtmultimedia5-dev qtquickcontrols2-5-dev qttools5-dev qttools5-dev-tools qtdeclarative5-dev \
- qml-module-qtgraphicaleffects qml-module-qtmultimedia qml-module-qtquick-controls2 qml-module-qtquick-layouts \
+ qml-module-qtgraphicaleffects qml-module-qtmultimedia qml-module-qtquick-controls2 qml-module-qtquick-layouts qml-module-qt-labs-platform\
qt5keychain-dev
```
diff --git a/io.github.NhekoReborn.Nheko.json b/io.github.NhekoReborn.Nheko.json
index 453d6c8a..c014aaea 100644
--- a/io.github.NhekoReborn.Nheko.json
+++ b/io.github.NhekoReborn.Nheko.json
@@ -220,7 +220,7 @@
"name": "mtxclient",
"sources": [
{
- "commit": "fee5298f068394958c2de935836a2c145f273906",
+ "commit": "004d4203ceb441239aafb17e1340cd063139d029",
"type": "git",
"url": "https://github.com/Nheko-Reborn/mtxclient.git"
}
diff --git a/nheko-nightly.flatpakref b/nheko-nightly.flatpakref
new file mode 100644
index 00000000..7d27bdfe
--- /dev/null
+++ b/nheko-nightly.flatpakref
@@ -0,0 +1,10 @@
+[Flatpak Ref]
+Title=Nheko Nightly
+Name=io.github.NhekoReborn.Nheko
+Branch=master
+Url=https://flatpak.neko.dev/repo/nightly
+Homepage=https://nheko-reborn.github.io/
+Icon=https://nheko.im/nheko-reborn/nheko/-/raw/master/resources/nheko.svg
+RuntimeRepo=https://dl.flathub.org/repo/flathub.flatpakrepo
+IsRuntime=false
+GPGKey=mDMEXENMphYJKwYBBAHaRw8BAQdAqn+Eo42lPoGpJ5HaOf4nFGfxR0QtOggJTCfsdbOyL4e0Kk5pY29sYXMgV2VybmVyIDxuaWNvbGFzLndlcm5lckBob3RtYWlsLmRlPoiWBBMWCAA+FiEE1YtGJCWmo3Elxv7bkgauGyMeBbsFAlxDTVUCGwMFCQtJjooFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQkgauGyMeBbs2rQD/dAEoOGT21BL85A8LmPK743EboBAjoRbWcI1hHnvS28AA/3b3HYGwgvTC6hQLyz75zjpeO5ZaUtbezRyDUR4xabMAtCROaWNvbGFzIFdlcm5lciA8bmljb2xhc0BuZWtvZGV2Lm5ldD6IlgQTFggAPhYhBNWLRiQlpqNxJcb+25IGrhsjHgW7BQJcQ01GAhsDBQkLSY6KBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEJIGrhsjHgW7GxwBANT4gL03Uu9N5KmTBihC7B71+0r7M/azPbUh86NthCeIAQCF2JXa0axBKhgQF5fWC5ncL+m8ZpH7C5rzDqVgO82WALQnTmljb2xhcyBXZXJuZXIgPG5pY29sYXMud2VybmVyQGdteC5uZXQ+iJYEExYIAD4WIQTVi0YkJaajcSXG/tuSBq4bIx4FuwUCXENMpgIbAwUJC0mOigULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCSBq4bIx4FuxU5APoCRDYlJW0oTsJs3lcTTB5Nsqb3X4iCEDCjIgsA3wtsIwEAlGBzD8ElCYi2+8m8esSRNlmpRcGoqgXbceLxPUXFpQu4OARcQ0ymEgorBgEEAZdVAQUBAQdAD8dBmT3iqrqdlxSw90L0SIH11fVxiX9MdWfBkTi6PzUDAQgHiH4EGBYIACYWIQTVi0YkJaajcSXG/tuSBq4bIx4FuwUCXENMpgIbDAUJC0mOigAKCRCSBq4bIx4Fu/LNAQDhH64IBic6h7H3uvtSAFT4xNn7Epobt2baIaDp7uKsQQEAyI+oc5dLknABwIOMrQQuZCmGejx9e4/8HEqLCdszhgG4MwRgNICHFgkrBgEEAdpHDwEBB0DR9eFFzfR62FIi7g+txcQenLvKFzhlyTz0wo3icOy6RYj1BBgWCAAmFiEE1YtGJCWmo3Elxv7bkgauGyMeBbsFAmA0gIcCGwIFCQlmAYAAgQkQkgauGyMeBbt2IAQZFggAHRYhBGz14re9h4cNPaFEKMjXXmEHc/LZBQJgNICHAAoJEMjXXmEHc/LZhVMBAPdYRspdeFh6E9BDxGubT705e/pZFdCHjCToDyxgdW5KAP9sU0hFI5VDHD1h98RzxSt7hc3jxyPSzbG1MBUJ9gbfCVhcAPsFfeZc3v5UBgmn4uICFEGjlzAWCQ7WctE6QTSkY5aL/wD9ETJH5lB+i/8km/sOBKQozXR0yHHw46gB6ZWMeN1wfgq4MwRgNPutFgkrBgEEAdpHDwEBB0APwMn0FJmnAds8IO8iCl/RHr7fz8xnpGd7E4zVgCNZpIj1BBgWCAAmFiEE1YtGJCWmo3Elxv7bkgauGyMeBbsFAmA0+60CGwIFCQANLwAAgQkQkgauGyMeBbt2IAQZFggAHRYhBAH7QBkzNfVIZJM93RNnXzGtBKQcBQJgNPutAAoJEBNnXzGtBKQcHnUA/0E2H5sxmfZ+EWFTso3X4NWu3uN2xF+MdNaY8C72f9H6AP91XaNmlB9gV61rg6wcB5E/j0998yWS9gltY1XY1ImqDPvlAP4sHFs5zuDazgKYxZ/kFhENCgEStdpnvJjt/DxmQPVT3AD/QK5vGoMTIeYjihv0QCnnRDfboTTZHlaEqJW8i02PQww=
diff --git a/nheko-nightly.flatpakrepo b/nheko-nightly.flatpakrepo
new file mode 100644
index 00000000..4fb1bc55
--- /dev/null
+++ b/nheko-nightly.flatpakrepo
@@ -0,0 +1,8 @@
+[Flatpak Repo]
+Title=Nheko Nightly
+Url=https://flatpak.neko.dev/repo/nightly
+Homepage=https://nheko.im/
+Comment=Nheko nightly release repository
+Description=Nheko nightly release repository
+Icon=https://nheko.im/nheko-reborn/nheko/-/raw/master/resources/nheko.svg
+GPGKey=mDMEXENMphYJKwYBBAHaRw8BAQdAqn+Eo42lPoGpJ5HaOf4nFGfxR0QtOggJTCfsdbOyL4e0Kk5pY29sYXMgV2VybmVyIDxuaWNvbGFzLndlcm5lckBob3RtYWlsLmRlPoiWBBMWCAA+FiEE1YtGJCWmo3Elxv7bkgauGyMeBbsFAlxDTVUCGwMFCQtJjooFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQkgauGyMeBbs2rQD/dAEoOGT21BL85A8LmPK743EboBAjoRbWcI1hHnvS28AA/3b3HYGwgvTC6hQLyz75zjpeO5ZaUtbezRyDUR4xabMAtCROaWNvbGFzIFdlcm5lciA8bmljb2xhc0BuZWtvZGV2Lm5ldD6IlgQTFggAPhYhBNWLRiQlpqNxJcb+25IGrhsjHgW7BQJcQ01GAhsDBQkLSY6KBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEJIGrhsjHgW7GxwBANT4gL03Uu9N5KmTBihC7B71+0r7M/azPbUh86NthCeIAQCF2JXa0axBKhgQF5fWC5ncL+m8ZpH7C5rzDqVgO82WALQnTmljb2xhcyBXZXJuZXIgPG5pY29sYXMud2VybmVyQGdteC5uZXQ+iJYEExYIAD4WIQTVi0YkJaajcSXG/tuSBq4bIx4FuwUCXENMpgIbAwUJC0mOigULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCSBq4bIx4FuxU5APoCRDYlJW0oTsJs3lcTTB5Nsqb3X4iCEDCjIgsA3wtsIwEAlGBzD8ElCYi2+8m8esSRNlmpRcGoqgXbceLxPUXFpQu4OARcQ0ymEgorBgEEAZdVAQUBAQdAD8dBmT3iqrqdlxSw90L0SIH11fVxiX9MdWfBkTi6PzUDAQgHiH4EGBYIACYWIQTVi0YkJaajcSXG/tuSBq4bIx4FuwUCXENMpgIbDAUJC0mOigAKCRCSBq4bIx4Fu/LNAQDhH64IBic6h7H3uvtSAFT4xNn7Epobt2baIaDp7uKsQQEAyI+oc5dLknABwIOMrQQuZCmGejx9e4/8HEqLCdszhgG4MwRgNICHFgkrBgEEAdpHDwEBB0DR9eFFzfR62FIi7g+txcQenLvKFzhlyTz0wo3icOy6RYj1BBgWCAAmFiEE1YtGJCWmo3Elxv7bkgauGyMeBbsFAmA0gIcCGwIFCQlmAYAAgQkQkgauGyMeBbt2IAQZFggAHRYhBGz14re9h4cNPaFEKMjXXmEHc/LZBQJgNICHAAoJEMjXXmEHc/LZhVMBAPdYRspdeFh6E9BDxGubT705e/pZFdCHjCToDyxgdW5KAP9sU0hFI5VDHD1h98RzxSt7hc3jxyPSzbG1MBUJ9gbfCVhcAPsFfeZc3v5UBgmn4uICFEGjlzAWCQ7WctE6QTSkY5aL/wD9ETJH5lB+i/8km/sOBKQozXR0yHHw46gB6ZWMeN1wfgq4MwRgNPutFgkrBgEEAdpHDwEBB0APwMn0FJmnAds8IO8iCl/RHr7fz8xnpGd7E4zVgCNZpIj1BBgWCAAmFiEE1YtGJCWmo3Elxv7bkgauGyMeBbsFAmA0+60CGwIFCQANLwAAgQkQkgauGyMeBbt2IAQZFggAHRYhBAH7QBkzNfVIZJM93RNnXzGtBKQcBQJgNPutAAoJEBNnXzGtBKQcHnUA/0E2H5sxmfZ+EWFTso3X4NWu3uN2xF+MdNaY8C72f9H6AP91XaNmlB9gV61rg6wcB5E/j0998yWS9gltY1XY1ImqDPvlAP4sHFs5zuDazgKYxZ/kFhENCgEStdpnvJjt/DxmQPVT3AD/QK5vGoMTIeYjihv0QCnnRDfboTTZHlaEqJW8i02PQww=
diff --git a/resources/qml/Completer.qml b/resources/qml/Completer.qml
index 27322172..f77f50e9 100644
--- a/resources/qml/Completer.qml
+++ b/resources/qml/Completer.qml
@@ -154,6 +154,35 @@ Popup {
}
+ DelegateChoice {
+ roleValue: "room"
+
+ RowLayout {
+ id: del
+
+ anchors.centerIn: parent
+
+ Avatar {
+ height: 24
+ width: 24
+ url: model.avatarUrl.replace("mxc://", "image://MxcImage/")
+ onClicked: popup.completionClicked(completer.completionAt(model.index))
+ }
+
+ Label {
+ text: model.roomName
+ color: model.index == popup.currentIndex ? colors.highlightedText : colors.text
+ }
+
+ Label {
+ text: "(" + model.roomAlias + ")"
+ color: model.index == popup.currentIndex ? colors.highlightedText : colors.buttonText
+ }
+
+ }
+
+ }
+
}
}
diff --git a/resources/qml/MessageInput.qml b/resources/qml/MessageInput.qml
index 863b09f7..c526aa2c 100644
--- a/resources/qml/MessageInput.qml
+++ b/resources/qml/MessageInput.qml
@@ -1,14 +1,16 @@
import "./voip"
-import QtQuick 2.9
+import QtQuick 2.12
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.2
import QtQuick.Window 2.2
import im.nheko 1.0
Rectangle {
+ id: inputBar
+
color: colors.window
Layout.fillWidth: true
- Layout.preferredHeight: textInput.height + 16
+ Layout.preferredHeight: row.implicitHeight
Layout.minimumHeight: 40
Component {
@@ -20,11 +22,9 @@ Rectangle {
}
RowLayout {
- id: inputBar
+ id: row
anchors.fill: parent
- anchors.margins: 8
- spacing: 16
ImageButton {
visible: CallManager.callsSupported
@@ -36,7 +36,7 @@ Rectangle {
image: CallManager.isOnCall ? ":/icons/icons/ui/end-call.png" : ":/icons/icons/ui/place-call.png"
ToolTip.visible: hovered
ToolTip.text: CallManager.isOnCall ? qsTr("Hang up") : qsTr("Place a call")
- Layout.leftMargin: 8
+ Layout.margins: 8
onClicked: {
if (TimelineManager.timeline) {
if (CallManager.haveCallInvite) {
@@ -57,7 +57,7 @@ Rectangle {
width: 22
height: 22
image: ":/icons/icons/ui/paper-clip-outline.png"
- Layout.leftMargin: CallManager.callsSupported ? 0 : 8
+ Layout.margins: 8
onClicked: TimelineManager.timeline.input.openFileSelection()
ToolTip.visible: hovered
ToolTip.text: qsTr("Send a file")
@@ -76,31 +76,13 @@ Rectangle {
}
- Flickable {
+ ScrollView {
id: textInput
- function ensureVisible(r) {
- if (contentX >= r.x)
- contentX = r.x;
- else if (contentX + width <= r.x + r.width)
- contentX = r.x + r.width - width;
- if (contentY >= r.y)
- contentY = r.y;
- else if (contentY + height <= r.y + r.height)
- contentY = r.y + r.height - height;
- }
-
- Layout.alignment: Qt.AlignBottom
+ Layout.alignment: Qt.AlignBottom // | Qt.AlignHCenter
Layout.maximumHeight: Window.height / 4
Layout.minimumHeight: Settings.fontSize
- Layout.fillWidth: true
- clip: true
- boundsBehavior: Flickable.StopAtBounds
- flickableDirection: Flickable.VerticalFlick
- implicitWidth: messageInput.width
- implicitHeight: messageInput.height
- contentWidth: messageInput.width
- contentHeight: messageInput.height
+ implicitWidth: inputBar.width - 4 * (22 + 16) - 24
TextArea {
id: messageInput
@@ -121,18 +103,11 @@ Rectangle {
selectByMouse: true
placeholderText: qsTr("Write a message...")
- //placeholderTextColor: colors.buttonText
- // only set the anchors on Qt 5.12 or higher
- // see https://doc.qt.io/qt-5/qml-qtquick-controls2-popup.html#anchors.centerIn-prop
- Component.onCompleted: {
- if (placeholderTextColor !== undefined)
- placeholderTextColor = colors.buttonText;
-
- }
+ placeholderTextColor: colors.buttonText
color: colors.text
width: textInput.width
wrapMode: TextEdit.Wrap
- padding: 0
+ padding: 8
focus: true
onTextChanged: {
if (TimelineManager.timeline)
@@ -140,7 +115,6 @@ Rectangle {
forceActiveFocus();
}
- onCursorRectangleChanged: textInput.ensureVisible(cursorRectangle)
onCursorPositionChanged: {
if (!TimelineManager.timeline)
return ;
@@ -182,6 +156,9 @@ Rectangle {
} else if (event.key == Qt.Key_Colon) {
messageInput.openCompleter(cursorPosition, "emoji");
popup.open();
+ } else if (event.key == Qt.Key_NumberSign) {
+ messageInput.openCompleter(cursorPosition, "room");
+ popup.open();
} else if (event.key == Qt.Key_Escape && popup.opened) {
completerTriggeredAt = -1;
popup.completerName = "";
@@ -199,7 +176,6 @@ Rectangle {
}
}
TimelineManager.timeline.input.send();
- messageInput.clear();
event.accepted = true;
} else if (event.key == Qt.Key_Tab) {
event.accepted = true;
@@ -231,6 +207,39 @@ Rectangle {
} else if (event.key == Qt.Key_Down && popup.opened) {
event.accepted = true;
popup.down();
+ } else if (event.key == Qt.Key_Up && event.modifiers == Qt.NoModifier) {
+ if (cursorPosition == 0) {
+ event.accepted = true;
+ var idx = TimelineManager.timeline.edit ? TimelineManager.timeline.idToIndex(TimelineManager.timeline.edit) + 1 : 0;
+ while (true) {
+ var id = TimelineManager.timeline.indexToId(idx);
+ if (!id || TimelineManager.timeline.getDump(id, "").isEditable) {
+ TimelineManager.timeline.edit = id;
+ cursorPosition = 0;
+ break;
+ }
+ idx++;
+ }
+ } else if (cursorPosition == messageInput.length) {
+ event.accepted = true;
+ cursorPosition = 0;
+ }
+ } else if (event.key == Qt.Key_Down && event.modifiers == Qt.NoModifier) {
+ if (cursorPosition == 0) {
+ event.accepted = true;
+ cursorPosition = messageInput.length;
+ } else if (cursorPosition == messageInput.length && TimelineManager.timeline.edit) {
+ event.accepted = true;
+ var idx = TimelineManager.timeline.idToIndex(TimelineManager.timeline.edit) - 1;
+ while (true) {
+ var id = TimelineManager.timeline.indexToId(idx);
+ if (!id || TimelineManager.timeline.getDump(id, "").isEditable) {
+ TimelineManager.timeline.edit = id;
+ break;
+ }
+ idx--;
+ }
+ }
}
}
background: null
@@ -292,15 +301,13 @@ Rectangle {
}
- ScrollBar.vertical: ScrollBar {
- }
-
}
ImageButton {
id: emojiButton
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
+ Layout.margins: 8
hoverEnabled: true
width: 22
height: 22
@@ -315,6 +322,7 @@ Rectangle {
ImageButton {
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
+ Layout.margins: 8
hoverEnabled: true
width: 22
height: 22
@@ -324,7 +332,6 @@ Rectangle {
ToolTip.text: qsTr("Send")
onClicked: {
TimelineManager.timeline.input.send();
- messageInput.clear();
}
}
diff --git a/resources/qml/MessageView.qml b/resources/qml/MessageView.qml
index e1641a36..eefde046 100644
--- a/resources/qml/MessageView.qml
+++ b/resources/qml/MessageView.qml
@@ -15,7 +15,7 @@ ScrollView {
ListView {
id: chat
- property int delegateMaxWidth: ((Settings.timelineMaxWidth > 100 && Settings.timelineMaxWidth < parent.availableWidth) ? Settings.timelineMaxWidth : parent.availableWidth) - parent.padding
+ property int delegateMaxWidth: ((Settings.timelineMaxWidth > 100 && Settings.timelineMaxWidth < parent.availableWidth) ? Settings.timelineMaxWidth : parent.availableWidth) - parent.padding * 2
model: TimelineManager.timeline
boundsBehavior: Flickable.StopAtBounds
diff --git a/resources/qml/RoomSettings.qml b/resources/qml/RoomSettings.qml
new file mode 100644
index 00000000..1165f472
--- /dev/null
+++ b/resources/qml/RoomSettings.qml
@@ -0,0 +1,281 @@
+import Qt.labs.platform 1.1 as Platform
+import QtQuick 2.9
+import QtQuick.Controls 2.3
+import QtQuick.Layouts 1.2
+import QtQuick.Window 2.3
+import im.nheko 1.0
+
+ApplicationWindow {
+ id: roomSettingsDialog
+
+ property var roomSettings
+
+ x: MainWindow.x + (MainWindow.width / 2) - (width / 2)
+ y: MainWindow.y + (MainWindow.height / 2) - (height / 2)
+ minimumWidth: 420
+ minimumHeight: 650
+ palette: colors
+ color: colors.window
+ modality: Qt.WindowModal
+
+ Shortcut {
+ sequence: StandardKey.Cancel
+ onActivated: roomSettingsDialog.close()
+ }
+
+ ColumnLayout {
+ id: contentLayout1
+
+ anchors.fill: parent
+ anchors.margins: 10
+ spacing: 10
+
+ Avatar {
+ url: roomSettings.roomAvatarUrl.replace("mxc://", "image://MxcImage/")
+ height: 130
+ width: 130
+ Layout.alignment: Qt.AlignHCenter
+ onClicked: {
+ if (roomSettings.canChangeAvatar)
+ roomSettings.updateAvatar();
+
+ }
+ }
+
+ BusyIndicator {
+ Layout.alignment: Qt.AlignHCenter
+ running: roomSettings.isLoading
+ visible: roomSettings.isLoading
+ }
+
+ Text {
+ id: errorText
+
+ text: "Error Text"
+ color: "red"
+ visible: opacity > 0
+ opacity: 0
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ SequentialAnimation {
+ id: hideErrorAnimation
+
+ running: false
+
+ PauseAnimation {
+ duration: 4000
+ }
+
+ NumberAnimation {
+ target: errorText
+ property: 'opacity'
+ to: 0
+ duration: 1000
+ }
+
+ }
+
+ Connections {
+ target: roomSettings
+ onDisplayError: {
+ errorText.text = errorMessage;
+ errorText.opacity = 1;
+ hideErrorAnimation.restart();
+ }
+ }
+
+ ColumnLayout {
+ Layout.alignment: Qt.AlignHCenter
+
+ MatrixText {
+ text: roomSettings.roomName
+ font.pixelSize: 24
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ MatrixText {
+ text: "%1 member(s)".arg(roomSettings.memberCount)
+ Layout.alignment: Qt.AlignHCenter
+ }
+
+ }
+
+ ImageButton {
+ Layout.alignment: Qt.AlignHCenter
+ image: ":/icons/icons/ui/edit.png"
+ visible: roomSettings.canChangeNameAndTopic
+ onClicked: roomSettings.openEditModal()
+ }
+
+ ScrollView {
+ Layout.maximumHeight: 75
+ Layout.alignment: Qt.AlignHCenter
+ width: parent.width
+
+ TextArea {
+ text: TimelineManager.escapeEmoji(roomSettings.roomTopic)
+ wrapMode: TextEdit.WordWrap
+ textFormat: TextEdit.RichText
+ readOnly: true
+ background: null
+ selectByMouse: true
+ color: colors.text
+ horizontalAlignment: TextEdit.AlignHCenter
+ onLinkActivated: TimelineManager.openLink(link)
+
+ CursorShape {
+ anchors.fill: parent
+ cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor
+ }
+
+ }
+
+ }
+
+ GridLayout {
+ columns: 2
+ rowSpacing: 10
+
+ MatrixText {
+ text: "SETTINGS"
+ font.bold: true
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ MatrixText {
+ text: "Notifications"
+ Layout.fillWidth: true
+ }
+
+ ComboBox {
+ model: ["Muted", "Mentions only", "All messages"]
+ currentIndex: roomSettings.notifications
+ onActivated: {
+ roomSettings.changeNotifications(index);
+ }
+ Layout.fillWidth: true
+ }
+
+ MatrixText {
+ text: "Room access"
+ Layout.fillWidth: true
+ }
+
+ ComboBox {
+ enabled: roomSettings.canChangeJoinRules
+ model: ["Anyone and guests", "Anyone", "Invited users"]
+ currentIndex: roomSettings.accessJoinRules
+ onActivated: {
+ roomSettings.changeAccessRules(index);
+ }
+ Layout.fillWidth: true
+ }
+
+ MatrixText {
+ text: "Encryption"
+ }
+
+ ToggleButton {
+ id: encryptionToggle
+
+ checked: roomSettings.isEncryptionEnabled
+ onClicked: {
+ if (roomSettings.isEncryptionEnabled) {
+ checked = true;
+ return ;
+ }
+ confirmEncryptionDialog.open();
+ }
+ Layout.alignment: Qt.AlignRight
+ }
+
+ Platform.MessageDialog {
+ id: confirmEncryptionDialog
+
+ title: qsTr("End-to-End Encryption")
+ text: qsTr("Encryption is currently experimental and things might break unexpectedly.
+ Please take note that it can't be disabled afterwards.")
+ modality: Qt.WindowModal
+ onAccepted: {
+ if (roomSettings.isEncryptionEnabled)
+ return ;
+
+ roomSettings.enableEncryption();
+ }
+ onRejected: {
+ encryptionToggle.checked = false;
+ }
+ buttons: Dialog.Ok | Dialog.Cancel
+ }
+
+ MatrixText {
+ visible: roomSettings.isEncryptionEnabled
+ text: "Respond to key requests"
+ }
+
+ ToggleButton {
+ visible: roomSettings.isEncryptionEnabled
+ ToolTip.text: qsTr("Whether or not the client should respond automatically with the session keys
+ upon request. Use with caution, this is a temporary measure to test the
+ E2E implementation until device verification is completed.")
+ checked: roomSettings.respondsToKeyRequests
+ onClicked: {
+ roomSettings.changeKeyRequestsPreference(checked);
+ }
+ Layout.alignment: Qt.AlignRight
+ }
+
+ Item {
+ // for adding extra space between sections
+ Layout.fillWidth: true
+ }
+
+ Item {
+ // for adding extra space between sections
+ Layout.fillWidth: true
+ }
+
+ MatrixText {
+ text: "INFO"
+ font.bold: true
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ MatrixText {
+ text: "Internal ID"
+ }
+
+ MatrixText {
+ text: roomSettings.roomId
+ font.pixelSize: 14
+ Layout.alignment: Qt.AlignRight
+ }
+
+ MatrixText {
+ text: "Room Version"
+ }
+
+ MatrixText {
+ text: roomSettings.roomVersion
+ font.pixelSize: 14
+ Layout.alignment: Qt.AlignRight
+ }
+
+ }
+
+ Button {
+ Layout.alignment: Qt.AlignRight
+ text: "Ok"
+ onClicked: close()
+ }
+
+ }
+
+}
diff --git a/resources/qml/TimelineView.qml b/resources/qml/TimelineView.qml
index 07145c7a..4eac48f2 100644
--- a/resources/qml/TimelineView.qml
+++ b/resources/qml/TimelineView.qml
@@ -52,6 +52,14 @@ Page {
}
+ Component {
+ id: roomSettingsComponent
+
+ RoomSettings {
+ }
+
+ }
+
Component {
id: mobileCallInviteDialog
@@ -175,6 +183,16 @@ Page {
}
}
+ Connections {
+ target: TimelineManager.timeline
+ onOpenRoomSettingsDialog: {
+ var roomSettings = roomSettingsComponent.createObject(timelineRoot, {
+ "roomSettings": settings
+ });
+ roomSettings.show();
+ }
+ }
+
Connections {
target: CallManager
onNewInviteState: {
diff --git a/resources/qml/ToggleButton.qml b/resources/qml/ToggleButton.qml
new file mode 100644
index 00000000..9f079c62
--- /dev/null
+++ b/resources/qml/ToggleButton.qml
@@ -0,0 +1,40 @@
+import QtQuick 2.5
+import QtQuick 2.12
+import QtQuick.Controls 2.12
+import im.nheko 1.0
+
+Switch {
+ id: toggleButton
+
+ implicitWidth: indicatorItem.width
+
+ indicator: Item {
+ id: indicatorItem
+
+ implicitWidth: 48
+ implicitHeight: 24
+ y: parent.height / 2 - height / 2
+
+ Rectangle {
+ height: 3 * parent.height / 4
+ radius: height / 2
+ width: parent.width - height
+ x: radius
+ y: parent.height / 2 - height / 2
+ color: toggleButton.checked ? "skyblue" : "grey"
+ border.color: "#cccccc"
+ }
+
+ Rectangle {
+ x: toggleButton.checked ? parent.width - width : 0
+ y: parent.height / 2 - height / 2
+ width: parent.height
+ height: width
+ radius: width / 2
+ color: toggleButton.down ? "whitesmoke" : "whitesmoke"
+ border.color: "#ebebeb"
+ }
+
+ }
+
+}
diff --git a/resources/qml/TopBar.qml b/resources/qml/TopBar.qml
index 273ed8ab..967aa11e 100644
--- a/resources/qml/TopBar.qml
+++ b/resources/qml/TopBar.qml
@@ -15,7 +15,7 @@ Rectangle {
MouseArea {
anchors.fill: parent
- onClicked: TimelineManager.openRoomSettings()
+ onClicked: TimelineManager.timeline.openRoomSettings()
}
GridLayout {
@@ -68,7 +68,7 @@ Rectangle {
MouseArea {
anchors.fill: parent
- onClicked: TimelineManager.openRoomSettings()
+ onClicked: TimelineManager.timeline.openRoomSettings()
}
}
@@ -114,7 +114,7 @@ Rectangle {
MenuItem {
text: qsTr("Settings")
- onTriggered: TimelineManager.openRoomSettings()
+ onTriggered: TimelineManager.timeline.openRoomSettings()
}
}
diff --git a/resources/res.qrc b/resources/res.qrc
index 2387fa75..e629a871 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -129,6 +129,7 @@
qml/EncryptionIndicator.qml
qml/ImageButton.qml
qml/MatrixText.qml
+ qml/ToggleButton.qml
qml/MessageInput.qml
qml/MessageView.qml
qml/NhekoBusyIndicator.qml
@@ -140,6 +141,7 @@
qml/TimelineRow.qml
qml/TopBar.qml
qml/TypingIndicator.qml
+ qml/RoomSettings.qml
qml/emoji/EmojiButton.qml
qml/emoji/EmojiPicker.qml
qml/UserProfile.qml
diff --git a/scripts/flat-manager-client b/scripts/flat-manager-client
new file mode 100755
index 00000000..99f23b70
--- /dev/null
+++ b/scripts/flat-manager-client
@@ -0,0 +1,772 @@
+#!/usr/bin/python3
+
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import asyncio
+import base64
+import binascii
+import errno
+import fnmatch
+import gzip
+import json
+import logging
+import os
+import sys
+import time
+import traceback
+from argparse import ArgumentParser
+from functools import reduce
+from urllib.parse import urljoin, urlparse, urlsplit, urlunparse, urlunsplit
+
+import aiohttp
+from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
+
+import gi
+gi.require_version('OSTree', '1.0')
+from gi.repository import Gio, GLib, OSTree
+
+UPLOAD_CHUNK_LIMIT = 4 * 1024 * 1024
+DEFAULT_LIMIT = 2 ** 16
+
+def eprint(*args, **kwargs):
+ print(*args, file=sys.stderr, **kwargs)
+
+class UsageException(Exception):
+ def __init__(self, msg):
+ self.msg = msg
+ def __str__(self):
+ return self.msg
+
+class ApiError(Exception):
+ def __init__(self, response, body):
+ self.url = str(response.url)
+ self.status = response.status
+
+ try:
+ self.body = json.loads(response);
+ except:
+ self.body = {"status": self.status, "error-type": "no-error", "message": "No json error details from server"}
+
+ def repr(self):
+ return {
+ "type": "api",
+ "url": self.url,
+ "status_code": self.status,
+ "details": self.body
+ }
+
+ def __str__(self):
+ return "Api call to %s failed with status %d, details: %s" % (self.url, self.status, self.body)
+
+
+# This is similar to the regular payload, but opens the file lazily
+class AsyncNamedFilePart(aiohttp.payload.Payload):
+ def __init__(self,
+ value,
+ disposition='attachment',
+ *args,
+ **kwargs):
+ self._file = None
+ if 'filename' not in kwargs:
+ kwargs['filename'] = os.path.basename(value)
+
+ super().__init__(value, *args, **kwargs)
+
+ if self._filename is not None and disposition is not None:
+ self.set_content_disposition(disposition, filename=self._filename, quote_fields=False)
+
+ self._size = os.stat(value).st_size
+
+ async def write(self, writer):
+ if self._file is None or self._file.closed:
+ self._file = open(self._value, 'rb')
+ try:
+ chunk = self._file.read(DEFAULT_LIMIT)
+ while chunk:
+ await writer.write(chunk)
+ chunk = self._file.read(DEFAULT_LIMIT)
+ finally:
+ self._file.close()
+
+ @property
+ def size(self):
+ return self._size
+
+def ostree_object_path(repo, obj):
+ repodir = repo.get_path().get_path()
+ return os.path.join(repodir, 'objects', obj[0:2], obj[2:])
+
+def ostree_get_dir_files(repo, objects, dirtree):
+ if dirtree.endswith(".dirtree"):
+ dirtree = dirtree[:-8]
+ dirtreev = repo.load_variant(OSTree.ObjectType.DIR_TREE, dirtree)[1]
+ iter = OSTree.RepoCommitTraverseIter()
+ iter.init_dirtree(repo, dirtreev, 0)
+ while True:
+ type = iter.next()
+ if type == OSTree.RepoCommitIterResult.END:
+ break
+ if type == OSTree.RepoCommitIterResult.ERROR:
+ break
+ if type == OSTree.RepoCommitIterResult.FILE:
+ d = iter.get_file()
+ objects.add(d.out_checksum + ".filez")
+ if type == OSTree.RepoCommitIterResult.DIR:
+ pass
+
+def local_needed_files(repo, metadata_objects):
+ objects = set()
+ for c in metadata_objects:
+ if c.endswith(".dirtree"):
+ ostree_get_dir_files(repo, objects, c)
+ return objects
+
+def local_needed_metadata_dirtree(repo, objects, dirtree_content, dirtree_meta):
+ objects.add(dirtree_meta + ".dirmeta")
+ dirtree_content_name = dirtree_content + ".dirtree"
+ if dirtree_content_name in objects:
+ return
+ objects.add(dirtree_content_name)
+
+ dirtreev = repo.load_variant(OSTree.ObjectType.DIR_TREE, dirtree_content)[1]
+ iter = OSTree.RepoCommitTraverseIter()
+ iter.init_dirtree(repo, dirtreev, 0)
+ while True:
+ type = iter.next()
+ if type == OSTree.RepoCommitIterResult.END:
+ break
+ if type == OSTree.RepoCommitIterResult.ERROR:
+ break
+ if type == OSTree.RepoCommitIterResult.FILE:
+ pass
+ if type == OSTree.RepoCommitIterResult.DIR:
+ d = iter.get_dir()
+ local_needed_metadata_dirtree(repo, objects, d.out_content_checksum, d.out_meta_checksum)
+
+def local_needed_metadata(repo, commits):
+ objects = set()
+ for rev in commits:
+ objects.add(rev + ".commit")
+ commitv = repo.load_variant(OSTree.ObjectType.COMMIT, rev)[1]
+ iter = OSTree.RepoCommitTraverseIter()
+ iter.init_commit(repo, commitv, 0)
+ while True:
+ type = iter.next()
+ if type == OSTree.RepoCommitIterResult.END:
+ break
+ if type == OSTree.RepoCommitIterResult.ERROR:
+ break
+ if type == OSTree.RepoCommitIterResult.FILE:
+ pass
+ if type == OSTree.RepoCommitIterResult.DIR:
+ d = iter.get_dir()
+ local_needed_metadata_dirtree(repo, objects, d.out_content_checksum, d.out_meta_checksum)
+ return objects
+
+
+def chunks(l, n):
+ """Yield successive n-sized chunks from l."""
+ for i in range(0, len(l), n):
+ yield l[i:i + n]
+
+async def missing_objects(session, build_url, token, wanted):
+ missing=[]
+ for chunk in chunks(wanted, 2000):
+ wanted_json=json.dumps({'wanted': chunk}).encode('utf-8')
+ data=gzip.compress(wanted_json)
+ headers = {
+ 'Authorization': 'Bearer ' + token,
+ 'Content-Encoding': 'gzip',
+ 'Content-Type': 'application/json'
+ }
+ resp = await session.get(build_url + "/missing_objects", data=data, headers=headers)
+ async with resp:
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+ data = await resp.json()
+ missing.extend(data["missing"])
+ return missing
+
+async def upload_files(session, build_url, token, files):
+ if len(files) == 0:
+ return
+ print("Uploading %d files (%d bytes)" % (len(files), reduce(lambda x, y: x + y, map(lambda f: f.size, files))))
+ with aiohttp.MultipartWriter() as writer:
+ for f in files:
+ writer.append(f)
+ writer.headers['Authorization'] = 'Bearer ' + token
+ resp = await session.request("post", build_url + '/upload', data=writer, headers=writer.headers)
+ async with resp:
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+
+async def upload_deltas(session, repo_path, build_url, token, deltas, refs, ignore_delta):
+ if not len(deltas):
+ return
+
+ req = []
+ for ref, commit in refs.items():
+ # Skip screenshots here
+ parts = ref.split("/")
+ if len(parts) == 4 and (parts[0] == "app" or parts[0] =="runtime") and not should_skip_delta(parts[1], ignore_delta):
+ for delta in deltas:
+ # Only upload from-scratch deltas, as these are the only reused ones
+ if delta == commit:
+ print(" %s: %s" % (ref, delta))
+ delta_name = delta_name_encode (delta)
+ delta_dir = repo_path + "/deltas/" + delta_name[:2] + "/" + delta_name[2:]
+ parts = os.listdir(delta_dir)
+ for part in parts:
+ req.append(AsyncNamedFilePart(delta_dir + "/" + part, filename = delta_name + "." + part + ".delta"))
+
+ if len(req):
+ await upload_files(session, build_url, token, req)
+
+
+async def upload_objects(session, repo_path, build_url, token, objects):
+ req = []
+ total_size = 0
+ for file_obj in objects:
+ named = get_object_multipart(repo_path, file_obj)
+ file_size = named.size
+ if total_size + file_size > UPLOAD_CHUNK_LIMIT: # The new object would bring us over the chunk limit
+ if len(req) > 0: # We already have some objects, upload those first
+ next_req = [named]
+ total_size = file_size
+ else:
+ next_req = []
+ req.append(named)
+ total_size = 0
+ await upload_files(session, build_url, token, req)
+ req = next_req
+ else:
+ total_size = total_size + file_size
+ req.append(named)
+
+ # Upload any remainder
+ await upload_files(session, build_url, token, req)
+
+async def create_ref(session, build_url, token, ref, commit):
+ print("Creating ref %s with commit %s" % (ref, commit))
+ resp = await session.post(build_url + "/build_ref", headers={'Authorization': 'Bearer ' + token}, json= { "ref": ref, "commit": commit} )
+ async with resp:
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+
+ data = await resp.json()
+ return data
+
+async def add_extra_ids(session, build_url, token, extra_ids):
+ print("Adding extra ids %s" % (extra_ids))
+ resp = await session.post(build_url + "/add_extra_ids", headers={'Authorization': 'Bearer ' + token}, json= { "ids": extra_ids} )
+ async with resp:
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+
+ data = await resp.json()
+ return data
+
+async def get_build(session, build_url, token):
+ resp = await session.get(build_url, headers={'Authorization': 'Bearer ' + token})
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+ data = await resp.json()
+ return data
+
+# For stupid reasons this is a string with json, lets expand it
+def reparse_job_results(job):
+ job["results"] = json.loads(job.get("results", "{}"))
+ return job
+
+async def get_job(session, job_url, token):
+ resp = await session.get(job_url, headers={'Authorization': 'Bearer ' + token}, json={})
+ async with resp:
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+ data = await resp.json()
+ return data
+
+async def wait_for_job(session, job_url, token):
+ reported_delay = False
+ old_job_status = 0
+ printed_len = 0
+ iterations_since_change=0
+ error_iterations = 0
+ while True:
+ try:
+ resp = await session.get(job_url, headers={'Authorization': 'Bearer ' + token}, json={'log-offset': printed_len})
+ async with resp:
+ if resp.status == 200:
+ error_iterations = 0
+ job = await resp.json()
+ job_status = job['status']
+ if job_status == 0 and not reported_delay:
+ reported_delay = True
+ start_after_struct = job.get("start_after", None)
+ if start_after_struct:
+ start_after = start_after_struct.get("secs_since_epoch", None)
+ now = time.time()
+ if start_after and start_after > now:
+ print("Waiting %d seconds before starting job" % (int(start_after - now)))
+ if job_status > 0 and old_job_status == 0:
+ print("/ Job was started");
+ old_job_status = job_status
+ log = job['log']
+ if len(log) > 0:
+ iterations_since_change=0
+ for line in log.splitlines(True):
+ print("| %s" % line, end="")
+ printed_len = printed_len + len(log)
+ else:
+ iterations_since_change=iterations_since_change+1
+ if job_status > 1:
+ if job_status == 2:
+ print("\ Job completed successfully")
+ else:
+ print("\ Job failed")
+ return job
+ else:
+ iterations_since_change=4 # Start at 4 so we ramp up the delay faster
+ error_iterations=error_iterations + 1
+ if error_iterations <= 5:
+ print("Unexpected response %s getting job log, ignoring" % resp.status)
+ else:
+ raise ApiError(resp, await resp.text())
+ except OSError as e:
+ if e.args[0] == errno.ECONNRESET:
+ # Client disconnected, retry
+ # Not sure exactly why, but i got a lot of ConnectionResetErrors here
+ # in tests. I guess the server stops reusing a http2 session after a bit
+ # Should be fine to retry with the backof
+ pass
+ else:
+ raise
+ # Some polling backoff to avoid loading the server
+ if iterations_since_change <= 1:
+ sleep_time=1
+ elif iterations_since_change < 5:
+ sleep_time=3
+ elif iterations_since_change < 15:
+ sleep_time=5
+ elif iterations_since_change < 30:
+ sleep_time=10
+ else:
+ sleep_time=60
+ time.sleep(sleep_time)
+
+async def commit_build(session, build_url, eol, eol_rebase, token_type, wait, token):
+ print("Committing build %s" % (build_url))
+ json = {
+ "endoflife": eol,
+ "endoflife_rebase": eol_rebase
+ }
+ if token_type != None:
+ json['token_type'] = token_type
+ resp = await session.post(build_url + "/commit", headers={'Authorization': 'Bearer ' + token}, json=json)
+ async with resp:
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+
+ job = await resp.json()
+ job_url = resp.headers['location'];
+
+ if wait:
+ print("Waiting for commit job")
+ job = await wait_for_job(session, job_url, token);
+
+ reparse_job_results(job)
+ job["location"] = job_url
+ return job
+
+async def publish_build(session, build_url, wait, token):
+ print("Publishing build %s" % (build_url))
+ resp = await session.post(build_url + "/publish", headers={'Authorization': 'Bearer ' + token}, json= { } )
+ async with resp:
+ if resp.status == 400:
+ body = await resp.text()
+ try:
+ msg = json.loads(body)
+ if msg.get("current-state", "") == "published":
+ print("the build has been already published")
+ return {}
+ except:
+ pass
+
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+
+ job = await resp.json()
+ job_url = resp.headers['location'];
+
+ if wait:
+ print("Waiting for publish job")
+ job = await wait_for_job(session, job_url, token);
+
+ reparse_job_results(job)
+ job["location"] = job_url
+ return job
+
+async def purge_build(session, build_url, token):
+ print("Purging build %s" % (build_url))
+ resp = await session.post(build_url + "/purge", headers={'Authorization': 'Bearer ' + token}, json= {} )
+ async with resp:
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+ return await resp.json()
+
+async def create_token(session, manager_url, token, name, subject, scope, duration):
+ token_url = urljoin(manager_url, "api/v1/token_subset")
+ resp = await session.post(token_url, headers={'Authorization': 'Bearer ' + token}, json = {
+ "name": name,
+ "sub": subject,
+ "scope": scope,
+ "duration": duration,
+ })
+ async with resp:
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+ return await resp.json()
+
+def get_object_multipart(repo_path, object):
+ return AsyncNamedFilePart(repo_path + "/objects/" + object[:2] + "/" + object[2:], filename=object)
+
+async def create_command(session, args):
+ build_url = urljoin(args.manager_url, "api/v1/build")
+ resp = await session.post(build_url, headers={'Authorization': 'Bearer ' + args.token}, json={
+ "repo": args.repo
+ })
+ async with resp:
+ if resp.status != 200:
+ raise ApiError(resp, await resp.text())
+ data = await resp.json()
+ data["location"] = resp.headers['location']
+ if not args.print_output:
+ print(resp.headers['location'])
+ return data
+
+def delta_name_part_encode(commit):
+ return base64.b64encode(binascii.unhexlify(commit), b"+_")[:-1].decode("utf-8")
+
+def delta_name_encode (delta):
+ return "-".join(map(delta_name_part_encode, delta.split("-")))
+
+def should_skip_delta(id, globs):
+ if globs:
+ for glob in globs:
+ if fnmatch.fnmatch(id, glob):
+ return True
+ return False
+
+def build_url_to_api(build_url):
+ parts = urlparse(build_url)
+ path = os.path.dirname(os.path.dirname(parts.path))
+ return urlunparse((parts.scheme, parts.netloc, path, None, None, None))
+
+@retry(
+ stop=stop_after_attempt(6),
+ wait=wait_fixed(10),
+ retry=retry_if_exception_type(ApiError),
+ reraise=True,
+)
+async def push_command(session, args):
+ local_repo = OSTree.Repo.new(Gio.File.new_for_path(args.repo_path))
+ try:
+ local_repo.open(None)
+ except GLib.Error as err:
+ raise UsageException("Can't open repo %s: %s" % (args.repo_path, err.message)) from err
+
+ refs = {}
+ if len(args.branches) == 0:
+ _, all_refs = local_repo.list_refs(None, None)
+ for ref in all_refs:
+ if ref.startswith("app/") or ref.startswith("runtime/") or ref.startswith("screenshots/"):
+ refs[ref] = all_refs[ref]
+ else:
+ for branch in args.branches:
+ _, rev = local_repo.resolve_rev(branch, False)
+ refs[branch] = rev
+
+ if (args.minimal_token):
+ id = os.path.basename(urlparse(args.build_url).path)
+ token = create_token(args.build_url, args.token, "minimal-upload", "build/%s" % (id), ["upload"], 60*60)["token"]
+ else:
+ token = args.token
+
+ print("Uploading refs to %s: %s"% (args.build_url, list(refs)))
+
+ metadata_objects = local_needed_metadata(local_repo, refs.values())
+
+ print("Refs contain %d metadata objects" % (len(metadata_objects)))
+
+ missing_metadata_objects = await missing_objects(session, args.build_url, token, list(metadata_objects))
+
+ print("Remote missing %d of those" % (len(missing_metadata_objects)))
+
+ file_objects = local_needed_files(local_repo, missing_metadata_objects)
+ print("Has %d file objects for those" % (len(file_objects)))
+
+ missing_file_objects = await missing_objects(session, args.build_url, token, list(file_objects))
+ print("Remote missing %d of those" % (len(missing_file_objects)))
+
+ # First upload all missing file objects
+ print("Uploading file objects")
+ await upload_objects(session, args.repo_path, args.build_url, token, missing_file_objects)
+
+ # Then all the metadata
+ print("Uploading metadata objects")
+ await upload_objects(session, args.repo_path, args.build_url, token, missing_metadata_objects)
+
+ _, deltas = local_repo.list_static_delta_names()
+ print("Uploading deltas")
+ await upload_deltas(session, args.repo_path, args.build_url, token, deltas, refs, args.ignore_delta)
+
+ # Then the refs
+ for ref, commit in refs.items():
+ await create_ref(session, args.build_url, token, ref, commit)
+
+ # Then any extra ids
+ if args.extra_id:
+ await add_extra_ids(session, args.build_url, token, args.extra_id)
+
+ commit_job = None
+ publish_job = None
+ update_job = None
+
+ # Note, this always uses the full token, as the minimal one only has upload permissions
+ if args.commit or args.publish:
+ commit_job = await commit_build(session, args.build_url, args.end_of_life, args.end_of_life_rebase, args.token_type, args.publish or args.wait, args.token)
+
+ if args.publish:
+ publish_job = await publish_build(session, args.build_url, args.wait or args.wait_update, args.token)
+ update_job_id = publish_job.get("results", {}).get("update-repo-job", None)
+ if update_job_id:
+ print("Queued repo update job %d" %(update_job_id))
+ update_job_url = build_url_to_api(args.build_url) + "/job/" + str(update_job_id)
+ if args.wait_update:
+ print("Waiting for repo update job")
+ update_job = await wait_for_job (session, update_job_url, token);
+ else:
+ update_job = await get_job(session, update_job_url, token)
+ reparse_job_results(update_job)
+ update_job["location"] = update_job_url
+
+ data = await get_build(session, args.build_url, args.token)
+ if commit_job:
+ data["commit_job"] = commit_job
+ if publish_job:
+ data["publish_job"] = publish_job
+ if update_job:
+ data["update_job"] = update_job
+ return data
+
+async def commit_command(session, args):
+ job = await commit_build(session, args.build_url, args.end_of_life, args.end_of_life_rebase, args.token_type, args.wait, args.token)
+ return job
+
+async def publish_command(session, args):
+ job = await publish_build(session, args.build_url, args.wait or args.wait_update, args.token)
+ update_job_id = job.get("results", {}).get("update-repo-job", None)
+ if update_job_id:
+ print("Queued repo update job %d" %(update_job_id))
+ update_job_url = build_url_to_api(args.build_url) + "/job/" + str(update_job_id)
+ if args.wait_update:
+ print("Waiting for repo update job")
+ update_job = await wait_for_job(session, update_job_url, args.token);
+ else:
+ update_job = await get_job(session, update_job_url, args.token)
+ reparse_job_results(update_job)
+ update_job["location"] = update_job_url
+ return job
+
+async def purge_command(session, args):
+ job = await purge_build(session, args.build_url, args.token)
+ return job
+
+async def create_token_command(session, args):
+ data = await create_token(session, args.manager_url, args.token, args.name, args.subject, args.scope, args.duration)
+ if not args.print_output:
+ print(data['token'])
+ return data
+
+async def follow_job_command(session, args):
+ job = await wait_for_job(session, args.job_url, args.token)
+ return job
+
+async def run_with_session(args):
+ timeout = aiohttp.ClientTimeout(total=90*60)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ result = await args.func(session, args)
+ return result
+
+if __name__ == '__main__':
+ progname = os.path.basename(sys.argv[0])
+
+ parser = ArgumentParser(prog=progname)
+ parser.add_argument('-v', '--verbose', action='store_true',
+ help='enable verbose output')
+ parser.add_argument('--debug', action='store_true',
+ help='enable debugging output')
+ parser.add_argument('--output', help='Write output json to file')
+ parser.add_argument('--print-output', action='store_true', help='Print output json')
+ parser.add_argument('--token', help='use this token')
+ parser.add_argument('--token-file', help='use token from file')
+ subparsers = parser.add_subparsers(title='subcommands',
+ dest='subparser_name',
+ description='valid subcommands',
+ help='additional help')
+
+ create_parser = subparsers.add_parser('create', help='Create new build')
+ create_parser.add_argument('manager_url', help='remote repo manager url')
+ create_parser.add_argument('repo', help='repo name')
+ create_parser.set_defaults(func=create_command)
+
+ push_parser = subparsers.add_parser('push', help='Push to repo manager')
+ push_parser.add_argument('build_url', help='remote build url')
+ push_parser.add_argument('repo_path', help='local repository')
+ push_parser.add_argument('branches', nargs='*', help='branches to push')
+ push_parser.add_argument('--commit', action='store_true',
+ help='commit build after pushing')
+ push_parser.add_argument('--publish', action='store_true',
+ help='publish build after committing')
+ push_parser.add_argument('--extra-id', action='append', help='add extra collection-id')
+ push_parser.add_argument('--ignore-delta', action='append', help='don\'t upload deltas matching this glob')
+ push_parser.add_argument('--wait', action='store_true',
+ help='wait for commit/publish to finish')
+ push_parser.add_argument('--wait-update', action='store_true',
+ help='wait for update-repo to finish')
+ push_parser.add_argument('--minimal-token', action='store_true',
+ help='Create minimal token for the upload')
+ push_parser.add_argument('--end-of-life', help='Set end of life')
+ push_parser.add_argument('--end-of-life-rebase', help='Set new ID which will supercede the current one')
+ push_parser.add_argument('--token-type', help='Set token type', type=int)
+ push_parser.set_defaults(func=push_command)
+
+ commit_parser = subparsers.add_parser('commit', help='Commit build')
+ commit_parser.add_argument('--wait', action='store_true',
+ help='wait for commit to finish')
+ commit_parser.add_argument('--end-of-life', help='Set end of life')
+ commit_parser.add_argument('--end-of-life-rebase', help='Set new ID which will supercede the current one')
+ commit_parser.add_argument('--token-type', help='Set token type', type=int)
+ commit_parser.add_argument('build_url', help='remote build url')
+ commit_parser.set_defaults(func=commit_command)
+
+ publish_parser = subparsers.add_parser('publish', help='Publish build')
+ publish_parser.add_argument('--wait', action='store_true',
+ help='wait for publish to finish')
+ publish_parser.add_argument('--wait-update', action='store_true',
+ help='wait for update-repo to finish')
+ publish_parser.add_argument('build_url', help='remote build url')
+ publish_parser.set_defaults(func=publish_command)
+
+ purge_parser = subparsers.add_parser('purge', help='Purge build')
+ purge_parser.add_argument('build_url', help='remote build url')
+ purge_parser.set_defaults(func=purge_command)
+
+ create_token_parser = subparsers.add_parser('create-token', help='Create subset token')
+ create_token_parser.add_argument('manager_url', help='remote repo manager url')
+ create_token_parser.add_argument('name', help='Name')
+ create_token_parser.add_argument('subject', help='Subject')
+ create_token_parser.add_argument('scope', nargs='*', help='Scope')
+ create_token_parser.add_argument('--duration', help='Duration until expires, in seconds',
+ default=60*60*24, # Default duration is one day
+ type=int)
+ create_token_parser.set_defaults(func=create_token_command)
+
+ follow_job_parser = subparsers.add_parser('follow-job', help='Follow existing job log')
+ follow_job_parser.add_argument('job_url', help='url of job')
+ follow_job_parser.set_defaults(func=follow_job_command)
+
+ args = parser.parse_args()
+
+ loglevel = logging.WARNING
+ if args.verbose:
+ loglevel = logging.INFO
+ if args.debug:
+ loglevel = logging.DEBUG
+
+ logging.basicConfig(format='%(module)s: %(levelname)s: %(message)s',
+ level=loglevel, stream=sys.stderr)
+
+ if not args.subparser_name:
+ print("No subcommand specified, see --help for usage")
+ exit(1)
+
+ if not args.token:
+ if args.token_file:
+ file = open(args.token_file, 'rb')
+ args.token = file.read().splitlines()[0].decode("utf-8").strip()
+ elif "REPO_TOKEN" in os.environ:
+ args.token = os.environ["REPO_TOKEN"]
+ else:
+ print("No token available, pass with --token, --token-file or $REPO_TOKEN")
+ exit(1)
+
+
+ res = 1
+ output = None
+ try:
+ loop = asyncio.get_event_loop()
+ result = loop.run_until_complete(run_with_session(args))
+
+ output = {
+ "command": args.subparser_name,
+ "result": result,
+ }
+ res = 0
+ except SystemExit:
+ # Something called sys.exit(), lets just exit
+ res = 1
+ raise # Pass on regular exit callse
+ except ApiError as e:
+ eprint(str(e))
+ output = {
+ "command": args.subparser_name,
+ "error": e.repr(),
+ }
+ except UsageException as e:
+ eprint(str(e))
+ output = {
+ "error": {
+ "type": "usage",
+ "details": {
+ "message": str(e),
+ }
+ }
+ }
+ except:
+ ei = sys.exc_info()
+ eprint("Unexpected %s exception in %s: %s" % (ei[0].__name__, args.subparser_name, ei[1]))
+ eprint(traceback.format_exc())
+ output = {
+ "command": args.subparser_name,
+ "error": {
+ "type": "exception",
+ "details": {
+ "error-type": ei[0].__name__,
+ "message": str(ei[1]),
+ }
+ }
+ }
+ res = 1
+
+ if output:
+ if args.print_output:
+ print(json.dumps(output, indent=4))
+ if args.output:
+ f = open(args.output,"w+")
+ f.write(json.dumps(output, indent=4))
+ f.write("\n")
+ f.close()
+ exit(res)
diff --git a/scripts/upload-to-flatpak-repo.sh b/scripts/upload-to-flatpak-repo.sh
new file mode 100755
index 00000000..fdb37f82
--- /dev/null
+++ b/scripts/upload-to-flatpak-repo.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+
+if [ -z "$1" ]; then
+ echo "Missing repo to upload!"
+ exit 1
+fi
+
+if [ -n "${CI_COMMIT_TAG}" ]; then
+ BUILD_URL=$(./flat-manager-client create https://flatpak.neko.dev stable)
+elif [ "master" = "${CI_COMMIT_REF_NAME}" ]; then
+ BUILD_URL=$(./flat-manager-client create https://flatpak.neko.dev nightly)
+fi
+
+if [ -z "${BUILD_URL}" ]; then
+ echo "No upload to repo."
+ exit 0
+fi
+
+BUILD_URL=${BUILD_URL/http:/https:}
+
+./flat-manager-client push $BUILD_URL $1
+./flat-manager-client commit --wait $BUILD_URL
+./flat-manager-client publish --wait $BUILD_URL
+
diff --git a/src/Cache.cpp b/src/Cache.cpp
index 8cf66d21..c81840c6 100644
--- a/src/Cache.cpp
+++ b/src/Cache.cpp
@@ -102,6 +102,20 @@ namespace {
std::unique_ptr instance_ = nullptr;
}
+template
+static T
+to(lmdb::val &value)
+{
+ static_assert(std::is_trivial_v, "Can only convert to trivial types!");
+ T temp;
+
+ if (value.size() < sizeof(T))
+ throw lmdb::runtime_error(__func__, MDB_BAD_VALSIZE);
+
+ std::memcpy(&temp, value.data(), sizeof(T));
+ return temp;
+}
+
bool
Cache::isHiddenEvent(lmdb::txn &txn,
mtx::events::collections::TimelineEvents e,
@@ -1667,14 +1681,14 @@ Cache::getTimelineMessages(lmdb::txn &txn, const std::string &room_id, uint64_t
auto cursor = lmdb::cursor::open(txn, orderDb);
if (index == std::numeric_limits::max()) {
if (cursor.get(indexVal, event_id, forward ? MDB_FIRST : MDB_LAST)) {
- index = *indexVal.data();
+ index = to(indexVal);
} else {
messages.end_of_cache = true;
return messages;
}
} else {
if (cursor.get(indexVal, event_id, MDB_SET)) {
- index = *indexVal.data();
+ index = to(indexVal);
} else {
messages.end_of_cache = true;
return messages;
@@ -1708,7 +1722,7 @@ Cache::getTimelineMessages(lmdb::txn &txn, const std::string &room_id, uint64_t
cursor.close();
// std::reverse(timeline.events.begin(), timeline.events.end());
- messages.next_index = *indexVal.data();
+ messages.next_index = to(indexVal);
messages.end_of_cache = !ret;
return messages;
@@ -1861,12 +1875,12 @@ Cache::getTimelineRange(const std::string &room_id)
}
TimelineRange range{};
- range.last = *indexVal.data();
+ range.last = to(indexVal);
if (!cursor.get(indexVal, val, MDB_FIRST)) {
return {};
}
- range.first = *indexVal.data();
+ range.first = to(indexVal);
return range;
}
@@ -1892,7 +1906,7 @@ Cache::getTimelineIndex(const std::string &room_id, std::string_view event_id)
return {};
}
- return *val.data();
+ return to(val);
}
std::optional
@@ -1920,7 +1934,7 @@ Cache::getEventIndex(const std::string &room_id, std::string_view event_id)
return {};
}
- return *val.data();
+ return to(val);
}
std::optional>
@@ -1951,7 +1965,7 @@ Cache::lastInvisibleEventAfter(const std::string &room_id, std::string_view even
if (!success) {
return {};
}
- uint64_t prevIdx = *indexVal.data();
+ uint64_t prevIdx = to(indexVal);
std::string prevId{eventIdVal.data(), eventIdVal.size()};
auto cursor = lmdb::cursor::open(txn, eventOrderDb);
@@ -1964,7 +1978,7 @@ Cache::lastInvisibleEventAfter(const std::string &room_id, std::string_view even
if (lmdb::dbi_get(txn, timelineDb, lmdb::val(evId.data(), evId.size()), temp)) {
return std::pair{prevIdx, std::string(prevId)};
} else {
- prevIdx = *indexVal.data();
+ prevIdx = to(indexVal);
prevId = std::move(evId);
}
}
@@ -1994,7 +2008,7 @@ Cache::getArrivalIndex(const std::string &room_id, std::string_view event_id)
return {};
}
- return *val.data();
+ return to(val);
}
std::optional
@@ -2775,13 +2789,13 @@ Cache::saveTimelineMessages(lmdb::txn &txn,
uint64_t index = std::numeric_limits::max() / 2;
auto cursor = lmdb::cursor::open(txn, orderDb);
if (cursor.get(indexVal, val, MDB_LAST)) {
- index = *indexVal.data();
+ index = to(indexVal);
}
uint64_t msgIndex = std::numeric_limits::max() / 2;
auto msgCursor = lmdb::cursor::open(txn, order2msgDb);
if (msgCursor.get(indexVal, val, MDB_LAST)) {
- msgIndex = *indexVal.data();
+ msgIndex = to(indexVal);
}
bool first = true;
@@ -2942,7 +2956,7 @@ Cache::saveOldMessages(const std::string &room_id, const mtx::responses::Message
{
auto cursor = lmdb::cursor::open(txn, orderDb);
if (cursor.get(indexVal, val, MDB_FIRST)) {
- index = *indexVal.data();
+ index = to(indexVal);
}
}
@@ -2950,7 +2964,7 @@ Cache::saveOldMessages(const std::string &room_id, const mtx::responses::Message
{
auto msgCursor = lmdb::cursor::open(txn, order2msgDb);
if (msgCursor.get(indexVal, val, MDB_FIRST)) {
- msgIndex = *indexVal.data();
+ msgIndex = to(indexVal);
}
}
@@ -3258,12 +3272,12 @@ Cache::deleteOldMessages()
uint64_t first, last;
if (cursor.get(indexVal, val, MDB_LAST)) {
- last = *indexVal.data();
+ last = to(indexVal);
} else {
continue;
}
if (cursor.get(indexVal, val, MDB_FIRST)) {
- first = *indexVal.data();
+ first = to(indexVal);
} else {
continue;
}
diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp
index 45802789..aae9271d 100644
--- a/src/ChatPage.cpp
+++ b/src/ChatPage.cpp
@@ -253,6 +253,7 @@ ChatPage::ChatPage(QSharedPointer userSettings, QWidget *parent)
this, &ChatPage::updateGroupsInfo, communitiesList_, &CommunitiesList::setCommunities);
connect(this, &ChatPage::leftRoom, this, &ChatPage::removeRoom);
+ connect(this, &ChatPage::newRoom, this, &ChatPage::changeRoom, Qt::QueuedConnection);
connect(this, &ChatPage::notificationsRetrieved, this, &ChatPage::sendNotifications);
connect(this,
&ChatPage::highlightedNotifsRetrieved,
@@ -920,6 +921,13 @@ ChatPage::joinRoom(const QString &room)
void
ChatPage::joinRoomVia(const std::string &room_id, const std::vector &via)
{
+ if (QMessageBox::Yes !=
+ QMessageBox::question(
+ this,
+ tr("Confirm join"),
+ tr("Do you really want to join %1?").arg(QString::fromStdString(room_id))))
+ return;
+
http::client()->join_room(
room_id, via, [this, room_id](const mtx::responses::RoomId &, mtx::http::RequestErr err) {
if (err) {
@@ -960,8 +968,9 @@ ChatPage::createRoom(const mtx::requests::CreateRoom &req)
return;
}
- emit showNotification(
- tr("Room %1 created.").arg(QString::fromStdString(res.room_id.to_string())));
+ QString newRoomId = QString::fromStdString(res.room_id.to_string());
+ emit showNotification(tr("Room %1 created.").arg(newRoomId));
+ emit newRoom(newRoomId);
});
}
@@ -982,6 +991,13 @@ ChatPage::leaveRoom(const QString &room_id)
});
}
+void
+ChatPage::changeRoom(const QString &room_id)
+{
+ view_manager_->setHistoryView(room_id);
+ room_list_->highlightSelectedRoom(room_id);
+}
+
void
ChatPage::inviteUser(QString userid, QString reason)
{
@@ -1308,6 +1324,13 @@ ChatPage::startChat(QString userid)
}
}
+ if (QMessageBox::Yes !=
+ QMessageBox::question(
+ this,
+ tr("Confirm invite"),
+ tr("Do you really want to start a private chat with %1?").arg(userid)))
+ return;
+
mtx::requests::CreateRoom req;
req.preset = mtx::requests::Preset::PrivateChat;
req.visibility = mtx::common::RoomVisibility::Private;
@@ -1326,14 +1349,14 @@ mxidFromSegments(QStringRef sigil, QStringRef mxid)
auto mxid_ = QUrl::fromPercentEncoding(mxid.toUtf8());
- if (sigil == "user") {
+ if (sigil == "u") {
return "@" + mxid_;
} else if (sigil == "roomid") {
return "!" + mxid_;
- } else if (sigil == "room") {
+ } else if (sigil == "r") {
return "#" + mxid_;
- } else if (sigil == "group") {
- return "+" + mxid_;
+ //} else if (sigil == "group") {
+ // return "+" + mxid_;
} else {
return "";
}
@@ -1383,7 +1406,7 @@ ChatPage::handleMatrixUri(const QByteArray &uri)
}
}
- if (sigil1 == "user") {
+ if (sigil1 == "u") {
if (action.isEmpty()) {
view_manager_->activeTimeline()->openUserProfile(mxid1);
} else if (action == "chat") {
@@ -1400,10 +1423,10 @@ ChatPage::handleMatrixUri(const QByteArray &uri)
}
}
- if (action == "join") {
+ if (action == "join" || action.isEmpty()) {
joinRoomVia(targetRoomId, vias);
}
- } else if (sigil1 == "room") {
+ } else if (sigil1 == "r") {
auto joined_rooms = cache::joinedRooms();
auto targetRoomAlias = mxid1.toStdString();
@@ -1418,7 +1441,7 @@ ChatPage::handleMatrixUri(const QByteArray &uri)
}
}
- if (action == "join") {
+ if (action == "join" || action.isEmpty()) {
joinRoomVia(mxid1.toStdString(), vias);
}
}
diff --git a/src/ChatPage.h b/src/ChatPage.h
index 917bd785..dc6b8299 100644
--- a/src/ChatPage.h
+++ b/src/ChatPage.h
@@ -154,6 +154,7 @@ signals:
void tryInitialSyncCb();
void newSyncResponse(const mtx::responses::Sync &res);
void leftRoom(const QString &room_id);
+ void newRoom(const QString &room_id);
void initializeRoomList(QMap);
void initializeViews(const mtx::responses::Rooms &rooms);
@@ -201,6 +202,7 @@ signals:
private slots:
void logout();
void removeRoom(const QString &room_id);
+ void changeRoom(const QString &room_id);
void dropToLoginPage(const QString &msg);
void handleSyncResponse(const mtx::responses::Sync &res);
diff --git a/src/LoginPage.cpp b/src/LoginPage.cpp
index 15aeb12a..cd54431d 100644
--- a/src/LoginPage.cpp
+++ b/src/LoginPage.cpp
@@ -147,16 +147,23 @@ LoginPage::LoginPage(QWidget *parent)
error_matrixid_label_->hide();
button_layout_ = new QHBoxLayout();
- button_layout_->setSpacing(0);
+ button_layout_->setSpacing(20);
button_layout_->setContentsMargins(0, 0, 0, 30);
login_button_ = new RaisedButton(tr("LOGIN"), this);
- login_button_->setMinimumSize(350, 65);
+ login_button_->setMinimumSize(150, 65);
login_button_->setFontSize(20);
login_button_->setCornerRadius(3);
+ sso_login_button_ = new RaisedButton(tr("SSO LOGIN"), this);
+ sso_login_button_->setMinimumSize(150, 65);
+ sso_login_button_->setFontSize(20);
+ sso_login_button_->setCornerRadius(3);
+ sso_login_button_->setVisible(false);
+
button_layout_->addStretch(1);
button_layout_->addWidget(login_button_);
+ button_layout_->addWidget(sso_login_button_);
button_layout_->addStretch(1);
error_label_ = new QLabel(this);
@@ -179,7 +186,17 @@ LoginPage::LoginPage(QWidget *parent)
this, &LoginPage::versionErrorCb, this, &LoginPage::versionError, Qt::QueuedConnection);
connect(back_button_, SIGNAL(clicked()), this, SLOT(onBackButtonClicked()));
- connect(login_button_, SIGNAL(clicked()), this, SLOT(onLoginButtonClicked()));
+ connect(login_button_, &RaisedButton::clicked, this, [this]() {
+ onLoginButtonClicked(passwordSupported ? LoginMethod::Password : LoginMethod::SSO);
+ });
+ connect(sso_login_button_, &RaisedButton::clicked, this, [this]() {
+ onLoginButtonClicked(LoginMethod::SSO);
+ });
+ connect(this,
+ &LoginPage::showErrorMessage,
+ this,
+ static_cast(&LoginPage::showError),
+ Qt::QueuedConnection);
connect(matrixid_input_, SIGNAL(returnPressed()), login_button_, SLOT(click()));
connect(password_input_, SIGNAL(returnPressed()), login_button_, SLOT(click()));
connect(deviceName_, SIGNAL(returnPressed()), login_button_, SLOT(click()));
@@ -314,16 +331,19 @@ LoginPage::checkHomeserverVersion()
http::client()->get_login(
[this](mtx::responses::LoginFlows flows, mtx::http::RequestErr err) {
if (err || flows.flows.empty())
- emit versionOkCb(LoginMethod::Password);
+ emit versionOkCb(true, false);
- LoginMethod loginMethod_ = LoginMethod::Password;
+ bool ssoSupported_ = false;
+ bool passwordSupported_ = false;
for (const auto &flow : flows.flows) {
if (flow.type == mtx::user_interactive::auth_types::sso) {
- loginMethod_ = LoginMethod::SSO;
- break;
+ ssoSupported_ = true;
+ } else if (flow.type ==
+ mtx::user_interactive::auth_types::password) {
+ passwordSupported_ = true;
}
}
- emit versionOkCb(loginMethod_);
+ emit versionOkCb(passwordSupported_, ssoSupported_);
});
});
}
@@ -355,28 +375,24 @@ LoginPage::versionError(const QString &error)
}
void
-LoginPage::versionOk(LoginMethod loginMethod_)
+LoginPage::versionOk(bool passwordSupported_, bool ssoSupported_)
{
- this->loginMethod = loginMethod_;
+ passwordSupported = passwordSupported_;
+ ssoSupported = ssoSupported_;
serverLayout_->removeWidget(spinner_);
matrixidLayout_->removeWidget(spinner_);
spinner_->stop();
- if (loginMethod == LoginMethod::SSO) {
- password_input_->hide();
- login_button_->setText(tr("SSO LOGIN"));
- } else {
- password_input_->show();
- login_button_->setText(tr("LOGIN"));
- }
+ sso_login_button_->setVisible(ssoSupported);
+ login_button_->setVisible(passwordSupported);
if (serverInput_->isVisible())
serverInput_->hide();
}
void
-LoginPage::onLoginButtonClicked()
+LoginPage::onLoginButtonClicked(LoginMethod loginMethod)
{
error_label_->setText("");
@@ -411,8 +427,8 @@ LoginPage::onLoginButtonClicked()
: deviceName_->text().toStdString(),
[this](const mtx::responses::Login &res, mtx::http::RequestErr err) {
if (err) {
- showError(error_label_,
- QString::fromStdString(err->matrix_error.error));
+ showErrorMessage(error_label_,
+ QString::fromStdString(err->matrix_error.error));
emit errorOccurred();
return;
}
@@ -437,7 +453,7 @@ LoginPage::onLoginButtonClicked()
http::client()->login(
req, [this](const mtx::responses::Login &res, mtx::http::RequestErr err) {
if (err) {
- showError(
+ showErrorMessage(
error_label_,
QString::fromStdString(err->matrix_error.error));
emit errorOccurred();
@@ -456,7 +472,7 @@ LoginPage::onLoginButtonClicked()
sso->deleteLater();
});
connect(sso, &SSOHandler::ssoFailed, this, [this, sso]() {
- showError(error_label_, tr("SSO login failed"));
+ showErrorMessage(error_label_, tr("SSO login failed"));
emit errorOccurred();
sso->deleteLater();
});
diff --git a/src/LoginPage.h b/src/LoginPage.h
index 5ed21dec..f6428cbb 100644
--- a/src/LoginPage.h
+++ b/src/LoginPage.h
@@ -56,9 +56,10 @@ signals:
//! Used to trigger the corresponding slot outside of the main thread.
void versionErrorCb(const QString &err);
- void versionOkCb(LoginPage::LoginMethod method);
+ void versionOkCb(bool passwordSupported, bool ssoSupported);
void loginOk(const mtx::responses::Login &res);
+ void showErrorMessage(QLabel *label, const QString &msg);
protected:
void paintEvent(QPaintEvent *event) override;
@@ -73,7 +74,7 @@ private slots:
void onBackButtonClicked();
// Callback for the login button.
- void onLoginButtonClicked();
+ void onLoginButtonClicked(LoginMethod loginMethod);
// Callback for probing the server found in the mxid
void onMatrixIdEntered();
@@ -84,7 +85,7 @@ private slots:
// Callback for errors produced during server probing
void versionError(const QString &error_message);
// Callback for successful server probing
- void versionOk(LoginPage::LoginMethod method);
+ void versionOk(bool passwordSupported, bool ssoSupported);
private:
void checkHomeserverVersion();
@@ -120,7 +121,7 @@ private:
QString inferredServerAddress_;
FlatButton *back_button_;
- RaisedButton *login_button_;
+ RaisedButton *login_button_, *sso_login_button_;
QWidget *form_widget_;
QHBoxLayout *form_wrapper_;
@@ -130,5 +131,6 @@ private:
TextField *password_input_;
TextField *deviceName_;
TextField *serverInput_;
- LoginMethod loginMethod = LoginMethod::Password;
+ bool passwordSupported = true;
+ bool ssoSupported = false;
};
diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp
index ab3c2cf2..ae532ef3 100644
--- a/src/MainWindow.cpp
+++ b/src/MainWindow.cpp
@@ -51,7 +51,6 @@
#include "dialogs/Logout.h"
#include "dialogs/MemberList.h"
#include "dialogs/ReadReceipts.h"
-#include "dialogs/RoomSettings.h"
MainWindow *MainWindow::instance_ = nullptr;
@@ -363,14 +362,6 @@ MainWindow::hasActiveUser()
settings.contains(prefix + "auth/user_id");
}
-void
-MainWindow::openRoomSettings(const QString &room_id)
-{
- auto dialog = new dialogs::RoomSettings(room_id, this);
-
- showDialog(dialog);
-}
-
void
MainWindow::openMemberListDialog(const QString &room_id)
{
diff --git a/src/MainWindow.h b/src/MainWindow.h
index bb219813..4a8ea642 100644
--- a/src/MainWindow.h
+++ b/src/MainWindow.h
@@ -54,7 +54,6 @@ class LeaveRoom;
class Logout;
class MemberList;
class ReCaptcha;
-class RoomSettings;
}
class MainWindow : public QMainWindow
@@ -78,7 +77,6 @@ public:
std::function callback);
void openJoinRoomDialog(std::function callback);
void openLogoutDialog();
- void openRoomSettings(const QString &room_id);
void openMemberListDialog(const QString &room_id);
void openReadReceiptsDialog(const QString &event_id);
diff --git a/src/RegisterPage.cpp b/src/RegisterPage.cpp
index 44ad7a3d..004d5b98 100644
--- a/src/RegisterPage.cpp
+++ b/src/RegisterPage.cpp
@@ -277,6 +277,7 @@ RegisterPage::RegisterPage(QWidget *parent)
if (!err) {
http::client()->set_user(res.user_id);
http::client()->set_access_token(res.access_token);
+ http::client()->set_device_id(res.device_id);
emit registerOk();
return;
diff --git a/src/RoomInfoListItem.h b/src/RoomInfoListItem.h
index baa8b98b..c2826f6f 100644
--- a/src/RoomInfoListItem.h
+++ b/src/RoomInfoListItem.h
@@ -217,4 +217,6 @@ private:
QColor bubbleBgColor_;
QColor bubbleFgColor_;
+
+ friend struct room_sort;
};
diff --git a/src/RoomList.cpp b/src/RoomList.cpp
index 67a7ac40..10042c94 100644
--- a/src/RoomList.cpp
+++ b/src/RoomList.cpp
@@ -353,8 +353,8 @@ RoomList::updateRoomDescription(const QString &roomid, const DescInfo &info)
struct room_sort
{
- bool operator()(const QSharedPointer a,
- const QSharedPointer b) const
+ bool operator()(const QSharedPointer &a,
+ const QSharedPointer &b) const
{
// Sort by "importance" (i.e. invites before mentions before
// notifs before new events before old events), then secondly
@@ -370,9 +370,9 @@ struct room_sort
// Now sort by recency
// Zero if empty, otherwise the time that the event occured
const uint64_t a_recency =
- a->lastMessageInfo().userid.isEmpty() ? 0 : a->lastMessageInfo().timestamp;
+ a->lastMsgInfo_.userid.isEmpty() ? 0 : a->lastMsgInfo_.timestamp;
const uint64_t b_recency =
- b->lastMessageInfo().userid.isEmpty() ? 0 : b->lastMessageInfo().timestamp;
+ b->lastMsgInfo_.userid.isEmpty() ? 0 : b->lastMsgInfo_.timestamp;
return a_recency > b_recency;
}
};
diff --git a/src/RoomsModel.cpp b/src/RoomsModel.cpp
new file mode 100644
index 00000000..4286f87b
--- /dev/null
+++ b/src/RoomsModel.cpp
@@ -0,0 +1,69 @@
+#include "RoomsModel.h"
+
+#include
+
+#include "Cache_p.h"
+#include "CompletionModelRoles.h"
+
+RoomsModel::RoomsModel(bool showOnlyRoomWithAliases, QObject *parent)
+ : QAbstractListModel(parent)
+ , showOnlyRoomWithAliases_(showOnlyRoomWithAliases)
+{
+ std::vector rooms_ = cache::joinedRooms();
+ roomInfos = cache::getRoomInfo(rooms_);
+
+ for (const auto &r : rooms_) {
+ auto roomAliasesList = cache::client()->getRoomAliases(r);
+
+ if (showOnlyRoomWithAliases_) {
+ if (roomAliasesList && !roomAliasesList->alias.empty()) {
+ roomids.push_back(QString::fromStdString(r));
+ roomAliases.push_back(
+ QString::fromStdString(roomAliasesList->alias));
+ }
+ } else {
+ roomids.push_back(QString::fromStdString(r));
+ roomAliases.push_back(
+ roomAliasesList ? QString::fromStdString(roomAliasesList->alias) : "");
+ }
+ }
+}
+
+QHash
+RoomsModel::roleNames() const
+{
+ return {{CompletionModel::CompletionRole, "completionRole"},
+ {CompletionModel::SearchRole, "searchRole"},
+ {CompletionModel::SearchRole2, "searchRole2"},
+ {Roles::RoomAlias, "roomAlias"},
+ {Roles::AvatarUrl, "avatarUrl"},
+ {Roles::RoomID, "roomid"},
+ {Roles::RoomName, "roomName"}};
+}
+
+QVariant
+RoomsModel::data(const QModelIndex &index, int role) const
+{
+ if (hasIndex(index.row(), index.column(), index.parent())) {
+ switch (role) {
+ case CompletionModel::CompletionRole: {
+ QString percentEncoding = QUrl::toPercentEncoding(roomAliases[index.row()]);
+ return QString("[%1](https://matrix.to/#/%2)")
+ .arg(roomAliases[index.row()], percentEncoding);
+ }
+ case CompletionModel::SearchRole:
+ case Qt::DisplayRole:
+ case Roles::RoomAlias:
+ return roomAliases[index.row()];
+ case CompletionModel::SearchRole2:
+ case Roles::RoomName:
+ return QString::fromStdString(roomInfos.at(roomids[index.row()]).name);
+ case Roles::AvatarUrl:
+ return QString::fromStdString(
+ roomInfos.at(roomids[index.row()]).avatar_url);
+ case Roles::RoomID:
+ return roomids[index.row()];
+ }
+ }
+ return {};
+}
diff --git a/src/RoomsModel.h b/src/RoomsModel.h
new file mode 100644
index 00000000..0e006448
--- /dev/null
+++ b/src/RoomsModel.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include "Cache.h"
+
+#include
+#include
+
+class RoomsModel : public QAbstractListModel
+{
+public:
+ enum Roles
+ {
+ AvatarUrl = Qt::UserRole,
+ RoomAlias,
+ RoomID,
+ RoomName,
+ };
+
+ RoomsModel(bool showOnlyRoomWithAliases = false, QObject *parent = nullptr);
+ QHash roleNames() const override;
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override
+ {
+ (void)parent;
+ return (int)roomids.size();
+ }
+ QVariant data(const QModelIndex &index, int role) const override;
+
+private:
+ std::vector roomids;
+ std::vector roomAliases;
+ std::map roomInfos;
+ bool showOnlyRoomWithAliases_;
+};
diff --git a/src/dialogs/RoomSettings.cpp b/src/dialogs/RoomSettings.cpp
deleted file mode 100644
index bd3cc26f..00000000
--- a/src/dialogs/RoomSettings.cpp
+++ /dev/null
@@ -1,865 +0,0 @@
-#include "dialogs/RoomSettings.h"
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-#include "Cache.h"
-#include "ChatPage.h"
-#include "Config.h"
-#include "Logging.h"
-#include "MatrixClient.h"
-#include "Utils.h"
-#include "ui/Avatar.h"
-#include "ui/FlatButton.h"
-#include "ui/LoadingIndicator.h"
-#include "ui/Painter.h"
-#include "ui/TextField.h"
-#include "ui/ToggleButton.h"
-
-using namespace dialogs;
-using namespace mtx::events;
-
-constexpr int BUTTON_SIZE = 36;
-constexpr int BUTTON_RADIUS = BUTTON_SIZE / 2;
-constexpr int WIDGET_MARGIN = 20;
-constexpr int TOP_WIDGET_MARGIN = 2 * WIDGET_MARGIN;
-constexpr int WIDGET_SPACING = 15;
-constexpr int TEXT_SPACING = 4;
-constexpr int BUTTON_SPACING = 2 * TEXT_SPACING;
-
-bool
-ClickableFilter::eventFilter(QObject *obj, QEvent *event)
-{
- if (event->type() == QEvent::MouseButtonRelease) {
- emit clicked();
- return true;
- }
-
- return QObject::eventFilter(obj, event);
-}
-
-EditModal::EditModal(const QString &roomId, QWidget *parent)
- : QWidget(parent)
- , roomId_{roomId}
-{
- setAutoFillBackground(true);
- setAttribute(Qt::WA_DeleteOnClose, true);
- setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
- setWindowModality(Qt::WindowModal);
-
- QFont largeFont;
- largeFont.setPointSizeF(largeFont.pointSizeF() * 1.4);
- setMinimumWidth(conf::window::minModalWidth);
- setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
-
- auto layout = new QVBoxLayout(this);
-
- applyBtn_ = new QPushButton(tr("Apply"), this);
- cancelBtn_ = new QPushButton(tr("Cancel"), this);
- cancelBtn_->setDefault(true);
-
- auto btnLayout = new QHBoxLayout;
- btnLayout->addStretch(1);
- btnLayout->setSpacing(15);
- btnLayout->addWidget(cancelBtn_);
- btnLayout->addWidget(applyBtn_);
-
- nameInput_ = new TextField(this);
- nameInput_->setLabel(tr("Name").toUpper());
- topicInput_ = new TextField(this);
- topicInput_->setLabel(tr("Topic").toUpper());
-
- errorField_ = new QLabel(this);
- errorField_->setWordWrap(true);
- errorField_->hide();
-
- layout->addWidget(nameInput_);
- layout->addWidget(topicInput_);
- layout->addLayout(btnLayout, 1);
-
- auto labelLayout = new QHBoxLayout;
- labelLayout->setAlignment(Qt::AlignHCenter);
- labelLayout->addWidget(errorField_);
- layout->addLayout(labelLayout);
-
- connect(applyBtn_, &QPushButton::clicked, this, &EditModal::applyClicked);
- connect(cancelBtn_, &QPushButton::clicked, this, &EditModal::close);
-
- auto window = QApplication::activeWindow();
- auto center = window->frameGeometry().center();
- move(center.x() - (width() * 0.5), center.y() - (height() * 0.5));
-}
-
-void
-EditModal::topicEventSent()
-{
- errorField_->hide();
- close();
-}
-
-void
-EditModal::nameEventSent(const QString &name)
-{
- errorField_->hide();
- emit nameChanged(name);
- close();
-}
-
-void
-EditModal::error(const QString &msg)
-{
- errorField_->setText(msg);
- errorField_->show();
-}
-
-void
-EditModal::applyClicked()
-{
- // Check if the values are changed from the originals.
- auto newName = nameInput_->text().trimmed();
- auto newTopic = topicInput_->text().trimmed();
-
- errorField_->hide();
-
- if (newName == initialName_ && newTopic == initialTopic_) {
- close();
- return;
- }
-
- using namespace mtx::events;
- auto proxy = std::make_shared();
- connect(proxy.get(), &ThreadProxy::topicEventSent, this, &EditModal::topicEventSent);
- connect(proxy.get(), &ThreadProxy::nameEventSent, this, &EditModal::nameEventSent);
- connect(proxy.get(), &ThreadProxy::error, this, &EditModal::error);
-
- if (newName != initialName_ && !newName.isEmpty()) {
- state::Name body;
- body.name = newName.toStdString();
-
- http::client()->send_state_event(
- roomId_.toStdString(),
- body,
- [proxy, newName](const mtx::responses::EventId &, mtx::http::RequestErr err) {
- if (err) {
- emit proxy->error(
- QString::fromStdString(err->matrix_error.error));
- return;
- }
-
- emit proxy->nameEventSent(newName);
- });
- }
-
- if (newTopic != initialTopic_ && !newTopic.isEmpty()) {
- state::Topic body;
- body.topic = newTopic.toStdString();
-
- http::client()->send_state_event(
- roomId_.toStdString(),
- body,
- [proxy](const mtx::responses::EventId &, mtx::http::RequestErr err) {
- if (err) {
- emit proxy->error(
- QString::fromStdString(err->matrix_error.error));
- return;
- }
-
- emit proxy->topicEventSent();
- });
- }
-}
-
-void
-EditModal::setFields(const QString &roomName, const QString &roomTopic)
-{
- initialName_ = roomName;
- initialTopic_ = roomTopic;
-
- nameInput_->setText(roomName);
- topicInput_->setText(roomTopic);
-}
-
-RoomSettings::RoomSettings(const QString &room_id, QWidget *parent)
- : QFrame(parent)
- , room_id_{std::move(room_id)}
-{
- retrieveRoomInfo();
-
- setAutoFillBackground(true);
- setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
- setWindowModality(Qt::WindowModal);
- setAttribute(Qt::WA_DeleteOnClose, true);
-
- QFont largeFont;
- largeFont.setPointSizeF(largeFont.pointSizeF() * 1.5);
-
- setMinimumWidth(conf::window::minModalWidth);
- setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
-
- auto layout = new QVBoxLayout(this);
- layout->setSpacing(WIDGET_SPACING);
- layout->setContentsMargins(WIDGET_MARGIN, TOP_WIDGET_MARGIN, WIDGET_MARGIN, WIDGET_MARGIN);
-
- QFont font;
- font.setWeight(QFont::Medium);
- auto settingsLabel = new QLabel(tr("Settings").toUpper(), this);
- settingsLabel->setFont(font);
-
- auto infoLabel = new QLabel(tr("Info").toUpper(), this);
- infoLabel->setFont(font);
-
- QFont monospaceFont = QFontDatabase::systemFont(QFontDatabase::FixedFont);
-
- auto roomIdLabel = new QLabel(room_id, this);
- roomIdLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
- roomIdLabel->setFont(monospaceFont);
-
- auto roomIdLayout = new QHBoxLayout;
- roomIdLayout->setMargin(0);
- roomIdLayout->addWidget(new QLabel(tr("Internal ID"), this),
- Qt::AlignBottom | Qt::AlignLeft);
- roomIdLayout->addWidget(roomIdLabel, 0, Qt::AlignBottom | Qt::AlignRight);
-
- auto roomVersionLabel = new QLabel(QString::fromStdString(info_.version), this);
- roomVersionLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
- roomVersionLabel->setFont(monospaceFont);
-
- auto roomVersionLayout = new QHBoxLayout;
- roomVersionLayout->setMargin(0);
- roomVersionLayout->addWidget(new QLabel(tr("Room Version"), this),
- Qt::AlignBottom | Qt::AlignLeft);
- roomVersionLayout->addWidget(roomVersionLabel, 0, Qt::AlignBottom | Qt::AlignRight);
-
- auto notifLabel = new QLabel(tr("Notifications"), this);
- notifCombo = new QComboBox(this);
- notifCombo->addItem(tr(
- "Muted")); //{"conditions":[{"kind":"event_match","key":"room_id","pattern":"!jxlRxnrZCsjpjDubDX:matrix.org"}],"actions":["dont_notify"]}
- notifCombo->addItem(tr("Mentions only")); // {"actions":["dont_notify"]}
- notifCombo->addItem(tr("All messages")); // delete rule
-
- connect(this, &RoomSettings::notifChanged, notifCombo, &QComboBox::setCurrentIndex);
- http::client()->get_pushrules(
- "global",
- "override",
- room_id_.toStdString(),
- [this](const mtx::pushrules::PushRule &rule, mtx::http::RequestErr &err) {
- if (err) {
- if (err->status_code == boost::beast::http::status::not_found)
- http::client()->get_pushrules(
- "global",
- "room",
- room_id_.toStdString(),
- [this](const mtx::pushrules::PushRule &rule,
- mtx::http::RequestErr &err) {
- if (err) {
- emit notifChanged(2); // all messages
- return;
- }
-
- if (rule.enabled)
- emit notifChanged(1); // mentions only
- });
- return;
- }
-
- if (rule.enabled)
- emit notifChanged(0); // muted
- else
- emit notifChanged(2); // all messages
- });
-
- connect(notifCombo, QOverload::of(&QComboBox::activated), [this](int index) {
- std::string room_id = room_id_.toStdString();
- if (index == 0) {
- // mute room
- // delete old rule first, then add new rule
- mtx::pushrules::PushRule rule;
- rule.actions = {mtx::pushrules::actions::dont_notify{}};
- mtx::pushrules::PushCondition condition;
- condition.kind = "event_match";
- condition.key = "room_id";
- condition.pattern = room_id;
- rule.conditions = {condition};
-
- http::client()->put_pushrules(
- "global",
- "override",
- room_id,
- rule,
- [room_id](mtx::http::RequestErr &err) {
- if (err)
- nhlog::net()->error(
- "failed to set pushrule for room {}: {} {}",
- room_id,
- static_cast(err->status_code),
- err->matrix_error.error);
- http::client()->delete_pushrules(
- "global", "room", room_id, [room_id](mtx::http::RequestErr &) {
- });
- });
- } else if (index == 1) {
- // mentions only
- // delete old rule first, then add new rule
- mtx::pushrules::PushRule rule;
- rule.actions = {mtx::pushrules::actions::dont_notify{}};
- http::client()->put_pushrules(
- "global", "room", room_id, rule, [room_id](mtx::http::RequestErr &err) {
- if (err)
- nhlog::net()->error(
- "failed to set pushrule for room {}: {} {}",
- room_id,
- static_cast(err->status_code),
- err->matrix_error.error);
- http::client()->delete_pushrules(
- "global",
- "override",
- room_id,
- [room_id](mtx::http::RequestErr &) {});
- });
- } else {
- // all messages
- http::client()->delete_pushrules(
- "global", "override", room_id, [room_id](mtx::http::RequestErr &) {
- http::client()->delete_pushrules(
- "global", "room", room_id, [room_id](mtx::http::RequestErr &) {
- });
- });
- }
- });
-
- auto notifOptionLayout_ = new QHBoxLayout;
- notifOptionLayout_->setMargin(0);
- notifOptionLayout_->addWidget(notifLabel, Qt::AlignBottom | Qt::AlignLeft);
- notifOptionLayout_->addWidget(notifCombo, 0, Qt::AlignBottom | Qt::AlignRight);
-
- auto accessLabel = new QLabel(tr("Room access"), this);
- accessCombo = new QComboBox(this);
- accessCombo->addItem(tr("Anyone and guests"));
- accessCombo->addItem(tr("Anyone"));
- accessCombo->addItem(tr("Invited users"));
- accessCombo->setDisabled(
- !canChangeJoinRules(room_id_.toStdString(), utils::localUser().toStdString()));
- connect(accessCombo, QOverload::of(&QComboBox::activated), [this](int index) {
- using namespace mtx::events::state;
-
- auto guest_access = [](int index) -> state::GuestAccess {
- state::GuestAccess event;
-
- if (index == 0)
- event.guest_access = state::AccessState::CanJoin;
- else
- event.guest_access = state::AccessState::Forbidden;
-
- return event;
- }(index);
-
- auto join_rule = [](int index) -> state::JoinRules {
- state::JoinRules event;
-
- switch (index) {
- case 0:
- case 1:
- event.join_rule = state::JoinRule::Public;
- break;
- default:
- event.join_rule = state::JoinRule::Invite;
- }
-
- return event;
- }(index);
-
- updateAccessRules(room_id_.toStdString(), join_rule, guest_access);
- });
-
- if (info_.join_rule == state::JoinRule::Public) {
- if (info_.guest_access) {
- accessCombo->setCurrentIndex(0);
- } else {
- accessCombo->setCurrentIndex(1);
- }
- } else {
- accessCombo->setCurrentIndex(2);
- }
-
- auto accessOptionLayout = new QHBoxLayout();
- accessOptionLayout->setMargin(0);
- accessOptionLayout->addWidget(accessLabel, Qt::AlignBottom | Qt::AlignLeft);
- accessOptionLayout->addWidget(accessCombo, 0, Qt::AlignBottom | Qt::AlignRight);
-
- auto encryptionLabel = new QLabel(tr("Encryption"), this);
- encryptionToggle_ = new Toggle(this);
-
- auto encryptionOptionLayout = new QHBoxLayout;
- encryptionOptionLayout->setMargin(0);
- encryptionOptionLayout->addWidget(encryptionLabel, Qt::AlignBottom | Qt::AlignLeft);
- encryptionOptionLayout->addWidget(encryptionToggle_, 0, Qt::AlignBottom | Qt::AlignRight);
-
- auto keyRequestsLabel = new QLabel(tr("Respond to key requests"), this);
- keyRequestsLabel->setToolTipDuration(6000);
- keyRequestsLabel->setToolTip(
- tr("Whether or not the client should respond automatically with the session keys\n"
- " upon request. Use with caution, this is a temporary measure to test the\n"
- " E2E implementation until device verification is completed."));
- keyRequestsToggle_ = new Toggle(this);
- connect(keyRequestsToggle_, &Toggle::toggled, this, [this](bool isOn) {
- utils::setKeyRequestsPreference(room_id_, isOn);
- });
-
- auto keyRequestsLayout = new QHBoxLayout;
- keyRequestsLayout->setMargin(0);
- keyRequestsLayout->setSpacing(0);
- keyRequestsLayout->addWidget(keyRequestsLabel, Qt::AlignBottom | Qt::AlignLeft);
- keyRequestsLayout->addWidget(keyRequestsToggle_, 0, Qt::AlignBottom | Qt::AlignRight);
-
- connect(encryptionToggle_, &Toggle::toggled, this, [this, keyRequestsLabel](bool isOn) {
- if (!isOn || usesEncryption_)
- return;
-
- QMessageBox msgBox;
- msgBox.setIcon(QMessageBox::Question);
- msgBox.setWindowTitle(tr("End-to-End Encryption"));
- msgBox.setText(tr(
- "Encryption is currently experimental and things might break unexpectedly.
"
- "Please take note that it can't be disabled afterwards."));
- msgBox.setStandardButtons(QMessageBox::Ok | QMessageBox::Cancel);
- msgBox.setDefaultButton(QMessageBox::Save);
- int ret = msgBox.exec();
-
- switch (ret) {
- case QMessageBox::Ok: {
- encryptionToggle_->setState(true);
- encryptionToggle_->setEnabled(false);
- enableEncryption();
- keyRequestsToggle_->show();
- keyRequestsLabel->show();
- break;
- }
- default: {
- break;
- }
- }
- });
-
- // Disable encryption button.
- if (usesEncryption_) {
- encryptionToggle_->setState(true);
- encryptionToggle_->setEnabled(false);
-
- keyRequestsToggle_->setState(utils::respondsToKeyRequests(room_id_));
- } else {
- encryptionToggle_->setState(false);
-
- keyRequestsLabel->hide();
- keyRequestsToggle_->hide();
- }
-
- // Hide encryption option for public rooms.
- if (!usesEncryption_ && (info_.join_rule == state::JoinRule::Public)) {
- encryptionToggle_->hide();
- encryptionLabel->hide();
-
- keyRequestsLabel->hide();
- keyRequestsToggle_->hide();
- }
-
- avatar_ = new Avatar(this, 128);
- avatar_->setLetter(utils::firstChar(QString::fromStdString(info_.name)));
- if (!info_.avatar_url.empty())
- avatar_->setImage(QString::fromStdString(info_.avatar_url));
-
- if (canChangeAvatar(room_id_.toStdString(), utils::localUser().toStdString())) {
- auto filter = new ClickableFilter(this);
- avatar_->installEventFilter(filter);
- avatar_->setCursor(Qt::PointingHandCursor);
- connect(filter, &ClickableFilter::clicked, this, &RoomSettings::updateAvatar);
- }
-
- roomNameLabel_ = new QLabel(QString::fromStdString(info_.name), this);
- roomNameLabel_->setFont(largeFont);
-
- auto membersLabel = new QLabel(tr("%n member(s)", "", (int)info_.member_count), this);
-
- auto textLayout = new QVBoxLayout;
- textLayout->addWidget(roomNameLabel_);
- textLayout->addWidget(membersLabel);
- textLayout->setAlignment(roomNameLabel_, Qt::AlignCenter | Qt::AlignTop);
- textLayout->setAlignment(membersLabel, Qt::AlignCenter | Qt::AlignTop);
- textLayout->setSpacing(TEXT_SPACING);
- textLayout->setMargin(0);
-
- setupEditButton();
-
- errorLabel_ = new QLabel(this);
- errorLabel_->setAlignment(Qt::AlignCenter);
- errorLabel_->hide();
-
- spinner_ = new LoadingIndicator(this);
- spinner_->setFixedHeight(30);
- spinner_->setFixedWidth(30);
- spinner_->hide();
- auto spinnerLayout = new QVBoxLayout;
- spinnerLayout->addWidget(spinner_);
- spinnerLayout->setAlignment(Qt::AlignCenter);
- spinnerLayout->setMargin(0);
- spinnerLayout->setSpacing(0);
-
- auto okBtn = new QPushButton("OK", this);
-
- auto buttonLayout = new QHBoxLayout();
- buttonLayout->setSpacing(15);
- buttonLayout->addStretch(1);
- buttonLayout->addWidget(okBtn);
-
- layout->addWidget(avatar_, Qt::AlignCenter | Qt::AlignTop);
- layout->addLayout(textLayout);
- layout->addLayout(btnLayout_);
- layout->addWidget(settingsLabel, Qt::AlignLeft);
- layout->addLayout(notifOptionLayout_);
- layout->addLayout(accessOptionLayout);
- layout->addLayout(encryptionOptionLayout);
- layout->addLayout(keyRequestsLayout);
- layout->addWidget(infoLabel, Qt::AlignLeft);
- layout->addLayout(roomIdLayout);
- layout->addLayout(roomVersionLayout);
- layout->addWidget(errorLabel_);
- layout->addLayout(buttonLayout);
- layout->addLayout(spinnerLayout);
- layout->addStretch(1);
-
- connect(this, &RoomSettings::enableEncryptionError, this, [this](const QString &msg) {
- encryptionToggle_->setState(false);
- keyRequestsToggle_->setState(false);
- keyRequestsToggle_->setEnabled(false);
- keyRequestsToggle_->hide();
-
- emit ChatPage::instance()->showNotification(msg);
- });
-
- connect(this, &RoomSettings::showErrorMessage, this, [this](const QString &msg) {
- if (!errorLabel_)
- return;
-
- stopLoadingSpinner();
-
- errorLabel_->show();
- errorLabel_->setText(msg);
- });
-
- connect(this, &RoomSettings::accessRulesUpdated, this, [this]() {
- stopLoadingSpinner();
- resetErrorLabel();
- });
-
- auto closeShortcut = new QShortcut(QKeySequence(QKeySequence::Cancel), this);
- connect(closeShortcut, &QShortcut::activated, this, &RoomSettings::close);
- connect(okBtn, &QPushButton::clicked, this, &RoomSettings::close);
-}
-
-void
-RoomSettings::setupEditButton()
-{
- btnLayout_ = new QHBoxLayout;
- btnLayout_->setSpacing(BUTTON_SPACING);
- btnLayout_->setMargin(0);
-
- if (!canChangeNameAndTopic(room_id_.toStdString(), utils::localUser().toStdString()))
- return;
-
- QIcon editIcon;
- editIcon.addFile(":/icons/icons/ui/edit.png");
- editFieldsBtn_ = new FlatButton(this);
- editFieldsBtn_->setFixedSize(BUTTON_SIZE, BUTTON_SIZE);
- editFieldsBtn_->setCornerRadius(BUTTON_RADIUS);
- editFieldsBtn_->setIcon(editIcon);
- editFieldsBtn_->setIcon(editIcon);
- editFieldsBtn_->setIconSize(QSize(BUTTON_RADIUS, BUTTON_RADIUS));
-
- connect(editFieldsBtn_, &QPushButton::clicked, this, [this]() {
- retrieveRoomInfo();
-
- auto modal = new EditModal(room_id_, this);
- modal->setFields(QString::fromStdString(info_.name),
- QString::fromStdString(info_.topic));
- modal->raise();
- modal->show();
- connect(modal, &EditModal::nameChanged, this, [this](const QString &newName) {
- if (roomNameLabel_)
- roomNameLabel_->setText(newName);
- });
- });
-
- btnLayout_->addStretch(1);
- btnLayout_->addWidget(editFieldsBtn_);
- btnLayout_->addStretch(1);
-}
-
-void
-RoomSettings::retrieveRoomInfo()
-{
- try {
- usesEncryption_ = cache::isRoomEncrypted(room_id_.toStdString());
- info_ = cache::singleRoomInfo(room_id_.toStdString());
- setAvatar();
- } catch (const lmdb::error &) {
- nhlog::db()->warn("failed to retrieve room info from cache: {}",
- room_id_.toStdString());
- }
-}
-
-void
-RoomSettings::enableEncryption()
-{
- const auto room_id = room_id_.toStdString();
- http::client()->enable_encryption(
- room_id, [room_id, this](const mtx::responses::EventId &, mtx::http::RequestErr err) {
- if (err) {
- int status_code = static_cast(err->status_code);
- nhlog::net()->warn("failed to enable encryption in room ({}): {} {}",
- room_id,
- err->matrix_error.error,
- status_code);
- emit enableEncryptionError(
- tr("Failed to enable encryption: %1")
- .arg(QString::fromStdString(err->matrix_error.error)));
- return;
- }
-
- nhlog::net()->info("enabled encryption on room ({})", room_id);
- });
-}
-
-void
-RoomSettings::showEvent(QShowEvent *event)
-{
- resetErrorLabel();
- stopLoadingSpinner();
-
- QWidget::showEvent(event);
-}
-
-bool
-RoomSettings::canChangeJoinRules(const std::string &room_id, const std::string &user_id) const
-{
- try {
- return cache::hasEnoughPowerLevel({EventType::RoomJoinRules}, room_id, user_id);
- } catch (const lmdb::error &e) {
- nhlog::db()->warn("lmdb error: {}", e.what());
- }
-
- return false;
-}
-
-bool
-RoomSettings::canChangeNameAndTopic(const std::string &room_id, const std::string &user_id) const
-{
- try {
- return cache::hasEnoughPowerLevel(
- {EventType::RoomName, EventType::RoomTopic}, room_id, user_id);
- } catch (const lmdb::error &e) {
- nhlog::db()->warn("lmdb error: {}", e.what());
- }
-
- return false;
-}
-
-bool
-RoomSettings::canChangeAvatar(const std::string &room_id, const std::string &user_id) const
-{
- try {
- return cache::hasEnoughPowerLevel({EventType::RoomAvatar}, room_id, user_id);
- } catch (const lmdb::error &e) {
- nhlog::db()->warn("lmdb error: {}", e.what());
- }
-
- return false;
-}
-
-void
-RoomSettings::updateAccessRules(const std::string &room_id,
- const mtx::events::state::JoinRules &join_rule,
- const mtx::events::state::GuestAccess &guest_access)
-{
- startLoadingSpinner();
- resetErrorLabel();
-
- http::client()->send_state_event(
- room_id,
- join_rule,
- [this, room_id, guest_access](const mtx::responses::EventId &,
- mtx::http::RequestErr err) {
- if (err) {
- nhlog::net()->warn("failed to send m.room.join_rule: {} {}",
- static_cast(err->status_code),
- err->matrix_error.error);
- emit showErrorMessage(QString::fromStdString(err->matrix_error.error));
-
- return;
- }
-
- http::client()->send_state_event(
- room_id,
- guest_access,
- [this](const mtx::responses::EventId &, mtx::http::RequestErr err) {
- if (err) {
- nhlog::net()->warn("failed to send m.room.guest_access: {} {}",
- static_cast(err->status_code),
- err->matrix_error.error);
- emit showErrorMessage(
- QString::fromStdString(err->matrix_error.error));
-
- return;
- }
-
- emit accessRulesUpdated();
- });
- });
-}
-
-void
-RoomSettings::stopLoadingSpinner()
-{
- if (spinner_) {
- spinner_->stop();
- spinner_->hide();
- }
-}
-
-void
-RoomSettings::startLoadingSpinner()
-{
- if (spinner_) {
- spinner_->start();
- spinner_->show();
- }
-}
-
-void
-RoomSettings::displayErrorMessage(const QString &msg)
-{
- stopLoadingSpinner();
-
- errorLabel_->show();
- errorLabel_->setText(msg);
-}
-
-void
-RoomSettings::setAvatar()
-{
- stopLoadingSpinner();
-
- if (avatar_)
- avatar_->setImage(QString::fromStdString(info_.avatar_url));
-}
-
-void
-RoomSettings::resetErrorLabel()
-{
- if (errorLabel_) {
- errorLabel_->hide();
- errorLabel_->clear();
- }
-}
-
-void
-RoomSettings::updateAvatar()
-{
- const QString picturesFolder =
- QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
- const QString fileName = QFileDialog::getOpenFileName(
- this, tr("Select an avatar"), picturesFolder, tr("All Files (*)"));
-
- if (fileName.isEmpty())
- return;
-
- QMimeDatabase db;
- QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent);
-
- const auto format = mime.name().split("/")[0];
-
- QFile file{fileName, this};
- if (format != "image") {
- displayErrorMessage(tr("The selected file is not an image"));
- return;
- }
-
- if (!file.open(QIODevice::ReadOnly)) {
- displayErrorMessage(tr("Error while reading file: %1").arg(file.errorString()));
- return;
- }
-
- if (spinner_) {
- startLoadingSpinner();
- resetErrorLabel();
- }
-
- // Events emitted from the http callbacks (different threads) will
- // be queued back into the UI thread through this proxy object.
- auto proxy = std::make_shared();
- connect(proxy.get(), &ThreadProxy::error, this, &RoomSettings::displayErrorMessage);
- connect(proxy.get(), &ThreadProxy::avatarChanged, this, &RoomSettings::setAvatar);
-
- const auto bin = file.peek(file.size());
- const auto payload = std::string(bin.data(), bin.size());
- const auto dimensions = QImageReader(&file).size();
-
- // First we need to create a new mxc URI
- // (i.e upload media to the Matrix content repository) for the new avatar.
- http::client()->upload(
- payload,
- mime.name().toStdString(),
- QFileInfo(fileName).fileName().toStdString(),
- [proxy = std::move(proxy),
- dimensions,
- payload,
- mimetype = mime.name().toStdString(),
- size = payload.size(),
- room_id = room_id_.toStdString(),
- content = std::move(bin)](const mtx::responses::ContentURI &res,
- mtx::http::RequestErr err) {
- if (err) {
- emit proxy->error(
- tr("Failed to upload image: %s")
- .arg(QString::fromStdString(err->matrix_error.error)));
- return;
- }
-
- using namespace mtx::events;
- state::Avatar avatar_event;
- avatar_event.image_info.w = dimensions.width();
- avatar_event.image_info.h = dimensions.height();
- avatar_event.image_info.mimetype = mimetype;
- avatar_event.image_info.size = size;
- avatar_event.url = res.content_uri;
-
- http::client()->send_state_event(
- room_id,
- avatar_event,
- [content = std::move(content), proxy = std::move(proxy)](
- const mtx::responses::EventId &, mtx::http::RequestErr err) {
- if (err) {
- emit proxy->error(
- tr("Failed to upload image: %s")
- .arg(QString::fromStdString(err->matrix_error.error)));
- return;
- }
-
- emit proxy->avatarChanged();
- });
- });
-}
diff --git a/src/dialogs/RoomSettings.h b/src/dialogs/RoomSettings.h
deleted file mode 100644
index e0918afd..00000000
--- a/src/dialogs/RoomSettings.h
+++ /dev/null
@@ -1,150 +0,0 @@
-#pragma once
-
-#include
-#include
-
-#include
-
-#include "CacheStructs.h"
-
-class Avatar;
-class FlatButton;
-class QPushButton;
-class QComboBox;
-class QHBoxLayout;
-class QShowEvent;
-class LoadingIndicator;
-class QLayout;
-class QPixmap;
-class TextField;
-class TextField;
-class Toggle;
-class QLabel;
-class QEvent;
-
-class ClickableFilter : public QObject
-{
- Q_OBJECT
-
-public:
- explicit ClickableFilter(QWidget *parent)
- : QObject(parent)
- {}
-
-signals:
- void clicked();
-
-protected:
- bool eventFilter(QObject *obj, QEvent *event) override;
-};
-
-/// Convenience class which connects events emmited from threads
-/// outside of main with the UI code.
-class ThreadProxy : public QObject
-{
- Q_OBJECT
-
-signals:
- void error(const QString &msg);
- void avatarChanged();
- void nameEventSent(const QString &);
- void topicEventSent();
-};
-
-class EditModal : public QWidget
-{
- Q_OBJECT
-
-public:
- EditModal(const QString &roomId, QWidget *parent = nullptr);
-
- void setFields(const QString &roomName, const QString &roomTopic);
-
-signals:
- void nameChanged(const QString &roomName);
-
-private slots:
- void topicEventSent();
- void nameEventSent(const QString &name);
- void error(const QString &msg);
-
- void applyClicked();
-
-private:
- QString roomId_;
- QString initialName_;
- QString initialTopic_;
-
- QLabel *errorField_;
-
- TextField *nameInput_;
- TextField *topicInput_;
-
- QPushButton *applyBtn_;
- QPushButton *cancelBtn_;
-};
-
-namespace dialogs {
-
-class RoomSettings : public QFrame
-{
- Q_OBJECT
-public:
- RoomSettings(const QString &room_id, QWidget *parent = nullptr);
-
-signals:
- void enableEncryptionError(const QString &msg);
- void showErrorMessage(const QString &msg);
- void accessRulesUpdated();
- void notifChanged(int index);
-
-protected:
- void showEvent(QShowEvent *event) override;
-
-private slots:
- //! The file dialog opens so the user can select and upload a new room avatar.
- void updateAvatar();
-
-private:
- //! Whether the user has enough power level to send m.room.join_rules events.
- bool canChangeJoinRules(const std::string &room_id, const std::string &user_id) const;
- //! Whether the user has enough power level to send m.room.name & m.room.topic events.
- bool canChangeNameAndTopic(const std::string &room_id, const std::string &user_id) const;
- //! Whether the user has enough power level to send m.room.avatar event.
- bool canChangeAvatar(const std::string &room_id, const std::string &user_id) const;
- void updateAccessRules(const std::string &room_id,
- const mtx::events::state::JoinRules &,
- const mtx::events::state::GuestAccess &);
- void stopLoadingSpinner();
- void startLoadingSpinner();
- void resetErrorLabel();
- void displayErrorMessage(const QString &msg);
-
- void setAvatar();
- void setupEditButton();
- //! Retrieve the current room information from cache.
- void retrieveRoomInfo();
- void enableEncryption();
-
- Avatar *avatar_ = nullptr;
-
- bool usesEncryption_ = false;
- QHBoxLayout *btnLayout_;
-
- FlatButton *editFieldsBtn_ = nullptr;
-
- RoomInfo info_;
- QString room_id_;
- QImage avatarImg_;
-
- QLabel *roomNameLabel_ = nullptr;
- QLabel *errorLabel_ = nullptr;
- LoadingIndicator *spinner_ = nullptr;
-
- QComboBox *notifCombo = nullptr;
- QComboBox *accessCombo = nullptr;
- Toggle *encryptionToggle_ = nullptr;
- Toggle *keyRequestsToggle_ = nullptr;
-};
-
-} // dialogs
diff --git a/src/notifications/ManagerLinux.cpp b/src/notifications/ManagerLinux.cpp
index fb424b2a..c7fd4023 100644
--- a/src/notifications/ManagerLinux.cpp
+++ b/src/notifications/ManagerLinux.cpp
@@ -109,7 +109,7 @@ NotificationsManager::closeNotification(uint id)
"org.freedesktop.Notifications");
auto call = closeCall.asyncCall("CloseNotification", (uint)id); // replace_id
auto watcher = new QDBusPendingCallWatcher{call, this};
- connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher, this]() {
+ connect(watcher, &QDBusPendingCallWatcher::finished, this, [watcher]() {
if (watcher->reply().type() == QDBusMessage::ErrorMessage) {
qDebug() << "D-Bus Error:" << watcher->reply().errorMessage();
};
diff --git a/src/timeline/InputBar.cpp b/src/timeline/InputBar.cpp
index 08cbd15b..b1580f97 100644
--- a/src/timeline/InputBar.cpp
+++ b/src/timeline/InputBar.cpp
@@ -19,6 +19,7 @@
#include "MainWindow.h"
#include "MatrixClient.h"
#include "Olm.h"
+#include "RoomsModel.h"
#include "TimelineModel.h"
#include "TimelineViewManager.h"
#include "UserSettingsPage.h"
@@ -121,6 +122,20 @@ InputBar::insertMimeData(const QMimeData *md)
}
}
+void
+InputBar::setText(QString newText)
+{
+ if (history_.empty())
+ history_.push_front(newText);
+ else
+ history_.front() = newText;
+ history_index_ = 0;
+
+ if (history_.size() == INPUT_HISTORY_SIZE)
+ history_.pop_back();
+
+ emit textChanged(newText);
+}
void
InputBar::updateState(int selectionStart_, int selectionEnd_, int cursorPosition_, QString text_)
{
@@ -186,6 +201,11 @@ InputBar::completerFor(QString completerName)
auto proxy = new CompletionProxyModel(emojiModel);
emojiModel->setParent(proxy);
return proxy;
+ } else if (completerName == "room") {
+ auto roomModel = new RoomsModel(true);
+ auto proxy = new CompletionProxyModel(roomModel);
+ roomModel->setParent(proxy);
+ return proxy;
}
return nullptr;
}
@@ -196,6 +216,10 @@ InputBar::send()
if (text().trimmed().isEmpty())
return;
+ nhlog::ui()->debug("Send: {}", text().toStdString());
+
+ auto wasEdit = !room->edit().isEmpty();
+
if (text().startsWith('/')) {
int command_end = text().indexOf(' ');
if (command_end == -1)
@@ -211,12 +235,10 @@ InputBar::send()
message(text());
}
- nhlog::ui()->debug("Send: {}", text().toStdString());
-
- if (history_.size() == INPUT_HISTORY_SIZE)
- history_.pop_back();
- history_.push_front("");
- history_index_ = 0;
+ if (!wasEdit) {
+ history_.push_front("");
+ setText("");
+ }
}
void
@@ -272,12 +294,10 @@ InputBar::message(QString msg, MarkdownOverride useMarkdown)
if (!room->reply().isEmpty()) {
text.relations.relations.push_back(
{mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
- room->resetReply();
}
text.relations.relations.push_back(
{mtx::common::RelationType::Replace, room->edit().toStdString()});
- room->resetEdit();
} else if (!room->reply().isEmpty()) {
auto related = room->relatedInfo(room->reply());
@@ -307,7 +327,6 @@ InputBar::message(QString msg, MarkdownOverride useMarkdown)
text.relations.relations.push_back(
{mtx::common::RelationType::InReplyTo, related.related_event});
- room->resetReply();
}
room->sendMessageEvent(text, mtx::events::EventType::RoomMessage);
@@ -330,12 +349,10 @@ InputBar::emote(QString msg)
if (!room->reply().isEmpty()) {
emote.relations.relations.push_back(
{mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
- room->resetReply();
}
if (!room->edit().isEmpty()) {
emote.relations.relations.push_back(
{mtx::common::RelationType::Replace, room->edit().toStdString()});
- room->resetEdit();
}
room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage);
@@ -366,12 +383,10 @@ InputBar::image(const QString &filename,
if (!room->reply().isEmpty()) {
image.relations.relations.push_back(
{mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
- room->resetReply();
}
if (!room->edit().isEmpty()) {
image.relations.relations.push_back(
{mtx::common::RelationType::Replace, room->edit().toStdString()});
- room->resetEdit();
}
room->sendMessageEvent(image, mtx::events::EventType::RoomMessage);
@@ -397,12 +412,10 @@ InputBar::file(const QString &filename,
if (!room->reply().isEmpty()) {
file.relations.relations.push_back(
{mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
- room->resetReply();
}
if (!room->edit().isEmpty()) {
file.relations.relations.push_back(
{mtx::common::RelationType::Replace, room->edit().toStdString()});
- room->resetEdit();
}
room->sendMessageEvent(file, mtx::events::EventType::RoomMessage);
@@ -429,12 +442,10 @@ InputBar::audio(const QString &filename,
if (!room->reply().isEmpty()) {
audio.relations.relations.push_back(
{mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
- room->resetReply();
}
if (!room->edit().isEmpty()) {
audio.relations.relations.push_back(
{mtx::common::RelationType::Replace, room->edit().toStdString()});
- room->resetEdit();
}
room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage);
@@ -460,12 +471,10 @@ InputBar::video(const QString &filename,
if (!room->reply().isEmpty()) {
video.relations.relations.push_back(
{mtx::common::RelationType::InReplyTo, room->reply().toStdString()});
- room->resetReply();
}
if (!room->edit().isEmpty()) {
video.relations.relations.push_back(
{mtx::common::RelationType::Replace, room->edit().toStdString()});
- room->resetEdit();
}
room->sendMessageEvent(video, mtx::events::EventType::RoomMessage);
diff --git a/src/timeline/InputBar.h b/src/timeline/InputBar.h
index 696a0dd9..4cb6da7b 100644
--- a/src/timeline/InputBar.h
+++ b/src/timeline/InputBar.h
@@ -41,7 +41,7 @@ public slots:
QString text() const;
QString previousText();
QString nextText();
- void setText(QString newText) { emit textChanged(newText); }
+ void setText(QString newText);
void send();
void paste(bool fromMouse);
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index 5c904932..d46a313a 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -362,6 +362,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
const static QRegularExpression replyFallback(
".*", QRegularExpression::DotMatchesEverythingOption);
+ auto ascent = QFontMetrics(UserSettings::instance()->font()).ascent();
+
bool isReply = relations(event).reply_to().has_value();
auto formattedBody_ = QString::fromStdString(formatted_body(event));
@@ -380,8 +382,14 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
formattedBody_ = formattedBody_.remove(replyFallback);
}
- formattedBody_.replace("]*)src=\"mxc://([^\"]*)\"([^>]*>)");
+ formattedBody_.replace(matchImgUri, "\\1 src=\"image://mxcImage/\\2\"\\3");
+ const static QRegularExpression matchEmoticonHeight(
+ "(]*)height=\"([^\"]*)\"([^>]*>)");
+ formattedBody_.replace(matchEmoticonHeight,
+ QString("\\1 height=\"%1\"\\3").arg(ascent));
return QVariant(utils::replaceEmoji(
utils::linkifyMessage(utils::escapeBlacklistedHtml(formattedBody_))));
@@ -491,6 +499,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
data(event, static_cast(ProportionalHeight)));
m.insert(names[Id], data(event, static_cast(Id)));
m.insert(names[State], data(event, static_cast(State)));
+ m.insert(names[IsEdited], data(event, static_cast(IsEdited)));
+ m.insert(names[IsEditable], data(event, static_cast(IsEditable)));
m.insert(names[IsEncrypted], data(event, static_cast(IsEncrypted)));
m.insert(names[IsRoomEncrypted], data(event, static_cast(IsRoomEncrypted)));
m.insert(names[ReplyTo], data(event, static_cast(ReplyTo)));
@@ -753,11 +763,6 @@ TimelineModel::setCurrentIndex(int index)
(!oldReadIndex || *oldReadIndex < nextEventIndexAndId->first)) {
readEvent(nextEventIndexAndId->second);
currentReadId = QString::fromStdString(nextEventIndexAndId->second);
-
- nhlog::net()->info("Marked as read {}, index {}, oldReadIndex {}",
- nextEventIndexAndId->second,
- nextEventIndexAndId->first,
- *oldReadIndex);
}
}
}
@@ -833,6 +838,14 @@ TimelineModel::openUserProfile(QString userid, bool global)
emit openProfile(userProfile);
}
+void
+TimelineModel::openRoomSettings()
+{
+ RoomSettings *settings = new RoomSettings(roomId(), this);
+ connect(this, &TimelineModel::roomAvatarUrlChanged, settings, &RoomSettings::avatarChanged);
+ openRoomSettingsDialog(settings);
+}
+
void
TimelineModel::replyAction(QString id)
{
@@ -1539,6 +1552,17 @@ TimelineModel::setEdit(QString newEdit)
if (edit_.startsWith('m'))
return;
+ if (newEdit.isEmpty()) {
+ resetEdit();
+ return;
+ }
+
+ if (edit_.isEmpty()) {
+ this->textBeforeEdit = input()->text();
+ this->replyBeforeEdit = reply_;
+ nhlog::ui()->debug("Stored: {}", textBeforeEdit.toStdString());
+ }
+
if (edit_ != newEdit) {
auto ev = events.get(newEdit.toStdString(), "");
if (ev && mtx::accessors::sender(*ev) == http::client()->user_id().to_string()) {
@@ -1573,8 +1597,14 @@ TimelineModel::resetEdit()
if (!edit_.isEmpty()) {
edit_ = "";
emit editChanged(edit_);
- input()->setText("");
- resetReply();
+ nhlog::ui()->debug("Restoring: {}", textBeforeEdit.toStdString());
+ input()->setText(textBeforeEdit);
+ textBeforeEdit.clear();
+ if (replyBeforeEdit.isEmpty())
+ resetReply();
+ else
+ setReply(replyBeforeEdit);
+ replyBeforeEdit.clear();
}
}
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index 83012cd8..e02539bb 100644
--- a/src/timeline/TimelineModel.h
+++ b/src/timeline/TimelineModel.h
@@ -11,6 +11,7 @@
#include "CacheCryptoStructs.h"
#include "EventStore.h"
#include "InputBar.h"
+#include "ui/RoomSettings.h"
#include "ui/UserProfile.h"
namespace mtx::http {
@@ -216,6 +217,7 @@ public:
Q_INVOKABLE void viewRawMessage(QString id) const;
Q_INVOKABLE void viewDecryptedRawMessage(QString id) const;
Q_INVOKABLE void openUserProfile(QString userid, bool global = false);
+ Q_INVOKABLE void openRoomSettings();
Q_INVOKABLE void editAction(QString id);
Q_INVOKABLE void replyAction(QString id);
Q_INVOKABLE void readReceiptsAction(QString id) const;
@@ -307,6 +309,7 @@ signals:
void newCallEvent(const mtx::events::collections::TimelineEvents &event);
void openProfile(UserProfile *profile);
+ void openRoomSettingsDialog(RoomSettings *settings);
void newMessageToSend(mtx::events::collections::TimelineEvents event);
void addPendingMessageToStore(mtx::events::collections::TimelineEvents event);
@@ -334,6 +337,7 @@ private:
QString currentId, currentReadId;
QString reply_, edit_;
+ QString textBeforeEdit, replyBeforeEdit;
std::vector typingUsers_;
TimelineViewManager *manager_;
@@ -351,4 +355,6 @@ TimelineModel::sendMessageEvent(const T &content, mtx::events::EventType eventTy
msgCopy.content = content;
msgCopy.type = eventType;
emit newMessageToSend(msgCopy);
+ resetReply();
+ resetEdit();
}
diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp
index b7d2bfb1..f2e6d571 100644
--- a/src/timeline/TimelineViewManager.cpp
+++ b/src/timeline/TimelineViewManager.cpp
@@ -128,6 +128,12 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
0,
"UserProfileModel",
"UserProfile needs to be instantiated on the C++ side");
+ qmlRegisterUncreatableType(
+ "im.nheko",
+ 1,
+ 0,
+ "RoomSettingsModel",
+ "Room Settings needs to be instantiated on the C++ side");
static auto self = this;
qmlRegisterSingletonType(
@@ -387,11 +393,6 @@ TimelineViewManager::openLeaveRoomDialog() const
{
MainWindow::instance()->openLeaveRoomDialog(timeline_->roomId());
}
-void
-TimelineViewManager::openRoomSettings() const
-{
- MainWindow::instance()->openRoomSettings(timeline_->roomId());
-}
void
TimelineViewManager::verifyUser(QString userid)
diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h
index 7c994a14..61fce574 100644
--- a/src/timeline/TimelineViewManager.h
+++ b/src/timeline/TimelineViewManager.h
@@ -70,7 +70,6 @@ public:
Q_INVOKABLE void openInviteUsersDialog();
Q_INVOKABLE void openMemberListDialog() const;
Q_INVOKABLE void openLeaveRoomDialog() const;
- Q_INVOKABLE void openRoomSettings() const;
Q_INVOKABLE void removeVerificationFlow(DeviceVerificationFlow *flow);
void verifyUser(QString userid);
diff --git a/src/ui/RoomSettings.cpp b/src/ui/RoomSettings.cpp
new file mode 100644
index 00000000..a264c78b
--- /dev/null
+++ b/src/ui/RoomSettings.cpp
@@ -0,0 +1,625 @@
+#include "RoomSettings.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "Cache.h"
+#include "Config.h"
+#include "Logging.h"
+#include "MatrixClient.h"
+#include "Utils.h"
+#include "ui/TextField.h"
+
+using namespace mtx::events;
+
+EditModal::EditModal(const QString &roomId, QWidget *parent)
+ : QWidget(parent)
+ , roomId_{roomId}
+{
+ setAutoFillBackground(true);
+ setAttribute(Qt::WA_DeleteOnClose, true);
+ setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
+ setWindowModality(Qt::WindowModal);
+
+ QFont largeFont;
+ largeFont.setPointSizeF(largeFont.pointSizeF() * 1.4);
+ setMinimumWidth(conf::window::minModalWidth);
+ setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Maximum);
+
+ auto layout = new QVBoxLayout(this);
+
+ applyBtn_ = new QPushButton(tr("Apply"), this);
+ cancelBtn_ = new QPushButton(tr("Cancel"), this);
+ cancelBtn_->setDefault(true);
+
+ auto btnLayout = new QHBoxLayout;
+ btnLayout->addStretch(1);
+ btnLayout->setSpacing(15);
+ btnLayout->addWidget(cancelBtn_);
+ btnLayout->addWidget(applyBtn_);
+
+ nameInput_ = new TextField(this);
+ nameInput_->setLabel(tr("Name").toUpper());
+ topicInput_ = new TextField(this);
+ topicInput_->setLabel(tr("Topic").toUpper());
+
+ errorField_ = new QLabel(this);
+ errorField_->setWordWrap(true);
+ errorField_->hide();
+
+ layout->addWidget(nameInput_);
+ layout->addWidget(topicInput_);
+ layout->addLayout(btnLayout, 1);
+
+ auto labelLayout = new QHBoxLayout;
+ labelLayout->setAlignment(Qt::AlignHCenter);
+ labelLayout->addWidget(errorField_);
+ layout->addLayout(labelLayout);
+
+ connect(applyBtn_, &QPushButton::clicked, this, &EditModal::applyClicked);
+ connect(cancelBtn_, &QPushButton::clicked, this, &EditModal::close);
+
+ auto window = QApplication::activeWindow();
+
+ if (window != nullptr) {
+ auto center = window->frameGeometry().center();
+ move(center.x() - (width() * 0.5), center.y() - (height() * 0.5));
+ }
+}
+
+void
+EditModal::topicEventSent(const QString &topic)
+{
+ errorField_->hide();
+ emit topicChanged(topic);
+ close();
+}
+
+void
+EditModal::nameEventSent(const QString &name)
+{
+ errorField_->hide();
+ emit nameChanged(name);
+ close();
+}
+
+void
+EditModal::error(const QString &msg)
+{
+ errorField_->setText(msg);
+ errorField_->show();
+}
+
+void
+EditModal::applyClicked()
+{
+ // Check if the values are changed from the originals.
+ auto newName = nameInput_->text().trimmed();
+ auto newTopic = topicInput_->text().trimmed();
+
+ errorField_->hide();
+
+ if (newName == initialName_ && newTopic == initialTopic_) {
+ close();
+ return;
+ }
+
+ using namespace mtx::events;
+ auto proxy = std::make_shared();
+ connect(proxy.get(), &ThreadProxy::topicEventSent, this, &EditModal::topicEventSent);
+ connect(proxy.get(), &ThreadProxy::nameEventSent, this, &EditModal::nameEventSent);
+ connect(proxy.get(), &ThreadProxy::error, this, &EditModal::error);
+
+ if (newName != initialName_ && !newName.isEmpty()) {
+ state::Name body;
+ body.name = newName.toStdString();
+
+ http::client()->send_state_event(
+ roomId_.toStdString(),
+ body,
+ [proxy, newName](const mtx::responses::EventId &, mtx::http::RequestErr err) {
+ if (err) {
+ emit proxy->error(
+ QString::fromStdString(err->matrix_error.error));
+ return;
+ }
+
+ emit proxy->nameEventSent(newName);
+ });
+ }
+
+ if (newTopic != initialTopic_ && !newTopic.isEmpty()) {
+ state::Topic body;
+ body.topic = newTopic.toStdString();
+
+ http::client()->send_state_event(
+ roomId_.toStdString(),
+ body,
+ [proxy, newTopic](const mtx::responses::EventId &, mtx::http::RequestErr err) {
+ if (err) {
+ emit proxy->error(
+ QString::fromStdString(err->matrix_error.error));
+ return;
+ }
+
+ emit proxy->topicEventSent(newTopic);
+ });
+ }
+}
+
+void
+EditModal::setFields(const QString &roomName, const QString &roomTopic)
+{
+ initialName_ = roomName;
+ initialTopic_ = roomTopic;
+
+ nameInput_->setText(roomName);
+ topicInput_->setText(roomTopic);
+}
+
+RoomSettings::RoomSettings(QString roomid, QObject *parent)
+ : QObject(parent)
+ , roomid_{std::move(roomid)}
+{
+ retrieveRoomInfo();
+
+ // get room setting notifications
+ http::client()->get_pushrules(
+ "global",
+ "override",
+ roomid_.toStdString(),
+ [this](const mtx::pushrules::PushRule &rule, mtx::http::RequestErr &err) {
+ if (err) {
+ if (err->status_code == boost::beast::http::status::not_found)
+ http::client()->get_pushrules(
+ "global",
+ "room",
+ roomid_.toStdString(),
+ [this](const mtx::pushrules::PushRule &rule,
+ mtx::http::RequestErr &err) {
+ if (err) {
+ notifications_ = 2; // all messages
+ emit notificationsChanged();
+ return;
+ }
+
+ if (rule.enabled) {
+ notifications_ = 1; // mentions only
+ emit notificationsChanged();
+ }
+ });
+ return;
+ }
+
+ if (rule.enabled) {
+ notifications_ = 0; // muted
+ emit notificationsChanged();
+ } else {
+ notifications_ = 2; // all messages
+ emit notificationsChanged();
+ }
+ });
+
+ // access rules
+ if (info_.join_rule == state::JoinRule::Public) {
+ if (info_.guest_access) {
+ accessRules_ = 0;
+ } else {
+ accessRules_ = 1;
+ }
+ } else {
+ accessRules_ = 2;
+ }
+ emit accessJoinRulesChanged();
+}
+
+QString
+RoomSettings::roomName() const
+{
+ return QString::fromStdString(info_.name);
+}
+
+QString
+RoomSettings::roomTopic() const
+{
+ return utils::linkifyMessage(QString::fromStdString(info_.topic).toHtmlEscaped());
+}
+
+QString
+RoomSettings::roomId() const
+{
+ return roomid_;
+}
+
+QString
+RoomSettings::roomVersion() const
+{
+ return QString::fromStdString(info_.version);
+}
+
+bool
+RoomSettings::isLoading() const
+{
+ return isLoading_;
+}
+
+QString
+RoomSettings::roomAvatarUrl()
+{
+ return QString::fromStdString(info_.avatar_url);
+}
+
+int
+RoomSettings::memberCount() const
+{
+ return info_.member_count;
+}
+
+void
+RoomSettings::retrieveRoomInfo()
+{
+ try {
+ usesEncryption_ = cache::isRoomEncrypted(roomid_.toStdString());
+ info_ = cache::singleRoomInfo(roomid_.toStdString());
+ } catch (const lmdb::error &) {
+ nhlog::db()->warn("failed to retrieve room info from cache: {}",
+ roomid_.toStdString());
+ }
+}
+
+int
+RoomSettings::notifications()
+{
+ return notifications_;
+}
+
+int
+RoomSettings::accessJoinRules()
+{
+ return accessRules_;
+}
+
+bool
+RoomSettings::respondsToKeyRequests()
+{
+ return usesEncryption_ && utils::respondsToKeyRequests(roomid_);
+}
+
+void
+RoomSettings::changeKeyRequestsPreference(bool isOn)
+{
+ utils::setKeyRequestsPreference(roomid_, isOn);
+ emit keyRequestsChanged();
+}
+
+void
+RoomSettings::enableEncryption()
+{
+ if (usesEncryption_)
+ return;
+
+ const auto room_id = roomid_.toStdString();
+ http::client()->enable_encryption(
+ room_id, [room_id, this](const mtx::responses::EventId &, mtx::http::RequestErr err) {
+ if (err) {
+ int status_code = static_cast(err->status_code);
+ nhlog::net()->warn("failed to enable encryption in room ({}): {} {}",
+ room_id,
+ err->matrix_error.error,
+ status_code);
+ emit displayError(
+ tr("Failed to enable encryption: %1")
+ .arg(QString::fromStdString(err->matrix_error.error)));
+ usesEncryption_ = false;
+ emit encryptionChanged();
+ return;
+ }
+
+ nhlog::net()->info("enabled encryption on room ({})", room_id);
+ });
+
+ usesEncryption_ = true;
+ emit encryptionChanged();
+}
+
+bool
+RoomSettings::canChangeJoinRules() const
+{
+ try {
+ return cache::hasEnoughPowerLevel({EventType::RoomJoinRules},
+ roomid_.toStdString(),
+ utils::localUser().toStdString());
+ } catch (const lmdb::error &e) {
+ nhlog::db()->warn("lmdb error: {}", e.what());
+ }
+
+ return false;
+}
+
+bool
+RoomSettings::canChangeNameAndTopic() const
+{
+ try {
+ return cache::hasEnoughPowerLevel({EventType::RoomName, EventType::RoomTopic},
+ roomid_.toStdString(),
+ utils::localUser().toStdString());
+ } catch (const lmdb::error &e) {
+ nhlog::db()->warn("lmdb error: {}", e.what());
+ }
+
+ return false;
+}
+
+bool
+RoomSettings::canChangeAvatar() const
+{
+ try {
+ return cache::hasEnoughPowerLevel(
+ {EventType::RoomAvatar}, roomid_.toStdString(), utils::localUser().toStdString());
+ } catch (const lmdb::error &e) {
+ nhlog::db()->warn("lmdb error: {}", e.what());
+ }
+
+ return false;
+}
+
+bool
+RoomSettings::isEncryptionEnabled() const
+{
+ return usesEncryption_;
+}
+
+void
+RoomSettings::openEditModal()
+{
+ retrieveRoomInfo();
+
+ auto modal = new EditModal(roomid_);
+ modal->setFields(QString::fromStdString(info_.name), QString::fromStdString(info_.topic));
+ modal->raise();
+ modal->show();
+ connect(modal, &EditModal::nameChanged, this, [this](const QString &newName) {
+ info_.name = newName.toStdString();
+ emit roomNameChanged();
+ });
+
+ connect(modal, &EditModal::topicChanged, this, [this](const QString &newTopic) {
+ info_.topic = newTopic.toStdString();
+ emit roomTopicChanged();
+ });
+}
+
+void
+RoomSettings::changeNotifications(int currentIndex)
+{
+ notifications_ = currentIndex;
+
+ std::string room_id = roomid_.toStdString();
+ if (notifications_ == 0) {
+ // mute room
+ // delete old rule first, then add new rule
+ mtx::pushrules::PushRule rule;
+ rule.actions = {mtx::pushrules::actions::dont_notify{}};
+ mtx::pushrules::PushCondition condition;
+ condition.kind = "event_match";
+ condition.key = "room_id";
+ condition.pattern = room_id;
+ rule.conditions = {condition};
+
+ http::client()->put_pushrules(
+ "global", "override", room_id, rule, [room_id](mtx::http::RequestErr &err) {
+ if (err)
+ nhlog::net()->error("failed to set pushrule for room {}: {} {}",
+ room_id,
+ static_cast(err->status_code),
+ err->matrix_error.error);
+ http::client()->delete_pushrules(
+ "global", "room", room_id, [room_id](mtx::http::RequestErr &) {});
+ });
+ } else if (notifications_ == 1) {
+ // mentions only
+ // delete old rule first, then add new rule
+ mtx::pushrules::PushRule rule;
+ rule.actions = {mtx::pushrules::actions::dont_notify{}};
+ http::client()->put_pushrules(
+ "global", "room", room_id, rule, [room_id](mtx::http::RequestErr &err) {
+ if (err)
+ nhlog::net()->error("failed to set pushrule for room {}: {} {}",
+ room_id,
+ static_cast(err->status_code),
+ err->matrix_error.error);
+ http::client()->delete_pushrules(
+ "global", "override", room_id, [room_id](mtx::http::RequestErr &) {});
+ });
+ } else {
+ // all messages
+ http::client()->delete_pushrules(
+ "global", "override", room_id, [room_id](mtx::http::RequestErr &) {
+ http::client()->delete_pushrules(
+ "global", "room", room_id, [room_id](mtx::http::RequestErr &) {});
+ });
+ }
+}
+
+void
+RoomSettings::changeAccessRules(int index)
+{
+ using namespace mtx::events::state;
+
+ auto guest_access = [](int index) -> state::GuestAccess {
+ state::GuestAccess event;
+
+ if (index == 0)
+ event.guest_access = state::AccessState::CanJoin;
+ else
+ event.guest_access = state::AccessState::Forbidden;
+
+ return event;
+ }(index);
+
+ auto join_rule = [](int index) -> state::JoinRules {
+ state::JoinRules event;
+
+ switch (index) {
+ case 0:
+ case 1:
+ event.join_rule = state::JoinRule::Public;
+ break;
+ default:
+ event.join_rule = state::JoinRule::Invite;
+ }
+
+ return event;
+ }(index);
+
+ updateAccessRules(roomid_.toStdString(), join_rule, guest_access);
+}
+
+void
+RoomSettings::updateAccessRules(const std::string &room_id,
+ const mtx::events::state::JoinRules &join_rule,
+ const mtx::events::state::GuestAccess &guest_access)
+{
+ isLoading_ = true;
+ emit loadingChanged();
+
+ http::client()->send_state_event(
+ room_id,
+ join_rule,
+ [this, room_id, guest_access](const mtx::responses::EventId &,
+ mtx::http::RequestErr err) {
+ if (err) {
+ nhlog::net()->warn("failed to send m.room.join_rule: {} {}",
+ static_cast(err->status_code),
+ err->matrix_error.error);
+ emit displayError(QString::fromStdString(err->matrix_error.error));
+ isLoading_ = false;
+ emit loadingChanged();
+ return;
+ }
+
+ http::client()->send_state_event(
+ room_id,
+ guest_access,
+ [this](const mtx::responses::EventId &, mtx::http::RequestErr err) {
+ if (err) {
+ nhlog::net()->warn("failed to send m.room.guest_access: {} {}",
+ static_cast(err->status_code),
+ err->matrix_error.error);
+ emit displayError(
+ QString::fromStdString(err->matrix_error.error));
+ }
+
+ isLoading_ = false;
+ emit loadingChanged();
+ });
+ });
+}
+
+void
+RoomSettings::stopLoading()
+{
+ isLoading_ = false;
+ emit loadingChanged();
+}
+
+void
+RoomSettings::avatarChanged()
+{
+ retrieveRoomInfo();
+ emit avatarUrlChanged();
+}
+
+void
+RoomSettings::updateAvatar()
+{
+ const QString picturesFolder =
+ QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
+ const QString fileName = QFileDialog::getOpenFileName(
+ nullptr, tr("Select an avatar"), picturesFolder, tr("All Files (*)"));
+
+ if (fileName.isEmpty())
+ return;
+
+ QMimeDatabase db;
+ QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent);
+
+ const auto format = mime.name().split("/")[0];
+
+ QFile file{fileName, this};
+ if (format != "image") {
+ emit displayError(tr("The selected file is not an image"));
+ return;
+ }
+
+ if (!file.open(QIODevice::ReadOnly)) {
+ emit displayError(tr("Error while reading file: %1").arg(file.errorString()));
+ return;
+ }
+
+ isLoading_ = true;
+ emit loadingChanged();
+
+ // Events emitted from the http callbacks (different threads) will
+ // be queued back into the UI thread through this proxy object.
+ auto proxy = std::make_shared();
+ connect(proxy.get(), &ThreadProxy::error, this, &RoomSettings::displayError);
+ connect(proxy.get(), &ThreadProxy::stopLoading, this, &RoomSettings::stopLoading);
+
+ const auto bin = file.peek(file.size());
+ const auto payload = std::string(bin.data(), bin.size());
+ const auto dimensions = QImageReader(&file).size();
+
+ // First we need to create a new mxc URI
+ // (i.e upload media to the Matrix content repository) for the new avatar.
+ http::client()->upload(
+ payload,
+ mime.name().toStdString(),
+ QFileInfo(fileName).fileName().toStdString(),
+ [proxy = std::move(proxy),
+ dimensions,
+ payload,
+ mimetype = mime.name().toStdString(),
+ size = payload.size(),
+ room_id = roomid_.toStdString(),
+ content = std::move(bin)](const mtx::responses::ContentURI &res,
+ mtx::http::RequestErr err) {
+ if (err) {
+ emit proxy->stopLoading();
+ emit proxy->error(
+ tr("Failed to upload image: %s")
+ .arg(QString::fromStdString(err->matrix_error.error)));
+ return;
+ }
+
+ using namespace mtx::events;
+ state::Avatar avatar_event;
+ avatar_event.image_info.w = dimensions.width();
+ avatar_event.image_info.h = dimensions.height();
+ avatar_event.image_info.mimetype = mimetype;
+ avatar_event.image_info.size = size;
+ avatar_event.url = res.content_uri;
+
+ http::client()->send_state_event(
+ room_id,
+ avatar_event,
+ [content = std::move(content), proxy = std::move(proxy)](
+ const mtx::responses::EventId &, mtx::http::RequestErr err) {
+ if (err) {
+ emit proxy->error(
+ tr("Failed to upload image: %s")
+ .arg(QString::fromStdString(err->matrix_error.error)));
+ return;
+ }
+
+ emit proxy->stopLoading();
+ });
+ });
+}
diff --git a/src/ui/RoomSettings.h b/src/ui/RoomSettings.h
new file mode 100644
index 00000000..25c6e588
--- /dev/null
+++ b/src/ui/RoomSettings.h
@@ -0,0 +1,135 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include
+
+#include "CacheStructs.h"
+
+class TextField;
+
+/// Convenience class which connects events emmited from threads
+/// outside of main with the UI code.
+class ThreadProxy : public QObject
+{
+ Q_OBJECT
+
+signals:
+ void error(const QString &msg);
+ void nameEventSent(const QString &);
+ void topicEventSent(const QString &);
+ void stopLoading();
+};
+
+class EditModal : public QWidget
+{
+ Q_OBJECT
+
+public:
+ EditModal(const QString &roomId, QWidget *parent = nullptr);
+
+ void setFields(const QString &roomName, const QString &roomTopic);
+
+signals:
+ void nameChanged(const QString &roomName);
+ void topicChanged(const QString &topic);
+
+private slots:
+ void topicEventSent(const QString &topic);
+ void nameEventSent(const QString &name);
+ void error(const QString &msg);
+
+ void applyClicked();
+
+private:
+ QString roomId_;
+ QString initialName_;
+ QString initialTopic_;
+
+ QLabel *errorField_;
+
+ TextField *nameInput_;
+ TextField *topicInput_;
+
+ QPushButton *applyBtn_;
+ QPushButton *cancelBtn_;
+};
+
+class RoomSettings : public QObject
+{
+ Q_OBJECT
+ Q_PROPERTY(QString roomId READ roomId CONSTANT)
+ Q_PROPERTY(QString roomVersion READ roomVersion CONSTANT)
+ Q_PROPERTY(QString roomName READ roomName NOTIFY roomNameChanged)
+ Q_PROPERTY(QString roomTopic READ roomTopic NOTIFY roomTopicChanged)
+ Q_PROPERTY(QString roomAvatarUrl READ roomAvatarUrl NOTIFY avatarUrlChanged)
+ Q_PROPERTY(int memberCount READ memberCount CONSTANT)
+ Q_PROPERTY(int notifications READ notifications NOTIFY notificationsChanged)
+ Q_PROPERTY(int accessJoinRules READ accessJoinRules NOTIFY accessJoinRulesChanged)
+ Q_PROPERTY(bool isLoading READ isLoading NOTIFY loadingChanged)
+ Q_PROPERTY(bool canChangeAvatar READ canChangeAvatar CONSTANT)
+ Q_PROPERTY(bool canChangeJoinRules READ canChangeJoinRules CONSTANT)
+ Q_PROPERTY(bool canChangeNameAndTopic READ canChangeNameAndTopic CONSTANT)
+ Q_PROPERTY(bool isEncryptionEnabled READ isEncryptionEnabled NOTIFY encryptionChanged)
+ Q_PROPERTY(bool respondsToKeyRequests READ respondsToKeyRequests NOTIFY keyRequestsChanged)
+
+public:
+ RoomSettings(QString roomid, QObject *parent = nullptr);
+
+ QString roomId() const;
+ QString roomName() const;
+ QString roomTopic() const;
+ QString roomVersion() const;
+ QString roomAvatarUrl();
+ int memberCount() const;
+ int notifications();
+ int accessJoinRules();
+ bool respondsToKeyRequests();
+ bool isLoading() const;
+ //! Whether the user has enough power level to send m.room.join_rules events.
+ bool canChangeJoinRules() const;
+ //! Whether the user has enough power level to send m.room.name & m.room.topic events.
+ bool canChangeNameAndTopic() const;
+ //! Whether the user has enough power level to send m.room.avatar event.
+ bool canChangeAvatar() const;
+ bool isEncryptionEnabled() const;
+
+ Q_INVOKABLE void enableEncryption();
+ Q_INVOKABLE void updateAvatar();
+ Q_INVOKABLE void openEditModal();
+ Q_INVOKABLE void changeAccessRules(int index);
+ Q_INVOKABLE void changeNotifications(int currentIndex);
+ Q_INVOKABLE void changeKeyRequestsPreference(bool isOn);
+
+signals:
+ void loadingChanged();
+ void roomNameChanged();
+ void roomTopicChanged();
+ void avatarUrlChanged();
+ void encryptionChanged();
+ void keyRequestsChanged();
+ void notificationsChanged();
+ void accessJoinRulesChanged();
+ void displayError(const QString &errorMessage);
+
+public slots:
+ void stopLoading();
+ void avatarChanged();
+
+private:
+ void retrieveRoomInfo();
+ void updateAccessRules(const std::string &room_id,
+ const mtx::events::state::JoinRules &,
+ const mtx::events::state::GuestAccess &);
+
+private:
+ QString roomid_;
+ bool usesEncryption_ = false;
+ bool isLoading_ = false;
+ RoomInfo info_;
+ int notifications_ = 0;
+ int accessRules_ = 0;
+};
\ No newline at end of file