diff --git a/TODO.md b/TODO.md
index f5631731..b17392d6 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,5 +1,9 @@
# TODO
+- clicking cancel on SSO "waiting" box doesn't do anything the first time
+- spam alt+shift+a when starting app on server browser → segfault
+- remove items.Device
+- register tab for sso servers?
- sever list
- cursor shape in HBox/HTabbedBox pages over fields
- login with account already added → infinite spinner in room list
diff --git a/src/backend/backend.py b/src/backend/backend.py
index 1d3d0c64..ad6bc1d7 100644
--- a/src/backend/backend.py
+++ b/src/backend/backend.py
@@ -2,12 +2,17 @@
import asyncio
import logging as log
+import math
import os
+import re
import sys
+import time
import traceback
+from datetime import datetime
from pathlib import Path
from typing import Any, DefaultDict, Dict, List, Optional, Tuple
+import aiohttp
from appdirs import AppDirs
import nio
@@ -18,7 +23,7 @@ from .matrix_client import MatrixClient
from .media_cache import MediaCache
from .models import SyncId
from .models.filters import FieldSubstringFilter
-from .models.items import Account, Event
+from .models.items import Account, Event, Homeserver, PingStatus
from .models.model import Model
from .models.model_store import ModelStore
from .presence import Presence
@@ -107,6 +112,9 @@ class Backend:
self._sso_server: Optional[SSOServer] = None
self._sso_server_task: Optional[asyncio.Future] = None
+ self._ping_tasks: Dict[str, asyncio.Future] = {}
+ self._stability_tasks: Dict[str, asyncio.Future] = {}
+
self.profile_cache: Dict[str, nio.ProfileGetResponse] = {}
self.get_profile_locks: DefaultDict[str, asyncio.Lock] = \
DefaultDict(asyncio.Lock) # {user_id: lock}
@@ -474,3 +482,139 @@ class Backend:
if device.ed25519 == ed25519_key:
client.blacklist_device(device)
+
+
+ async def _ping_homeserver(
+ self, session: aiohttp.ClientSession, homeserver_url: str,
+ ) -> None:
+ """Ping a homeserver present in our model and set its `ping` field."""
+
+ item = self.models["homeservers"][homeserver_url]
+ times = []
+
+ for i in range(16):
+ start = time.time()
+
+ try:
+ await session.get(f"{homeserver_url}/_matrix/client/versions")
+ except Exception as err:
+ log.warning("Failed pinging %s: %r", homeserver_url, err)
+ item.status = PingStatus.Failed
+ return
+
+ times.append(round((time.time() - start) * 1000))
+
+ if i == 7 or i == 15:
+ item.set_fields(
+ ping=sum(times) // len(times), status=PingStatus.Done,
+ )
+
+
+ async def _get_homeserver_stability(
+ self,
+ session: aiohttp.ClientSession,
+ homeserver_url: str,
+ uptimerobot_id: int,
+ ) -> None:
+ api = "https://matrixservers.anchel.nl/api/getMonitor/wkMJmFGvo2?m={}"
+
+ response = await session.get(api.format(uptimerobot_id))
+ logs = (await response.json())["monitor"]["logs"]
+ stability = 100.0
+
+ for period in logs:
+ started_at = datetime.fromtimestamp(period["time"])
+ time_since_now = datetime.now() - started_at
+
+ if time_since_now.days > 30 or period["class"] != "danger":
+ continue
+
+ lasted_hours, lasted_mins = [
+ int(x.split()[0]) for x in period["duration"].split(", ")
+ ]
+ lasted_mins += lasted_hours * 60
+ stability -= (
+ (lasted_mins * stability / 1000) /
+ max(1, time_since_now.days / 3)
+ )
+
+ self.models["homeservers"][homeserver_url].stability = stability
+
+
+ async def _add_homeserver_item(
+ self,
+ session: aiohttp.ClientSession,
+ homeserver_url: str,
+ uptimerobot_id: int,
+ **fields,
+ ) -> Homeserver:
+ """Add homeserver to our model & start info-gathering tasks."""
+
+ if not re.match(r"^https?://.+", homeserver_url):
+ homeserver_url = f"https://{homeserver_url}"
+
+ if fields.get("country") == "USA":
+ fields["country"] = "United States"
+
+ if homeserver_url in self._ping_tasks:
+ self._ping_tasks[homeserver_url].cancel()
+
+ if homeserver_url in self._stability_tasks:
+ self._stability_tasks[homeserver_url].cancel()
+
+ item = Homeserver(id=homeserver_url, **fields)
+ self.models["homeservers"][homeserver_url] = item
+
+ self._ping_tasks[homeserver_url] = asyncio.ensure_future(
+ self._ping_homeserver(session, homeserver_url),
+ )
+
+ self._stability_tasks[homeserver_url] = asyncio.ensure_future(
+ self._get_homeserver_stability(
+ session, item.id, uptimerobot_id,
+ ),
+ )
+
+ return item
+
+
+ async def fetch_homeservers(self) -> None:
+ """Retrieve a list of public homeservers and add them to our model."""
+
+ api_list = "https://publiclist.anchel.nl/staticlist.json"
+
+ tmout = aiohttp.ClientTimeout(total=20)
+ session = aiohttp.ClientSession(raise_for_status=True, timeout=tmout)
+ response = await session.get(api_list)
+ data = (await response.json())["staticlist"]
+
+ await self._add_homeserver_item(
+ session = session,
+ homeserver_url = "https://matrix-client.matrix.org",
+ name = "matrix.org",
+ site_url = "https://matrix.org",
+ country = "Cloudflare",
+ uptimerobot_id = 783115140,
+ )
+
+ await self._add_homeserver_item(
+ session = session,
+ homeserver_url = "https://mozilla.modular.im",
+ name = "mozilla.org",
+ site_url = "https://mozilla.org",
+ country = "United States",
+ uptimerobot_id = 784321494,
+ )
+
+ for server in data:
+ if server["homeserver"].startswith("http://"): # insecure server
+ continue
+
+ await self._add_homeserver_item(
+ session = session,
+ homeserver_url = server["homeserver"],
+ name = server["name"],
+ site_url = server["url"],
+ country = server["country"],
+ uptimerobot_id = server["uptrid"],
+ )
diff --git a/src/backend/models/items.py b/src/backend/models/items.py
index 8b591cea..9ec2f0b8 100644
--- a/src/backend/models/items.py
+++ b/src/backend/models/items.py
@@ -30,6 +30,30 @@ class TypeSpecifier(AutoStrEnum):
MembershipChange = auto()
+class PingStatus(AutoStrEnum):
+ """Enum for the status of a homeserver ping operation."""
+
+ Done = auto()
+ Pinging = auto()
+ Failed = auto()
+
+
+@dataclass
+class Homeserver(ModelItem):
+ """A homeserver we can connect to. The `id` field is the server's URL."""
+
+ id: str = field()
+ name: str = field()
+ site_url: str = field()
+ country: str = field()
+ ping: int = -1
+ status: PingStatus = PingStatus.Pinging
+ stability: float = -1
+
+ def __lt__(self, other: "Homeserver") -> bool:
+ return (self.name.lower(), self.id) < (other.name.lower(), other.id)
+
+
@dataclass
class Account(ModelItem):
"""A logged in matrix account."""
diff --git a/src/gui/Base/HBox.qml b/src/gui/Base/HBox.qml
index 4fd87cbd..a78fc771 100644
--- a/src/gui/Base/HBox.qml
+++ b/src/gui/Base/HBox.qml
@@ -3,8 +3,9 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
-HFlickableColumnPage {
+HColumnPage {
implicitWidth: Math.min(parent.width, theme.controls.box.defaultWidth)
+ padding: theme.spacing
background: Rectangle {
color: theme.controls.box.background
diff --git a/src/gui/Pages/AddAccount/ServerBrowser.qml b/src/gui/Pages/AddAccount/ServerBrowser.qml
index e8d8db8a..e673b95a 100644
--- a/src/gui/Pages/AddAccount/ServerBrowser.qml
+++ b/src/gui/Pages/AddAccount/ServerBrowser.qml
@@ -2,12 +2,13 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
+import "../.."
import "../../Base"
import "../../Base/Buttons"
import "../../PythonBridge"
HBox {
- id: page
+ id: box
property string acceptedUserUrl: ""
property string acceptedUrl: ""
@@ -15,22 +16,35 @@ HBox {
property string saveName: "serverBrowser"
property var saveProperties: ["acceptedUserUrl"]
+ property string loadingIconStep: "server-ping-bad"
+
property Future connectFuture: null
+ property Future fetchServersFuture: null
signal accepted()
- function takeFocus() { serverField.item.forceActiveFocus() }
+ function takeFocus() { serverField.item.field.forceActiveFocus() }
+
+ function fetchServers() {
+ fetchServersFuture = py.callCoro("fetch_homeservers", [], () => {
+ fetchServersFuture = null
+ }, (type, args, error, traceback) => {
+ fetchServersFuture = null
+ // TODO
+ print( traceback)
+ })
+ }
function connect() {
if (connectFuture) connectFuture.cancel()
connectTimeout.restart()
- const args = [serverField.item.cleanText]
+ const args = [serverField.item.field.cleanText]
connectFuture = py.callCoro("server_info", args, ([url, flows]) => {
connectTimeout.stop()
- errorMessage.text = ""
- connectFuture = null
+ serverField.errorLabel.text = ""
+ connectFuture = null
if (! (
flows.includes("m.login.password") ||
@@ -39,7 +53,7 @@ HBox {
flows.includes("m.login.token")
)
)) {
- errorMessage.text =
+ serverField.errorLabel.text =
qsTr("No supported sign-in method for this homeserver.")
return
}
@@ -63,106 +77,137 @@ HBox {
py.showError(type, traceback, uuid)
- errorMessage.text = text
+ serverField.errorLabel.text = text
})
}
- function cancel() {
- if (page.connectFuture) return
- connectTimeout.stop()
- connectFuture.cancel()
- connectFuture = null
+ padding: 0
+ implicitWidth: theme.controls.box.defaultWidth * 1.25
+ contentHeight: Math.min(
+ window.height,
+ Math.max(
+ serverList.contentHeight,
+ // busyIndicatorLoader.height + theme.spacing * 2, TODO
+ )
+ )
+
+ header: HLabel {
+ text: qsTr(
+ "Choose a homeserver to create your account on, or the " +
+ "server on which you made an account to sign in to:"
+ )
+ wrapMode: HLabel.Wrap
+ padding: theme.spacing
}
+ footer: HLabeledItem {
+ id: serverField
- footer: AutoDirectionLayout {
- ApplyButton {
- id: applyButton
- enabled: serverField.item.cleanText && ! serverField.item.error
- text: qsTr("Connect")
- icon.name: "server-connect"
- loading: page.connectFuture !== null
- disableWhileLoading: false
- onClicked: page.connect()
- }
+ readonly property bool knownServerChosen:
+ serverList.model.find(item.cleanText) !== null
- CancelButton {
- id: cancelButton
- enabled: page.connectFuture !== null
- onClicked: page.cancel()
+ label.text: qsTr("Homeserver address:")
+ label.topPadding: theme.spacing
+ label.leftPadding: label.topPadding
+ label.rightPadding: label.topPadding
+ errorLabel.leftPadding: label.topPadding
+ errorLabel.rightPadding: label.topPadding
+ errorLabel.bottomPadding: label.topPadding
+
+ Layout.fillWidth: true
+ Layout.margins: theme.spacing
+
+ HRowLayout {
+ readonly property alias field: field
+ readonly property alias apply: apply
+
+ width: parent.width
+
+ HTextField {
+ id: field
+
+ readonly property string cleanText:
+ text.toLowerCase().trim().replace(/\/+$/, "")
+
+ error: text && ! /https?:\/\/.+/.test(cleanText)
+ defaultText: window.getState(
+ box, "acceptedUserUrl", "",
+ )
+ placeholderText: "https://example.org"
+
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ }
+
+ HButton {
+ id: apply
+ enabled: field.cleanText && ! field.error
+ icon.name: "apply"
+ icon.color: theme.colors.positiveBackground
+ loading: box.connectFuture !== null
+ disableWhileLoading: false
+ onClicked: box.connect()
+
+ Layout.fillHeight: true
+ }
}
}
- onKeyboardAccept: if (applyButton.enabled) page.connect()
- onKeyboardCancel: if (cancelButton.enabled) page.cancel()
+ onKeyboardAccept: if (serverField.item.apply.enabled) box.connect()
onAccepted: window.saveState(this)
Timer {
id: connectTimeout
interval: 30 * 1000
onTriggered: {
- errorMessage.text =
+ serverField.errorLabel.text =
serverField.knownServerChosen ?
qsTr("This homeserver seems unavailable. Verify your inter" +
- "net connection or try again in a few minutes.") :
+ "net connection or try again later.") :
qsTr("This homeserver seems unavailable. Verify the " +
"entered address, your internet connection or try " +
- "again in a few minutes.")
+ "again later.")
}
}
- HLabeledItem {
- id: serverField
-
- // 2019-11-11 https://www.hello-matrix.net/public_servers.php
- readonly property var knownServers: [
- "https://matrix.org",
- "https://chat.weho.st",
- "https://tchncs.de",
- "https://chat.privacytools.io",
- "https://hackerspaces.be",
- "https://matrix.allmende.io",
- "https://feneas.org",
- "https://junta.pl",
- "https://perthchat.org",
- "https://matrix.tedomum.net",
- "https://converser.eu",
- "https://ru-matrix.org",
- "https://matrix.sibnsk.net",
- "https://alternanet.fr",
- ]
-
- readonly property bool knownServerChosen:
- knownServers.includes(item.cleanText)
-
- label.text: qsTr("Homeserver:")
-
- Layout.fillWidth: true
-
- HTextField {
- readonly property string cleanText:
- text.toLowerCase().trim().replace(/\/+$/, "")
-
- width: parent.width
- error: ! /https?:\/\/.+/.test(cleanText)
- defaultText:
- window.getState(page, "acceptedUserUrl", "https://matrix.org")
- }
+ Timer {
+ interval: 1000
+ running: fetchServersFuture === null && serverList.count === 0
+ repeat: true
+ triggeredOnStart: true
+ onTriggered: box.fetchServers()
}
- HLabel {
- id: errorMessage
- wrapMode: HLabel.Wrap
- horizontalAlignment: Text.AlignHCenter
- color: theme.colors.errorText
+ Timer {
+ interval: theme.animationDuration * 2
+ running: true
+ repeat: true
+ onTriggered:
+ box.loadingIconStep = "server-ping-" + (
+ box.loadingIconStep === "server-ping-bad" ? "medium" :
+ box.loadingIconStep === "server-ping-medium" ? "good" :
+ "bad"
+ )
+ }
- visible: Layout.maximumHeight > 0
- Layout.maximumHeight: text ? implicitHeight : 0
- Behavior on Layout.maximumHeight { HNumberAnimation {} }
+ HListView {
+ id: serverList
+ clip: true
+ model: ModelStore.get("homeservers")
+
+ delegate: ServerDelegate {
+ width: serverList.width
+ loadingIconStep: box.loadingIconStep
+ onClicked: {
+ serverField.item.field.text = model.id
+ serverField.item.apply.clicked()
+ }
+ }
Layout.fillWidth: true
+ Layout.fillHeight: true
}
}
diff --git a/src/gui/Pages/AddAccount/ServerDelegate.qml b/src/gui/Pages/AddAccount/ServerDelegate.qml
new file mode 100644
index 00000000..0b5dcc78
--- /dev/null
+++ b/src/gui/Pages/AddAccount/ServerDelegate.qml
@@ -0,0 +1,83 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+import QtQuick 2.12
+import QtQuick.Layouts 1.12
+import "../../Base"
+import "../../Base/HTile"
+
+HTile {
+ id: root
+
+ property string loadingIconStep
+
+
+ contentOpacity: model.status === "Failed" ? 0.3 : 1 // XXX
+ rightPadding: 0
+
+ contentItem: ContentRow {
+ tile: root
+ spacing: 0
+
+ HIcon {
+ id: signalIcon
+
+ svgName:
+ model.status === "Failed" ? "server-ping-fail" :
+ model.status === "Pinging" ? root.loadingIconStep :
+ model.ping < 400 ? "server-ping-good" :
+ model.ping < 800 ? "server-ping-medium" :
+ "server-ping-bad"
+
+ colorize:
+ model.status === "Failed" ? theme.colors.negativeBackground :
+ model.status === "Pinging" ? theme.colors.accentBackground :
+ model.ping < 400 ? theme.colors.positiveBackground :
+ model.ping < 800 ? theme.colors.middleBackground :
+ theme.colors.negativeBackground
+
+ Layout.fillHeight: true
+ Layout.rightMargin: theme.spacing
+
+ Behavior on colorize { HColorAnimation {} }
+ }
+
+ HColumnLayout {
+ Layout.rightMargin: theme.spacing
+
+ TitleLabel {
+ text: model.name
+ }
+
+ SubtitleLabel {
+ tile: root
+ text: model.country
+ }
+ }
+
+ TitleRightInfoLabel {
+ tile: root
+ font.pixelSize: theme.fontSize.normal
+
+ text:
+ model.stability === -1 ?
+ "" :
+ qsTr("%1%").arg(Math.max(0, parseInt(model.stability, 10)))
+
+ color:
+ model.stability >= 95 ? theme.colors.positiveText :
+ model.stability >= 85 ? theme.colors.warningText :
+ theme.colors.errorText
+ }
+
+ HButton {
+ icon.name: "server-visit-website"
+ toolTip.text: qsTr("Visit website")
+ backgroundColor: "transparent"
+ onClicked: Qt.openUrlExternally(model.site_url)
+
+ Layout.fillHeight: true
+ }
+ }
+
+ Behavior on contentOpacity { HNumberAnimation {} }
+}
diff --git a/src/gui/Utils.qml b/src/gui/Utils.qml
index 135f38e8..af488926 100644
--- a/src/gui/Utils.qml
+++ b/src/gui/Utils.qml
@@ -397,8 +397,8 @@ QtObject {
}
- function round(floatNumber) {
- return parseFloat(floatNumber.toFixed(2))
+ function round(floatNumber, decimalDigits=2) {
+ return parseFloat(floatNumber.toFixed(decimalDigits))
}
diff --git a/src/icons/thin/server-connect.svg b/src/icons/thin/server-connect.svg
deleted file mode 100644
index c5d41de2..00000000
--- a/src/icons/thin/server-connect.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/src/icons/thin/server-ping-bad.svg b/src/icons/thin/server-ping-bad.svg
new file mode 100644
index 00000000..00ca5339
--- /dev/null
+++ b/src/icons/thin/server-ping-bad.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/icons/thin/server-ping-fail.svg b/src/icons/thin/server-ping-fail.svg
new file mode 100644
index 00000000..e4728028
--- /dev/null
+++ b/src/icons/thin/server-ping-fail.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/thin/server-ping-good.svg b/src/icons/thin/server-ping-good.svg
new file mode 100644
index 00000000..e0f465c5
--- /dev/null
+++ b/src/icons/thin/server-ping-good.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/icons/thin/server-ping-medium.svg b/src/icons/thin/server-ping-medium.svg
new file mode 100644
index 00000000..c3c02777
--- /dev/null
+++ b/src/icons/thin/server-ping-medium.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/icons/thin/server-visit-website.svg b/src/icons/thin/server-visit-website.svg
new file mode 100644
index 00000000..f1b34d3d
--- /dev/null
+++ b/src/icons/thin/server-visit-website.svg
@@ -0,0 +1,5 @@
+