From 007fcb1c02cb3b2c04b10bc541c65d738c3c03f5 Mon Sep 17 00:00:00 2001
From: s3lph <5564491+s3lph@users.noreply.github.com>
Date: Wed, 19 Jan 2022 01:25:58 +0100
Subject: [PATCH] Add rooms & pending invites to client page, with the options
to leave a room and accept, ignore or reject an invite
---
.../fcb4ea0fce29_create_invite_table.py | 29 ++++
maubot/client.py | 63 +++++++--
maubot/db.py | 38 +++++-
maubot/management/api/client.py | 33 ++++-
maubot/management/api/spec.yaml | 117 ++++++++++++++++
maubot/management/frontend/src/api.js | 26 +++-
.../frontend/src/pages/dashboard/Client.js | 128 +++++++++++++++++-
.../frontend/src/style/base/elements.sass | 26 ++++
.../frontend/src/style/base/vars.sass | 1 +
.../management/frontend/src/style/index.sass | 1 +
.../src/style/pages/client/index.sass | 3 +
.../src/style/pages/mixins/roomlist.sass | 37 +++++
12 files changed, 482 insertions(+), 20 deletions(-)
create mode 100644 alembic/versions/fcb4ea0fce29_create_invite_table.py
create mode 100644 maubot/management/frontend/src/style/pages/mixins/roomlist.sass
diff --git a/alembic/versions/fcb4ea0fce29_create_invite_table.py b/alembic/versions/fcb4ea0fce29_create_invite_table.py
new file mode 100644
index 0000000..17a4415
--- /dev/null
+++ b/alembic/versions/fcb4ea0fce29_create_invite_table.py
@@ -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')
diff --git a/maubot/client.py b/maubot/client.py
index 5495c5b..3ecfa18 100644
--- a/maubot/client.py
+++ b/maubot/client.py
@@ -13,21 +13,22 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-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
diff --git a/maubot/db.py b/maubot/db.py
index 3817882..35fe172 100644
--- a/maubot/db.py
+++ b/maubot/db.py
@@ -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"):
diff --git a/maubot/management/api/client.py b/maubot/management/api/client.py
index c74f9e9..5240397 100644
--- a/maubot/management/api/client.py
+++ b/maubot/management/api/client.py
@@ -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)
diff --git a/maubot/management/api/spec.yaml b/maubot/management/api/spec.yaml
index 8529599..4432a0e 100644
--- a/maubot/management/api/spec.yaml
+++ b/maubot/management/api/spec.yaml
@@ -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
diff --git a/maubot/management/frontend/src/api.js b/maubot/management/frontend/src/api.js
index d6a30dd..d797989 100644
--- a/maubot/management/frontend/src/api.js
+++ b/maubot/management/frontend/src/api.js
@@ -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,
}
diff --git a/maubot/management/frontend/src/pages/dashboard/Client.js b/maubot/management/frontend/src/pages/dashboard/Client.js
index 806e112..5cbb4b6 100644
--- a/maubot/management/frontend/src/pages/dashboard/Client.js
+++ b/maubot/management/frontend/src/pages/dashboard/Client.js
@@ -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 {
{this.state.error}
>
+ renderRoomsAndInvites = () => <>
+
+
Joined Rooms
+
+ {this.state.rooms.map((room, i) =>
+
+ {room} |
+
+
+ |
+
,
+ )}
+
+ {this.state.rooms.length === 0 ?
Not in any rooms. : ""}
+
Pending Invites
+
+ {this.state.invites.map((invite, i) =>
+
+ {invite.room} |
+
+
+
+
+ |
+
,
+ )}
+
+ {this.state.invites.length === 0 ?
+
No pending invites. : ""}
+
+ >
+
render() {
return <>
@@ -333,6 +458,7 @@ class Client extends BaseMainView {
{this.renderPreferences()}
{this.renderPrefButtons()}
{this.renderInstances()}
+ {this.renderRoomsAndInvites()}
>
diff --git a/maubot/management/frontend/src/style/base/elements.sass b/maubot/management/frontend/src/style/base/elements.sass
index 4e671f4..6eef6fa 100644
--- a/maubot/management/frontend/src/style/base/elements.sass
+++ b/maubot/management/frontend/src/style/base/elements.sass
@@ -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
diff --git a/maubot/management/frontend/src/style/base/vars.sass b/maubot/management/frontend/src/style/base/vars.sass
index 70a7e50..897aecc 100644
--- a/maubot/management/frontend/src/style/base/vars.sass
+++ b/maubot/management/frontend/src/style/base/vars.sass
@@ -24,6 +24,7 @@ $error: #B71C1C
$error-dark: #7F0000
$error-light: #F05545
$warning: orange
+$warning-dark: #bf7b00
$border-color: #DDD
$text-color: #212121
diff --git a/maubot/management/frontend/src/style/index.sass b/maubot/management/frontend/src/style/index.sass
index 87ad984..69b7e51 100644
--- a/maubot/management/frontend/src/style/index.sass
+++ b/maubot/management/frontend/src/style/index.sass
@@ -26,6 +26,7 @@
@import pages/mixins/upload-container
@import pages/mixins/instancelist
+@import pages/mixins/roomlist
@import pages/login
@import pages/dashboard
diff --git a/maubot/management/frontend/src/style/pages/client/index.sass b/maubot/management/frontend/src/style/pages/client/index.sass
index 54e0c67..c656377 100644
--- a/maubot/management/frontend/src/style/pages/client/index.sass
+++ b/maubot/management/frontend/src/style/pages/client/index.sass
@@ -36,6 +36,9 @@
> div.instances
+instancelist
+ > div.rooms
+ +roomlist
+
input.fingerprint
font-family: "Fira Code", monospace
font-size: 0.8em
diff --git a/maubot/management/frontend/src/style/pages/mixins/roomlist.sass b/maubot/management/frontend/src/style/pages/mixins/roomlist.sass
new file mode 100644
index 0000000..e33753b
--- /dev/null
+++ b/maubot/management/frontend/src/style/pages/mixins/roomlist.sass
@@ -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