Implement inviting to room

This commit is contained in:
miruka 2019-12-11 12:42:59 -04:00
parent fd2f8c9828
commit 001c643406
11 changed files with 259 additions and 26 deletions

17
TODO.md
View File

@ -1,4 +1,4 @@
- scale vs current zoom methods?
- binding on ...
- better cancel for all boxes
- Media
@ -56,6 +56,7 @@
- Quote links color in room subtitles (e.g. "> http://foo.orgA)" )
- UI
- Scrollable popup
- Make theme error/etc text colors more like name colors
- In account settings, display name field text should be colored
- Way to open context menus without a right mouse button
@ -144,15 +145,9 @@
- Client improvements
- Refetch profile after manual profile change, don't wait for a room event
- Prevent starting multiple instances, causes problems with E2E DB
(sending new messages from second instances makes them undecryptable to
first instance until it's restarted)
- Could be fixed by "listening for a `RoomKeyEvent`that has the same
session id as the undecryptable `MegolmEvent`, then retry decrypting
the message with `decrypt_event()`" - poljar
- [Soft logouts](https://github.com/poljar/matrix-nio/commit/aba10)
- Prevent starting multiple client instances, causes problems with E2E DB
- Check if username exists on login screen
- [Soft logouts](https://github.com/poljar/matrix-nio/commit/aba10)
- `pyotherside.atexit()`
- Logout previous session if adding an account that's already connected
- Config file format
@ -166,7 +161,6 @@
- Fetch all members when using the filter members bar
- Direct chats category
it should be the peer's display name instead.
- Animate RoomEventDelegate DayBreak apparition
- Live-reloading accounts.json
@ -178,7 +172,8 @@
- RoomMessageMedia and RoomAvatarEvent info attributes
- `m.room.aliases` events
- MatrixRoom invited members list
- When inviting someone to direct chat, room is "Empty room" until accepted,
- Verify room has correct name when creating direct chat and peer hasn't
accepted the invite yet
- Left room events after client reboot
- `org.matrix.room.preview_urls` events
- Support "Empty room (was ...)" after peer left

View File

@ -3,6 +3,8 @@ from dataclasses import dataclass, field
import nio
# Matrix Errors
@dataclass
class MatrixError(Exception):
http_code: int = 400
@ -29,6 +31,18 @@ class MatrixForbidden(MatrixError):
m_code: str = "M_FORBIDDEN"
@dataclass
class MatrixBadJson(MatrixError):
http_code: int = 403
m_code: str = "M_BAD_JSON"
@dataclass
class MatrixNotJson(MatrixError):
http_code: int = 403
m_code: str = "M_NOT_JSON"
@dataclass
class MatrixUserDeactivated(MatrixError):
http_code: int = 403
@ -47,6 +61,8 @@ class MatrixTooLarge(MatrixError):
m_code: str = "M_TOO_LARGE"
# Client errors
@dataclass
class UserNotFound(Exception):
user_id: str = field()

View File

@ -9,7 +9,8 @@ from contextlib import suppress
from datetime import datetime
from pathlib import Path
from typing import (
Any, DefaultDict, Dict, NamedTuple, Optional, Set, Tuple, Type, Union,
Any, DefaultDict, Dict, List, NamedTuple, Optional, Set, Tuple, Type,
Union,
)
from urllib.parse import urlparse
from uuid import UUID, uuid4
@ -51,6 +52,10 @@ class MatrixImageInfo(NamedTuple):
class MatrixClient(nio.AsyncClient):
user_id_regex = re.compile(r"^@.+:.+")
room_id_or_alias_regex = re.compile(r"^[#!].+:.+")
http_s_url = re.compile(r"^https?://")
def __init__(self,
backend,
user: str,
@ -500,7 +505,7 @@ class MatrixClient(nio.AsyncClient):
if invite == self.user_id:
raise InvalidUserInContext(invite)
if not re.match(r"^@.+:.+", invite):
if not self.user_id_regex.match(invite):
raise InvalidUserId(invite)
if isinstance(await self.get_profile(invite), nio.ProfileGetError):
@ -548,15 +553,15 @@ class MatrixClient(nio.AsyncClient):
async def room_join(self, alias_or_id_or_url: str) -> str:
string = alias_or_id_or_url.strip()
if re.match(r"^https?://", string):
if self.http_s_url.match(string):
for part in urlparse(string).fragment.split("/"):
if re.match(r"^[#!].+:.+", part):
if self.room_id_or_alias_regex.match(part):
string = part
break
else:
raise ValueError(f"No alias or room id found in url {string}")
if not re.match(r"^[#!].+:.+", string):
if not self.room_id_or_alias_regex.match(string):
raise ValueError("Not an alias or room id")
response = await super().join(string)
@ -575,6 +580,43 @@ class MatrixClient(nio.AsyncClient):
self.models.pop((Member, room_id), None)
async def room_mass_invite(
self, room_id: str, *user_ids: str,
) -> Tuple[List[str], List[Tuple[str, Exception]]]:
user_ids = tuple(
uid for uid in user_ids
# Server would return a 403 forbidden for users already in the room
if uid not in self.all_rooms[room_id].users
)
async def invite(user_id):
if not self.user_id_regex.match(user_id):
return InvalidUserId(user_id)
if isinstance(await self.get_profile(invite), nio.ProfileGetError):
return UserNotFound(user_id)
return await self.room_invite(room_id, user_id)
coros = [invite(uid) for uid in user_ids]
successes = []
errors: list = []
responses = await asyncio.gather(*coros)
for user_id, response in zip(user_ids, responses):
if isinstance(response, nio.RoomInviteError):
errors.append((user_id, MatrixError.from_nio(response)))
elif isinstance(response, Exception):
errors.append((user_id, response))
else:
successes.append(user_id)
return (successes, errors)
async def generate_thumbnail(
self, data: UploadData, is_svg: bool = False,
) -> Tuple[bytes, MatrixImageInfo]:
@ -760,6 +802,7 @@ class MatrixClient(nio.AsyncClient):
last_ev = None
inviter = getattr(room, "inviter", "") or ""
levels = room.power_levels
self.models[Room, self.user_id][room.room_id] = Room(
room_id = room.room_id,
@ -771,7 +814,11 @@ class MatrixClient(nio.AsyncClient):
inviter_avatar =
(room.avatar_url(inviter) or "") if inviter else "",
left = left,
last_event = last_ev,
can_invite =
levels.users.get(self.user_id, 0) >= levels.defaults.invite,
last_event = last_ev,
)
# List members that left the room, then remove them from our model

View File

@ -47,6 +47,8 @@ class Room(ModelItem):
left: bool = False
typing_members: List[str] = field(default_factory=list)
can_invite: bool = True
# Event.serialized
last_event: Optional[Dict[str, Any]] = field(default=None, repr=False)

View File

@ -3,7 +3,7 @@ import QtQuick.Layouts 1.12
import "../utils.js" as Utils
Rectangle {
id: interfaceBox
id: box
color: theme.controls.box.background
implicitWidth: theme.controls.box.defaultWidth
implicitHeight: childrenRect.height
@ -17,6 +17,10 @@ Rectangle {
property string focusButton: ""
property string clickButtonOnEnter: ""
property bool fillAvailableHeight: false
property HButton firstButton: null
default property alias body: interfaceBody.data
@ -39,6 +43,11 @@ Rectangle {
id: mainColumn
width: parent.width
Binding on height {
value: box.height
when: box.fillAvailableHeight
}
HColumnLayout {
id: interfaceBody
spacing: theme.spacing * 1.5
@ -56,6 +65,12 @@ Rectangle {
id: buttonRepeater
model: []
onItemAdded:
if (index === 0) firstButton = buttonRepeater.itemAt(0)
onItemRemoved:
if (index === 0) firstButton = null
HButton {
id: button
text: modelData.text

View File

@ -9,7 +9,6 @@ Popup {
padding: 0
margins: theme.spacing
enter: Transition {
HScaleAnimator { from: 0; to: 1; overshoot: 4 }
}
@ -22,4 +21,11 @@ Popup {
background: Rectangle {
color: theme.controls.popup.background
}
readonly property int maximumPreferredWidth:
window.width - leftMargin - rightMargin - leftInset - rightInset
readonly property int maximumPreferredHeight:
window.height - topMargin - bottomMargin - topInset - bottomInset
}

View File

@ -25,6 +25,7 @@ ScrollView {
property alias placeholderText: textArea.placeholderText
property alias text: textArea.text
property alias area: textArea
property var focusItemOnTab: null
TextArea {
@ -53,5 +54,8 @@ ScrollView {
event.modifiers & Qt.AltModifier ||
event.modifiers & Qt.MetaModifier
) event.accepted = true
KeyNavigation.priority: KeyNavigation.BeforeItem
KeyNavigation.tab: focusItemOnTab
}
}

View File

@ -64,12 +64,30 @@ HColumnLayout {
}
HButton {
enabled: false // TODO
id: inviteButton
icon.name: "room-send-invite"
topPadding: 0
bottomPadding: 0
toolTip.text: qsTr("Invite to this room")
backgroundColor: theme.chat.roomPane.inviteButton.background
enabled: chat.ready ? chat.roomInfo.can_invite : false
toolTip.text:
enabled ?
qsTr("Invite members to this room") :
qsTr("No permission to invite members in this room")
topPadding: 0 // XXX
bottomPadding: 0
onClicked: Utils.makePopup(
"Popups/InviteToRoomPopup.qml",
chat,
{
userId: chat.userId,
roomId: chat.roomId,
invitingAllowed: Qt.binding(() => inviteButton.enabled),
},
)
// onEnabledChanged: if (openedPopup && ! enabled)
Layout.fillHeight: true
}

View File

@ -37,7 +37,7 @@ HBox {
let txt = qsTr("Unknown error - %1: %2").arg(type).arg(args)
if (type === "InvalidUserInContext")
txt = qsTr("You can't invite yourself!")
txt = qsTr("Can't start chatting with yourself")
if (type === "InvalidUserId")
txt = qsTr("Invalid user ID, expected format is " +

View File

@ -14,22 +14,29 @@ HPopup {
default property alias boxData: box.body
property alias box: box
property bool fillAvailableHeight: false
property alias summary: summary
property alias details: details
property bool okClicked: false
property string okText: qsTr("OK")
property bool okEnabled: true
property bool okClicked: false
Binding on height {
value: popup.maximumPreferredHeight
when: popup.fillAvailableHeight
}
HBox {
id: box
clickButtonOnEnter: "ok"
implicitWidth: Math.min(
window.width - popup.leftMargin - popup.rightMargin,
theme.controls.popup.defaultWidth,
)
fillAvailableHeight: popup.fillAvailableHeight
clickButtonOnEnter: "ok"
buttonModel: [
{ name: "ok", text: okText, iconName: "ok", enabled: okEnabled},
@ -44,6 +51,11 @@ HPopup {
})
Binding on height {
value: popup.maximumPreferredHeight
when: popup.fillAvailableHeight
}
HLabel {
id: summary
wrapMode: Text.Wrap

View File

@ -0,0 +1,118 @@
import QtQuick 2.12
import QtQuick.Layouts 1.12
import "../Base"
BoxPopup {
id: popup
// fillAvailableHeight: true
summary.text: qsTr("Invite room members")
okText: qsTr("Invite")
okEnabled: invitingAllowed && Boolean(inviteArea.text.trim())
onOpened: inviteArea.area.forceActiveFocus()
onInvitingAllowedChanged:
if (! invitingAllowed && inviteFuture) inviteFuture.cancel()
box.buttonCallbacks: ({
ok: button => {
button.loading = true
const inviteesLeft = inviteArea.text.trim().split(/\s+/).filter(
user => ! successfulInvites.includes(user)
)
inviteFuture = py.callClientCoro(
userId,
"room_mass_invite",
[roomId, ...inviteesLeft],
([successes, errors]) => {
if (errors.length < 1) {
popup.close()
return
}
successfulInvites = successes
failedInvites = errors
button.loading = false
}
)
},
cancel: button => {
if (inviteFuture) inviteFuture.cancel()
popup.close()
},
})
property string userId
property string roomId
property bool invitingAllowed: true
property var inviteFuture: null
property var successfulInvites: []
property var failedInvites: []
HScrollableTextArea {
id: inviteArea
focusItemOnTab: box.firstButton
area.placeholderText:
qsTr("User IDs (e.g. @bob:matrix.org @alice:localhost)")
Layout.fillWidth: true
Layout.fillHeight: true
}
HLabel {
id: errorMessage
visible: Layout.maximumHeight > 0
wrapMode: Text.Wrap
color: theme.colors.errorText
text:
invitingAllowed ?
allErrors :
qsTr("You do not have permission to invite members in this room")
Layout.maximumHeight: text ? implicitHeight : 0
Layout.fillWidth: true
readonly property string allErrors: {
// TODO: handle these: real user not found
const lines = []
for (let [user, error] of failedInvites) {
const type = py.getattr(
py.getattr(error, "__class__"), "__name__",
)
lines.push(
type === "InvalidUserId" ?
qsTr("%1 is not a valid user ID, expected format is " +
"@username:homeserver").arg(user) :
type === "UserNotFound" ?
qsTr("%1 not found, please verify the entered user ID")
.arg(user) :
type === "MatrixUnsupportedRoomVersion" ?
qsTr("%1's server does not support this room's version")
.arg(user) :
type === "MatrixForbidden" ?
qsTr("%1 is banned from this room")
.arg(user) :
qsTr("Unknown error while inviting %1: %2 - %3")
.arg(user).arg(type).arg(py.getattr(error, "args"))
)
}
return lines.join("\n\n")
}
Behavior on Layout.maximumHeight { HNumberAnimation {} }
}
}