Merge remote-tracking branch 'upstream/master' into screenshare-x11

pull/484/head
trilene 4 years ago
commit 55fb00c67b
  1. 6
      .gitlab-ci.yml
  2. 1
      AppImageBuilder.yml
  3. 8
      CMakeLists.txt
  4. 7
      README.md
  5. 2
      io.github.NhekoReborn.Nheko.json
  6. 10
      nheko-nightly.flatpakref
  7. 8
      nheko-nightly.flatpakrepo
  8. 29
      resources/qml/Completer.qml
  9. 93
      resources/qml/MessageInput.qml
  10. 2
      resources/qml/MessageView.qml
  11. 281
      resources/qml/RoomSettings.qml
  12. 18
      resources/qml/TimelineView.qml
  13. 40
      resources/qml/ToggleButton.qml
  14. 6
      resources/qml/TopBar.qml
  15. 2
      resources/res.qrc
  16. 772
      scripts/flat-manager-client
  17. 24
      scripts/upload-to-flatpak-repo.sh
  18. 46
      src/Cache.cpp
  19. 43
      src/ChatPage.cpp
  20. 2
      src/ChatPage.h
  21. 60
      src/LoginPage.cpp
  22. 12
      src/LoginPage.h
  23. 9
      src/MainWindow.cpp
  24. 2
      src/MainWindow.h
  25. 1
      src/RegisterPage.cpp
  26. 2
      src/RoomInfoListItem.h
  27. 8
      src/RoomList.cpp
  28. 69
      src/RoomsModel.cpp
  29. 33
      src/RoomsModel.h
  30. 865
      src/dialogs/RoomSettings.cpp
  31. 150
      src/dialogs/RoomSettings.h
  32. 2
      src/notifications/ManagerLinux.cpp
  33. 47
      src/timeline/InputBar.cpp
  34. 2
      src/timeline/InputBar.h
  35. 48
      src/timeline/TimelineModel.cpp
  36. 6
      src/timeline/TimelineModel.h
  37. 11
      src/timeline/TimelineViewManager.cpp
  38. 1
      src/timeline/TimelineViewManager.h
  39. 625
      src/ui/RoomSettings.cpp
  40. 135
      src/ui/RoomSettings.h

@ -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"

@ -79,6 +79,7 @@ AppDir:
- libxv1
- libxxf86vm1
- libzstd1
- qml-module-qt-labs-platform
- qml-module-qtgraphicaleffects
- qml-module-qtmultimedia
- qml-module-qtquick-controls2

@ -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

@ -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/)
<a href='https://flatpak.neko.dev/repo/nightly/appstream/io.github.NhekoReborn.Nheko.flatpakref' download='nheko-nightly.flatpakref'><img alt='Download Nightly Flatpak' src='https://img.shields.io/badge/download-flatpak--nightly-green'/></a>
[![#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)
<a href='https://flathub.org/apps/details/io.github.NhekoReborn.Nheko'><img width='240' alt='Download on Flathub' src='https://flathub.org/assets/badges/flathub-badge-en.png'/></a>
@ -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
```

@ -220,7 +220,7 @@
"name": "mtxclient",
"sources": [
{
"commit": "fee5298f068394958c2de935836a2c145f273906",
"commit": "004d4203ceb441239aafb17e1340cd063139d029",
"type": "git",
"url": "https://github.com/Nheko-Reborn/mtxclient.git"
}

@ -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=

@ -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=

@ -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
}
}
}
}
}

@ -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();
}
}

@ -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

@ -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. <br>
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()
}
}
}

@ -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: {

@ -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"
}
}
}

@ -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()
}
}

@ -129,6 +129,7 @@
<file>qml/EncryptionIndicator.qml</file>
<file>qml/ImageButton.qml</file>
<file>qml/MatrixText.qml</file>
<file>qml/ToggleButton.qml</file>
<file>qml/MessageInput.qml</file>
<file>qml/MessageView.qml</file>
<file>qml/NhekoBusyIndicator.qml</file>
@ -140,6 +141,7 @@
<file>qml/TimelineRow.qml</file>
<file>qml/TopBar.qml</file>
<file>qml/TypingIndicator.qml</file>
<file>qml/RoomSettings.qml</file>
<file>qml/emoji/EmojiButton.qml</file>
<file>qml/emoji/EmojiPicker.qml</file>
<file>qml/UserProfile.qml</file>

@ -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)

@ -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

@ -102,6 +102,20 @@ namespace {
std::unique_ptr<Cache> instance_ = nullptr;
}
template<class T>
static T
to(lmdb::val &value)
{
static_assert(std::is_trivial_v<T>, "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<uint64_t>::max()) {
if (cursor.get(indexVal, event_id, forward ? MDB_FIRST : MDB_LAST)) {
index = *indexVal.data<uint64_t>();
index = to<uint64_t>(indexVal);
} else {
messages.end_of_cache = true;
return messages;
}
} else {
if (cursor.get(indexVal, event_id, MDB_SET)) {
index = *indexVal.data<uint64_t>();
index = to<uint64_t>(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<uint64_t>();
messages.next_index = to<uint64_t>(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<uint64_t>();
range.last = to<uint64_t>(indexVal);
if (!cursor.get(indexVal, val, MDB_FIRST)) {
return {};
}
range.first = *indexVal.data<uint64_t>();
range.first = to<uint64_t>(indexVal);
return range;
}
@ -1892,7 +1906,7 @@ Cache::getTimelineIndex(const std::string &room_id, std::string_view event_id)
return {};
}
return *val.data<uint64_t>();
return to<uint64_t>(val);
}
std::optional<uint64_t>
@ -1920,7 +1934,7 @@ Cache::getEventIndex(const std::string &room_id, std::string_view event_id)
return {};
}
return *val.data<uint64_t>();
return to<uint64_t>(val);
}
std::optional<std::pair<uint64_t, std::string>>
@ -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>();
uint64_t prevIdx = to<uint64_t>(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<uint64_t>();
prevIdx = to<uint64_t>(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<uint64_t>();
return to<uint64_t>(val);
}
std::optional<std::string>
@ -2775,13 +2789,13 @@ Cache::saveTimelineMessages(lmdb::txn &txn,
uint64_t index = std::numeric_limits<uint64_t>::max() / 2;
auto cursor = lmdb::cursor::open(txn, orderDb);
if (cursor.get(indexVal, val, MDB_LAST)) {
index = *indexVal.data<int64_t>();
index = to<uint64_t>(indexVal);
}
uint64_t msgIndex = std::numeric_limits<uint64_t>::max() / 2;
auto msgCursor = lmdb::cursor::open(txn, order2msgDb);
if (msgCursor.get(indexVal, val, MDB_LAST)) {
msgIndex = *indexVal.data<uint64_t>();
msgIndex = to<uint64_t>(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<uint64_t>();
index = to<uint64_t>(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<uint64_t>();
msgIndex = to<uint64_t>(indexVal);
}
}
@ -3258,12 +3272,12 @@ Cache::deleteOldMessages()
uint64_t first, last;
if (cursor.get(indexVal, val, MDB_LAST)) {
last = *indexVal.data<uint64_t>();
last = to<uint64_t>(indexVal);
} else {
continue;
}
if (cursor.get(indexVal, val, MDB_FIRST)) {
first = *indexVal.data<uint64_t>();
first = to<uint64_t>(indexVal);
} else {
continue;
}

@ -253,6 +253,7 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> 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<std::string> &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);
}
}

@ -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<QString, RoomInfo>);
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);

@ -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<void (LoginPage::*)(QLabel *, const QString &)>(&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();
});

@ -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;
};

@ -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)
{

@ -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<void(const mtx::requests::CreateRoom &request)> callback);
void openJoinRoomDialog(std::function<void(const QString &room_id)> callback);
void openLogoutDialog();
void openRoomSettings(const QString &room_id);
void openMemberListDialog(const QString &room_id);
void openReadReceiptsDialog(const QString &event_id);

@ -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;

@ -217,4 +217,6 @@ private:
QColor bubbleBgColor_;
QColor bubbleFgColor_;
friend struct room_sort;
};

@ -353,8 +353,8 @@ RoomList::updateRoomDescription(const QString &roomid, const DescInfo &info)
struct room_sort
{
bool operator()(const QSharedPointer<RoomInfoListItem> a,
const QSharedPointer<RoomInfoListItem> b) const
bool operator()(const QSharedPointer<RoomInfoListItem> &a,
const QSharedPointer<RoomInfoListItem> &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;
}
};

@ -0,0 +1,69 @@
#include "RoomsModel.h"
#include <QUrl>
#include "Cache_p.h"
#include "CompletionModelRoles.h"
RoomsModel::RoomsModel(bool showOnlyRoomWithAliases, QObject *parent)
: QAbstractListModel(parent)
, showOnlyRoomWithAliases_(showOnlyRoomWithAliases)
{
std::vector<std::string> 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<int, QByteArray>
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 {};
}

@ -0,0 +1,33 @@
#pragma once
#include "Cache.h"
#include <QAbstractListModel>
#include <QString>
class RoomsModel : public QAbstractListModel
{
public:
enum Roles
{
AvatarUrl = Qt::UserRole,
RoomAlias,
RoomID,
RoomName,
};
RoomsModel(bool showOnlyRoomWithAliases = false, QObject *parent = nullptr);
QHash<int, QByteArray> 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<QString> roomids;
std::vector<QString> roomAliases;
std::map<QString, RoomInfo> roomInfos;
bool showOnlyRoomWithAliases_;
};

@ -1,865 +0,0 @@
#include "dialogs/RoomSettings.h"
#include <QApplication>
#include <QComboBox>
#include <QEvent>
#include <QFileDialog>
#include <QFontDatabase>
#include <QImageReader>
#include <QLabel>
#include <QMessageBox>
#include <QMimeDatabase>
#include <QPainter>
#include <QPixmap>
#include <QPushButton>
#include <QShortcut>
#include <QShowEvent>
#include <QStandardPaths>
#include <QStyleOption>
#include <QVBoxLayout>
#include <mtx/responses/common.hpp>
#include <mtx/responses/media.hpp>
#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<ThreadProxy>();
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<int>::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<int>(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<int>(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<int>::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. <br>"
"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<int>(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<int>(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<int>(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<ThreadProxy>();
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();
});
});
}

@ -1,150 +0,0 @@
#pragma once
#include <QFrame>
#include <QImage>
#include <mtx/events/guest_access.hpp>
#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

@ -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();
};

@ -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);

@ -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);

@ -362,6 +362,8 @@ TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int r
const static QRegularExpression replyFallback(
"<mx-reply>.*</mx-reply>", 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("<img src=\"mxc:&#47;&#47;", "<img src=\"image://mxcImage/");
formattedBody_.replace("<img src=\"mxc://", "<img src=\"image://mxcImage/");
// TODO(Nico): Don't parse html with a regex
const static QRegularExpression matchImgUri(
"(<img [^>]*)src=\"mxc://([^\"]*)\"([^>]*>)");
formattedBody_.replace(matchImgUri, "\\1 src=\"image://mxcImage/\\2\"\\3");
const static QRegularExpression matchEmoticonHeight(
"(<img data-mx-emoticon [^>]*)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<int>(ProportionalHeight)));
m.insert(names[Id], data(event, static_cast<int>(Id)));
m.insert(names[State], data(event, static_cast<int>(State)));
m.insert(names[IsEdited], data(event, static_cast<int>(IsEdited)));
m.insert(names[IsEditable], data(event, static_cast<int>(IsEditable)));
m.insert(names[IsEncrypted], data(event, static_cast<int>(IsEncrypted)));
m.insert(names[IsRoomEncrypted], data(event, static_cast<int>(IsRoomEncrypted)));
m.insert(names[ReplyTo], data(event, static_cast<int>(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();
}
}

@ -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<QString> 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();
}

@ -128,6 +128,12 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
0,
"UserProfileModel",
"UserProfile needs to be instantiated on the C++ side");
qmlRegisterUncreatableType<RoomSettings>(
"im.nheko",
1,
0,
"RoomSettingsModel",
"Room Settings needs to be instantiated on the C++ side");
static auto self = this;
qmlRegisterSingletonType<MainWindow>(
@ -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)

@ -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);

@ -0,0 +1,625 @@
#include "RoomSettings.h"
#include <QApplication>
#include <QFileDialog>
#include <QHBoxLayout>
#include <QImageReader>
#include <QMimeDatabase>
#include <QStandardPaths>
#include <QVBoxLayout>
#include <mtx/responses/common.hpp>
#include <mtx/responses/media.hpp>
#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<ThreadProxy>();
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<int>(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<int>(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<int>(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<int>(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<int>(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<ThreadProxy>();
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();
});
});
}

@ -0,0 +1,135 @@
#pragma once
#include <QLabel>
#include <QObject>
#include <QPushButton>
#include <QString>
#include <mtx/events/guest_access.hpp>
#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;
};
Loading…
Cancel
Save