Add rooms & pending invites to client page, with the options to leave a room and accept, ignore or reject an invite

This commit is contained in:
s3lph 2022-01-19 01:25:58 +01:00
parent 3e8e034a5a
commit 007fcb1c02
12 changed files with 482 additions and 20 deletions

View file

@ -0,0 +1,29 @@
"""create invite table
Revision ID: fcb4ea0fce29
Revises: 90aa88820eab
Create Date: 2022-01-18 02:16:53.954662
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fcb4ea0fce29'
down_revision = '90aa88820eab'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('invite',
sa.Column('client', sa.String(255), sa.ForeignKey("client.id", onupdate="CASCADE", ondelete="CASCADE"), primary_key=True),
sa.Column('room', sa.String(255), nullable=False, primary_key=True),
sa.Column('date', sa.DateTime(), nullable=False),
sa.Column('inviter', sa.String(255), nullable=False)
)
def downgrade():
op.drop_table('invite')

View file

@ -13,21 +13,22 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Iterable, Optional, Set, Callable, Any, Awaitable, Union, TYPE_CHECKING
from typing import Dict, Iterable, List, Optional, Set, Callable, Any, Awaitable, Union, TYPE_CHECKING
import asyncio
import logging
from datetime import datetime
from aiohttp import ClientSession
from mautrix.errors import MatrixInvalidToken
from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership,
StateEvent, EventType, Filter, RoomFilter, RoomEventFilter, EventFilter,
PresenceState, StateFilter, DeviceID)
PresenceState, StateFilter, DeviceID, RoomID)
from mautrix.client import InternalEventType
from mautrix.client.state_store.sqlalchemy import SQLStateStore as BaseSQLStateStore
from .lib.store_proxy import SyncStoreProxy
from .db import DBClient
from .db import DBClient, DBInvite
from .matrix import MaubotMatrixClient
try:
@ -65,9 +66,11 @@ class Client:
remote_displayname: Optional[str]
remote_avatar_url: Optional[ContentURI]
remote_rooms: Optional[List[RoomID]]
def __init__(self, db_instance: DBClient) -> None:
self.db_instance = db_instance
self.db_invites = DBInvite.get(self.id)
self.cache[self.id] = self
self.log = log.getChild(self.id)
self.references = set()
@ -75,6 +78,7 @@ class Client:
self.sync_ok = True
self.remote_displayname = None
self.remote_avatar_url = None
self.remote_rooms = None
self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver,
token=self.access_token, client_session=self.http_client,
log=self.log, loop=self.loop, device_id=self.device_id,
@ -88,8 +92,7 @@ class Client:
self.client.ignore_initial_sync = True
self.client.ignore_first_sync = True
self.client.presence = PresenceState.ONLINE if self.online else PresenceState.OFFLINE
if self.autojoin:
self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite)
self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite)
self.client.add_event_handler(EventType.ROOM_TOMBSTONE, self._handle_tombstone)
self.client.add_event_handler(InternalEventType.SYNC_ERRORED, self._set_sync_ok(False))
self.client.add_event_handler(InternalEventType.SYNC_SUCCESSFUL, self._set_sync_ok(True))
@ -253,6 +256,13 @@ class Client:
"avatar_url": self.avatar_url,
"remote_displayname": self.remote_displayname,
"remote_avatar_url": self.remote_avatar_url,
"invites": [{
"client": i.client,
"room": i.room,
"date": i.date.timestamp(),
"inviter": i.inviter
} for i in self.invites],
"rooms": self.remote_rooms,
"instances": [instance.to_dict() for instance in self.references],
}
@ -275,11 +285,23 @@ class Client:
self.log.info(f"{evt.room_id} tombstoned with no replacement, ignoring")
return
_, server = self.client.parse_user_id(evt.sender)
DBInvite.update_tombstone(user.id, evt.room_id, evt.content.replacement_room)
await self.client.join_room(evt.content.replacement_room, servers=[server])
async def _handle_invite(self, evt: StrippedStateEvent) -> None:
if evt.state_key == self.id and evt.content.membership == Membership.INVITE:
if evt.state_key != self.id or evt.content.membership != Membership.INVITE:
return
if self.autojoin:
await self.client.join_room(evt.room_id)
await self._update_remote_profile()
else:
self.log.debug('Inserting invite into database for later handling')
DBInvite(
client=self.id,
room=evt.room_id,
date=datetime.fromtimestamp(evt.timestamp//1000),
inviter=evt.sender
).upsert()
async def update_started(self, started: bool) -> None:
if started is None or started == self.started:
@ -307,6 +329,26 @@ class Client:
else:
await self._update_remote_profile()
async def join_room(self, room: RoomID) -> None:
if room is None:
return
await self.client.join_room(room)
DBInvite(client=self.id, room=room).delete()
await self._update_remote_profile()
async def leave_room(self, room: RoomID) -> None:
if room is None:
return
await self.client.leave_room(room)
DBInvite(client=self.id, room=room).delete()
await self._update_remote_profile()
async def ignore_invite(self, room: RoomID) -> None:
if room is None:
return
DBInvite(client=self.id, room=room).delete()
await self._update_remote_profile()
async def update_access_details(self, access_token: Optional[str], homeserver: Optional[str],
device_id: Optional[str] = None) -> None:
if not access_token and not homeserver:
@ -354,6 +396,7 @@ class Client:
async def _update_remote_profile(self) -> None:
profile = await self.client.get_profile(self.id)
self.remote_displayname, self.remote_avatar_url = profile.displayname, profile.avatar_url
self.remote_rooms = await self.client.get_joined_rooms()
# region Properties
@ -412,12 +455,12 @@ class Client:
def autojoin(self, value: bool) -> None:
if value == self.db_instance.autojoin:
return
if value:
self.client.add_event_handler(EventType.ROOM_MEMBER, self._handle_invite)
else:
self.client.remove_event_handler(EventType.ROOM_MEMBER, self._handle_invite)
self.db_instance.autojoin = value
@property
def invites(self) -> List[DBInvite]:
return DBInvite.get(self.id)
@property
def online(self) -> bool:
return self.db_instance.online

View file

@ -16,12 +16,13 @@
from typing import Iterable, Optional
import logging
import sys
from datetime import datetime
from sqlalchemy import Column, String, Boolean, ForeignKey, Text
from sqlalchemy import Column, String, Boolean, ForeignKey, Text, DateTime
from sqlalchemy.engine.base import Engine
import sqlalchemy as sql
from mautrix.types import UserID, FilterID, DeviceID, SyncToken, ContentURI
from mautrix.types import UserID, RoomID, FilterID, DeviceID, SyncToken, ContentURI
from mautrix.util.db import Base
from mautrix.client.state_store.sqlalchemy import RoomState, UserProfile
@ -76,11 +77,42 @@ class DBClient(Base):
return cls._select_one_or_none(cls.c.id == id)
class DBInvite(Base):
__tablename__ = "invite"
client: UserID = Column(String(255), ForeignKey("client.id", onupdate="CASCADE", ondelete="CASCADE"), primary_key=True)
room: RoomID = Column(String(255), nullable=False, primary_key=True)
date: datetime = Column(DateTime(), nullable=False)
inviter: UserID = Column(String(255), nullable=False)
@classmethod
def all(cls) -> Iterable['DBInvite']:
return cls._select_all()
@classmethod
def get(cls, client: UserID, room: RoomID) -> Optional['DBInvite']:
return cls._select_one_or_none(cls.c.client == client, cls.c.room == room)
@classmethod
def get(cls, client: UserID) -> Iterable['DBInvite']:
return cls._select_all(cls.c.client == client)
@classmethod
def update_tombstone(cls, client: UserID, old: RoomID, new: RoomID) -> Optional['DBInvite']:
invite = cls.get(client=client, room=old)
if not invite:
return None
invite.delete()
invite.room = new
invite.insert()
return invite
def init(config: Config) -> Engine:
db = sql.create_engine(config["database"])
Base.metadata.bind = db
for table in (DBPlugin, DBClient, RoomState, UserProfile):
for table in (DBPlugin, DBClient, RoomState, UserProfile, DBInvite):
table.bind(db)
if not db.has_table("alembic_version"):

View file

@ -89,11 +89,7 @@ async def _update_client(client: Client, data: dict, is_login: bool = False) ->
except MatrixConnectionError:
return resp.bad_client_connection_details
except ValueError as e:
str_err = str(e)
if str_err.startswith("MXID mismatch"):
return resp.mxid_mismatch(str(e)[len("MXID mismatch: "):])
elif str_err.startswith("Device ID mismatch"):
return resp.device_id_mismatch(str(e)[len("Device ID mismatch: "):])
return resp.mxid_mismatch(str(e)[len("MXID mismatch: "):])
with client.db_instance.edit_mode():
await client.update_avatar_url(data.get("avatar_url", None))
await client.update_displayname(data.get("displayname", None))
@ -155,3 +151,30 @@ async def clear_client_cache(request: web.Request) -> web.Response:
return resp.client_not_found
client.clear_cache()
return resp.ok
@routes.post("/client/{id}/room/{room}/join")
async def join_room(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
room_id = request.match_info.get("room", None)
client = Client.get(user_id, None)
await client.join_room(room_id)
return resp.updated(client.to_dict(), is_login=False)
@routes.post("/client/{id}/room/{room}/leave")
async def leave_room(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
room_id = request.match_info.get("room", None)
client = Client.get(user_id, None)
await client.leave_room(room_id)
return resp.updated(client.to_dict(), is_login=False)
@routes.post("/client/{id}/room/{room}/ignore")
async def leave_room(request: web.Request) -> web.Response:
user_id = request.match_info.get("id", None)
room_id = request.match_info.get("room", None)
client = Client.get(user_id, None)
await client.ignore_invite(room_id)
return resp.updated(client.to_dict(), is_login=False)

View file

@ -425,6 +425,93 @@ paths:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
'/client/{id}/room/{room}/join':
parameters:
- name: id
in: path
description: The Matrix user ID of the client to join a room with
required: true
schema:
type: string
- name: room
in: path
description: The Matrix room to join
required: true
schema:
type: string
post:
operationId: join_room
summary: Join a Matrix room or accept an invite
tags: [Clients]
responses:
200:
description: Joined the room
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
'/client/{id}/room/{room}/leave':
parameters:
- name: id
in: path
description: The Matrix user ID of the client to leave a room with
required: true
schema:
type: string
- name: id
in: path
description: The Matrix room to leave or reject
required: true
schema:
type: string
post:
operationId: leave_room
summary: Leave a Matrix room or reject an invite
tags: [Clients]
responses:
200:
description: Left the room
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
'/client/{id}/room/{room}/ignore':
parameters:
- name: id
in: path
description: The Matrix user ID of the client to remove the invite from
required: true
schema:
type: string
- name: id
in: path
description: The Matrix room whose invite to remove
required: true
schema:
type: string
post:
operationId: ignore_invite
summary: Ignore a room invitation (do not answer it, but remove from the database)
tags: [Clients]
responses:
200:
description: Removed the invitation.
content:
application/json:
schema:
$ref: '#/components/schemas/MatrixClient'
401:
$ref: '#/components/responses/Unauthorized'
404:
$ref: '#/components/responses/ClientNotFound'
/client/auth/servers:
get:
operationId: get_client_auth_servers
@ -710,6 +797,36 @@ components:
type: string
example: 'mxc://maunium.net/FsPQQTntCCqhJMFtwArmJdaU'
description: The content URI of the avatar for this client.
rooms:
type: array
readOnly: true
description: List of room IDs of currently joined room.
items:
type: string
example: '!foobar:example.org'
invites:
type: array
readOnly: true
description: List of pending invites.
items:
type: object
properties:
client:
type: string
example: '@foobar:example.org'
description: The client that was invited to a room.
room:
type: string
example: '!foobar:example.org'
description: The room the client was invited to.
date:
type: integer
example: 1642546523
description: UNIX timestamp when the invite was created.
inviter:
type: string
example: '@bar:example.org'
description: The user who invited this client to the room.
instances:
type: array
readOnly: true

View file

@ -233,6 +233,30 @@ export async function clearClientCache(id) {
return await resp.json()
}
export async function joinRoom(id, room) {
const resp = await fetch(`${BASE_PATH}/client/${id}/room/${room}/join`, {
headers: getHeaders(),
method: "POST",
})
return await resp.json()
}
export async function leaveRoom(id, room) {
const resp = await fetch(`${BASE_PATH}/client/${id}/room/${room}/leave`, {
headers: getHeaders(),
method: "POST",
})
return await resp.json()
}
export async function ignoreInvite(id, room) {
const resp = await fetch(`${BASE_PATH}/client/${id}/room/${room}/ignore`, {
headers: getHeaders(),
method: "POST",
})
return await resp.json()
}
export const getClientAuthServers = () => defaultGet("/client/auth/servers")
export async function doClientAuth(server, type, username, password) {
@ -253,5 +277,5 @@ export default {
getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, clearClientCache,
getClientAuthServers, doClientAuth,
joinRoom, leaveRoom, ignoreInvite, getClientAuthServers, doClientAuth,
}

View file

@ -64,7 +64,7 @@ class Client extends BaseMainView {
get entryKeys() {
return ["id", "displayname", "homeserver", "avatar_url", "access_token", "device_id",
"sync", "autojoin", "online", "enabled", "started"]
"sync", "autojoin", "online", "enabled", "started", "rooms", "invites"]
}
get initialState() {
@ -81,6 +81,11 @@ class Client extends BaseMainView {
enabled: true,
online: true,
started: false,
rooms: [],
invites: [],
joiningRooms: [],
ignoringRooms: [],
leavingRooms: [],
instances: [],
@ -102,6 +107,9 @@ class Client extends BaseMainView {
delete client.clearingCache
delete client.error
delete client.instances
delete client.joiningRooms
delete client.ignoringRooms
delete client.leavingRooms
return client
}
@ -188,6 +196,75 @@ class Client extends BaseMainView {
}
}
joinRoom = async room => {
if (this.state.joiningRooms.includes(room)) {
return
}
this.setState({
joiningRooms: this.state.joiningRooms.concat([room]),
})
const resp = await api.joinRoom(this.state.id, room)
if (resp.id) {
this.setState({
joiningRooms: this.state.joiningRooms.filter(jr => jr !== room),
invites: resp.invites,
rooms: resp.rooms,
error: "",
})
} else {
this.setState({
joiningRooms: this.state.joiningRooms.filter(jr => jr !== room),
error: resp.error,
})
}
}
leaveRoom = async room => {
if (this.state.leavingRooms.includes(room)) {
return
}
this.setState({
leavingRooms: this.state.leavingRooms.concat([room]),
})
const resp = await api.leaveRoom(this.state.id, room)
if (resp.id) {
this.setState({
leavingRooms: this.state.leavingRooms.filter(jr => jr !== room),
invites: resp.invites,
rooms: resp.rooms,
error: "",
})
} else {
this.setState({
leavingRooms: this.state.leavingRooms.filter(jr => jr !== room),
error: resp.error,
})
}
}
ignoreInvite = async room => {
if (this.state.ignoringRooms.includes(room)) {
return
}
this.setState({
ignoringRooms: this.state.ignoringRooms.concat([room]),
})
const resp = await api.ignoreInvite(this.state.id, room)
if (resp.id) {
this.setState({
ignoringRooms: this.state.ignoringRooms.filter(jr => jr !== room),
invites: resp.invites,
rooms: resp.rooms,
error: "",
})
} else {
this.setState({
ignoringRooms: this.state.ignoringRooms.filter(jr => jr !== room),
error: resp.error,
})
}
}
get loading() {
return this.state.saving || this.state.startingOrStopping
|| this.clearingCache || this.state.deleting
@ -325,6 +402,54 @@ class Client extends BaseMainView {
<div className="error">{this.state.error}</div>
</>
renderRoomsAndInvites = () => <>
<div className="rooms">
<h3>Joined Rooms</h3>
<table class="rooms-list">
{this.state.rooms.map((room, i) =>
<tr>
<td>{room}</td>
<td class="right">
<button class="button error-color" onClick={ev => this.leaveRoom(room)}
disabled={this.state.leavingRooms.includes(room)}
title={"Leave this room"}>
{this.state.leavingRooms.includes(room) ? <Spinner/> : "✗"}
</button>
</td>
</tr>,
)}
</table>
{this.state.rooms.length === 0 ? <span class="no-rooms">Not in any rooms.</span> : ""}
<h3>Pending Invites</h3>
<table class="rooms-list">
{this.state.invites.map((invite, i) =>
<tr>
<td>{invite.room}</td>
<td class="right">
<button class="button main-color" title={"Accept this invite"}
onClick={ev => this.joinRoom(invite.room)}
disabled={this.state.joiningRooms.includes(invite.room)}>
{this.state.joiningRooms.includes(invite.room) ? <Spinner/> : "✓"}
</button>
<button class="button warning-color" title={"Ignore this invite"}
onClick={ev => this.ignoreInvite(invite.room)}
disabled={this.state.ignoringRooms.includes(invite.room)}>
{this.state.ignoringRooms.includes(invite.room) ? <Spinner/> : "🗑"}
</button>
<button class="button error-color" title={"Reject this invite"}
onClick={ev => this.leaveRoom(invite.room)}
disabled={this.state.leavingRooms.includes(invite.room)}>
{this.state.leavingRooms.includes(invite.room) ? <Spinner/> : "✗"}
</button>
</td>
</tr>,
)}
</table>
{this.state.invites.length === 0 ?
<span class="no-rooms">No pending invites.</span> : ""}
</div>
</>
render() {
return <>
<div className="client">
@ -333,6 +458,7 @@ class Client extends BaseMainView {
{this.renderPreferences()}
{this.renderPrefButtons()}
{this.renderInstances()}
{this.renderRoomsAndInvites()}
</div>
</div>
</>

View file

@ -50,12 +50,38 @@
background-color: $background-dark !important
color: $text-color
=error-color-button()
background-color: $error-light
color: $inverted-text-color
&:hover:not(:disabled)
background-color: $error
&:disabled.disabled-bg
background-color: $background-dark !important
color: $text-color
=warning-color-button()
background-color: $warning
color: $inverted-text-color
&:hover:not(:disabled)
background-color: $warning-dark
&:disabled.disabled-bg
background-color: $background-dark !important
color: $text-color
.button
+button
&.main-color
+main-color-button
&.error-color
+error-color-button
&.warning-color
+warning-color-button
=button-group()
width: 100%
display: flex

View file

@ -24,6 +24,7 @@ $error: #B71C1C
$error-dark: #7F0000
$error-light: #F05545
$warning: orange
$warning-dark: #bf7b00
$border-color: #DDD
$text-color: #212121

View file

@ -26,6 +26,7 @@
@import pages/mixins/upload-container
@import pages/mixins/instancelist
@import pages/mixins/roomlist
@import pages/login
@import pages/dashboard

View file

@ -36,6 +36,9 @@
> div.instances
+instancelist
> div.rooms
+roomlist
input.fingerprint
font-family: "Fira Code", monospace
font-size: 0.8em

View file

@ -0,0 +1,37 @@
=roomlist()
margin: 1rem 0
> h3
margin: .5rem
width: 100%
> span.no-rooms
margin: .5rem
font-size: .875rem
font-weight: lighter
> table.rooms-list
width: 100%
margin: .5rem
border-collapse: collapse
text-align: left
td.right
display: flex
justify-content: flex-end
align-content: flex-end
flex-direction: row
display: flex
.button
margin: .1rem
height: 31px
width: 48px
> .spinner
+thick-spinner
+white-spinner
width: 16px
> tr
border-top: 1px solid $border-color