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 # 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/>. # 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 asyncio
import logging import logging
from datetime import datetime
from aiohttp import ClientSession from aiohttp import ClientSession
from mautrix.errors import MatrixInvalidToken from mautrix.errors import MatrixInvalidToken
from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership, from mautrix.types import (UserID, SyncToken, FilterID, ContentURI, StrippedStateEvent, Membership,
StateEvent, EventType, Filter, RoomFilter, RoomEventFilter, EventFilter, StateEvent, EventType, Filter, RoomFilter, RoomEventFilter, EventFilter,
PresenceState, StateFilter, DeviceID) PresenceState, StateFilter, DeviceID, RoomID)
from mautrix.client import InternalEventType from mautrix.client import InternalEventType
from mautrix.client.state_store.sqlalchemy import SQLStateStore as BaseSQLStateStore from mautrix.client.state_store.sqlalchemy import SQLStateStore as BaseSQLStateStore
from .lib.store_proxy import SyncStoreProxy from .lib.store_proxy import SyncStoreProxy
from .db import DBClient from .db import DBClient, DBInvite
from .matrix import MaubotMatrixClient from .matrix import MaubotMatrixClient
try: try:
@ -65,9 +66,11 @@ class Client:
remote_displayname: Optional[str] remote_displayname: Optional[str]
remote_avatar_url: Optional[ContentURI] remote_avatar_url: Optional[ContentURI]
remote_rooms: Optional[List[RoomID]]
def __init__(self, db_instance: DBClient) -> None: def __init__(self, db_instance: DBClient) -> None:
self.db_instance = db_instance self.db_instance = db_instance
self.db_invites = DBInvite.get(self.id)
self.cache[self.id] = self self.cache[self.id] = self
self.log = log.getChild(self.id) self.log = log.getChild(self.id)
self.references = set() self.references = set()
@ -75,6 +78,7 @@ class Client:
self.sync_ok = True self.sync_ok = True
self.remote_displayname = None self.remote_displayname = None
self.remote_avatar_url = None self.remote_avatar_url = None
self.remote_rooms = None
self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver, self.client = MaubotMatrixClient(mxid=self.id, base_url=self.homeserver,
token=self.access_token, client_session=self.http_client, token=self.access_token, client_session=self.http_client,
log=self.log, loop=self.loop, device_id=self.device_id, log=self.log, loop=self.loop, device_id=self.device_id,
@ -88,7 +92,6 @@ class Client:
self.client.ignore_initial_sync = True self.client.ignore_initial_sync = True
self.client.ignore_first_sync = True self.client.ignore_first_sync = True
self.client.presence = PresenceState.ONLINE if self.online else PresenceState.OFFLINE 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(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_ERRORED, self._set_sync_ok(False))
@ -253,6 +256,13 @@ class Client:
"avatar_url": self.avatar_url, "avatar_url": self.avatar_url,
"remote_displayname": self.remote_displayname, "remote_displayname": self.remote_displayname,
"remote_avatar_url": self.remote_avatar_url, "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], "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") self.log.info(f"{evt.room_id} tombstoned with no replacement, ignoring")
return return
_, server = self.client.parse_user_id(evt.sender) _, 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]) await self.client.join_room(evt.content.replacement_room, servers=[server])
async def _handle_invite(self, evt: StrippedStateEvent) -> None: 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.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: async def update_started(self, started: bool) -> None:
if started is None or started == self.started: if started is None or started == self.started:
@ -307,6 +329,26 @@ class Client:
else: else:
await self._update_remote_profile() 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], async def update_access_details(self, access_token: Optional[str], homeserver: Optional[str],
device_id: Optional[str] = None) -> None: device_id: Optional[str] = None) -> None:
if not access_token and not homeserver: if not access_token and not homeserver:
@ -354,6 +396,7 @@ class Client:
async def _update_remote_profile(self) -> None: async def _update_remote_profile(self) -> None:
profile = await self.client.get_profile(self.id) profile = await self.client.get_profile(self.id)
self.remote_displayname, self.remote_avatar_url = profile.displayname, profile.avatar_url self.remote_displayname, self.remote_avatar_url = profile.displayname, profile.avatar_url
self.remote_rooms = await self.client.get_joined_rooms()
# region Properties # region Properties
@ -412,12 +455,12 @@ class Client:
def autojoin(self, value: bool) -> None: def autojoin(self, value: bool) -> None:
if value == self.db_instance.autojoin: if value == self.db_instance.autojoin:
return 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 self.db_instance.autojoin = value
@property
def invites(self) -> List[DBInvite]:
return DBInvite.get(self.id)
@property @property
def online(self) -> bool: def online(self) -> bool:
return self.db_instance.online return self.db_instance.online

View File

@ -16,12 +16,13 @@
from typing import Iterable, Optional from typing import Iterable, Optional
import logging import logging
import sys 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 from sqlalchemy.engine.base import Engine
import sqlalchemy as sql 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.util.db import Base
from mautrix.client.state_store.sqlalchemy import RoomState, UserProfile 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) 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: def init(config: Config) -> Engine:
db = sql.create_engine(config["database"]) db = sql.create_engine(config["database"])
Base.metadata.bind = db Base.metadata.bind = db
for table in (DBPlugin, DBClient, RoomState, UserProfile): for table in (DBPlugin, DBClient, RoomState, UserProfile, DBInvite):
table.bind(db) table.bind(db)
if not db.has_table("alembic_version"): 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: except MatrixConnectionError:
return resp.bad_client_connection_details return resp.bad_client_connection_details
except ValueError as e: except ValueError as e:
str_err = str(e)
if str_err.startswith("MXID mismatch"):
return resp.mxid_mismatch(str(e)[len("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: "):])
with client.db_instance.edit_mode(): with client.db_instance.edit_mode():
await client.update_avatar_url(data.get("avatar_url", None)) await client.update_avatar_url(data.get("avatar_url", None))
await client.update_displayname(data.get("displayname", 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 return resp.client_not_found
client.clear_cache() client.clear_cache()
return resp.ok 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' $ref: '#/components/responses/Unauthorized'
404: 404:
$ref: '#/components/responses/ClientNotFound' $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: /client/auth/servers:
get: get:
operationId: get_client_auth_servers operationId: get_client_auth_servers
@ -710,6 +797,36 @@ components:
type: string type: string
example: 'mxc://maunium.net/FsPQQTntCCqhJMFtwArmJdaU' example: 'mxc://maunium.net/FsPQQTntCCqhJMFtwArmJdaU'
description: The content URI of the avatar for this client. 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: instances:
type: array type: array
readOnly: true readOnly: true

View File

@ -233,6 +233,30 @@ export async function clearClientCache(id) {
return await resp.json() 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 const getClientAuthServers = () => defaultGet("/client/auth/servers")
export async function doClientAuth(server, type, username, password) { export async function doClientAuth(server, type, username, password) {
@ -253,5 +277,5 @@ export default {
getInstanceDatabase, queryInstanceDatabase, getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin, getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, clearClientCache, 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() { get entryKeys() {
return ["id", "displayname", "homeserver", "avatar_url", "access_token", "device_id", return ["id", "displayname", "homeserver", "avatar_url", "access_token", "device_id",
"sync", "autojoin", "online", "enabled", "started"] "sync", "autojoin", "online", "enabled", "started", "rooms", "invites"]
} }
get initialState() { get initialState() {
@ -81,6 +81,11 @@ class Client extends BaseMainView {
enabled: true, enabled: true,
online: true, online: true,
started: false, started: false,
rooms: [],
invites: [],
joiningRooms: [],
ignoringRooms: [],
leavingRooms: [],
instances: [], instances: [],
@ -102,6 +107,9 @@ class Client extends BaseMainView {
delete client.clearingCache delete client.clearingCache
delete client.error delete client.error
delete client.instances delete client.instances
delete client.joiningRooms
delete client.ignoringRooms
delete client.leavingRooms
return client 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() { get loading() {
return this.state.saving || this.state.startingOrStopping return this.state.saving || this.state.startingOrStopping
|| this.clearingCache || this.state.deleting || this.clearingCache || this.state.deleting
@ -325,6 +402,54 @@ class Client extends BaseMainView {
<div className="error">{this.state.error}</div> <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() { render() {
return <> return <>
<div className="client"> <div className="client">
@ -333,6 +458,7 @@ class Client extends BaseMainView {
{this.renderPreferences()} {this.renderPreferences()}
{this.renderPrefButtons()} {this.renderPrefButtons()}
{this.renderInstances()} {this.renderInstances()}
{this.renderRoomsAndInvites()}
</div> </div>
</div> </div>
</> </>

View File

@ -50,12 +50,38 @@
background-color: $background-dark !important background-color: $background-dark !important
color: $text-color 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
+button +button
&.main-color &.main-color
+main-color-button +main-color-button
&.error-color
+error-color-button
&.warning-color
+warning-color-button
=button-group() =button-group()
width: 100% width: 100%
display: flex display: flex

View File

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

View File

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

View File

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