Add a public server list to the initial login page

This commit is contained in:
miruka 2020-08-19 00:17:24 -04:00
parent 1a6273681d
commit 2fa8b2c5f9
13 changed files with 402 additions and 83 deletions

View File

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

View File

@ -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"],
)

View File

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

View File

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

View File

@ -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,21 +16,34 @@ 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 = ""
serverField.errorLabel.text = ""
connectFuture = null
if (! (
@ -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
readonly property bool knownServerChosen:
serverList.model.find(item.cleanText) !== null
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: page.connect()
}
onClicked: box.connect()
CancelButton {
id: cancelButton
enabled: page.connectFuture !== null
onClicked: page.cancel()
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
Timer {
interval: 1000
running: fetchServersFuture === null && serverList.count === 0
repeat: true
triggeredOnStart: true
onTriggered: box.fetchServers()
}
// 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",
]
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"
)
}
readonly property bool knownServerChosen:
knownServers.includes(item.cleanText)
HListView {
id: serverList
clip: true
model: ModelStore.get("homeservers")
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")
}
}
HLabel {
id: errorMessage
wrapMode: HLabel.Wrap
horizontalAlignment: Text.AlignHCenter
color: theme.colors.errorText
visible: Layout.maximumHeight > 0
Layout.maximumHeight: text ? implicitHeight : 0
Behavior on Layout.maximumHeight { HNumberAnimation {} }
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
}
}

View File

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

View File

@ -397,8 +397,8 @@ QtObject {
}
function round(floatNumber) {
return parseFloat(floatNumber.toFixed(2))
function round(floatNumber, decimalDigits=2) {
return parseFloat(floatNumber.toFixed(decimalDigits))
}

View File

@ -1,3 +0,0 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.73 20.183c-.106-.526-.236-1.046-.398-1.555.897-.811.86-2.197-.072-2.883.582-2.318.61-4.849-.002-7.288.186-.111.348-.254.475-.425 1.466.412 2.831 1.066 4.052 1.911.14.665.215 1.352.215 2.057 0 3.382-1.692 6.372-4.27 8.183zm-15.73-8.183c0-.855.12-1.682.323-2.475.699-.044 1.393-.04 2.147.032l.04.226c-.921.775-1.75 1.661-2.487 2.674zm3.183-1.19c.848.643 2.083.436 2.662-.49 2.898 1.06 5.339 3.077 6.94 5.666-.766.775-.756 1.998.019 2.695-.681 1.231-1.548 2.345-2.56 3.307-4.902.117-8.924-3.262-9.969-7.697.772-1.316 1.755-2.494 2.908-3.481zm2.886-1.901c1.991-.974 4.155-1.432 6.324-1.377.305.611.93 1.076 1.666 1.166h.006c.557 2.157.583 4.472.029 6.7l-.223.023c-1.724-2.825-4.433-5.131-7.763-6.301zm6.062 12.857c.702-.817 1.311-1.695 1.813-2.627l.27-.008c.172.562.308 1.139.408 1.729-.777.406-1.612.714-2.491.906zm7.103-13.598c-1.009-.56-2.076-1.002-3.189-1.311-.108-.995-1.041-1.824-2.119-1.816-.552-1.019-1.232-1.975-2.024-2.854 3.321.642 6.061 2.93 7.332 5.981zm-6.472-2.708c-.257.22-.443.515-.524.858-2.456-.03-4.778.526-6.848 1.565-.85-.638-2.07-.421-2.646.483-.728-.076-1.379-.092-2.024-.072 1.476-3.683 5.076-6.294 9.28-6.294h.001c1.097.994 2.034 2.16 2.761 3.46z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,5 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m12 3c-4.687 0-8.929 2.015-12 5.272l2.388 2.533c2.46-2.609 5.859-4.222 9.612-4.222s7.152 1.613 9.612 4.222l2.388-2.533c-3.071-3.257-7.313-5.272-12-5.272z" fill-opacity=".45"/>
<path d="m6.466 15.13c1.417-1.502 3.373-2.431 5.534-2.431s4.118.929 5.534 2.431l2.33-2.472c-2.012-2.134-4.793-3.454-7.864-3.454s-5.852 1.32-7.864 3.455z" fill-opacity=".5"/>
<path d="m8.213 16.984c.97-1.028 2.308-1.664 3.787-1.664s2.817.636 3.787 1.664l-3.787 4.016z"/>
</svg>

After

Width:  |  Height:  |  Size: 558 B

View File

@ -0,0 +1,3 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m23 20.168-8.185-8.187 8.185-8.174-2.832-2.807-8.182 8.179-8.176-8.179-2.81 2.81 8.186 8.196-8.186 8.184 2.81 2.81 8.203-8.192 8.18 8.192z"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@ -0,0 +1,3 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m8.213 16.984c.97-1.028 2.308-1.664 3.787-1.664s2.817.636 3.787 1.664l-3.787 4.016zm-1.747-1.854c1.417-1.502 3.373-2.431 5.534-2.431s4.118.929 5.534 2.431l2.33-2.472c-2.012-2.134-4.793-3.454-7.864-3.454s-5.852 1.32-7.864 3.455zm-4.078-4.325c2.46-2.609 5.859-4.222 9.612-4.222s7.152 1.613 9.612 4.222l2.388-2.533c-3.071-3.257-7.313-5.272-12-5.272s-8.929 2.015-12 5.272z"/>
</svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@ -0,0 +1,5 @@
<svg height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m12 3c-4.687 0-8.929 2.015-12 5.272l2.388 2.533c2.46-2.609 5.859-4.222 9.612-4.222s7.152 1.613 9.612 4.222l2.388-2.533c-3.071-3.257-7.313-5.272-12-5.272z" fill-opacity=".45"/>
<path d="m6.466 15.13c1.417-1.502 3.373-2.431 5.534-2.431s4.118.929 5.534 2.431l2.33-2.472c-2.012-2.134-4.793-3.454-7.864-3.454s-5.852 1.32-7.864 3.455z"/>
<path d="m8.213 16.984c.97-1.028 2.308-1.664 3.787-1.664s2.817.636 3.787 1.664l-3.787 4.016z"/>
</svg>

After

Width:  |  Height:  |  Size: 540 B

View File

@ -0,0 +1,5 @@
<svg fill="none" height="24" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m18 13v6a2 2 0 0 1 -2 2h-11a2 2 0 0 1 -2-2v-11a2 2 0 0 1 2-2h6"/>
<path d="m15 3h6v6"/>
<path d="m10 14 11-11"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B