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 # 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 - sever list
- cursor shape in HBox/HTabbedBox pages over fields - cursor shape in HBox/HTabbedBox pages over fields
- login with account already added → infinite spinner in room list - login with account already added → infinite spinner in room list

View File

@ -2,12 +2,17 @@
import asyncio import asyncio
import logging as log import logging as log
import math
import os import os
import re
import sys import sys
import time
import traceback import traceback
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, DefaultDict, Dict, List, Optional, Tuple from typing import Any, DefaultDict, Dict, List, Optional, Tuple
import aiohttp
from appdirs import AppDirs from appdirs import AppDirs
import nio import nio
@ -18,7 +23,7 @@ from .matrix_client import MatrixClient
from .media_cache import MediaCache from .media_cache import MediaCache
from .models import SyncId from .models import SyncId
from .models.filters import FieldSubstringFilter 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 import Model
from .models.model_store import ModelStore from .models.model_store import ModelStore
from .presence import Presence from .presence import Presence
@ -107,6 +112,9 @@ class Backend:
self._sso_server: Optional[SSOServer] = None self._sso_server: Optional[SSOServer] = None
self._sso_server_task: Optional[asyncio.Future] = 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.profile_cache: Dict[str, nio.ProfileGetResponse] = {}
self.get_profile_locks: DefaultDict[str, asyncio.Lock] = \ self.get_profile_locks: DefaultDict[str, asyncio.Lock] = \
DefaultDict(asyncio.Lock) # {user_id: lock} DefaultDict(asyncio.Lock) # {user_id: lock}
@ -474,3 +482,139 @@ class Backend:
if device.ed25519 == ed25519_key: if device.ed25519 == ed25519_key:
client.blacklist_device(device) 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() 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 @dataclass
class Account(ModelItem): class Account(ModelItem):
"""A logged in matrix account.""" """A logged in matrix account."""

View File

@ -3,8 +3,9 @@
import QtQuick 2.12 import QtQuick 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
HFlickableColumnPage { HColumnPage {
implicitWidth: Math.min(parent.width, theme.controls.box.defaultWidth) implicitWidth: Math.min(parent.width, theme.controls.box.defaultWidth)
padding: theme.spacing
background: Rectangle { background: Rectangle {
color: theme.controls.box.background color: theme.controls.box.background

View File

@ -2,12 +2,13 @@
import QtQuick 2.12 import QtQuick 2.12
import QtQuick.Layouts 1.12 import QtQuick.Layouts 1.12
import "../.."
import "../../Base" import "../../Base"
import "../../Base/Buttons" import "../../Base/Buttons"
import "../../PythonBridge" import "../../PythonBridge"
HBox { HBox {
id: page id: box
property string acceptedUserUrl: "" property string acceptedUserUrl: ""
property string acceptedUrl: "" property string acceptedUrl: ""
@ -15,21 +16,34 @@ HBox {
property string saveName: "serverBrowser" property string saveName: "serverBrowser"
property var saveProperties: ["acceptedUserUrl"] property var saveProperties: ["acceptedUserUrl"]
property string loadingIconStep: "server-ping-bad"
property Future connectFuture: null property Future connectFuture: null
property Future fetchServersFuture: null
signal accepted() 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() { function connect() {
if (connectFuture) connectFuture.cancel() if (connectFuture) connectFuture.cancel()
connectTimeout.restart() connectTimeout.restart()
const args = [serverField.item.cleanText] const args = [serverField.item.field.cleanText]
connectFuture = py.callCoro("server_info", args, ([url, flows]) => { connectFuture = py.callCoro("server_info", args, ([url, flows]) => {
connectTimeout.stop() connectTimeout.stop()
errorMessage.text = "" serverField.errorLabel.text = ""
connectFuture = null connectFuture = null
if (! ( if (! (
@ -39,7 +53,7 @@ HBox {
flows.includes("m.login.token") flows.includes("m.login.token")
) )
)) { )) {
errorMessage.text = serverField.errorLabel.text =
qsTr("No supported sign-in method for this homeserver.") qsTr("No supported sign-in method for this homeserver.")
return return
} }
@ -63,106 +77,137 @@ HBox {
py.showError(type, traceback, uuid) py.showError(type, traceback, uuid)
errorMessage.text = text serverField.errorLabel.text = text
}) })
} }
function cancel() {
if (page.connectFuture) return
connectTimeout.stop() padding: 0
connectFuture.cancel() implicitWidth: theme.controls.box.defaultWidth * 1.25
connectFuture = null 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 { readonly property bool knownServerChosen:
ApplyButton { serverList.model.find(item.cleanText) !== null
id: applyButton
enabled: serverField.item.cleanText && ! serverField.item.error label.text: qsTr("Homeserver address:")
text: qsTr("Connect") label.topPadding: theme.spacing
icon.name: "server-connect" label.leftPadding: label.topPadding
loading: page.connectFuture !== null 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 disableWhileLoading: false
onClicked: page.connect() onClicked: box.connect()
}
CancelButton { Layout.fillHeight: true
id: cancelButton }
enabled: page.connectFuture !== null
onClicked: page.cancel()
} }
} }
onKeyboardAccept: if (applyButton.enabled) page.connect() onKeyboardAccept: if (serverField.item.apply.enabled) box.connect()
onKeyboardCancel: if (cancelButton.enabled) page.cancel()
onAccepted: window.saveState(this) onAccepted: window.saveState(this)
Timer { Timer {
id: connectTimeout id: connectTimeout
interval: 30 * 1000 interval: 30 * 1000
onTriggered: { onTriggered: {
errorMessage.text = serverField.errorLabel.text =
serverField.knownServerChosen ? serverField.knownServerChosen ?
qsTr("This homeserver seems unavailable. Verify your inter" + 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 " + qsTr("This homeserver seems unavailable. Verify the " +
"entered address, your internet connection or try " + "entered address, your internet connection or try " +
"again in a few minutes.") "again later.")
} }
} }
HLabeledItem { Timer {
id: serverField 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 Timer {
readonly property var knownServers: [ interval: theme.animationDuration * 2
"https://matrix.org", running: true
"https://chat.weho.st", repeat: true
"https://tchncs.de", onTriggered:
"https://chat.privacytools.io", box.loadingIconStep = "server-ping-" + (
"https://hackerspaces.be", box.loadingIconStep === "server-ping-bad" ? "medium" :
"https://matrix.allmende.io", box.loadingIconStep === "server-ping-medium" ? "good" :
"https://feneas.org", "bad"
"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: HListView {
knownServers.includes(item.cleanText) id: serverList
clip: true
model: ModelStore.get("homeservers")
label.text: qsTr("Homeserver:") delegate: ServerDelegate {
width: serverList.width
Layout.fillWidth: true loadingIconStep: box.loadingIconStep
onClicked: {
HTextField { serverField.item.field.text = model.id
readonly property string cleanText: serverField.item.apply.clicked()
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 {} }
Layout.fillWidth: true 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) { function round(floatNumber, decimalDigits=2) {
return parseFloat(floatNumber.toFixed(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