diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 02b93db..0dd8f68 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8, 3.9] + python-version: ["3.8", "3.9", "3.10"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/Makefile b/Makefile index ecaa497..e513173 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ docs: build cp Changelog docs/changelog.rst tox -edocs -clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts +clean: clean-build clean-pyc clean-test clean-dev ## remove all build, test, coverage and Python artifacts clean-build: ## remove build artifacts rm -fr build/ @@ -46,6 +46,9 @@ clean-test: ## remove test and coverage artifacts rm -fr htmlcov/ rm -fr .pytest_cache +clean-dev: + rm -rf $(VENVDIR) + test: dev ## Run all the tox tests tox -p all @@ -73,5 +76,9 @@ docker-dev: test ## Make a development docker container tagged with hemna6969/a docker build -t hemna6969/aprsd:master -f docker/Dockerfile-dev docker update-requirements: dev ## Update the requirements.txt and dev-requirements.txt files + rm requirements.txt + rm dev-requirements.txt + touch requirements.txt + touch dev-requirements.txt $(VENV)/pip-compile requirements.in $(VENV)/pip-compile dev-requirements.in diff --git a/aprsd/aprsd.py b/aprsd/aprsd.py index 5d5587c..7a0afb5 100644 --- a/aprsd/aprsd.py +++ b/aprsd/aprsd.py @@ -68,9 +68,9 @@ def main(): # The commands themselves live in the cmds directory from .cmds import ( # noqa completion, dev, healthcheck, list_plugins, listen, send_message, - server, + server, webchat, ) - cli() + cli(auto_envvar_prefix="APRSD") def signal_handler(sig, frame): diff --git a/aprsd/cli_helper.py b/aprsd/cli_helper.py index a52f260..c70f22c 100644 --- a/aprsd/cli_helper.py +++ b/aprsd/cli_helper.py @@ -4,7 +4,8 @@ import typing as t import click from aprsd import config as aprsd_config -from aprsd import log +from aprsd.logging import log +from aprsd.utils import trace F = t.TypeVar("F", bound=t.Callable[..., t.Any]) @@ -59,6 +60,8 @@ def process_standard_options(f: F) -> F: ctx.obj["loglevel"], ctx.obj["quiet"], ) + if ctx.obj["config"]["aprsd"].get("trace", False): + trace.setup_tracing(["method", "api"]) del kwargs["loglevel"] del kwargs["config_file"] diff --git a/aprsd/client.py b/aprsd/client.py index 06dc1cd..89f259b 100644 --- a/aprsd/client.py +++ b/aprsd/client.py @@ -6,8 +6,9 @@ import aprslib from aprslib.exceptions import LoginError from aprsd import config as aprsd_config -from aprsd import exception, trace +from aprsd import exception from aprsd.clients import aprsis, kiss +from aprsd.utils import trace LOG = logging.getLogger("APRSD") @@ -51,7 +52,8 @@ class Client: def reset(self): """Call this to force a rebuild/reconnect.""" - del self._client + if self._client: + del self._client @abc.abstractmethod def setup_connection(self): @@ -129,6 +131,7 @@ class APRSISClient(Client): backoff = backoff * 2 continue LOG.debug(f"Logging in to APRS-IS with user '{user}'") + self._client = aprs_client return aprs_client @@ -153,8 +156,8 @@ class KISSClient(Client): # Ensure that the config vars are correctly set if KISSClient.is_enabled(config): config.check_option( - "kiss.callsign", - default_fail=aprsd_config.DEFAULT_CONFIG_DICT["kiss"]["callsign"], + "aprsd.callsign", + default_fail=aprsd_config.DEFAULT_CONFIG_DICT["aprsd"]["callsign"], ) transport = KISSClient.transport(config) if transport == TRANSPORT_SERIALKISS: @@ -192,8 +195,8 @@ class KISSClient(Client): @trace.trace def setup_connection(self): - ax25client = kiss.Aioax25Client(self.config) - return ax25client + client = kiss.KISS3Client(self.config) + return client class ClientFactory: @@ -220,7 +223,7 @@ class ClientFactory: elif KISSClient.is_enabled(self.config): key = KISSClient.transport(self.config) - LOG.debug(f"GET client {key}") + LOG.debug(f"GET client '{key}'") builder = self._builders.get(key) if not builder: raise ValueError(key) diff --git a/aprsd/clients/aprsis.py b/aprsd/clients/aprsis.py index ac7bdac..635d27b 100644 --- a/aprsd/clients/aprsis.py +++ b/aprsd/clients/aprsis.py @@ -1,5 +1,7 @@ import logging import select +import socket +import threading import aprslib from aprslib import is_py3 @@ -7,6 +9,7 @@ from aprslib.exceptions import ( ConnectionDrop, ConnectionError, GenericError, LoginError, ParseError, UnknownFormat, ) +import wrapt import aprsd from aprsd import stats @@ -23,11 +26,30 @@ class Aprsdis(aprslib.IS): # timeout in seconds select_timeout = 1 + lock = threading.Lock() def stop(self): self.thread_stop = True LOG.info("Shutdown Aprsdis client.") + def is_socket_closed(self, sock: socket.socket) -> bool: + try: + # this will try to read bytes without blocking and also without removing them from buffer (peek only) + data = sock.recv(16, socket.MSG_DONTWAIT | socket.MSG_PEEK) + if len(data) == 0: + return True + except BlockingIOError: + return False # socket is open and reading from it would block + except ConnectionResetError: + return True # socket was closed for some other reason + except Exception: + self.logger.exception( + "unexpected exception when checking if a socket is closed", + ) + return False + return False + + @wrapt.synchronized(lock) def send(self, msg): """Send an APRS Message object.""" line = str(msg) diff --git a/aprsd/clients/kiss.py b/aprsd/clients/kiss.py index 7ea1ce6..f3d85f3 100644 --- a/aprsd/clients/kiss.py +++ b/aprsd/clients/kiss.py @@ -1,20 +1,19 @@ -import asyncio import logging -from aioax25 import interface -from aioax25 import kiss as kiss -from aioax25.aprs import APRSInterface +import aprslib +from ax253 import Frame +import kiss + +from aprsd import messaging +from aprsd.utils import trace LOG = logging.getLogger("APRSD") -class Aioax25Client: +class KISS3Client: def __init__(self, config): self.config = config - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - self.loop = asyncio.get_event_loop() self.setup() def setup(self): @@ -24,71 +23,89 @@ class Aioax25Client: False, ): LOG.debug( - "Setting up Serial KISS connection to {}".format( + "KISS({}) Serial connection to {}".format( + kiss.__version__, self.config["kiss"]["serial"]["device"], ), ) - self.kissdev = kiss.SerialKISSDevice( - device=self.config["kiss"]["serial"]["device"], - baudrate=self.config["kiss"]["serial"].get("baudrate", 9600), - loop=self.loop, + self.kiss = kiss.SerialKISS( + port=self.config["kiss"]["serial"]["device"], + speed=self.config["kiss"]["serial"].get("baudrate", 9600), + strip_df_start=True, ) elif "tcp" in self.config["kiss"] and self.config["kiss"]["tcp"].get( "enabled", False, ): LOG.debug( - "Setting up KISSTCP Connection to {}:{}".format( + "KISS({}) TCP Connection to {}:{}".format( + kiss.__version__, self.config["kiss"]["tcp"]["host"], self.config["kiss"]["tcp"]["port"], ), ) - self.kissdev = kiss.TCPKISSDevice( - self.config["kiss"]["tcp"]["host"], - self.config["kiss"]["tcp"]["port"], - loop=self.loop, - log=LOG, + self.kiss = kiss.TCPKISS( + host=self.config["kiss"]["tcp"]["host"], + port=int(self.config["kiss"]["tcp"]["port"]), + strip_df_start=True, ) - self.kissdev.open() - self.kissport0 = self.kissdev[0] - - LOG.debug("Creating AX25Interface") - self.ax25int = interface.AX25Interface(kissport=self.kissport0, loop=self.loop) - - LOG.debug("Creating APRSInterface") - self.aprsint = APRSInterface( - ax25int=self.ax25int, - mycall=self.config["kiss"]["callsign"], - log=LOG, - ) + LOG.debug("Starting KISS interface connection") + self.kiss.start() + @trace.trace def stop(self): - LOG.debug(self.kissdev) - self.kissdev._close() - self.loop.stop() + try: + self.kiss.stop() + self.kiss.loop.call_soon_threadsafe( + self.kiss.protocol.transport.close, + ) + except Exception as ex: + LOG.exception(ex) def set_filter(self, filter): # This does nothing right now. pass - def consumer(self, callback, blocking=True, immortal=False, raw=False): - callsign = self.config["kiss"]["callsign"] - call = callsign.split("-") - if len(call) > 1: - callsign = call[0] - ssid = int(call[1]) - else: - ssid = 0 - self.aprsint.bind(callback=callback, callsign=callsign, ssid=ssid, regex=False) - self.loop.run_forever() + def parse_frame(self, frame_bytes): + frame = Frame.from_bytes(frame_bytes) + # Now parse it with aprslib + packet = aprslib.parse(str(frame)) + kwargs = { + "frame": str(frame), + "packet": packet, + } + self._parse_callback(**kwargs) + + def consumer(self, callback, blocking=False, immortal=False, raw=False): + LOG.debug("Start blocking KISS consumer") + self._parse_callback = callback + self.kiss.read(callback=self.parse_frame, min_frames=None) + LOG.debug("END blocking KISS consumer") def send(self, msg): """Send an APRS Message object.""" - payload = f"{msg._filter_for_send()}" - self.aprsint.send_message( - addressee=msg.tocall, - message=payload, + + # payload = (':%-9s:%s' % ( + # msg.tocall, + # payload + # )).encode('US-ASCII'), + # payload = str(msg).encode('US-ASCII') + if isinstance(msg, messaging.AckMessage): + msg_payload = f"ack{msg.id}" + else: + msg_payload = f"{msg.message}{{{str(msg.id)}" + payload = ( + ":{:<9}:{}".format( + msg.tocall, + msg_payload, + ) + ).encode("US-ASCII") + LOG.debug(f"Send '{payload}' TO KISS") + frame = Frame.ui( + destination=msg.tocall, + source=msg.fromcall, path=["WIDE1-1", "WIDE2-1"], - oneshot=True, + info=payload, ) + self.kiss.write(frame) diff --git a/aprsd/cmds/dev.py b/aprsd/cmds/dev.py index 383ffcf..c3979ed 100644 --- a/aprsd/cmds/dev.py +++ b/aprsd/cmds/dev.py @@ -8,8 +8,9 @@ import logging import click # local imports here -from aprsd import cli_helper, client, messaging, packets, plugin, stats, trace +from aprsd import cli_helper, client, messaging, packets, plugin, stats, utils from aprsd.aprsd import cli +from aprsd.utils import trace LOG = logging.getLogger("APRSD") @@ -69,6 +70,14 @@ def test_plugin( """Test an individual APRSD plugin given a python path.""" config = ctx.obj["config"] + flat_config = utils.flatten_dict(config) + LOG.info("Using CONFIG values:") + for x in flat_config: + if "password" in x or "aprsd.web.users.admin" in x: + LOG.info(f"{x} = XXXXXXXXXXXXXXXXXXX") + else: + LOG.info(f"{x} = {flat_config[x]}") + if not aprs_login: if not config.exists("aprs.login"): click.echo("Must set --aprs_login or APRS_LOGIN") diff --git a/aprsd/cmds/listen.py b/aprsd/cmds/listen.py index c3ebe27..1afbcef 100644 --- a/aprsd/cmds/listen.py +++ b/aprsd/cmds/listen.py @@ -14,10 +14,9 @@ from rich.console import Console # local imports here import aprsd -from aprsd import ( - cli_helper, client, messaging, packets, stats, threads, trace, utils, -) +from aprsd import cli_helper, client, messaging, packets, stats, threads, utils from aprsd.aprsd import cli +from aprsd.utils import trace # setup the global logger @@ -140,19 +139,24 @@ def listen( # Creates the client object LOG.info("Creating client connection") - client.factory.create().client - aprs_client = client.factory.create().client + aprs_client = client.factory.create() + console.log(aprs_client) LOG.debug(f"Filter by '{filter}'") - aprs_client.set_filter(filter) + aprs_client.client.set_filter(filter) + + packets.PacketList(config=config) + + keepalive = threads.KeepAliveThread(config=config) + keepalive.start() while True: try: # This will register a packet consumer with aprslib # When new packets come in the consumer will process # the packet - with console.status("Listening for packets"): - aprs_client.consumer(rx_packet, raw=False) + # with console.status("Listening for packets"): + aprs_client.client.consumer(rx_packet, raw=False) except aprslib.exceptions.ConnectionDrop: LOG.error("Connection dropped, reconnecting") time.sleep(5) diff --git a/aprsd/cmds/server.py b/aprsd/cmds/server.py index b173bdb..928d00c 100644 --- a/aprsd/cmds/server.py +++ b/aprsd/cmds/server.py @@ -7,10 +7,11 @@ import click import aprsd from aprsd import ( cli_helper, client, flask, messaging, packets, plugin, stats, threads, - trace, utils, + utils, ) from aprsd import aprsd as aprsd_main from aprsd.aprsd import cli +from aprsd.threads import rx LOG = logging.getLogger("APRSD") @@ -58,8 +59,6 @@ def server(ctx, flush): else: LOG.info(f"{x} = {flat_config[x]}") - if config["aprsd"].get("trace", False): - trace.setup_tracing(["method", "api"]) stats.APRSDStats(config) # Initialize the client factory and create @@ -97,7 +96,7 @@ def server(ctx, flush): plugin_manager = plugin.PluginManager(config) plugin_manager.setup_plugins() - rx_thread = threads.APRSDRXThread( + rx_thread = rx.APRSDPluginRXThread( msg_queues=threads.msg_queues, config=config, ) diff --git a/aprsd/cmds/webchat.py b/aprsd/cmds/webchat.py new file mode 100644 index 0000000..15f94fc --- /dev/null +++ b/aprsd/cmds/webchat.py @@ -0,0 +1,617 @@ +import datetime +import json +import logging +from logging.handlers import RotatingFileHandler +import queue +import signal +import sys +import threading +import time + +import aprslib +import click +import flask +from flask import request +from flask.logging import default_handler +import flask_classful +from flask_httpauth import HTTPBasicAuth +from flask_socketio import Namespace, SocketIO +from werkzeug.security import check_password_hash, generate_password_hash +import wrapt + +import aprsd +from aprsd import cli_helper, client +from aprsd import config as aprsd_config +from aprsd import messaging, packets, stats, threads, utils +from aprsd.aprsd import cli +from aprsd.logging import rich as aprsd_logging +from aprsd.threads import aprsd as aprsd_thread +from aprsd.threads import rx +from aprsd.utils import objectstore, trace + + +LOG = logging.getLogger("APRSD") +auth = HTTPBasicAuth() +users = None +rx_msg_queue = queue.Queue(maxsize=20) +tx_msg_queue = queue.Queue(maxsize=20) +control_queue = queue.Queue(maxsize=20) +msg_queues = { + "rx": rx_msg_queue, + "control": control_queue, + "tx": tx_msg_queue, +} + + +def signal_handler(sig, frame): + + click.echo("signal_handler: called") + LOG.info( + f"Ctrl+C, Sending all threads({len(threads.APRSDThreadList())}) exit! " + f"Can take up to 10 seconds {datetime.datetime.now()}", + ) + threads.APRSDThreadList().stop_all() + if "subprocess" not in str(frame): + time.sleep(1.5) + # messaging.MsgTrack().save() + # packets.WatchList().save() + # packets.SeenList().save() + LOG.info(stats.APRSDStats()) + LOG.info("Telling flask to bail.") + signal.signal(signal.SIGTERM, sys.exit(0)) + sys.exit(0) + + +class SentMessages(objectstore.ObjectStoreMixin): + _instance = None + lock = threading.Lock() + + data = {} + + def __new__(cls, *args, **kwargs): + """This magic turns this into a singleton.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @wrapt.synchronized(lock) + def add(self, msg): + self.data[msg.id] = self.create(msg.id) + self.data[msg.id]["from"] = msg.fromcall + self.data[msg.id]["to"] = msg.tocall + self.data[msg.id]["message"] = msg.message.rstrip("\n") + self.data[msg.id]["raw"] = str(msg).rstrip("\n") + + def create(self, id): + return { + "id": id, + "ts": time.time(), + "ack": False, + "from": None, + "to": None, + "raw": None, + "message": None, + "status": None, + "last_update": None, + "reply": None, + } + + @wrapt.synchronized(lock) + def __len__(self): + return len(self.data.keys()) + + @wrapt.synchronized(lock) + def get(self, id): + if id in self.data: + return self.data[id] + + @wrapt.synchronized(lock) + def get_all(self): + return self.data + + @wrapt.synchronized(lock) + def set_status(self, id, status): + if id in self.data: + self.data[id]["last_update"] = str(datetime.datetime.now()) + self.data[id]["status"] = status + + @wrapt.synchronized(lock) + def ack(self, id): + """The message got an ack!""" + if id in self.data: + self.data[id]["last_update"] = str(datetime.datetime.now()) + self.data[id]["ack"] = True + + @wrapt.synchronized(lock) + def reply(self, id, packet): + """We got a packet back from the sent message.""" + if id in self.data: + self.data[id]["reply"] = packet + + +# HTTPBasicAuth doesn't work on a class method. +# This has to be out here. Rely on the APRSDFlask +# class to initialize the users from the config +@auth.verify_password +def verify_password(username, password): + global users + + if username in users and check_password_hash(users.get(username), password): + return username + + +class WebChatRXThread(rx.APRSDRXThread): + """Class that connects to aprsis/kiss and waits for messages.""" + + def connected(self, connected=True): + self.connected = connected + + def stop(self): + self.thread_stop = True + client.factory.create().client.stop() + + def loop(self): + # setup the consumer of messages and block until a messages + msg = None + try: + msg = self.msg_queues["tx"].get_nowait() + except queue.Empty: + pass + + try: + if msg: + LOG.debug("GOT msg from TX queue!!") + msg.send() + except ( + aprslib.exceptions.ConnectionDrop, + aprslib.exceptions.ConnectionError, + ): + LOG.error("Connection dropped, reconnecting") + # Put it back on the queue to send. + self.msg_queues["tx"].put(msg) + # Force the deletion of the client object connected to aprs + # This will cause a reconnect, next time client.get_client() + # is called + self._client.reset() + time.sleep(2) + + try: + # When new packets come in the consumer will process + # the packet + + # This call blocks until thread stop() is called. + self._client.client.consumer( + self.process_packet, raw=False, blocking=False, + ) + + except ( + aprslib.exceptions.ConnectionDrop, + aprslib.exceptions.ConnectionError, + ): + LOG.error("Connection dropped, reconnecting") + time.sleep(5) + # Force the deletion of the client object connected to aprs + # This will cause a reconnect, next time client.get_client() + # is called + self._client.reset() + return True + return True + + def process_packet(self, *args, **kwargs): + # packet = self._client.decode_packet(*args, **kwargs) + if "packet" in kwargs: + packet = kwargs["packet"] + else: + packet = self._client.decode_packet(*args, **kwargs) + + LOG.debug(f"GOT Packet {packet}") + self.msg_queues["rx"].put(packet) + + +class WebChatTXThread(aprsd_thread.APRSDThread): + """Class that """ + def __init__(self, msg_queues, config, socketio): + super().__init__("_TXThread_") + self.msg_queues = msg_queues + self.config = config + self.socketio = socketio + self.connected = False + + def loop(self): + try: + msg = self.msg_queues["control"].get_nowait() + self.connected = msg["connected"] + except queue.Empty: + pass + try: + packet = self.msg_queues["rx"].get_nowait() + if packet: + # we got a packet and we need to send it to the + # web socket + self.process_packet(packet) + except queue.Empty: + pass + except Exception as ex: + LOG.exception(ex) + time.sleep(1) + + return True + + def process_ack_packet(self, packet): + ack_num = packet.get("msgNo") + LOG.info(f"We got ack for our sent message {ack_num}") + messaging.log_packet(packet) + SentMessages().ack(int(ack_num)) + self.socketio.emit( + "ack", SentMessages().get(int(ack_num)), + namespace="/sendmsg", + ) + stats.APRSDStats().ack_rx_inc() + self.got_ack = True + + def process_packet(self, packet): + LOG.info(f"process PACKET {packet}") + tocall = packet.get("addresse", None) + fromcall = packet["from"] + msg = packet.get("message_text", None) + msg_id = packet.get("msgNo", "0") + msg_response = packet.get("response", None) + + if tocall == self.config["aprsd"]["callsign"] and msg_response == "ack": + self.process_ack_packet(packet) + elif tocall == self.config["aprsd"]["callsign"]: + messaging.log_message( + "Received Message", + packet["raw"], + msg, + fromcall=fromcall, + msg_num=msg_id, + ) + # let any threads do their thing, then ack + # send an ack last + ack = messaging.AckMessage( + self.config["aprsd"]["callsign"], + fromcall, + msg_id=msg_id, + ) + ack.send() + + packets.PacketList().add(packet) + stats.APRSDStats().msgs_rx_inc() + message = packet.get("message_text", None) + msg = { + "id": 0, + "ts": time.time(), + "ack": False, + "from": fromcall, + "to": packet["to"], + "raw": packet["raw"], + "message": message, + "status": None, + "last_update": None, + "reply": None, + } + self.socketio.emit( + "new", msg, + namespace="/sendmsg", + ) + + +class WebChatFlask(flask_classful.FlaskView): + config = None + + def set_config(self, config): + global users + self.config = config + self.users = {} + for user in self.config["aprsd"]["web"]["users"]: + self.users[user] = generate_password_hash( + self.config["aprsd"]["web"]["users"][user], + ) + + users = self.users + + @auth.login_required + def index(self): + stats = self._stats() + + if self.config["aprs"].get("enabled", True): + transport = "aprs-is" + aprs_connection = ( + "APRS-IS Server: " + "{}".format(stats["stats"]["aprs-is"]["server"]) + ) + else: + # We might be connected to a KISS socket? + if client.KISSClient.is_enabled(self.config): + transport = client.KISSClient.transport(self.config) + if transport == client.TRANSPORT_TCPKISS: + aprs_connection = ( + "TCPKISS://{}:{}".format( + self.config["kiss"]["tcp"]["host"], + self.config["kiss"]["tcp"]["port"], + ) + ) + elif transport == client.TRANSPORT_SERIALKISS: + aprs_connection = ( + "SerialKISS://{}@{} baud".format( + self.config["kiss"]["serial"]["device"], + self.config["kiss"]["serial"]["baudrate"], + ) + ) + + stats["transport"] = transport + stats["aprs_connection"] = aprs_connection + LOG.debug(f"initial stats = {stats}") + + return flask.render_template( + "index.html", + initial_stats=stats, + aprs_connection=aprs_connection, + callsign=self.config["aprsd"]["callsign"], + version=aprsd.__version__, + ) + + @auth.login_required + def send_message_status(self): + LOG.debug(request) + msgs = SentMessages() + info = msgs.get_all() + return json.dumps(info) + + def _stats(self): + stats_obj = stats.APRSDStats() + now = datetime.datetime.now() + + time_format = "%m-%d-%Y %H:%M:%S" + stats_dict = stats_obj.stats() + # Webchat doesnt need these + del stats_dict["aprsd"]["watch_list"] + del stats_dict["aprsd"]["seen_list"] + # del stats_dict["email"] + # del stats_dict["plugins"] + # del stats_dict["messages"] + + result = { + "time": now.strftime(time_format), + "stats": stats_dict, + } + + return result + + def stats(self): + return json.dumps(self._stats()) + + +class SendMessageNamespace(Namespace): + """Class to handle the socketio interactions.""" + _config = None + got_ack = False + reply_sent = False + msg = None + request = None + + def __init__(self, namespace=None, config=None, msg_queues=None): + self._config = config + self._msg_queues = msg_queues + super().__init__(namespace) + + def on_connect(self): + global socketio + LOG.debug("Web socket connected") + socketio.emit( + "connected", {"data": "/sendmsg Connected"}, + namespace="/sendmsg", + ) + msg = {"connected": True} + self._msg_queues["control"].put(msg) + + def on_disconnect(self): + LOG.debug("WS Disconnected") + msg = {"connected": False} + self._msg_queues["control"].put(msg) + + def on_send(self, data): + global socketio + LOG.debug(f"WS: on_send {data}") + self.request = data + data["from"] = self._config["aprs"]["login"] + msg = messaging.TextMessage( + data["from"], + data["to"], + data["message"], + ) + self.msg = msg + msgs = SentMessages() + msgs.add(msg) + msgs.set_status(msg.id, "Sending") + obj = msgs.get(self.msg.id) + socketio.emit( + "sent", obj, + namespace="/sendmsg", + ) + msg.send() + # self._msg_queues["tx"].put(msg) + + def handle_message(self, data): + LOG.debug(f"WS Data {data}") + + def handle_json(self, data): + LOG.debug(f"WS json {data}") + + +def setup_logging(config, flask_app, loglevel, quiet): + flask_log = logging.getLogger("werkzeug") + flask_app.logger.removeHandler(default_handler) + flask_log.removeHandler(default_handler) + + log_level = aprsd_config.LOG_LEVELS[loglevel] + flask_log.setLevel(log_level) + date_format = config["aprsd"].get( + "dateformat", + aprsd_config.DEFAULT_DATE_FORMAT, + ) + + if not config["aprsd"]["web"].get("logging_enabled", False): + # disable web logging + flask_log.disabled = True + flask_app.logger.disabled = True + # return + + if config["aprsd"].get("rich_logging", False) and not quiet: + log_format = "%(message)s" + log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) + rh = aprsd_logging.APRSDRichHandler( + show_thread=True, thread_width=15, + rich_tracebacks=True, omit_repeated_times=False, + ) + rh.setFormatter(log_formatter) + flask_log.addHandler(rh) + + log_file = config["aprsd"].get("logfile", None) + + if log_file: + log_format = config["aprsd"].get( + "logformat", + aprsd_config.DEFAULT_LOG_FORMAT, + ) + log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format) + fh = RotatingFileHandler( + log_file, maxBytes=(10248576 * 5), + backupCount=4, + ) + fh.setFormatter(log_formatter) + flask_log.addHandler(fh) + + +@trace.trace +def init_flask(config, loglevel, quiet): + global socketio + + flask_app = flask.Flask( + "aprsd", + static_url_path="/static", + static_folder="web/chat/static", + template_folder="web/chat/templates", + ) + setup_logging(config, flask_app, loglevel, quiet) + server = WebChatFlask() + server.set_config(config) + flask_app.route("/", methods=["GET"])(server.index) + flask_app.route("/stats", methods=["GET"])(server.stats) + # flask_app.route("/send-message", methods=["GET"])(server.send_message) + flask_app.route("/send-message-status", methods=["GET"])(server.send_message_status) + + socketio = SocketIO( + flask_app, logger=False, engineio_logger=False, + async_mode="threading", + ) + # async_mode="gevent", + # async_mode="eventlet", + # import eventlet + # eventlet.monkey_patch() + + socketio.on_namespace( + SendMessageNamespace( + "/sendmsg", config=config, + msg_queues=msg_queues, + ), + ) + return socketio, flask_app + + +# main() ### +@cli.command() +@cli_helper.add_options(cli_helper.common_options) +@click.option( + "-f", + "--flush", + "flush", + is_flag=True, + show_default=True, + default=False, + help="Flush out all old aged messages on disk.", +) +@click.option( + "-p", + "--port", + "port", + show_default=True, + default=80, + help="Port to listen to web requests", +) +@click.pass_context +@cli_helper.process_standard_options +def webchat(ctx, flush, port): + """Web based HAM Radio chat program!""" + ctx.obj["config_file"] + loglevel = ctx.obj["loglevel"] + quiet = ctx.obj["quiet"] + config = ctx.obj["config"] + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + if not quiet: + click.echo("Load config") + + level, msg = utils._check_version() + if level: + LOG.warning(msg) + else: + LOG.info(msg) + LOG.info(f"APRSD Started version: {aprsd.__version__}") + + flat_config = utils.flatten_dict(config) + LOG.info("Using CONFIG values:") + for x in flat_config: + if "password" in x or "aprsd.web.users.admin" in x: + LOG.info(f"{x} = XXXXXXXXXXXXXXXXXXX") + else: + LOG.info(f"{x} = {flat_config[x]}") + + stats.APRSDStats(config) + + # Initialize the client factory and create + # The correct client object ready for use + client.ClientFactory.setup(config) + # Make sure we have 1 client transport enabled + if not client.factory.is_client_enabled(): + LOG.error("No Clients are enabled in config.") + sys.exit(-1) + + if not client.factory.is_client_configured(): + LOG.error("APRS client is not properly configured in config file.") + sys.exit(-1) + + packets.PacketList(config=config) + messaging.MsgTrack(config=config) + packets.WatchList(config=config) + packets.SeenList(config=config) + + (socketio, app) = init_flask(config, loglevel, quiet) + rx_thread = WebChatRXThread( + msg_queues=msg_queues, + config=config, + ) + LOG.info("Start RX Thread") + rx_thread.start() + tx_thread = WebChatTXThread( + msg_queues=msg_queues, + config=config, + socketio=socketio, + ) + LOG.info("Start TX Thread") + tx_thread.start() + + keepalive = threads.KeepAliveThread(config=config) + LOG.info("Start KeepAliveThread") + keepalive.start() + LOG.info("Start socketio.run()") + socketio.run( + app, + host=config["aprsd"]["web"]["host"], + port=port, + ) + + LOG.info("WebChat exiting!!!! Bye.") diff --git a/aprsd/config.py b/aprsd/config.py index 93b56ea..c96abf6 100644 --- a/aprsd/config.py +++ b/aprsd/config.py @@ -57,13 +57,13 @@ DEFAULT_CONFIG_DICT = { "ham": {"callsign": "NOCALL"}, "aprs": { "enabled": True, + # Only used as the login for aprsis. "login": "CALLSIGN", "password": "00000", "host": "rotate.aprs2.net", "port": 14580, }, "kiss": { - "callsign": "NOCALL", "tcp": { "enabled": False, "host": "direwolf.ip.address", @@ -76,11 +76,14 @@ DEFAULT_CONFIG_DICT = { }, }, "aprsd": { + # Callsign to use for all packets to/from aprsd instance + # regardless of the client (aprsis vs kiss) + "callsign": "NOCALL", "logfile": "/tmp/aprsd.log", "logformat": DEFAULT_LOG_FORMAT, "dateformat": DEFAULT_DATE_FORMAT, "save_location": DEFAULT_CONFIG_DIR, - "rich_logging": False, + "rich_logging": True, "trace": False, "enabled_plugins": CORE_MESSAGE_PLUGINS, "units": "imperial", @@ -177,16 +180,35 @@ class Config(collections.UserDict): if not self.exists(path): if type(path) is list: path = ".".join(path) - raise exception.MissingConfigOption(path) + raise exception.MissingConfigOptionException(path) val = self.get(path) if val == default_fail: # We have to fail and bail if the user hasn't edited # this config option. - raise exception.ConfigOptionBogusDefaultException(path, default_fail) + raise exception.ConfigOptionBogusDefaultException( + path, default_fail, + ) def add_config_comments(raw_yaml): + end_idx = utils.end_substr(raw_yaml, "ham:") + if end_idx != -1: + # lets insert a comment + raw_yaml = utils.insert_str( + raw_yaml, + "\n # Callsign that owns this instance of APRSD.", + end_idx, + ) + end_idx = utils.end_substr(raw_yaml, "aprsd:") + if end_idx != -1: + # lets insert a comment + raw_yaml = utils.insert_str( + raw_yaml, + "\n # Callsign to use for all APRSD Packets as the to/from." + "\n # regardless of client type (aprsis vs tcpkiss vs serial)", + end_idx, + ) end_idx = utils.end_substr(raw_yaml, "aprs:") if end_idx != -1: # lets insert a comment @@ -326,6 +348,11 @@ def parse_config(config_file): config, ["aprsd"], ) + check_option( + config, + "aprsd.callsign", + default_fail=DEFAULT_CONFIG_DICT["aprsd"]["callsign"], + ) # Ensure they change the admin password if config.get("aprsd.web.enabled") is True: diff --git a/aprsd/flask.py b/aprsd/flask.py index 77e983b..21de182 100644 --- a/aprsd/flask.py +++ b/aprsd/flask.py @@ -18,9 +18,10 @@ from werkzeug.security import check_password_hash, generate_password_hash import aprsd from aprsd import client from aprsd import config as aprsd_config -from aprsd import log, messaging, packets, plugin, stats, threads, utils +from aprsd import messaging, packets, plugin, stats, threads, utils from aprsd.clients import aprsis -from aprsd.logging import logging as aprsd_logging +from aprsd.logging import log +from aprsd.logging import rich as aprsd_logging LOG = logging.getLogger("APRSD") @@ -600,8 +601,8 @@ def init_flask(config, loglevel, quiet): flask_app = flask.Flask( "aprsd", static_url_path="/static", - static_folder="web/static", - template_folder="web/templates", + static_folder="web/admin/static", + template_folder="web/admin/templates", ) setup_logging(config, flask_app, loglevel, quiet) server = APRSDFlask() diff --git a/aprsd/log.py b/aprsd/logging/log.py similarity index 98% rename from aprsd/log.py rename to aprsd/logging/log.py index 96135f9..855547d 100644 --- a/aprsd/log.py +++ b/aprsd/logging/log.py @@ -5,7 +5,7 @@ import queue import sys from aprsd import config as aprsd_config -from aprsd.logging import logging as aprsd_logging +from aprsd.logging import rich as aprsd_logging LOG = logging.getLogger("APRSD") diff --git a/aprsd/logging/logging.py b/aprsd/logging/rich.py similarity index 100% rename from aprsd/logging/logging.py rename to aprsd/logging/rich.py diff --git a/aprsd/messaging.py b/aprsd/messaging.py index adb3829..4c3643f 100644 --- a/aprsd/messaging.py +++ b/aprsd/messaging.py @@ -6,7 +6,8 @@ import re import threading import time -from aprsd import client, objectstore, packets, stats, threads +from aprsd import client, packets, stats, threads +from aprsd.utils import objectstore LOG = logging.getLogger("APRSD") @@ -238,7 +239,10 @@ class RawMessage(Message): last_send_age = last_send_time = None def __init__(self, message, allow_delay=True): - super().__init__(fromcall=None, tocall=None, msg_id=None, allow_delay=allow_delay) + super().__init__( + fromcall=None, tocall=None, msg_id=None, + allow_delay=allow_delay, + ) self._raw_message = message def dict(self): @@ -282,12 +286,8 @@ class TextMessage(Message): last_send_time = last_send_age = None def __init__( - self, - fromcall, - tocall, - message, - msg_id=None, - allow_delay=True, + self, fromcall, tocall, message, + msg_id=None, allow_delay=True, ): super().__init__( fromcall=fromcall, tocall=tocall, diff --git a/aprsd/packets.py b/aprsd/packets.py index b7e6ecc..83d795d 100644 --- a/aprsd/packets.py +++ b/aprsd/packets.py @@ -3,7 +3,10 @@ import logging import threading import time -from aprsd import objectstore, utils +import wrapt + +from aprsd import utils +from aprsd.utils import objectstore LOG = logging.getLogger("APRSD") @@ -17,6 +20,7 @@ class PacketList: """Class to track all of the packets rx'd and tx'd by aprsd.""" _instance = None + lock = threading.Lock() config = None packet_list = {} @@ -28,7 +32,6 @@ class PacketList: if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.packet_list = utils.RingBuffer(1000) - cls._instance.lock = threading.Lock() cls._instance.config = kwargs["config"] return cls._instance @@ -36,50 +39,51 @@ class PacketList: if config: self.config = config + @wrapt.synchronized(lock) def __iter__(self): - with self.lock: - return iter(self.packet_list) + return iter(self.packet_list) + @wrapt.synchronized(lock) def add(self, packet): - with self.lock: - packet["ts"] = time.time() - if ( - "fromcall" in packet - and packet["fromcall"] == self.config["aprs"]["login"] - ): - self.total_tx += 1 - else: - self.total_recv += 1 - self.packet_list.append(packet) - SeenList().update_seen(packet) + packet["ts"] = time.time() + if ( + "fromcall" in packet + and packet["fromcall"] == self.config["aprs"]["login"] + ): + self.total_tx += 1 + else: + self.total_recv += 1 + self.packet_list.append(packet) + SeenList().update_seen(packet) + @wrapt.synchronized(lock) def get(self): - with self.lock: - return self.packet_list.get() + return self.packet_list.get() + @wrapt.synchronized(lock) def total_received(self): - with self.lock: - return self.total_recv + return self.total_recv + @wrapt.synchronized(lock) def total_sent(self): - with self.lock: - return self.total_tx + return self.total_tx class WatchList(objectstore.ObjectStoreMixin): """Global watch list and info for callsigns.""" _instance = None + lock = threading.Lock() data = {} config = None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) - cls._instance.lock = threading.Lock() - cls._instance.config = kwargs["config"] + if "config" in kwargs: + cls._instance.config = kwargs["config"] + cls._instance._init_store() cls._instance.data = {} - cls._instance._init_store() return cls._instance def __init__(self, config=None): @@ -110,12 +114,12 @@ class WatchList(objectstore.ObjectStoreMixin): def callsign_in_watchlist(self, callsign): return callsign in self.data + @wrapt.synchronized(lock) def update_seen(self, packet): - with self.lock: - callsign = packet["from"] - if self.callsign_in_watchlist(callsign): - self.data[callsign]["last"] = datetime.datetime.now() - self.data[callsign]["packets"].append(packet) + callsign = packet["from"] + if self.callsign_in_watchlist(callsign): + self.data[callsign]["last"] = datetime.datetime.now() + self.data[callsign]["packets"].append(packet) def last_seen(self, callsign): if self.callsign_in_watchlist(callsign): @@ -158,18 +162,20 @@ class SeenList(objectstore.ObjectStoreMixin): """Global callsign seen list.""" _instance = None + lock = threading.Lock() data = {} config = None def __new__(cls, *args, **kwargs): if cls._instance is None: cls._instance = super().__new__(cls) - cls._instance.lock = threading.Lock() - cls._instance.config = kwargs["config"] + if "config" in kwargs: + cls._instance.config = kwargs["config"] + cls._instance._init_store() cls._instance.data = {} - cls._instance._init_store() return cls._instance + @wrapt.synchronized(lock) def update_seen(self, packet): callsign = None if "fromcall" in packet: diff --git a/aprsd/plugin.py b/aprsd/plugin.py index 7a6aff1..0564085 100644 --- a/aprsd/plugin.py +++ b/aprsd/plugin.py @@ -492,4 +492,5 @@ class PluginManager: self._pluggy_pm.register(obj) def get_plugins(self): - return self._pluggy_pm.get_plugins() + if self._pluggy_pm: + return self._pluggy_pm.get_plugins() diff --git a/aprsd/plugins/email.py b/aprsd/plugins/email.py index d734e06..c5b0430 100644 --- a/aprsd/plugins/email.py +++ b/aprsd/plugins/email.py @@ -11,7 +11,8 @@ import time import imapclient from validate_email import validate_email -from aprsd import messaging, plugin, stats, threads, trace +from aprsd import messaging, plugin, stats, threads +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/fortune.py b/aprsd/plugins/fortune.py index b30fc73..43ff0a7 100644 --- a/aprsd/plugins/fortune.py +++ b/aprsd/plugins/fortune.py @@ -2,7 +2,8 @@ import logging import shutil import subprocess -from aprsd import plugin, trace +from aprsd import plugin +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/location.py b/aprsd/plugins/location.py index 939661b..ddc97ef 100644 --- a/aprsd/plugins/location.py +++ b/aprsd/plugins/location.py @@ -2,7 +2,8 @@ import logging import re import time -from aprsd import plugin, plugin_utils, trace +from aprsd import plugin, plugin_utils +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/notify.py b/aprsd/plugins/notify.py index 09e4a20..878a40f 100644 --- a/aprsd/plugins/notify.py +++ b/aprsd/plugins/notify.py @@ -1,6 +1,7 @@ import logging -from aprsd import messaging, packets, plugin, trace +from aprsd import messaging, packets, plugin +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/ping.py b/aprsd/plugins/ping.py index b709574..6304a45 100644 --- a/aprsd/plugins/ping.py +++ b/aprsd/plugins/ping.py @@ -1,7 +1,8 @@ import logging import time -from aprsd import plugin, trace +from aprsd import plugin +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/query.py b/aprsd/plugins/query.py index bdc273a..a21062e 100644 --- a/aprsd/plugins/query.py +++ b/aprsd/plugins/query.py @@ -2,7 +2,8 @@ import datetime import logging import re -from aprsd import messaging, plugin, trace +from aprsd import messaging, plugin +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/time.py b/aprsd/plugins/time.py index 8cfdd83..f668544 100644 --- a/aprsd/plugins/time.py +++ b/aprsd/plugins/time.py @@ -2,10 +2,10 @@ import logging import re import time -from opencage.geocoder import OpenCageGeocode import pytz -from aprsd import fuzzyclock, plugin, plugin_utils, trace +from aprsd import plugin, plugin_utils +from aprsd.utils import fuzzy, trace LOG = logging.getLogger("APRSD") @@ -32,7 +32,7 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase): local_short_str = local_t.strftime("%H:%M %Z") local_hour = local_t.strftime("%H") local_min = local_t.strftime("%M") - cur_time = fuzzyclock.fuzzy(int(local_hour), int(local_min), 1) + cur_time = fuzzy(int(local_hour), int(local_min), 1) reply = "{} ({})".format( cur_time, @@ -49,68 +49,6 @@ class TimePlugin(plugin.APRSDRegexCommandPluginBase): return self.build_date_str(localzone) -class TimeOpenCageDataPlugin(TimePlugin, plugin.APRSFIKEYMixin): - """geocage based timezone fetching.""" - - command_regex = "^[tT]" - command_name = "time" - short_description = "Current time of GPS beacon timezone. Uses OpenCage" - - def setup(self): - self.ensure_aprs_fi_key() - - @trace.trace - def process(self, packet): - fromcall = packet.get("from") - message = packet.get("message_text", None) - # ack = packet.get("msgNo", "0") - - api_key = self.config["services"]["aprs.fi"]["apiKey"] - - # optional second argument is a callsign to search - a = re.search(r"^.*\s+(.*)", message) - if a is not None: - searchcall = a.group(1) - searchcall = searchcall.upper() - else: - # if no second argument, search for calling station - searchcall = fromcall - - try: - aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall) - except Exception as ex: - LOG.error(f"Failed to fetch aprs.fi data {ex}") - return "Failed to fetch location" - - # LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data)) - if not len(aprs_data["entries"]): - LOG.error("Didn't get any entries from aprs.fi") - return "Failed to fetch aprs.fi location" - - lat = aprs_data["entries"][0]["lat"] - lon = aprs_data["entries"][0]["lng"] - - try: - self.config.exists("opencagedata.apiKey") - except Exception as ex: - LOG.error(f"Failed to find config opencage:apiKey {ex}") - return "No opencage apiKey found" - - try: - opencage_key = self.config["opencagedata"]["apiKey"] - geocoder = OpenCageGeocode(opencage_key) - results = geocoder.reverse_geocode(lat, lon) - except Exception as ex: - LOG.error(f"Couldn't fetch opencagedata api '{ex}'") - # Default to UTC instead - localzone = pytz.timezone("UTC") - else: - tzone = results[0]["annotations"]["timezone"]["name"] - localzone = pytz.timezone(tzone) - - return self.build_date_str(localzone) - - class TimeOWMPlugin(TimePlugin, plugin.APRSFIKEYMixin): """OpenWeatherMap based timezone fetching.""" diff --git a/aprsd/plugins/version.py b/aprsd/plugins/version.py index 6a05690..80ce257 100644 --- a/aprsd/plugins/version.py +++ b/aprsd/plugins/version.py @@ -1,7 +1,8 @@ import logging import aprsd -from aprsd import plugin, stats, trace +from aprsd import plugin, stats +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/plugins/weather.py b/aprsd/plugins/weather.py index 1880fef..acf1208 100644 --- a/aprsd/plugins/weather.py +++ b/aprsd/plugins/weather.py @@ -4,7 +4,8 @@ import re import requests -from aprsd import plugin, plugin_utils, trace +from aprsd import plugin, plugin_utils +from aprsd.utils import trace LOG = logging.getLogger("APRSD") diff --git a/aprsd/stats.py b/aprsd/stats.py index e629157..8562fc3 100644 --- a/aprsd/stats.py +++ b/aprsd/stats.py @@ -2,6 +2,8 @@ import datetime import logging import threading +import wrapt + import aprsd from aprsd import packets, plugin, utils @@ -12,7 +14,7 @@ LOG = logging.getLogger("APRSD") class APRSDStats: _instance = None - lock = None + lock = threading.Lock() config = None start_time = None @@ -39,7 +41,6 @@ class APRSDStats: if cls._instance is None: cls._instance = super().__new__(cls) # any initializetion here - cls._instance.lock = threading.Lock() cls._instance.start_time = datetime.datetime.now() cls._instance._aprsis_keepalive = datetime.datetime.now() return cls._instance @@ -48,128 +49,129 @@ class APRSDStats: if config: self.config = config + @wrapt.synchronized(lock) @property def uptime(self): - with self.lock: - return datetime.datetime.now() - self.start_time + return datetime.datetime.now() - self.start_time + @wrapt.synchronized(lock) @property def memory(self): - with self.lock: - return self._mem_current + return self._mem_current + @wrapt.synchronized(lock) def set_memory(self, memory): - with self.lock: - self._mem_current = memory + self._mem_current = memory + @wrapt.synchronized(lock) @property def memory_peak(self): - with self.lock: - return self._mem_peak + return self._mem_peak + @wrapt.synchronized(lock) def set_memory_peak(self, memory): - with self.lock: - self._mem_peak = memory + self._mem_peak = memory + @wrapt.synchronized(lock) @property def aprsis_server(self): - with self.lock: - return self._aprsis_server + return self._aprsis_server + @wrapt.synchronized(lock) def set_aprsis_server(self, server): - with self.lock: - self._aprsis_server = server + self._aprsis_server = server + @wrapt.synchronized(lock) @property def aprsis_keepalive(self): - with self.lock: - return self._aprsis_keepalive + return self._aprsis_keepalive + @wrapt.synchronized(lock) def set_aprsis_keepalive(self): - with self.lock: - self._aprsis_keepalive = datetime.datetime.now() + self._aprsis_keepalive = datetime.datetime.now() + @wrapt.synchronized(lock) @property def msgs_tx(self): - with self.lock: - return self._msgs_tx + return self._msgs_tx + @wrapt.synchronized(lock) def msgs_tx_inc(self): - with self.lock: - self._msgs_tx += 1 + self._msgs_tx += 1 + @wrapt.synchronized(lock) @property def msgs_rx(self): - with self.lock: - return self._msgs_rx + return self._msgs_rx + @wrapt.synchronized(lock) def msgs_rx_inc(self): - with self.lock: - self._msgs_rx += 1 + self._msgs_rx += 1 + @wrapt.synchronized(lock) @property def msgs_mice_rx(self): - with self.lock: - return self._msgs_mice_rx + return self._msgs_mice_rx + @wrapt.synchronized(lock) def msgs_mice_inc(self): - with self.lock: - self._msgs_mice_rx += 1 + self._msgs_mice_rx += 1 + @wrapt.synchronized(lock) @property def ack_tx(self): - with self.lock: - return self._ack_tx + return self._ack_tx + @wrapt.synchronized(lock) def ack_tx_inc(self): - with self.lock: - self._ack_tx += 1 + self._ack_tx += 1 + @wrapt.synchronized(lock) @property def ack_rx(self): - with self.lock: - return self._ack_rx + return self._ack_rx + @wrapt.synchronized(lock) def ack_rx_inc(self): - with self.lock: - self._ack_rx += 1 + self._ack_rx += 1 + @wrapt.synchronized(lock) @property def msgs_tracked(self): - with self.lock: - return self._msgs_tracked + return self._msgs_tracked + @wrapt.synchronized(lock) def msgs_tracked_inc(self): - with self.lock: - self._msgs_tracked += 1 + self._msgs_tracked += 1 + @wrapt.synchronized(lock) @property def email_tx(self): - with self.lock: - return self._email_tx + return self._email_tx + @wrapt.synchronized(lock) def email_tx_inc(self): - with self.lock: - self._email_tx += 1 + self._email_tx += 1 + @wrapt.synchronized(lock) @property def email_rx(self): - with self.lock: - return self._email_rx + return self._email_rx + @wrapt.synchronized(lock) def email_rx_inc(self): - with self.lock: - self._email_rx += 1 + self._email_rx += 1 + @wrapt.synchronized(lock) @property def email_thread_time(self): - with self.lock: - return self._email_thread_last_time + return self._email_thread_last_time + @wrapt.synchronized(lock) def email_thread_update(self): - with self.lock: - self._email_thread_last_time = datetime.datetime.now() + self._email_thread_last_time = datetime.datetime.now() + @wrapt.synchronized(lock) def stats(self): now = datetime.datetime.now() if self._email_thread_last_time: @@ -185,20 +187,20 @@ class APRSDStats: pm = plugin.PluginManager() plugins = pm.get_plugins() plugin_stats = {} + if plugins: + def full_name_with_qualname(obj): + return "{}.{}".format( + obj.__class__.__module__, + obj.__class__.__qualname__, + ) - def full_name_with_qualname(obj): - return "{}.{}".format( - obj.__class__.__module__, - obj.__class__.__qualname__, - ) - - for p in plugins: - plugin_stats[full_name_with_qualname(p)] = { - "enabled": p.enabled, - "rx": p.rx_count, - "tx": p.tx_count, - "version": p.version, - } + for p in plugins: + plugin_stats[full_name_with_qualname(p)] = { + "enabled": p.enabled, + "rx": p.rx_count, + "tx": p.tx_count, + "version": p.version, + } wl = packets.WatchList() sl = packets.SeenList() @@ -207,30 +209,30 @@ class APRSDStats: "aprsd": { "version": aprsd.__version__, "uptime": utils.strfdelta(self.uptime), - "memory_current": self.memory, + "memory_current": int(self.memory), "memory_current_str": utils.human_size(self.memory), - "memory_peak": self.memory_peak, + "memory_peak": int(self.memory_peak), "memory_peak_str": utils.human_size(self.memory_peak), "watch_list": wl.get_all(), "seen_list": sl.get_all(), }, "aprs-is": { - "server": self.aprsis_server, + "server": str(self.aprsis_server), "callsign": self.config["aprs"]["login"], "last_update": last_aprsis_keepalive, }, "messages": { - "tracked": self.msgs_tracked, - "sent": self.msgs_tx, - "recieved": self.msgs_rx, - "ack_sent": self.ack_tx, - "ack_recieved": self.ack_rx, - "mic-e recieved": self.msgs_mice_rx, + "tracked": int(self.msgs_tracked), + "sent": int(self.msgs_tx), + "recieved": int(self.msgs_rx), + "ack_sent": int(self.ack_tx), + "ack_recieved": int(self.ack_rx), + "mic-e recieved": int(self.msgs_mice_rx), }, "email": { "enabled": self.config["aprsd"]["email"]["enabled"], - "sent": self._email_tx, - "recieved": self._email_rx, + "sent": int(self._email_tx), + "recieved": int(self._email_rx), "thread_last_update": last_update, }, "plugins": plugin_stats, diff --git a/aprsd/threads/__init__.py b/aprsd/threads/__init__.py new file mode 100644 index 0000000..fd4da3b --- /dev/null +++ b/aprsd/threads/__init__.py @@ -0,0 +1,13 @@ +import queue + +# Make these available to anyone importing +# aprsd.threads +from .aprsd import APRSDThread, APRSDThreadList # noqa: F401 +from .keep_alive import KeepAliveThread # noqa: F401 +from .rx import APRSDRXThread # noqa: F401 + + +rx_msg_queue = queue.Queue(maxsize=20) +msg_queues = { + "rx": rx_msg_queue, +} diff --git a/aprsd/threads/aprsd.py b/aprsd/threads/aprsd.py new file mode 100644 index 0000000..819ee7f --- /dev/null +++ b/aprsd/threads/aprsd.py @@ -0,0 +1,94 @@ +import abc +import logging +from queue import Queue +import threading + +import wrapt + + +LOG = logging.getLogger("APRSD") + + +class APRSDThreadList: + """Singleton class that keeps track of application wide threads.""" + + _instance = None + + threads_list = [] + lock = threading.Lock() + global_queue = Queue() + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls.threads_list = [] + return cls._instance + + @wrapt.synchronized(lock) + def add(self, thread_obj): + thread_obj.set_global_queue(self.global_queue) + self.threads_list.append(thread_obj) + + @wrapt.synchronized(lock) + def remove(self, thread_obj): + self.threads_list.remove(thread_obj) + + @wrapt.synchronized(lock) + def stop_all(self): + self.global_queue.put_nowait({"quit": True}) + """Iterate over all threads and call stop on them.""" + for th in self.threads_list: + LOG.info(f"Stopping Thread {th.name}") + th.stop() + + @wrapt.synchronized(lock) + def __len__(self): + return len(self.threads_list) + + +class APRSDThread(threading.Thread, metaclass=abc.ABCMeta): + + global_queue = None + + def __init__(self, name): + super().__init__(name=name) + self.thread_stop = False + APRSDThreadList().add(self) + + def set_global_queue(self, global_queue): + self.global_queue = global_queue + + def _should_quit(self): + """ see if we have a quit message from the global queue.""" + if self.thread_stop: + return True + if self.global_queue.empty(): + return False + msg = self.global_queue.get(timeout=1) + if not msg: + return False + if "quit" in msg and msg["quit"] is True: + # put the message back on the queue for others + self.global_queue.put_nowait(msg) + self.thread_stop = True + return True + + def stop(self): + self.thread_stop = True + + @abc.abstractmethod + def loop(self): + pass + + def _cleanup(self): + """Add code to subclass to do any cleanup""" + + def run(self): + LOG.debug("Starting") + while not self._should_quit(): + can_loop = self.loop() + if not can_loop: + self.stop() + self._cleanup() + APRSDThreadList().remove(self) + LOG.debug("Exiting") diff --git a/aprsd/threads/keep_alive.py b/aprsd/threads/keep_alive.py new file mode 100644 index 0000000..95c7ea0 --- /dev/null +++ b/aprsd/threads/keep_alive.py @@ -0,0 +1,88 @@ +import datetime +import logging +import time +import tracemalloc + +from aprsd import client, messaging, packets, stats, utils +from aprsd.threads import APRSDThread, APRSDThreadList + + +LOG = logging.getLogger("APRSD") + + +class KeepAliveThread(APRSDThread): + cntr = 0 + checker_time = datetime.datetime.now() + + def __init__(self, config): + tracemalloc.start() + super().__init__("KeepAlive") + self.config = config + max_timeout = {"hours": 0.0, "minutes": 2, "seconds": 0} + self.max_delta = datetime.timedelta(**max_timeout) + + def loop(self): + if self.cntr % 60 == 0: + tracker = messaging.MsgTrack() + stats_obj = stats.APRSDStats() + pl = packets.PacketList() + thread_list = APRSDThreadList() + now = datetime.datetime.now() + last_email = stats_obj.email_thread_time + if last_email: + email_thread_time = utils.strfdelta(now - last_email) + else: + email_thread_time = "N/A" + + last_msg_time = utils.strfdelta(now - stats_obj.aprsis_keepalive) + + current, peak = tracemalloc.get_traced_memory() + stats_obj.set_memory(current) + stats_obj.set_memory_peak(peak) + + try: + login = self.config["aprsd"]["callsign"] + except KeyError: + login = self.config["ham"]["callsign"] + + keepalive = ( + "{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} " + "Last:{} Email: {} - RAM Current:{} Peak:{} Threads:{}" + ).format( + login, + utils.strfdelta(stats_obj.uptime), + pl.total_recv, + pl.total_tx, + len(tracker), + stats_obj.msgs_tx, + stats_obj.msgs_rx, + last_msg_time, + email_thread_time, + utils.human_size(current), + utils.human_size(peak), + len(thread_list), + ) + LOG.info(keepalive) + + # See if we should reset the aprs-is client + # Due to losing a keepalive from them + delta_dict = utils.parse_delta_str(last_msg_time) + delta = datetime.timedelta(**delta_dict) + + if delta > self.max_delta: + # We haven't gotten a keepalive from aprs-is in a while + # reset the connection.a + if not client.KISSClient.is_enabled(self.config): + LOG.warning(f"Resetting connection to APRS-IS {delta}") + client.factory.create().reset() + + # Check version every hour + delta = now - self.checker_time + if delta > datetime.timedelta(hours=1): + self.checker_time = now + level, msg = utils._check_version() + if level: + LOG.warning(msg) + self.cntr += 1 + time.sleep(1) + return True diff --git a/aprsd/threads.py b/aprsd/threads/rx.py similarity index 55% rename from aprsd/threads.py rename to aprsd/threads/rx.py index c81275e..b28dbff 100644 --- a/aprsd/threads.py +++ b/aprsd/threads/rx.py @@ -1,162 +1,15 @@ import abc -import datetime import logging -import queue -import threading import time -import tracemalloc import aprslib -from aprsd import client, messaging, packets, plugin, stats, utils +from aprsd import client, messaging, packets, plugin, stats +from aprsd.threads import APRSDThread LOG = logging.getLogger("APRSD") -RX_THREAD = "RX" -EMAIL_THREAD = "Email" - -rx_msg_queue = queue.Queue(maxsize=20) -msg_queues = { - "rx": rx_msg_queue, -} - - -class APRSDThreadList: - """Singleton class that keeps track of application wide threads.""" - - _instance = None - - threads_list = [] - lock = None - - def __new__(cls, *args, **kwargs): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls.lock = threading.Lock() - cls.threads_list = [] - return cls._instance - - def add(self, thread_obj): - with self.lock: - self.threads_list.append(thread_obj) - - def remove(self, thread_obj): - with self.lock: - self.threads_list.remove(thread_obj) - - def stop_all(self): - """Iterate over all threads and call stop on them.""" - with self.lock: - for th in self.threads_list: - LOG.debug(f"Stopping Thread {th.name}") - th.stop() - - def __len__(self): - with self.lock: - return len(self.threads_list) - - -class APRSDThread(threading.Thread, metaclass=abc.ABCMeta): - def __init__(self, name): - super().__init__(name=name) - self.thread_stop = False - APRSDThreadList().add(self) - - def stop(self): - self.thread_stop = True - - @abc.abstractmethod - def loop(self): - pass - - def run(self): - LOG.debug("Starting") - while not self.thread_stop: - can_loop = self.loop() - if not can_loop: - self.stop() - APRSDThreadList().remove(self) - LOG.debug("Exiting") - - -class KeepAliveThread(APRSDThread): - cntr = 0 - checker_time = datetime.datetime.now() - - def __init__(self, config): - tracemalloc.start() - super().__init__("KeepAlive") - self.config = config - max_timeout = {"hours": 0.0, "minutes": 2, "seconds": 0} - self.max_delta = datetime.timedelta(**max_timeout) - - def loop(self): - if self.cntr % 60 == 0: - tracker = messaging.MsgTrack() - stats_obj = stats.APRSDStats() - pl = packets.PacketList() - thread_list = APRSDThreadList() - now = datetime.datetime.now() - last_email = stats_obj.email_thread_time - if last_email: - email_thread_time = utils.strfdelta(now - last_email) - else: - email_thread_time = "N/A" - - last_msg_time = utils.strfdelta(now - stats_obj.aprsis_keepalive) - - current, peak = tracemalloc.get_traced_memory() - stats_obj.set_memory(current) - stats_obj.set_memory_peak(peak) - - try: - login = self.config["aprs"]["login"] - except KeyError: - login = self.config["ham"]["callsign"] - - keepalive = ( - "{} - Uptime {} RX:{} TX:{} Tracker:{} Msgs TX:{} RX:{} " - "Last:{} Email: {} - RAM Current:{} Peak:{} Threads:{}" - ).format( - login, - utils.strfdelta(stats_obj.uptime), - pl.total_recv, - pl.total_tx, - len(tracker), - stats_obj.msgs_tx, - stats_obj.msgs_rx, - last_msg_time, - email_thread_time, - utils.human_size(current), - utils.human_size(peak), - len(thread_list), - ) - LOG.info(keepalive) - - # See if we should reset the aprs-is client - # Due to losing a keepalive from them - delta_dict = utils.parse_delta_str(last_msg_time) - delta = datetime.timedelta(**delta_dict) - - if delta > self.max_delta: - # We haven't gotten a keepalive from aprs-is in a while - # reset the connection.a - if not client.KISSClient.is_enabled(self.config): - LOG.warning("Resetting connection to APRS-IS.") - client.factory.create().reset() - - # Check version every hour - delta = now - self.checker_time - if delta > datetime.timedelta(hours=1): - self.checker_time = now - level, msg = utils._check_version() - if level: - LOG.warning(msg) - self.cntr += 1 - time.sleep(1) - return True - class APRSDRXThread(APRSDThread): def __init__(self, msg_queues, config): @@ -186,7 +39,10 @@ class APRSDRXThread(APRSDThread): self.process_packet, raw=False, blocking=False, ) - except aprslib.exceptions.ConnectionDrop: + except ( + aprslib.exceptions.ConnectionDrop, + aprslib.exceptions.ConnectionError, + ): LOG.error("Connection dropped, reconnecting") time.sleep(5) # Force the deletion of the client object connected to aprs @@ -196,6 +52,12 @@ class APRSDRXThread(APRSDThread): # Continue to loop return True + @abc.abstractmethod + def process_packet(self, *args, **kwargs): + pass + + +class APRSDPluginRXThread(APRSDRXThread): def process_packet(self, *args, **kwargs): packet = self._client.decode_packet(*args, **kwargs) thread = APRSDProcessPacketThread(packet=packet, config=self.config) @@ -239,7 +101,7 @@ class APRSDProcessPacketThread(APRSDThread): # We don't put ack packets destined for us through the # plugins. - if tocall == self.config["aprs"]["login"] and msg_response == "ack": + if tocall == self.config["aprsd"]["callsign"] and msg_response == "ack": self.process_ack_packet(packet) else: # It's not an ACK for us, so lets run it through @@ -253,12 +115,12 @@ class APRSDProcessPacketThread(APRSDThread): ) # Only ack messages that were sent directly to us - if tocall == self.config["aprs"]["login"]: + if tocall == self.config["aprsd"]["callsign"]: stats.APRSDStats().msgs_rx_inc() # let any threads do their thing, then ack # send an ack last ack = messaging.AckMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, msg_id=msg_id, ) @@ -280,7 +142,7 @@ class APRSDProcessPacketThread(APRSDThread): subreply.send() else: msg = messaging.TextMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, subreply, ) @@ -300,7 +162,7 @@ class APRSDProcessPacketThread(APRSDThread): LOG.debug(f"Sending '{reply}'") msg = messaging.TextMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, reply, ) @@ -308,10 +170,10 @@ class APRSDProcessPacketThread(APRSDThread): # If the message was for us and we didn't have a # response, then we send a usage statement. - if tocall == self.config["aprs"]["login"] and not replied: + if tocall == self.config["aprsd"]["callsign"] and not replied: LOG.warning("Sending help!") msg = messaging.TextMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, "Unknown command! Send 'help' message for help", ) @@ -320,10 +182,10 @@ class APRSDProcessPacketThread(APRSDThread): LOG.error("Plugin failed!!!") LOG.exception(ex) # Do we need to send a reply? - if tocall == self.config["aprs"]["login"]: + if tocall == self.config["aprsd"]["callsign"]: reply = "A Plugin failed! try again?" msg = messaging.TextMessage( - self.config["aprs"]["login"], + self.config["aprsd"]["callsign"], fromcall, reply, ) diff --git a/aprsd/utils.py b/aprsd/utils/__init__.py similarity index 71% rename from aprsd/utils.py rename to aprsd/utils/__init__.py index 124443c..02042f9 100644 --- a/aprsd/utils.py +++ b/aprsd/utils/__init__.py @@ -2,25 +2,17 @@ import collections import errno -import functools import os import re -import threading import update_checker import aprsd - -def synchronized(wrapped): - lock = threading.Lock() - - @functools.wraps(wrapped) - def _wrap(*args, **kwargs): - with lock: - return wrapped(*args, **kwargs) - - return _wrap +from .fuzzyclock import fuzzy # noqa: F401 +# Make these available by anyone importing +# aprsd.utils +from .ring_buffer import RingBuffer # noqa: F401 def env(*vars, **kwargs): @@ -129,42 +121,3 @@ def parse_delta_str(s): else: m = re.match(r"(?P\d+):(?P\d+):(?P\d[\.\d+]*)", s) return {key: float(val) for key, val in m.groupdict().items()} - - -class RingBuffer: - """class that implements a not-yet-full buffer""" - - def __init__(self, size_max): - self.max = size_max - self.data = [] - - class __Full: - """class that implements a full buffer""" - - def append(self, x): - """Append an element overwriting the oldest one.""" - self.data[self.cur] = x - self.cur = (self.cur + 1) % self.max - - def get(self): - """return list of elements in correct order""" - return self.data[self.cur :] + self.data[: self.cur] - - def __len__(self): - return len(self.data) - - def append(self, x): - """append an element at the end of the buffer""" - - self.data.append(x) - if len(self.data) == self.max: - self.cur = 0 - # Permanently change self's class from non-full to full - self.__class__ = self.__Full - - def get(self): - """Return a list of elements from the oldest to the newest.""" - return self.data - - def __len__(self): - return len(self.data) diff --git a/aprsd/fuzzyclock.py b/aprsd/utils/fuzzyclock.py similarity index 100% rename from aprsd/fuzzyclock.py rename to aprsd/utils/fuzzyclock.py diff --git a/aprsd/objectstore.py b/aprsd/utils/objectstore.py similarity index 100% rename from aprsd/objectstore.py rename to aprsd/utils/objectstore.py diff --git a/aprsd/utils/ring_buffer.py b/aprsd/utils/ring_buffer.py new file mode 100644 index 0000000..4029ce4 --- /dev/null +++ b/aprsd/utils/ring_buffer.py @@ -0,0 +1,37 @@ +class RingBuffer: + """class that implements a not-yet-full buffer""" + + def __init__(self, size_max): + self.max = size_max + self.data = [] + + class __Full: + """class that implements a full buffer""" + + def append(self, x): + """Append an element overwriting the oldest one.""" + self.data[self.cur] = x + self.cur = (self.cur + 1) % self.max + + def get(self): + """return list of elements in correct order""" + return self.data[self.cur :] + self.data[: self.cur] + + def __len__(self): + return len(self.data) + + def append(self, x): + """append an element at the end of the buffer""" + + self.data.append(x) + if len(self.data) == self.max: + self.cur = 0 + # Permanently change self's class from non-full to full + self.__class__ = self.__Full + + def get(self): + """Return a list of elements from the oldest to the newest.""" + return self.data + + def __len__(self): + return len(self.data) diff --git a/aprsd/trace.py b/aprsd/utils/trace.py similarity index 100% rename from aprsd/trace.py rename to aprsd/utils/trace.py diff --git a/aprsd/web/__init__.py b/aprsd/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aprsd/web/admin/__init__.py b/aprsd/web/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aprsd/web/static/css/index.css b/aprsd/web/admin/static/css/index.css similarity index 100% rename from aprsd/web/static/css/index.css rename to aprsd/web/admin/static/css/index.css diff --git a/aprsd/web/static/css/prism.css b/aprsd/web/admin/static/css/prism.css similarity index 100% rename from aprsd/web/static/css/prism.css rename to aprsd/web/admin/static/css/prism.css diff --git a/aprsd/web/static/css/tabs.css b/aprsd/web/admin/static/css/tabs.css similarity index 100% rename from aprsd/web/static/css/tabs.css rename to aprsd/web/admin/static/css/tabs.css diff --git a/aprsd/web/admin/static/images/Untitled.png b/aprsd/web/admin/static/images/Untitled.png new file mode 100644 index 0000000..666fbc4 Binary files /dev/null and b/aprsd/web/admin/static/images/Untitled.png differ diff --git a/aprsd/web/static/images/aprs-symbols-16-0.png b/aprsd/web/admin/static/images/aprs-symbols-16-0.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-16-0.png rename to aprsd/web/admin/static/images/aprs-symbols-16-0.png diff --git a/aprsd/web/static/images/aprs-symbols-16-1.png b/aprsd/web/admin/static/images/aprs-symbols-16-1.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-16-1.png rename to aprsd/web/admin/static/images/aprs-symbols-16-1.png diff --git a/aprsd/web/static/images/aprs-symbols-64-0.png b/aprsd/web/admin/static/images/aprs-symbols-64-0.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-64-0.png rename to aprsd/web/admin/static/images/aprs-symbols-64-0.png diff --git a/aprsd/web/static/images/aprs-symbols-64-1.png b/aprsd/web/admin/static/images/aprs-symbols-64-1.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-64-1.png rename to aprsd/web/admin/static/images/aprs-symbols-64-1.png diff --git a/aprsd/web/static/images/aprs-symbols-64-2.png b/aprsd/web/admin/static/images/aprs-symbols-64-2.png similarity index 100% rename from aprsd/web/static/images/aprs-symbols-64-2.png rename to aprsd/web/admin/static/images/aprs-symbols-64-2.png diff --git a/aprsd/web/static/js/charts.js b/aprsd/web/admin/static/js/charts.js similarity index 100% rename from aprsd/web/static/js/charts.js rename to aprsd/web/admin/static/js/charts.js diff --git a/aprsd/web/static/js/logs.js b/aprsd/web/admin/static/js/logs.js similarity index 100% rename from aprsd/web/static/js/logs.js rename to aprsd/web/admin/static/js/logs.js diff --git a/aprsd/web/static/js/main.js b/aprsd/web/admin/static/js/main.js similarity index 100% rename from aprsd/web/static/js/main.js rename to aprsd/web/admin/static/js/main.js diff --git a/aprsd/web/static/js/prism.js b/aprsd/web/admin/static/js/prism.js similarity index 100% rename from aprsd/web/static/js/prism.js rename to aprsd/web/admin/static/js/prism.js diff --git a/aprsd/web/static/js/send-message.js b/aprsd/web/admin/static/js/send-message.js similarity index 100% rename from aprsd/web/static/js/send-message.js rename to aprsd/web/admin/static/js/send-message.js diff --git a/aprsd/web/static/js/tabs.js b/aprsd/web/admin/static/js/tabs.js similarity index 100% rename from aprsd/web/static/js/tabs.js rename to aprsd/web/admin/static/js/tabs.js diff --git a/aprsd/web/static/json-viewer/jquery.json-viewer.css b/aprsd/web/admin/static/json-viewer/jquery.json-viewer.css similarity index 100% rename from aprsd/web/static/json-viewer/jquery.json-viewer.css rename to aprsd/web/admin/static/json-viewer/jquery.json-viewer.css diff --git a/aprsd/web/static/json-viewer/jquery.json-viewer.js b/aprsd/web/admin/static/json-viewer/jquery.json-viewer.js similarity index 100% rename from aprsd/web/static/json-viewer/jquery.json-viewer.js rename to aprsd/web/admin/static/json-viewer/jquery.json-viewer.js diff --git a/aprsd/web/templates/index.html b/aprsd/web/admin/templates/index.html similarity index 100% rename from aprsd/web/templates/index.html rename to aprsd/web/admin/templates/index.html diff --git a/aprsd/web/templates/messages.html b/aprsd/web/admin/templates/messages.html similarity index 100% rename from aprsd/web/templates/messages.html rename to aprsd/web/admin/templates/messages.html diff --git a/aprsd/web/templates/send-message.html b/aprsd/web/admin/templates/send-message.html similarity index 100% rename from aprsd/web/templates/send-message.html rename to aprsd/web/admin/templates/send-message.html diff --git a/aprsd/web/chat/static/css/index.css b/aprsd/web/chat/static/css/index.css new file mode 100644 index 0000000..b39acab --- /dev/null +++ b/aprsd/web/chat/static/css/index.css @@ -0,0 +1,94 @@ +body { + background: #eeeeee; + margin: 2em; + text-align: center; + font-family: system-ui, sans-serif; +} + +footer { + padding: 2em; + text-align: center; + height: 10vh; +} + +.ui.segment { + background: #eeeeee; +} + +ul.list { + list-style-type: disc; +} +ul.list li { + list-style-position: outside; +} + +#left { + margin-right: 2px; + height: 300px; +} +#right { + height: 300px; +} +#center { + height: 300px; +} + +#title { + font-size: 4em; +} +#version{ + font-size: .5em; +} +#uptime, #aprsis { + font-size: 1em; +} +#callsign { + font-size: 1.4em; + color: #00F; + padding-top: 8px; + margin:10px; +} + +#title_rx { + background-color: darkseagreen; + text-align: left; +} + +#title_tx { + background-color: lightcoral; + text-align: left; +} + +.aprsd_1 { + background-image: url(/static/images/aprs-symbols-16-0.png); + background-repeat: no-repeat; + background-position: -160px -48px; + width: 16px; + height: 16px; +} + +#msgsTabsDiv .ui.tab { + margin:0px; + padding:0px; + display: block; +} + +#msgsTabsDiv .header, .tiny.text, .content, .break, + .thumbs.down.outline.icon, + .phone.volume.icon + { + display: inline-block; + float: left; + position: relative; +} +#msgsTabsDiv .tiny.text { + width:100px; +} +#msgsTabsDiv .tiny.header { + width:100px; + text-align: left; +} +#msgsTabsDiv .break { + margin: 2px; + text-align: left; +} diff --git a/aprsd/web/chat/static/css/style.css.map b/aprsd/web/chat/static/css/style.css.map new file mode 100644 index 0000000..299ffa9 --- /dev/null +++ b/aprsd/web/chat/static/css/style.css.map @@ -0,0 +1 @@ +{"version":3,"file":"css/style.css","mappings":"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AC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,UAAa,CACzB,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,UAAa,CACzB,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,aAAgB,CAC5B,UAAY,UAAa,CACzB,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,wBAA2B,CACvC,UAAY,o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","sources":["webpack://thelounge/./node_modules/normalize.css/normalize.css","webpack://thelounge/./client/css/fontawesome.css","webpack://thelounge/./node_modules/primer-tooltips/build/build.css","webpack://thelounge/./client/css/style.css","webpack://thelounge/./client/components/NetworkList.vue","webpack://thelounge/./client/components/ConfirmDialog.vue","webpack://thelounge/./client/components/Mentions.vue","webpack://thelounge/./client/components/NetworkForm.vue","webpack://thelounge/./client/components/Session.vue","webpack://thelounge/./client/components/Windows/Settings.vue","webpack://thelounge/./client/components/MessageSearchForm.vue","webpack://thelounge/./client/components/Windows/SearchResults.vue"],"sourcesContent":["/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */\n\n/* Document\n ========================================================================== */\n\n/**\n * 1. Correct the line height in all browsers.\n * 2. Prevent adjustments of font size after orientation changes in iOS.\n */\n\nhtml {\n line-height: 1.15; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n}\n\n/* Sections\n ========================================================================== */\n\n/**\n * Remove the margin in all browsers.\n */\n\nbody {\n margin: 0;\n}\n\n/**\n * Render the `main` element consistently in IE.\n */\n\nmain {\n display: block;\n}\n\n/**\n * Correct the font size and margin on `h1` elements within `section` and\n * `article` contexts in Chrome, Firefox, and Safari.\n */\n\nh1 {\n font-size: 2em;\n margin: 0.67em 0;\n}\n\n/* Grouping content\n ========================================================================== */\n\n/**\n * 1. Add the correct box sizing in Firefox.\n * 2. Show the overflow in Edge and IE.\n */\n\nhr {\n box-sizing: content-box; /* 1 */\n height: 0; /* 1 */\n overflow: visible; /* 2 */\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\npre {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/* Text-level semantics\n ========================================================================== */\n\n/**\n * Remove the gray background on active links in IE 10.\n */\n\na {\n background-color: transparent;\n}\n\n/**\n * 1. Remove the bottom border in Chrome 57-\n * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n */\n\nabbr[title] {\n border-bottom: none; /* 1 */\n text-decoration: underline; /* 2 */\n text-decoration: underline dotted; /* 2 */\n}\n\n/**\n * Add the correct font weight in Chrome, Edge, and Safari.\n */\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/**\n * 1. Correct the inheritance and scaling of font size in all browsers.\n * 2. Correct the odd `em` font sizing in all browsers.\n */\n\ncode,\nkbd,\nsamp {\n font-family: monospace, monospace; /* 1 */\n font-size: 1em; /* 2 */\n}\n\n/**\n * Add the correct font size in all browsers.\n */\n\nsmall {\n font-size: 80%;\n}\n\n/**\n * Prevent `sub` and `sup` elements from affecting the line height in\n * all browsers.\n */\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\n/* Embedded content\n ========================================================================== */\n\n/**\n * Remove the border on images inside links in IE 10.\n */\n\nimg {\n border-style: none;\n}\n\n/* Forms\n ========================================================================== */\n\n/**\n * 1. Change the font styles in all browsers.\n * 2. Remove the margin in Firefox and Safari.\n */\n\nbutton,\ninput,\noptgroup,\nselect,\ntextarea {\n font-family: inherit; /* 1 */\n font-size: 100%; /* 1 */\n line-height: 1.15; /* 1 */\n margin: 0; /* 2 */\n}\n\n/**\n * Show the overflow in IE.\n * 1. Show the overflow in Edge.\n */\n\nbutton,\ninput { /* 1 */\n overflow: visible;\n}\n\n/**\n * Remove the inheritance of text transform in Edge, Firefox, and IE.\n * 1. Remove the inheritance of text transform in Firefox.\n */\n\nbutton,\nselect { /* 1 */\n text-transform: none;\n}\n\n/**\n * Correct the inability to style clickable types in iOS and Safari.\n */\n\nbutton,\n[type=\"button\"],\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button;\n}\n\n/**\n * Remove the inner border and padding in Firefox.\n */\n\nbutton::-moz-focus-inner,\n[type=\"button\"]::-moz-focus-inner,\n[type=\"reset\"]::-moz-focus-inner,\n[type=\"submit\"]::-moz-focus-inner {\n border-style: none;\n padding: 0;\n}\n\n/**\n * Restore the focus styles unset by the previous rule.\n */\n\nbutton:-moz-focusring,\n[type=\"button\"]:-moz-focusring,\n[type=\"reset\"]:-moz-focusring,\n[type=\"submit\"]:-moz-focusring {\n outline: 1px dotted ButtonText;\n}\n\n/**\n * Correct the padding in Firefox.\n */\n\nfieldset {\n padding: 0.35em 0.75em 0.625em;\n}\n\n/**\n * 1. Correct the text wrapping in Edge and IE.\n * 2. Correct the color inheritance from `fieldset` elements in IE.\n * 3. Remove the padding so developers are not caught out when they zero out\n * `fieldset` elements in all browsers.\n */\n\nlegend {\n box-sizing: border-box; /* 1 */\n color: inherit; /* 2 */\n display: table; /* 1 */\n max-width: 100%; /* 1 */\n padding: 0; /* 3 */\n white-space: normal; /* 1 */\n}\n\n/**\n * Add the correct vertical alignment in Chrome, Firefox, and Opera.\n */\n\nprogress {\n vertical-align: baseline;\n}\n\n/**\n * Remove the default vertical scrollbar in IE 10+.\n */\n\ntextarea {\n overflow: auto;\n}\n\n/**\n * 1. Add the correct box sizing in IE 10.\n * 2. Remove the padding in IE 10.\n */\n\n[type=\"checkbox\"],\n[type=\"radio\"] {\n box-sizing: border-box; /* 1 */\n padding: 0; /* 2 */\n}\n\n/**\n * Correct the cursor style of increment and decrement buttons in Chrome.\n */\n\n[type=\"number\"]::-webkit-inner-spin-button,\n[type=\"number\"]::-webkit-outer-spin-button {\n height: auto;\n}\n\n/**\n * 1. Correct the odd appearance in Chrome and Safari.\n * 2. Correct the outline style in Safari.\n */\n\n[type=\"search\"] {\n -webkit-appearance: textfield; /* 1 */\n outline-offset: -2px; /* 2 */\n}\n\n/**\n * Remove the inner padding in Chrome and Safari on macOS.\n */\n\n[type=\"search\"]::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/**\n * 1. Correct the inability to style clickable types in iOS and Safari.\n * 2. Change font properties to `inherit` in Safari.\n */\n\n::-webkit-file-upload-button {\n -webkit-appearance: button; /* 1 */\n font: inherit; /* 2 */\n}\n\n/* Interactive\n ========================================================================== */\n\n/*\n * Add the correct display in Edge, IE 10+, and Firefox.\n */\n\ndetails {\n display: block;\n}\n\n/*\n * Add the correct display in all browsers.\n */\n\nsummary {\n display: list-item;\n}\n\n/* Misc\n ========================================================================== */\n\n/**\n * Add the correct display in IE 10+.\n */\n\ntemplate {\n display: none;\n}\n\n/**\n * Add the correct display in IE 10.\n */\n\n[hidden] {\n display: none;\n}\n","@font-face {\n\t/* We use free solid icons - https://fontawesome.com/icons?s=solid&m=free */\n\tfont-family: FontAwesome;\n\tfont-weight: normal;\n\tfont-style: normal;\n\tsrc:\n\t\turl(\"../fonts/fa-solid-900.woff2\") format(\"woff2\"),\n\t\turl(\"../fonts/fa-solid-900.woff\") format(\"woff\");\n}\n",".tooltipped{position:relative}.tooltipped::after{position:absolute;z-index:1000000;display:none;padding:.5em .75em;font:normal normal 11px/1.5 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\";-webkit-font-smoothing:subpixel-antialiased;color:#fff;text-align:center;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-wrap:break-word;white-space:pre;pointer-events:none;content:attr(aria-label);background:#1b1f23;border-radius:3px;opacity:0}.tooltipped::before{position:absolute;z-index:1000001;display:none;width:0;height:0;color:#1b1f23;pointer-events:none;content:\"\";border:6px solid transparent;opacity:0}@keyframes tooltip-appear{from{opacity:0}to{opacity:1}}.tooltipped:hover::before,.tooltipped:hover::after,.tooltipped:active::before,.tooltipped:active::after,.tooltipped:focus::before,.tooltipped:focus::after{display:inline-block;text-decoration:none;animation-name:tooltip-appear;animation-duration:.1s;animation-fill-mode:forwards;animation-timing-function:ease-in;animation-delay:.4s}.tooltipped-no-delay:hover::before,.tooltipped-no-delay:hover::after,.tooltipped-no-delay:active::before,.tooltipped-no-delay:active::after,.tooltipped-no-delay:focus::before,.tooltipped-no-delay:focus::after{animation-delay:0s}.tooltipped-multiline:hover::after,.tooltipped-multiline:active::after,.tooltipped-multiline:focus::after{display:table-cell}.tooltipped-s::after,.tooltipped-se::after,.tooltipped-sw::after{top:100%;right:50%;margin-top:6px}.tooltipped-s::before,.tooltipped-se::before,.tooltipped-sw::before{top:auto;right:50%;bottom:-7px;margin-right:-6px;border-bottom-color:#1b1f23}.tooltipped-se::after{right:auto;left:50%;margin-left:-16px}.tooltipped-sw::after{margin-right:-16px}.tooltipped-n::after,.tooltipped-ne::after,.tooltipped-nw::after{right:50%;bottom:100%;margin-bottom:6px}.tooltipped-n::before,.tooltipped-ne::before,.tooltipped-nw::before{top:-7px;right:50%;bottom:auto;margin-right:-6px;border-top-color:#1b1f23}.tooltipped-ne::after{right:auto;left:50%;margin-left:-16px}.tooltipped-nw::after{margin-right:-16px}.tooltipped-s::after,.tooltipped-n::after{transform:translateX(50%)}.tooltipped-w::after{right:100%;bottom:50%;margin-right:6px;transform:translateY(50%)}.tooltipped-w::before{top:50%;bottom:50%;left:-7px;margin-top:-6px;border-left-color:#1b1f23}.tooltipped-e::after{bottom:50%;left:100%;margin-left:6px;transform:translateY(50%)}.tooltipped-e::before{top:50%;right:-7px;bottom:50%;margin-top:-6px;border-right-color:#1b1f23}.tooltipped-align-right-1::after,.tooltipped-align-right-2::after{right:0;margin-right:0}.tooltipped-align-right-1::before{right:10px}.tooltipped-align-right-2::before{right:15px}.tooltipped-align-left-1::after,.tooltipped-align-left-2::after{left:0;margin-left:0}.tooltipped-align-left-1::before{left:5px}.tooltipped-align-left-2::before{left:10px}.tooltipped-multiline::after{width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:250px;word-wrap:break-word;white-space:pre-line;border-collapse:separate}.tooltipped-multiline.tooltipped-s::after,.tooltipped-multiline.tooltipped-n::after{right:auto;left:50%;transform:translateX(-50%)}.tooltipped-multiline.tooltipped-w::after,.tooltipped-multiline.tooltipped-e::after{right:100%}@media screen and (min-width: 0\\0){.tooltipped-multiline::after{width:250px}}.tooltipped-sticky::before,.tooltipped-sticky::after{display:inline-block}.tooltipped-sticky.tooltipped-multiline::after{display:table-cell}\n","@import \"../../node_modules/normalize.css/normalize.css\";\n@import \"fontawesome.css\";\n@import \"../../node_modules/primer-tooltips/build/build.css\";\n\n:root {\n\t/* Main text color */\n\t--body-color: #222;\n\n\t/* Secondary text color, dimmed. Make sure to keep contrast WCAG 2.0 AA compliant on var(--window-bg-color) */\n\t--body-color-muted: #767676;\n\n\t/* Background color of the whole page */\n\t--body-bg-color: #415364;\n\n\t/* Main button color. Applies to border, text, and background on hover */\n\t--button-color: #84ce88;\n\t--button-text-color-hover: #fff;\n\n\t/* Color for sidebar overlay and other things that dim the viewport when something else is on top */\n\t--overlay-bg-color: rgb(0 0 0 / 50%);\n\n\t/* Links and link-looking buttons */\n\t--link-color: #50a656;\n\n\t/* Background color of the main window */\n\t--window-bg-color: #fff;\n\n\t/* Text color for and headings in windows */\n\t--window-heading-color: #6c797a;\n\n\t/* Color of the date marker, text and separator */\n\t--date-marker-color: rgb(0 107 59 / 50%);\n\n\t/* Color of the unread message marker, text and separator */\n\t--unread-marker-color: rgb(231 76 60 / 50%);\n\n\t/* Background and left-border color of highlight messages */\n\t--highlight-bg-color: #efe8dc;\n\t--highlight-border-color: #b08c4f;\n\n\t/* Color of the progress bar that appears as a file is being uploaded to the server. Defaults to button color */\n\t--upload-progressbar-color: var(--button-color);\n}\n\n::placeholder {\n\tcolor: rgb(0 0 0 / 35%);\n\topacity: 1; /* fix opacity in Firefox */\n}\n\nhtml {\n\tbox-sizing: border-box;\n\t-webkit-tap-highlight-color: transparent; /* remove tap highlight on touch devices */\n}\n\n*,\n*::before,\n*::after {\n\tbox-sizing: inherit;\n}\n\ninput,\nbutton,\nselect,\ntextarea {\n\tfont: inherit;\n\tcolor: inherit;\n}\n\nimg {\n\tvertical-align: middle;\n}\n\n.sr-only {\n\tposition: absolute;\n\twidth: 1px;\n\theight: 1px;\n\tmargin: -1px;\n\tpadding: 0;\n\toverflow: hidden;\n\tclip: rect(0, 0, 0, 0);\n\tborder: 0;\n}\n\nabbr[title] {\n\tcursor: help;\n}\n\nhtml,\nbody {\n\theight: 100%;\n\toverscroll-behavior: none; /* prevent overscroll navigation actions */\n}\n\nbody {\n\tbackground: var(--body-bg-color);\n\tcolor: var(--body-color);\n\tfont: 16px -apple-system, system-ui, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n\tmargin: 0;\n\tuser-select: none;\n\tcursor: default;\n\ttouch-action: none;\n\n\t/**\n\t * Disable pull-to-refresh on mobile that conflicts with scrolling the message list.\n\t * See http://stackoverflow.com/a/29313685/1935861\n\t */\n\toverflow: hidden; /* iOS Safari requires overflow rather than overflow-y */\n}\n\nbody.force-no-select * {\n\tuser-select: none !important;\n}\n\na,\na:hover,\na:focus {\n\tcolor: var(--link-color);\n\ttext-decoration: none;\n}\n\na:hover {\n\ttext-decoration: underline;\n}\n\na:focus {\n\toutline: thin dotted;\n\toutline: 5px auto -webkit-focus-ring-color;\n\toutline-offset: -2px;\n}\n\nh1,\nh2,\nh3 {\n\tfont: inherit;\n\tline-height: inherit;\n\tmargin: 0;\n}\n\nbutton {\n\tborder: none;\n\tbackground: none;\n\tmargin: 0;\n\toutline: none;\n\tpadding: 0;\n\tuser-select: inherit;\n\tcursor: pointer;\n}\n\ncode,\npre,\n#chat .msg[data-type=\"monospace_block\"] .text,\n.irc-monospace,\ntextarea#user-specified-css-input {\n\tfont-family: Consolas, Menlo, Monaco, \"Lucida Console\", \"DejaVu Sans Mono\", \"Courier New\", monospace;\n}\n\ncode,\n.irc-monospace {\n\tfont-size: 13px;\n\tpadding: 2px 4px;\n\tcolor: #e74c3c;\n\tbackground-color: #f9f2f4;\n\tborder-radius: 2px;\n}\n\npre {\n\tdisplay: block;\n\tpadding: 9.5px;\n\tmargin: 0 0 10px;\n\tfont-size: 13px;\n\tline-height: 1.4286;\n\tcolor: #333;\n\tword-break: break-all;\n\tword-wrap: break-word;\n\tbackground-color: #f5f5f5;\n\tborder-radius: 4px;\n}\n\nkbd {\n\tdisplay: inline-block;\n\tfont-family: inherit;\n\tline-height: 1em;\n\tmin-width: 28px; /* Ensure 1-char keys have the same width */\n\tmargin: 0 1px;\n\tpadding: 4px 6px;\n\tcolor: #444;\n\ttext-align: center;\n\ttext-shadow: 0 1px 0 #fff;\n\tbackground-color: white;\n\tbackground-image: linear-gradient(180deg, rgb(0 0 0 / 5%), transparent);\n\tborder: 1px solid #bbb;\n\tborder-radius: 4px;\n\tbox-shadow: 0 2px 0 #bbb, inset 0 1px 1px #fff, inset 0 -1px 3px #ccc;\n}\n\np {\n\tmargin: 0 0 10px;\n}\n\n.btn {\n\tborder: 2px solid var(--button-color);\n\tborder-radius: 3px;\n\tcolor: var(--button-color);\n\tdisplay: inline-block;\n\tfont-size: 12px;\n\tfont-weight: bold;\n\tletter-spacing: 1px;\n\tmargin-bottom: 10px;\n\tpadding: 9px 17px;\n\ttext-transform: uppercase;\n\ttransition: background 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;\n\tword-spacing: 3px;\n\tcursor: pointer; /* This is useful for `` elements */\n}\n\n.btn-small {\n\tpadding: 5px 13px;\n}\n\n.btn:disabled,\n.btn:hover,\n.btn:focus {\n\tbackground: var(--button-color);\n\tcolor: var(--button-text-color-hover);\n\topacity: 1;\n}\n\n.input:focus,\n.btn:active,\n.btn:focus {\n\toutline: 0;\n\tbox-shadow: 0 0 0 3px rgb(132 206 136 / 50%);\n}\n\n.btn:active {\n\topacity: 0.8;\n}\n\n.btn:disabled {\n\topacity: 0.6;\n}\n\n.btn-sm {\n\tpadding: 4px 8px;\n\tborder-width: 1px;\n\tletter-spacing: 0;\n\tword-spacing: 0;\n\ttext-transform: none;\n}\n\n.container {\n\tpadding: 0 15px;\n\tmargin-bottom: 20px;\n\twidth: 480px;\n\talign-self: center;\n\ttouch-action: pan-y;\n}\n\n#js-copy-hack,\n#loading pre,\n#help .container,\n#changelog .container,\n.header .title,\n.header .topic,\n#chat .messages {\n\tuser-select: text;\n\tcursor: text;\n}\n\n#js-copy-hack {\n\tposition: absolute;\n\tleft: -999999px;\n}\n\n#chat #js-copy-hack .msg[data-type=\"condensed\"]:not(.closed) .msg,\n#chat #js-copy-hack > .msg {\n\tdisplay: block;\n}\n\n.only-copy {\n\tfont-size: 0;\n\topacity: 0;\n\twidth: 0.01px; /* Must be non-zero to be the first selected character on Firefox */\n\tdisplay: inline-block;\n}\n\n/* Icons */\n\n#viewport .lt::before,\n#viewport .rt::before,\n#chat button.mentions::before,\n#chat button.close::before,\n#chat button.menu::before,\n#chat button.search::before,\n.channel-list-item::before,\n#footer .icon,\n#chat .count::before,\n#connect .extra-help,\n#settings .extra-help,\n#settings #play::before,\n#form #upload::before,\n#form #submit::before,\n#chat .msg[data-type=\"away\"] .from::before,\n#chat .msg[data-type=\"back\"] .from::before,\n#chat .msg[data-type=\"invite\"] .from::before,\n#chat .msg[data-type=\"join\"] .from::before,\n#chat .msg[data-type=\"kick\"] .from::before,\n#chat .msg[data-type=\"login\"] .from::before,\n#chat .msg[data-type=\"logout\"] .from::before,\n#chat .msg[data-type=\"part\"] .from::before,\n#chat .msg[data-type=\"quit\"] .from::before,\n#chat .msg[data-type=\"topic\"] .from::before,\n#chat .msg[data-type=\"mode_channel\"] .from::before,\n#chat .msg[data-type=\"mode_user\"] .from::before,\n#chat .msg[data-type=\"mode\"] .from::before,\n#chat .msg[data-command=\"motd\"] .from::before,\n#chat .msg[data-command=\"help\"] .from::before,\n#chat .msg[data-command=\"info\"] .from::before,\n#chat .msg[data-type=\"ctcp\"] .from::before,\n#chat .msg[data-type=\"ctcp_request\"] .from::before,\n#chat .msg[data-type=\"whois\"] .from::before,\n#chat .msg[data-type=\"nick\"] .from::before,\n#chat .msg[data-type=\"action\"] .from::before,\n#chat .msg[data-type=\"plugin\"] .from::before,\n#chat .msg[data-type=\"raw\"] .from::before,\n#chat .msg-statusmsg span::before,\n#chat .msg-shown-in-active span::before,\n#chat .toggle-button::after,\n#chat .toggle-content .more-caret::before,\n#chat .scroll-down-arrow::after,\n#chat .topic-container .save-topic span::before,\n#version-checker::before,\n.context-menu-item::before,\n#help .website-link::before,\n#help .documentation-link::before,\n#help .report-issue-link::before,\n#image-viewer .previous-image-btn::before,\n#image-viewer .next-image-btn::before,\n#image-viewer .open-btn::before,\n.channel-list-item .not-secure-icon::before,\n.channel-list-item .not-connected-icon::before,\n.channel-list-item .parted-channel-icon::before,\n.jump-to-input::before,\n.password-container .reveal-password span,\n#sidebar .collapse-network-icon::before {\n\tfont: normal normal normal 14px/1 FontAwesome;\n\tfont-size: inherit; /* Can't have font-size inherit on line above, so need to override */\n\t-webkit-font-smoothing: antialiased;\n\t-moz-osx-font-smoothing: grayscale;\n}\n\n#viewport .lt::before { content: \"\\f0c9\"; /* http://fontawesome.io/icon/bars/ */ }\n#viewport .rt::before { content: \"\\f0c0\"; /* https://fontawesome.com/icons/users?style=solid */ }\n#chat button.menu::before { content: \"\\f142\"; /* http://fontawesome.io/icon/ellipsis-v/ */ }\n#chat button.mentions::before { content: \"\\f1fa\"; /* https://fontawesome.com/icons/at?style=solid */ }\n#chat button.search::before { content: \"\\f002\"; /* https://fontawesome.com/icons/search?style=solid */ }\n#chat button.close::before { content: \"\\f00d\"; /* https://fontawesome.com/icons/times?style=solid */ }\n\n.context-menu-join::before { content: \"\\f067\"; /* http://fontawesome.io/icon/plus/ */ }\n.context-menu-user::before { content: \"\\f007\"; /* http://fontawesome.io/icon/user/ */ }\n.context-menu-close::before { content: \"\\f00d\"; /* http://fontawesome.io/icon/times/ */ }\n.context-menu-list::before { content: \"\\f03a\"; /* http://fontawesome.io/icon/list/ */ }\n.context-menu-disconnect::before { content: \"\\f127\"; /* https://fontawesome.com/icons/unlink?style=solid */ }\n.context-menu-connect::before { content: \"\\f0c1\"; /* https://fontawesome.com/icons/link?style=solid */ }\n.context-menu-action-whois::before { content: \"\\f05a\"; /* http://fontawesome.io/icon/info-circle/ */ }\n.context-menu-action-ignore::before { content: \"\\f506\"; /* https://fontawesome.com/icons/user-slash?style=solid */ }\n.context-menu-action-kick::before { content: \"\\f05e\"; /* http://fontawesome.io/icon/ban/ */ }\n.context-menu-action-set-mode::before { content: \"\\f067\"; /* http://fontawesome.io/icon/plus/ */ }\n.context-menu-action-revoke-mode::before { content: \"\\f068\"; /* http://fontawesome.io/icon/minus/ */ }\n.context-menu-network::before { content: \"\\f233\"; /* https://fontawesome.com/icons/server?style=solid */ }\n.context-menu-edit::before { content: \"\\f303\"; /* https://fontawesome.com/icons/pencil-alt?style=solid */ }\n.context-menu-clear-history::before { content: \"\\f1f8\"; /* https://fontawesome.com/icons/trash?style=solid */ }\n.context-menu-mute::before { content: \"\\f6a9\"; /* https://fontawesome.com/v5.15/icons/volume-mute?style=solid */ }\n\n.channel-list-item .not-secure-icon::before {\n\tcontent: \"\\f071\"; /* https://fontawesome.com/icons/exclamation-triangle?style=solid */\n}\n\n.channel-list-item .not-connected-icon::before,\n.channel-list-item .parted-channel-icon::before {\n\tcontent: \"\\f127\"; /* https://fontawesome.com/icons/unlink?style=solid */\n}\n\n.context-menu-query::before,\n.context-menu-action-query::before,\n.channel-list-item[data-type=\"query\"]::before {\n\tcontent: \"\\f075\"; /* https://fontawesome.com/icons/comment?style=solid */\n}\n\n.context-menu-chan::before,\n.channel-list-item[data-type=\"channel\"]::before { content: \"\\f086\"; /* http://fontawesome.io/icon/comments/ */ }\n\n.channel-list-item[data-type=\"special\"]::before { content: \"\\f03a\"; /* http://fontawesome.io/icon/list/ */ }\n\n.channel-list-item.has-draft:not(.active):not([data-type=\"lobby\"])::before {\n\tcontent: \"\\f304\"; /* https://fontawesome.com/icons/pen?style=solid */\n}\n\n#footer .connect::before { content: \"\\f067\"; /* http://fontawesome.io/icon/plus/ */ }\n#footer .settings::before { content: \"\\f013\"; /* http://fontawesome.io/icon/cog/ */ }\n#footer .help::before { content: \"\\f059\"; /* http://fontawesome.io/icon/question/ */ }\n\n#form #upload::before { content: \"\\f0c6\"; /* https://fontawesome.com/icons/paperclip?style=solid */ }\n#form #submit::before { content: \"\\f1d8\"; /* http://fontawesome.io/icon/paper-plane/ */ }\n\n#chat .msg[data-type=\"away\"] .from::before,\n#chat .msg[data-type=\"back\"] .from::before {\n\tcontent: \"\\f017\"; /* https://fontawesome.com/icons/clock?style=solid */\n\tcolor: #7f8c8d;\n}\n\n#help .website-link::before,\n#help .documentation-link::before,\n#help .report-issue-link::before {\n\tdisplay: inline-block;\n\tmargin-right: 5px;\n\n\t/* These 2 directives are loosely taken from .fa-fw */\n\twidth: 1.35em;\n\ttext-align: center;\n}\n\n#help .website-link::before { content: \"\\f0ac\"; /* http://fontawesome.io/icon/globe/ */ }\n#help .documentation-link::before { content: \"\\f19d\"; /* http://fontawesome.io/icon/graduation-cap/ */ }\n#help .report-issue-link::before { content: \"\\f188\"; /* http://fontawesome.io/icon/bug/ */ }\n\n#chat .msg[data-type=\"invite\"] .from::before {\n\tcontent: \"\\f0e0\"; /* https://fontawesome.com/icons/envelope?style=solid */\n\tcolor: #2ecc40;\n}\n\n#chat .msg[data-type=\"login\"] .from::before {\n\tcontent: \"\\f007\"; /* https://fontawesome.com/icons/user?style=solid */\n\tcolor: #2ecc40;\n}\n\n#chat .msg[data-type=\"logout\"] .from::before {\n\tcontent: \"\\f007\"; /* https://fontawesome.com/icons/user?style=solid */\n\tcolor: #ff4136;\n}\n\n#chat .msg[data-type=\"part\"] .from::before,\n#chat .msg[data-type=\"quit\"] .from::before {\n\tcontent: \"\\f2f5\"; /* https://fontawesome.com/icons/sign-out-alt?style=solid */\n\tcolor: #ff4136;\n\tdisplay: inline-block;\n\ttransform: rotate(180deg);\n}\n\n#chat .msg[data-type=\"topic\"] .from::before {\n\tcontent: \"\\f0a1\"; /* http://fontawesome.io/icon/bullhorn/ */\n\tcolor: #2ecc40;\n}\n\n#chat .msg[data-type=\"mode_channel\"] .from::before,\n#chat .msg[data-type=\"mode_user\"] .from::before,\n#chat .msg[data-type=\"mode\"] .from::before {\n\tcontent: \"\\f05a\"; /* http://fontawesome.io/icon/info-circle/ */\n\tcolor: #2ecc40;\n}\n\n#chat .msg[data-command=\"motd\"] .from::before {\n\tcontent: \"\\f02e\"; /* https://fontawesome.com/icons/bookmark?style=solid */\n\tcolor: var(--body-color-muted);\n}\n\n#chat .msg[data-command=\"help\"] .from::before {\n\tcontent: \"\\f059\"; /* https://fontawesome.com/icons/question-circle?style=solid */\n\tcolor: var(--body-color-muted);\n}\n\n#chat .msg[data-command=\"info\"] .from::before {\n\tcontent: \"\\f05a\"; /* https://fontawesome.com/icons/info-circle?style=solid */\n\tcolor: var(--body-color-muted);\n}\n\n#chat .msg[data-type=\"ctcp\"] .from::before,\n#chat .msg[data-type=\"ctcp_request\"] .from::before {\n\tcontent: \"\\f15c\"; /* https://fontawesome.com/icons/file-alt?style=solid */\n\tcolor: var(--body-color-muted);\n}\n\n#chat .msg[data-type=\"whois\"] .from::before {\n\tcontent: \"\\f007\"; /* http://fontawesome.io/icon/user/ */\n\tcolor: #2ecc40;\n}\n\n#chat .msg[data-type=\"nick\"] .from::before {\n\tcontent: \"\\f007\"; /* http://fontawesome.io/icon/user/ */\n\tcolor: #2ecc40;\n}\n\n#chat .msg[data-type=\"join\"] .from::before {\n\tcontent: \"\\f2f6\"; /* https://fontawesome.com/icons/sign-in-alt?style=solid */\n\tcolor: #2ecc40;\n}\n\n#chat .msg[data-type=\"kick\"] .from::before {\n\tcontent: \"\\f05e\"; /* http://fontawesome.io/icon/ban/ */\n\tcolor: #ff4136;\n}\n\n#chat .msg[data-type=\"raw\"] .from::before {\n\tcontent: \"\\f101\"; /* https://fontawesome.com/icons/angle-double-right?style=solid */\n}\n\n#chat .msg.self[data-type=\"raw\"] .from::before {\n\tcontent: \"\\f359\"; /* https://fontawesome.com/icons/arrow-alt-circle-left?style=solid */\n\tcolor: #2ecc40;\n}\n\n#chat .msg[data-type=\"action\"] .from::before {\n\tcontent: \"\\f005\"; /* http://fontawesome.io/icon/star/ */\n}\n\n#chat .msg[data-type=\"plugin\"] .from::before {\n\tcontent: \"\\f1e6\"; /* http://fontawesome.io/icon/plug/ */\n\ttransform: rotate(45deg);\n\tdisplay: inline-block;\n\tpadding: 1px;\n}\n\n#chat .msg-statusmsg,\n#chat .msg-shown-in-active {\n\tcursor: help;\n\tmargin-right: 5px;\n}\n\n#chat .msg-statusmsg span::before,\n#chat .msg-shown-in-active span::before {\n\tfont-size: 10px;\n\tcontent: \"\\f06e\"; /* https://fontawesome.com/icons/eye?style=solid */\n}\n\n#chat .msg-statusmsg {\n\tborder-radius: 2px;\n\tpadding: 2px 4px;\n\tbackground-color: #ff9e18;\n\tcolor: #222;\n}\n\n#chat .toggle-button {\n\tdisplay: inline-block;\n\ttransition: opacity 0.2s, transform 0.2s;\n\n\t/* These 2 directives are loosely taken from .fa-fw */\n\twidth: 1.35em;\n\ttext-align: center;\n}\n\n#chat .toggle-button::after {\n\tcontent: \"\\f0da\"; /* http://fontawesome.io/icon/caret-right/ */\n}\n\n#chat .count::before {\n\tcolor: #cfcfcf;\n\tcontent: \"\\f002\"; /* http://fontawesome.io/icon/search/ */\n\tposition: absolute;\n\tright: 13px;\n\tline-height: 45px;\n}\n\n#connect .extra-help::before,\n#settings .extra-help::before {\n\tcontent: \"\\f059\"; /* http://fontawesome.io/icon/question-circle/ */\n}\n\n#settings #play::before {\n\tcontent: \"\\f028\"; /* http://fontawesome.io/icon/volume-up/ */\n\tmargin-right: 9px;\n}\n\n#image-viewer .previous-image-btn::before {\n\tcontent: \"\\f104\"; /* http://fontawesome.io/icon/angle-left/ */\n}\n\n#image-viewer .next-image-btn::before {\n\tcontent: \"\\f105\"; /* http://fontawesome.io/icon/angle-right/ */\n}\n\n#image-viewer .open-btn::before {\n\tcontent: \"\\f35d\"; /* https://fontawesome.com/icons/external-link-alt?style=solid */\n}\n\n/* End icons */\n\n#viewport {\n\tdisplay: flex;\n\theight: 100%;\n}\n\n#form button,\n.header button,\n.reveal-password span {\n\ttransition: opacity 0.2s;\n}\n\n#form button:hover,\n.header button:hover,\n.reveal-password span:hover {\n\topacity: 0.6;\n}\n\n#viewport .lt,\n#viewport .rt,\n#chat button.mentions,\n#chat button.search,\n#chat button.menu,\n#chat button.close {\n\tcolor: #607992;\n\tdisplay: flex;\n\tfont-size: 14px;\n\tline-height: 1;\n\theight: 36px;\n\twidth: 36px;\n\tmargin-top: 6px;\n\tflex-shrink: 0;\n}\n\n#viewport .lt::before,\n#viewport .rt::before,\n#chat button.mentions::before,\n#chat button.search::before,\n#chat button.menu::before,\n#chat button.close::before {\n\twidth: 36px;\n\tline-height: 36px; /* Fix alignment in Microsoft Edge */\n}\n\n/* Channel list button stays fixed when scrolling... */\n#viewport .lt {\n\tposition: fixed;\n}\n\n/* ... Except on chat windows, relative to include the notification dot */\n#viewport #chat .lt {\n\tposition: relative;\n}\n\n/* Notification dot on the top right corner of the menu icon */\n#viewport .lt::after {\n\tcontent: \"\";\n\tposition: absolute;\n\ttop: 9px;\n\tright: 7px;\n\tbackground-color: #e74c3c;\n\twidth: 10px;\n\theight: 10px;\n\tborder-radius: 50%;\n\tborder: 2px solid var(--window-bg-color);\n\topacity: 0;\n\ttransition: opacity 0.2s;\n\tbackground-clip: padding-box; /* Fix border-radius bleeding color */\n}\n\n#viewport.notified .lt::after {\n\topacity: 1;\n}\n\n#viewport.userlist-open #chat .userlist {\n\tdisplay: flex;\n}\n\n#sidebar {\n\tdisplay: none;\n\tflex-direction: column;\n\twidth: 220px;\n\tmax-height: 100%;\n\twill-change: transform;\n\tcolor: #b7c5d1; /* same as .channel-list-item color */\n}\n\n#viewport.menu-open #sidebar {\n\tdisplay: flex;\n}\n\n#sidebar .scrollable-area {\n\toverflow-x: auto;\n\tflex-grow: 1;\n\ttouch-action: pan-y;\n\tscrollbar-width: thin;\n\toverscroll-behavior: contain;\n\t-webkit-overflow-scrolling: touch;\n}\n\n#sidebar .logo-container {\n\ttext-align: center;\n}\n\n#sidebar .logo,\n#sidebar .logo-inverted {\n\theight: 45px;\n}\n\n#sidebar .logo {\n\tdisplay: none;\n}\n\n.channel-list-item,\n#sidebar .empty {\n\tfont-size: 14px;\n}\n\n.channel-list-item {\n\tdisplay: flex;\n\tpadding: 8px 14px;\n\tposition: relative;\n\tcursor: pointer;\n}\n\n/* Channels/queries must be white on hover and active */\n#footer button:hover,\n#footer button.active,\n.channel-list-item:hover,\n.channel-list-item.active {\n\tcolor: #fff;\n}\n\n/* All lobbies/channels/queries and footer buttons must have a half-transparent\nbackground on hover (unless active) */\n.channel-list-item:hover,\n#footer button:hover {\n\tbackground-color: rgb(48 62 74 / 50%); /* #303e4a x 50% alpha */\n}\n\n/* Darker background and defualt cursor for active channels */\n#footer button.active,\n.channel-list-item.active {\n\tbackground-color: #303e4a;\n\tcursor: default;\n}\n\n/* Remove background on hovered/active channel when sorting/drag-and-dropping */\n.ui-sortable-ghost,\n.ui-sortable-dragging .channel-list-item,\n.ui-sortable-dragging,\n.ui-sortable-dragging:hover,\n.ui-sortable-dragging.active,\n.ui-sortable-dragging-touch-cue .channel-list-item,\n.ui-sortable-dragging-touch-cue,\n.ui-sortable-dragging-touch-cue:hover,\n.ui-sortable-dragging-touch-cue.active {\n\tbackground: transparent;\n}\n\n.ui-sortable-ghost::after,\n.ui-sortable-dragging-touch-cue:not(.ui-sortable-dragging)::after {\n\tbackground: var(--body-bg-color);\n\tborder: 1px dashed #99a2b4;\n\tborder-radius: 6px;\n\tcontent: \" \";\n\tdisplay: block;\n\tposition: absolute;\n\tleft: 10px;\n\ttop: 0;\n\tbottom: 0;\n\tright: 10px;\n}\n\n.ui-sortable-dragging-touch-cue:not(.ui-sortable-ghost)::after {\n\tbackground: transparent;\n}\n\n#sidebar .network {\n\tposition: relative;\n\tmargin-bottom: 20px;\n\ttouch-action: pan-y;\n}\n\n#sidebar .empty {\n\tflex-grow: 1;\n\tline-height: 1.6;\n\tpadding: 40px 20px;\n\ttext-align: center;\n}\n\n.channel-list-item[data-type=\"lobby\"] {\n\tcolor: #84ce88;\n\tfont-size: 15px;\n\tfont-weight: bold;\n\tpadding-left: 0;\n}\n\n.channel-list-item .lobby-wrap {\n\tdisplay: flex;\n\tflex-grow: 1;\n\toverflow: hidden;\n}\n\n.channel-list-item[data-type=\"lobby\"]:hover,\n.channel-list-item[data-type=\"lobby\"].active {\n\tcolor: #c0f8c3;\n}\n\n.channel-list-item .not-connected-tooltip,\n.channel-list-item .not-secure-tooltip,\n.channel-list-item .parted-channel-tooltip {\n\tmargin: 0 8px;\n}\n\n.channel-list-item.not-secure {\n\tcolor: #f39c12;\n}\n\n.channel-list-item.not-secure:hover,\n.channel-list-item.not-secure.active {\n\tcolor: #f8c572;\n}\n\n.channel-list-item.not-connected,\n.channel-list-item.parted-channel {\n\tcolor: #e74c3c;\n}\n\n.channel-list-item.not-connected:hover,\n.channel-list-item.not-connected.active,\n.channel-list-item.parted-channel:hover,\n.channel-list-item.parted-channel.active {\n\tcolor: #f1978e;\n}\n\n.channel-list-item.is-muted {\n\topacity: 0.5;\n}\n\n.channel-list-item::before {\n\twidth: 14px;\n\tmargin-right: 12px;\n\tline-height: 18px;\n}\n\n.channel-list-item .name {\n\tposition: relative;\n\tflex-grow: 1;\n\toverflow: hidden;\n\twhite-space: nowrap;\n\tmargin-right: 5px;\n}\n\n.header .topic,\n.channel-list-item .name {\n\tmask-image: linear-gradient(to left, transparent, black 20px);\n}\n\n.channel-list-item .badge,\n#sidebar .add-channel-tooltip,\n.channel-list-item .close-tooltip {\n\tflex-shrink: 0;\n\tline-height: 1;\n}\n\n.channel-list-item .badge {\n\tbackground: rgb(255 255 255 / 6%);\n\tborder-radius: 3px;\n\tcolor: #afb6c0;\n\tfont-size: 10px;\n\tpadding: 4px 6px;\n\ttransition: background-color 0.2s, color 0.2s;\n}\n\n.channel-list-item .badge:empty {\n\tdisplay: none;\n}\n\n.channel-list-item .badge.highlight {\n\tbackground: #fff;\n\tcolor: #49505a;\n}\n\n.channel-list-item .close {\n\twidth: 18px;\n\theight: 18px;\n\tdisplay: none;\n\ttransition: opacity 0.2s, background-color 0.2s;\n}\n\n.channel-list-item .close::before {\n\tfont-size: 20px;\n\tfont-weight: normal;\n\tdisplay: inline-block;\n\tline-height: 16px;\n\ttext-align: center;\n\tcontent: \"×\";\n\tcolor: #fff;\n}\n\n.channel-list-item.active .close {\n\topacity: 0.4;\n\tdisplay: unset;\n}\n\n.channel-list-item.active .close:hover {\n\topacity: 1;\n}\n\n.channel-list-item[data-type=\"lobby\"] .add-channel {\n\tborder-radius: 3px;\n\twidth: 18px;\n\theight: 18px;\n\topacity: 0.4;\n\ttransition: opacity 0.2s, background-color 0.2s, transform 0.2s;\n}\n\n.channel-list-item[data-type=\"lobby\"] .add-channel::before {\n\tfont-size: 20px;\n\tfont-weight: normal;\n\tdisplay: inline-block;\n\tline-height: 16px;\n\ttext-align: center;\n\tcontent: \"+\";\n\tcolor: #fff;\n}\n\n.channel-list-item[data-type=\"lobby\"] .add-channel:hover {\n\topacity: 1;\n}\n\n.channel-list-item[data-type=\"lobby\"] .add-channel.opened {\n\t/* translateZ(0) enables hardware acceleration, this is to avoid jittering when animating */\n\ttransform: rotate(45deg) translateZ(0);\n}\n\n#sidebar .network .collapse-network {\n\twidth: 40px;\n\topacity: 0.4;\n\tpadding-left: 11px;\n\ttransition: opacity 0.2s;\n\tflex-shrink: 0;\n}\n\n#sidebar .network .collapse-network-icon {\n\tdisplay: block;\n\twidth: 20px;\n\theight: 20px;\n\ttransition: transform 0.2s;\n}\n\n#sidebar .network.collapsed .collapse-network-icon {\n\ttransform: rotate(-90deg);\n}\n\n#sidebar .network .collapse-network-icon::before {\n\tcontent: \"\\f0d7\"; /* http://fontawesome.io/icon/caret-down/ */\n\tcolor: #fff;\n}\n\n#sidebar .collapse-network:hover {\n\topacity: 1;\n}\n\n#footer {\n\theight: 45px;\n\tfont-size: 14px;\n\tflex-shrink: 0;\n\tdisplay: flex;\n\tjustify-content: center;\n}\n\n#footer button {\n\tcolor: #b7c5d1;\n\tdisplay: inline-block;\n\twidth: 45px;\n\theight: 100%;\n\tborder-radius: 5px;\n}\n\n#footer .help.notified::after {\n\tcontent: \"\\f021\";\n\tposition: absolute;\n\tbottom: 10px;\n\tright: 7px;\n\tpadding: 2px;\n\tfont-size: 10px;\n\tborder-radius: 50%;\n\tcolor: var(--link-color);\n\tbackground: var(--body-bg-color);\n}\n\n.window li,\n.window p,\n.window label,\n#settings .error {\n\tfont-size: 14px;\n}\n\n.input {\n\tbackground-color: white;\n\tborder: 1px solid #cdd3da;\n\tborder-radius: 2px;\n\tcolor: #222;\n\tfont-size: 14px;\n\tmargin: 2px 0;\n\tmargin-bottom: 10px;\n\tpadding: 0 10px;\n\ttransition: border-color 0.2s, box-shadow 0.2s;\n\twidth: 100%;\n\theight: 35px;\n\tline-height: 35px;\n}\n\n.input:disabled {\n\tbackground-color: #ddd;\n}\n\n.input:not(:disabled):hover,\n.input:not(:disabled):focus {\n\tborder-color: #84ce88;\n}\n\ntextarea.input {\n\tresize: vertical;\n\tmin-height: 35px;\n\tpadding: 6px 10px;\n\tline-height: 1.5;\n}\n\n.window {\n\tbackground: var(--window-bg-color);\n\tdisplay: flex;\n\tflex-direction: column;\n\tflex: 1 1 auto;\n\tposition: relative;\n\toverflow-y: auto;\n\theight: 100%;\n\tscrollbar-width: thin;\n\toverscroll-behavior: contain;\n\t-webkit-overflow-scrolling: touch;\n}\n\n#loading,\n#chat .chat-view {\n\t/* flexbox does not seem to scroll without doing this */\n\tposition: absolute;\n\tbottom: 0;\n\tleft: 0;\n\tright: 0;\n\ttop: 0;\n}\n\n.window h1 {\n\tfont-size: 36px;\n}\n\n.window h2 {\n\tborder-bottom: 1px solid currentcolor;\n\tcolor: var(--window-heading-color);\n\tfont-size: 22px;\n\tmargin: 30px 0 10px;\n\tpadding-bottom: 7px;\n}\n\n.window h2 small {\n\tfont-size: 16px;\n\tline-height: 30px;\n}\n\n.window h3 {\n\tcolor: var(--window-heading-color);\n\tfont-size: 18px;\n\tmargin: 20px 0 10px;\n}\n\n.header {\n\tline-height: 45px;\n\theight: 45px;\n\tpadding: 0 6px;\n\tdisplay: flex;\n\tflex-shrink: 0;\n\toverflow: hidden;\n}\n\n#chat .header {\n\tborder-bottom: 1px solid #e7e7e7;\n}\n\n.header .title {\n\tfont-size: 15px;\n\tpadding-left: 6px;\n\tflex-shrink: 1;\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n.topic-container {\n\tposition: relative;\n\tflex-grow: 1;\n\tpadding-left: 10px;\n}\n\n.header .topic {\n\tcolor: var(--body-color-muted);\n\tmargin-left: 8px;\n\tword-break: break-all;\n\tflex-grow: 1;\n\toverflow: hidden;\n\tfont-size: 14px;\n\tflex-shrink: 99999999;\n\tmin-width: 25px;\n}\n\n.header .topic.empty {\n\tmin-width: 0;\n}\n\n.header .topic-input {\n\tcolor: inherit;\n\tbackground: transparent;\n\tborder: 1px solid #cdd3da;\n\tborder-radius: 2px;\n\tpadding-right: 37px;\n\tpadding-left: 10px;\n\twidth: 100%;\n\theight: 35px;\n\toverflow: hidden;\n\tfont-size: 14px;\n\tline-height: normal;\n\toutline: none;\n}\n\n.topic-container .save-topic {\n\tposition: absolute;\n\ttop: 6px;\n\tright: 0;\n}\n\n.topic-container .save-topic span {\n\tfont-size: 16px;\n\tcolor: #607992;\n\twidth: 35px;\n\theight: 35px;\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n\tcursor: pointer;\n\tappearance: none;\n}\n\n.topic-container .save-topic span:hover {\n\topacity: 0.6;\n}\n\n#chat {\n\toverflow: hidden;\n\tflex: 1 0 auto;\n\tposition: relative;\n}\n\n#chat .chat-view {\n\tdisplay: flex;\n\tflex-direction: column;\n}\n\n#chat .msg[data-type=\"condensed\"] {\n\tflex-wrap: wrap;\n}\n\n#chat .msg[data-type=\"condensed\"] .content {\n\tflex: 1;\n}\n\n/* Ensures expanded status messages always take up the full width */\n#chat .msg[data-type=\"condensed\"] .msg {\n\tflex-basis: 100%;\n}\n\n#chat .condensed-summary .content {\n\tdisplay: block;\n\tcursor: pointer;\n\tuser-select: none;\n}\n\n#chat .condensed-summary {\n\tdisplay: flex;\n}\n\n#chat .condensed-summary .content:hover {\n\ttext-decoration: underline;\n}\n\n#chat .msg.closed[data-type=\"condensed\"] .msg {\n\tdisplay: none;\n}\n\n#chat .condensed-summary .time {\n\tvisibility: hidden;\n}\n\n#form,\n.messages .msg,\n.userlist {\n\tfont-size: 14px;\n\tline-height: 1.4;\n}\n\n#chat .chat-content {\n\tdisplay: flex;\n\tflex-direction: row-reverse;\n\tflex-grow: 1;\n\toverflow: hidden;\n\tposition: relative;\n}\n\n#chat .chat {\n\toverflow: auto;\n\toverflow-x: hidden;\n\tdisplay: flex;\n\tflex-grow: 1;\n\tflex-direction: column;\n\tscrollbar-width: thin;\n\toverscroll-behavior: contain;\n\t-webkit-overflow-scrolling: touch;\n\toutline: none;\n}\n\n#chat .userlist {\n\tborder-left: 1px solid #e7e7e7;\n\twidth: 180px;\n\tdisplay: none;\n\tflex-direction: column;\n\tflex-shrink: 0;\n\ttouch-action: pan-y;\n}\n\n/**\n * Toggled via JavaScript\n */\n#sidebar .join-form {\n\tpadding: 0 18px 8px;\n}\n\n#sidebar .join-form .input {\n\tdisplay: block;\n\tmargin: 5px auto;\n}\n\n#sidebar .join-form .btn {\n\tdisplay: block;\n\twidth: 100%;\n\tmargin: auto;\n}\n\n#chat .show-more {\n\tpadding: 10px;\n\tpadding-top: 15px;\n\tpadding-bottom: 0;\n\twidth: 100%;\n}\n\n#chat .show-more .btn {\n\twidth: 100%;\n\tmargin: 0;\n}\n\n.scroll-down {\n\tposition: absolute;\n\tbottom: 16px;\n\tright: 16px;\n\tz-index: 2;\n\tpointer-events: none;\n\topacity: 0;\n\ttransform: translateY(16px);\n\ttransition: transform 0.2s, opacity 0.2s;\n\tcursor: pointer;\n}\n\n.scroll-down-shown {\n\topacity: 1;\n\ttransform: none;\n\tpointer-events: auto;\n}\n\n.scroll-down-arrow {\n\twidth: 36px;\n\theight: 36px;\n\tline-height: 34px;\n\tborder-radius: 50%;\n\tbackground: var(--window-bg-color);\n\tcolor: var(--button-color);\n\tborder: 2px solid var(--button-color);\n\ttext-align: center;\n\ttransition: background 0.2s, color 0.2s;\n\tbox-shadow: 0 6px 10px 0 rgb(0 0 0 / 15%);\n}\n\n.scroll-down:hover .scroll-down-arrow {\n\tbackground: var(--button-color);\n\tcolor: var(--button-text-color-hover);\n}\n\n.scroll-down-arrow::after {\n\tcontent: \"\\f107\"; /* https://fontawesome.com/icons/angle-down?style=solid */\n}\n\n.userlist-open .chat-view[data-type=\"channel\"] .scroll-down {\n\tright: 196px;\n}\n\n#chat .messages {\n\tpadding: 10px 0;\n\ttouch-action: pan-y;\n}\n\n#chat .chat-view:not([data-type=\"special\"]) .messages {\n\tmargin-top: auto;\n}\n\n#chat .msg {\n\tword-wrap: break-word;\n\tword-break: break-word; /* Webkit-specific */\n\tdisplay: flex;\n\talign-items: flex-start;\n\tposition: relative;\n}\n\n#chat .unread-marker {\n\tposition: relative;\n\ttext-align: center;\n\tmargin: 0 10px;\n\tz-index: 0;\n\tfont-weight: bold;\n\tfont-size: 12px;\n}\n\n#chat .unread-marker::before {\n\tposition: absolute;\n\tz-index: -1;\n\tcontent: \"\";\n\tleft: 0;\n\tright: 0;\n\ttop: 50%;\n\tborder-top: 1px solid var(--unread-marker-color);\n}\n\n#chat .unread-marker-text::before {\n\tcontent: \"New messages\";\n\tbackground-color: var(--window-bg-color);\n\tcolor: var(--unread-marker-color);\n\tpadding: 0 10px;\n}\n\n#chat .date-marker {\n\tposition: relative;\n\ttext-align: center;\n\tmargin: 0 10px;\n\tz-index: 0;\n\tfont-weight: bold;\n\tfont-size: 12px;\n}\n\n#chat .date-marker::before {\n\tposition: absolute;\n\tz-index: -1;\n\tcontent: \"\";\n\tleft: 0;\n\tright: 0;\n\ttop: 50%;\n\tborder-top: 1px solid var(--date-marker-color);\n}\n\n#chat .date-marker-text::before {\n\tcontent: attr(aria-label);\n\tbackground-color: var(--window-bg-color);\n\tcolor: var(--date-marker-color);\n\tpadding: 0 10px;\n}\n\n#chat .time,\n#chat .from,\n#chat .content {\n\tpadding: 3px 0;\n\tflex: 0 0 auto;\n}\n\n#chat .time {\n\tcolor: var(--body-color-muted);\n\tpadding-left: 10px;\n\twidth: 55px;\n\tfont-variant-numeric: tabular-nums;\n\tbox-sizing: content-box; /* highlights have a border-left */\n}\n\n#chat.time-12h .time,\n#chat.time-seconds .time {\n\twidth: 75px;\n}\n\n#chat.time-seconds.time-12h .time {\n\twidth: 90px;\n}\n\n#chat .from {\n\tpadding-right: 10px;\n\ttext-align: right;\n\twidth: 134px;\n\toverflow: hidden;\n\twhite-space: nowrap;\n\tposition: relative;\n}\n\n#chat .content {\n\tflex: 1 1 auto;\n\tmin-width: 0;\n\tpadding-left: 10px;\n\tpadding-right: 6px;\n\tborder-left: 1px solid #f6f6f6;\n\toverflow: hidden; /* Prevents Zalgo text to expand beyond messages */\n\ttext-align: left; /* so RTL text will still be aligned left, not right */\n}\n\n#chat .msg[data-type=\"unhandled\"] .from {\n\tcolor: var(--body-color-muted);\n}\n\n#chat .chat-view[data-type=\"special\"] table th {\n\tword-break: normal;\n}\n\n/* Parsed nicks and channels */\n\n#chat .user,\n.inline-channel {\n\tcursor: pointer;\n}\n\n.chat .user:hover,\n.inline-channel:hover {\n\ttext-decoration: underline;\n}\n\n/* Nicknames */\n\n#chat .user {\n\tcolor: #50a656;\n}\n\n#chat.colored-nicks .user.color-1 { color: #107ead; }\n#chat.colored-nicks .user.color-2 { color: #a86500; }\n#chat.colored-nicks .user.color-3 { color: #008a3c; }\n#chat.colored-nicks .user.color-4 { color: #e00096; }\n#chat.colored-nicks .user.color-5 { color: #f0000c; }\n#chat.colored-nicks .user.color-6 { color: #000094; }\n#chat.colored-nicks .user.color-7 { color: #006441; }\n#chat.colored-nicks .user.color-8 { color: #00566e; }\n#chat.colored-nicks .user.color-9 { color: #e6006b; }\n#chat.colored-nicks .user.color-10 { color: #0d8766; }\n#chat.colored-nicks .user.color-11 { color: #006b3b; }\n#chat.colored-nicks .user.color-12 { color: #00857e; }\n#chat.colored-nicks .user.color-13 { color: #00465b; }\n#chat.colored-nicks .user.color-14 { color: #eb005a; }\n#chat.colored-nicks .user.color-15 { color: #e62600; }\n#chat.colored-nicks .user.color-16 { color: #0f8546; }\n#chat.colored-nicks .user.color-17 { color: #e60067; }\n#chat.colored-nicks .user.color-18 { color: #eb002b; }\n#chat.colored-nicks .user.color-19 { color: #eb003f; }\n#chat.colored-nicks .user.color-20 { color: #007a56; }\n#chat.colored-nicks .user.color-21 { color: #095092; }\n#chat.colored-nicks .user.color-22 { color: #000bde; }\n#chat.colored-nicks .user.color-23 { color: #008577; }\n#chat.colored-nicks .user.color-24 { color: #00367d; }\n#chat.colored-nicks .user.color-25 { color: #007e9e; }\n#chat.colored-nicks .user.color-26 { color: #006119; }\n#chat.colored-nicks .user.color-27 { color: #007ea8; }\n#chat.colored-nicks .user.color-28 { color: #3c8500; }\n#chat.colored-nicks .user.color-29 { color: #e6007e; }\n#chat.colored-nicks .user.color-30 { color: #c75300; }\n#chat.colored-nicks .user.color-31 { color: #eb0400; }\n#chat.colored-nicks .user.color-32 { color: #e60082; }\n\n#chat .self .content {\n\tcolor: var(--body-color-muted);\n}\n\n#chat .msg.channel_list_loading .text {\n\tcolor: #999;\n\tfont-style: italic;\n\tpadding-left: 20px;\n}\n\n#chat .msg.channel_list_truncated .text {\n\tcolor: #f00;\n\tpadding-left: 20px;\n}\n\n#chat table.channel-list,\n#chat table.ban-list,\n#chat table.invite-list,\n#chat table.ignore-list {\n\tmargin: 5px 10px;\n\twidth: calc(100% - 30px);\n}\n\n#chat table.channel-list th,\n#chat table.ban-list th,\n#chat table.invite-list th,\n#chat table.ignore-list th,\n#chat table.channel-list td,\n#chat table.ban-list td,\n#chat table.invite-list td {\n\tpadding: 5px;\n\tvertical-align: top;\n\tborder-bottom: #eee 1px solid;\n}\n\n#chat table.channel-list .channel {\n\twidth: 80px;\n}\n\n#chat table.channel-list .channel,\n#chat table.channel-list .topic,\n#chat table.ban-list .hostmask,\n#chat table.ban-list .banned_by,\n#chat table.ban-list .banned_at,\n#chat table.ignore-list .hostmask,\n#chat table.ignore-list .when {\n\ttext-align: left;\n}\n\n#chat table.channel-list .users {\n\ttext-align: center;\n\twidth: 50px;\n}\n\n#chat.hide-motd .msg[data-command=\"motd\"] {\n\tdisplay: none !important;\n}\n\n#chat .msg[data-type=\"monospace_block\"] .text {\n\tbackground: #f6f6f6;\n\tdisplay: inline-block;\n\tborder-radius: 4px;\n\tpadding: 6px;\n}\n\n#chat .msg[data-type=\"condensed\"] .content,\n#chat .msg[data-type=\"away\"] .content,\n#chat .msg[data-type=\"back\"] .content,\n#chat .msg[data-type=\"join\"] .content,\n#chat .msg[data-type=\"kick\"] .content,\n#chat .msg[data-type=\"mode\"] .content,\n#chat .msg[data-type=\"nick\"] .content,\n#chat .msg[data-type=\"part\"] .content,\n#chat .msg[data-type=\"quit\"] .content,\n#chat .msg[data-type=\"topic\"] .content,\n#chat .msg[data-type=\"topic_set_by\"] .content {\n\tcolor: var(--body-color-muted);\n}\n\n#chat .msg[data-type=\"action\"] .from,\n#chat .msg[data-type=\"action\"] .content,\n#chat .msg[data-type=\"action\"] .user {\n\tcolor: #f39c12;\n}\n\n#chat .msg[data-type=\"notice\"] .time,\n#chat .msg[data-type=\"wallops\"] .time,\n#chat .msg[data-type=\"notice\"] .content,\n#chat .msg[data-type=\"wallops\"] .content,\n#chat .msg[data-type=\"notice\"] .user,\n#chat .msg[data-type=\"wallops\"] .user {\n\tcolor: #0074d9;\n}\n\n#chat .msg[data-type=\"notice\"] .from .user::before {\n\tcontent: \"Notice: \";\n}\n\n#chat .msg[data-type=\"wallops\"] .from .user::before {\n\tcontent: \"Wallops: \";\n}\n\n#chat .msg[data-type=\"error\"],\n#chat .msg[data-type=\"error\"] .from {\n\tcolor: #e74c3c;\n}\n\n#chat .chat-view[data-type=\"channel\"] .msg.highlight {\n\tbackground-color: var(--highlight-bg-color);\n\tborder-left: 5px solid var(--highlight-border-color);\n}\n\n#chat .chat-view[data-type=\"channel\"] .msg.highlight .time {\n\tpadding-left: 5px;\n\tcolor: #696969;\n}\n\n#chat .chat-view[data-type=\"channel\"] .msg.highlight .content {\n\tborder-left: 1px solid var(--highlight-bg-color);\n}\n\n#chat .preview-size {\n\tmargin-left: 5px;\n\tuser-select: none;\n}\n\n#chat .toggle-content.opened .more-caret, /* Expand/Collapse link previews */\n#chat .toggle-button.opened, /* Thumbnail toggle */\n#chat .msg:not(.closed)[data-type=\"condensed\"] .toggle-button { /* Expanded status message toggle */\n\ttransform: rotate(90deg);\n}\n\n#chat .preview {\n\tdisplay: flex; /* Fix odd margin added by inline-flex in .toggle-content */\n}\n\n#chat .toggle-content {\n\tbackground: #f6f6f6;\n\tborder-radius: 5px;\n\tmax-width: 100%;\n\tmargin: 0;\n\tmargin-top: 6px;\n\toverflow: hidden;\n\tbox-shadow: 0 1px 3px rgb(0 0 0 / 20%);\n\tdisplay: inline-flex !important;\n\talign-items: flex-start;\n\twhite-space: normal;\n}\n\n/* This applies to images of preview-type-image and thumbnails of preview-type-link */\n#chat .toggle-content img {\n\tmax-width: 100%;\n\tmax-height: 128px;\n\tdisplay: block;\n\tcursor: zoom-in;\n}\n\n#chat .toggle-content pre.prefetch-error {\n\tpadding: 0;\n\tmargin: 0;\n\tcolor: inherit;\n\tbackground-color: transparent;\n}\n\n#chat .toggle-content .prefetch-error {\n\tdisplay: none;\n}\n\n#chat .toggle-content.opened .prefetch-error {\n\tdisplay: inline;\n}\n\n/* This applies to thumbnails of preview-type-link only */\n#chat .toggle-content .thumb {\n\tmax-height: 54px;\n\tmax-width: 96px;\n}\n\n#chat .toggle-type-error,\n#chat .toggle-content .toggle-text {\n\tpadding: 8px 10px;\n}\n\n#chat .toggle-content .toggle-text {\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-align: initial;\n}\n\n#chat .toggle-content.opened .toggle-text {\n\twhite-space: normal;\n}\n\n#chat .toggle-content .head {\n\tdisplay: flex;\n\talign-items: flex-start;\n\tfont-weight: bold;\n}\n\n#chat .toggle-type-error,\n#chat .toggle-text .body {\n\tcolor: #717171;\n}\n\n#chat .toggle-text a {\n\tcolor: inherit;\n}\n\n#chat .toggle-text .overflowable {\n\ttext-overflow: ellipsis;\n\toverflow: hidden;\n\tflex-grow: 1;\n}\n\n#chat .toggle-content .more {\n\tcolor: var(--link-color);\n\tfont-weight: normal;\n\tmargin-left: 10px;\n\tflex-shrink: 0;\n}\n\n#chat .toggle-content .more:hover {\n\ttext-decoration: underline;\n}\n\n#chat .toggle-content .more::after {\n\tcontent: \" \" attr(aria-label);\n}\n\n#chat .toggle-content .more-caret {\n\tdisplay: inline-block;\n\ttransition: transform 0.2s;\n}\n\n#chat .toggle-content .more-caret::before {\n\tcontent: \"\\f0da\"; /* https://fontawesome.com/icons/caret-right?style=solid */\n}\n\n#chat audio {\n\twidth: 600px;\n\tmax-width: 100%;\n}\n\n#chat .toggle-type-video {\n\tmax-width: 640px;\n}\n\n#chat video {\n\tmax-width: 100%;\n\tmax-height: 240px;\n}\n\n/* Do not display an empty div when there are no previews. Useful for example in\npart/quit messages where we don't load previews (adds a blank line otherwise) */\n#chat .preview:empty {\n\tdisplay: none;\n}\n\n#chat .userlist .count {\n\tbackground: #fafafa;\n\theight: 45px;\n\tflex-shrink: 0;\n\tposition: relative;\n}\n\n#chat .userlist .search {\n\tcolor: var(--body-color);\n\tappearance: none;\n\tborder: 0;\n\tbackground: none;\n\tfont: inherit;\n\toutline: 0;\n\tpadding: 13px;\n\tpadding-right: 30px;\n\twidth: 100%;\n}\n\n#chat .userlist .names {\n\tflex-grow: 1;\n\toverflow: auto;\n\toverflow-x: hidden;\n\tpadding-bottom: 10px;\n\twidth: 100%;\n\ttouch-action: pan-y;\n\tscrollbar-width: thin;\n\toverscroll-behavior: contain;\n\t-webkit-overflow-scrolling: touch;\n}\n\n#chat .names .user {\n\tdisplay: block;\n\tline-height: 1.6;\n\tpadding: 0 16px;\n\twhite-space: nowrap;\n}\n\n#chat .user-mode {\n\tmargin-bottom: 15px;\n}\n\n#chat .user-mode::before {\n\tbackground: var(--window-bg-color);\n\tcolor: var(--body-color-muted);\n\tdisplay: block;\n\tfont-size: 0.85em;\n\tline-height: 1.6;\n\tpadding: 5px 16px;\n\tposition: sticky;\n\ttop: 0;\n}\n\n#chat .user-mode.owner::before {\n\tcontent: \"Owners\";\n}\n\n#chat .user-mode.admin::before {\n\tcontent: \"Administrators\";\n}\n\n#chat .user-mode.op::before {\n\tcontent: \"Operators\";\n}\n\n#chat .user-mode.half-op::before {\n\tcontent: \"Half-Operators\";\n}\n\n#chat .user-mode.voice::before {\n\tcontent: \"Voiced\";\n}\n\n#chat .user-mode.normal::before {\n\tcontent: \"Users\";\n}\n\n#chat .user-mode-search::before {\n\tcontent: \"Search Results\";\n}\n\n#loading {\n\tdisplay: flex;\n\tfont-size: 14px;\n\theight: 100%;\n}\n\n#loading .window {\n\theight: initial;\n\tdisplay: flex;\n\tflex-direction: column;\n}\n\n#loading p {\n\tmargin-top: 10px;\n}\n\n#loading-slow,\n#loading-reload {\n\tvisibility: hidden;\n}\n\n#loading summary {\n\toutline: none;\n\tcursor: pointer;\n}\n\n#loading pre {\n\ttext-align: left;\n\twhite-space: normal;\n}\n\n#sign-in .container,\n#loading-reload-container,\n#loading-status-container {\n\tflex: 1 0 auto;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tflex-direction: column;\n}\n\n#loading-reload-container {\n\tflex-grow: 0;\n}\n\n#loading .logo-inverted,\n.window .logo-inverted {\n\tdisplay: none; /* In dark themes, inverted logo must be used instead */\n}\n\n#sign-in label {\n\tdisplay: block;\n\tmargin-top: 10px;\n\twidth: 100%;\n}\n\n#sign-in .btn {\n\tmargin-top: 25px;\n}\n\n#sign-in .error {\n\tcolor: #e74c3c;\n\tmargin-top: 1em;\n\twidth: 100%;\n}\n\n#connect .connect-row {\n\tdisplay: flex;\n}\n\n#connect .connect-row > .input,\n#connect .connect-row > .input-wrap {\n\tflex-grow: 1;\n}\n\n#connect label {\n\twidth: 25%;\n\tflex-shrink: 0;\n\tmargin-top: 11px;\n}\n\n#connect .tls {\n\twidth: 100%;\n\tdisplay: block;\n\tmargin-top: 6px;\n}\n\n#connect .tls input,\n#connect input[name=\"proxyEnabled\"] {\n\tmargin: 3px 10px 0 0;\n}\n\n#connect\\:host,\n#connect\\:proxyHost {\n\twidth: 70%;\n}\n\n#connect\\:port,\n#connect\\:proxyPort {\n\twidth: 25%;\n}\n\n#connect\\:portseparator,\n#connect\\:proxyPortSeparator {\n\twidth: 5%;\n\ttext-align: center;\n\tdisplay: inline-block;\n}\n\n#connect .btn {\n\tmargin-top: 15px;\n\twidth: 100%;\n}\n\n#settings .apple-push-unsupported,\n#settings .settings-sync-panel {\n\tpadding: 10px;\n\tmargin-bottom: 16px;\n\tborder-radius: 2px;\n\tbackground-color: #d9edf7;\n\tcolor: #31708f;\n}\n\n#settings .settings-sync-panel p:last-child {\n\tmargin-bottom: 0;\n}\n\n#settings .settings-sync-panel .btn {\n\tcolor: #007bff;\n\tborder-color: #007bff;\n\tmargin-bottom: 0;\n}\n\n#settings .settings-sync-panel .btn:hover,\n#settings .settings-sync-panel .btn:focus {\n\tbackground-color: #007bff;\n\tcolor: #fff;\n}\n\n#settings .settings-sync-panel .btn:active,\n#settings .settings-sync-panel .btn:focus {\n\tbox-shadow: 0 0 0 3px rgb(0 123 255 / 50%);\n}\n\n#settings .apple-push-unsupported a {\n\tcolor: inherit;\n\ttext-decoration: underline;\n}\n\n#settings .opt {\n\tdisplay: block;\n\tpadding: 5px 0 5px 1px;\n}\n\n#settings .opt input {\n\tmargin-right: 6px;\n}\n\n#connect .extra-help,\n#settings .extra-help {\n\tcursor: help;\n}\n\n#settings h2 .extra-help {\n\tfont-size: 0.8em;\n}\n\n#settings #play {\n\tfont-size: 14px;\n\ttransition: opacity 0.2s;\n\tcolor: var(--window-heading-color);\n}\n\n#settings #play:hover {\n\topacity: 0.8;\n}\n\n#settings #change-password .error,\n#settings #change-password .success {\n\tmargin-bottom: 1em;\n}\n\n#settings #change-password .error {\n\tcolor: #e74c3c;\n}\n\n#settings #change-password .success {\n\tcolor: #2ecc40;\n}\n\n#settings .error {\n\tcolor: #e74c3c;\n\tmargin-top: 0.2em;\n}\n\n.password-container {\n\tposition: relative;\n}\n\n.password-container input {\n\tpadding-right: 37px;\n}\n\n#sign-in .password-container {\n\twidth: 100%;\n}\n\n#sign-in .password-container .reveal-password {\n\ttop: 31px;\n}\n\n.password-container .reveal-password {\n\tposition: absolute;\n\ttop: 2px;\n\tright: 0;\n\tappearance: none;\n}\n\n.password-container .reveal-password span {\n\tfont-size: 16px;\n\tcolor: #607992;\n\twidth: 35px;\n\theight: 35px;\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n\tcursor: pointer;\n}\n\n.password-container .reveal-password span::before {\n\tcontent: \"\\f06e\"; /* https://fontawesome.com/icons/eye?style=solid */\n}\n\n.topic-container .save-topic span::before {\n\tcontent: \"\\f00c\"; /* https://fontawesome.com/icons/check?style=solid */\n}\n\n.password-container .reveal-password-visible span::before {\n\tcontent: \"\\f070\"; /* https://fontawesome.com/icons/eye-slash?style=solid */\n\tcolor: #ff4136;\n}\n\n#help .help-version-title {\n\tdisplay: flex;\n\tjustify-content: space-between;\n}\n\n#help .help-item {\n\tdisplay: table-row;\n\tfont-size: 14px;\n}\n\n#help .help-item .subject,\n#help .help-item .description {\n\tdisplay: table-cell;\n\tpadding-bottom: 15px;\n}\n\n#help .help-item .subject {\n\twhite-space: nowrap;\n\tpadding-right: 15px;\n}\n\n#help .help-item .subject.gesture {\n\tfont-weight: bold;\n}\n\n#help .help-item .description p {\n\tmargin-bottom: 0;\n}\n\n.whois {\n\tdisplay: grid;\n\tgrid-template-columns: max-content auto;\n\tmargin: 0;\n}\n\n.whois dt {\n\tgrid-column-start: 1;\n\tmargin-right: 20px;\n}\n\n.whois dd {\n\tgrid-column-start: 2;\n}\n\n.changelog-text {\n\tline-height: 1.5;\n}\n\n.changelog-text p {\n\tmargin-bottom: 16px;\n}\n\n.window#changelog h3 {\n\tfont-size: 20px;\n\tborder-bottom: 1px solid currentcolor;\n\tcolor: var(--window-heading-color);\n\tmargin: 30px 0 10px;\n\tpadding-bottom: 7px;\n}\n\n.window#chat-container {\n\t/*\n\t\tChat has its own scrollbar, so remove the one on parent\n\t\tThis caused a performance issue in Chrome\n\t*/\n\toverflow: hidden;\n}\n\n#version-checker {\n\tdisplay: flex;\n\talign-items: center;\n\tpadding: 10px;\n\tmargin-bottom: 16px;\n\tborder-radius: 2px;\n\ttransition: color 0.2s, background-color 0.2s;\n}\n\n#version-checker p,\n#version-checker button {\n\tmargin-bottom: 0;\n}\n\n#version-checker p {\n\tflex: 1;\n\tpadding-top: 6px;\n\tpadding-bottom: 6px;\n}\n\n#version-checker::before {\n\tmargin-left: 6px;\n\tmargin-right: 12px;\n\tfont-size: 1.2em;\n}\n\n#version-checker.loading {\n\tbackground-color: #d9edf7;\n\tcolor: #31708f;\n}\n\n#version-checker.loading::before {\n\tcontent: \"\\f253\"; /* https://fontawesome.com/icons/hourglass-end?style=solid */\n}\n\n#version-checker.new-version,\n#version-checker.new-packages {\n\tcolor: #8a6d3b;\n\tbackground-color: #fcf8e3;\n}\n\n#version-checker.new-version::before,\n#version-checker.new-packages::before {\n\tcontent: \"\\f164\"; /* https://fontawesome.com/icons/thumbs-up?style=solid */\n}\n\n#version-checker.error {\n\tcolor: #a94442;\n\tbackground-color: #f2dede;\n}\n\n#version-checker.error::before {\n\tcontent: \"\\f06a\"; /* http://fontawesome.io/icon/exclamation-circle/ */\n}\n\n#version-checker.up-to-date {\n\tbackground-color: #dff0d8;\n\tcolor: #3c763d;\n}\n\n#version-checker.up-to-date::before {\n\tcontent: \"\\f00c\"; /* http://fontawesome.io/icon/check/ */\n}\n\n#upload-progressbar {\n\tbackground: var(--upload-progressbar-color);\n\tbox-shadow: 0 0 10px var(--upload-progressbar-color);\n\twidth: 0%;\n\theight: 2px;\n\tvisibility: hidden;\n\tposition: absolute;\n\ttop: -1px; /* put it on top of #form's border */\n\tleft: 0;\n}\n\n#upload-progressbar.upload-progressbar-visible {\n\tvisibility: visible;\n\ttransition: 0.3s width ease-in-out;\n}\n\n#form {\n\tflex: 0 0 auto;\n\tborder: 0;\n\tborder-top: 1px solid #e7e7e7;\n\tborder-radius: 0;\n\tmargin: 0;\n\tpadding: 6px;\n\tbackground: white;\n\tdisplay: flex;\n\talign-items: flex-end;\n\tposition: relative;\n}\n\n#user-visible-error {\n\tfont-size: 14px;\n\tline-height: 1.5;\n\tfont-weight: 600;\n\tpadding: 10px;\n\tword-spacing: 3px;\n\ttext-transform: uppercase;\n\tbackground: #e74c3c;\n\tcolor: #fff;\n\ttext-align: center;\n\tcursor: pointer;\n}\n\n#form #nick {\n\tbackground: #f6f6f6;\n\tcolor: #666;\n\tfont-size: 13px;\n\tmargin: 4px;\n\tline-height: 24px;\n\tpadding: 0 8px;\n\tborder-radius: 2px;\n\tdisplay: none;\n}\n\n.public #form #nick {\n\tdisplay: block;\n}\n\n#form #input {\n\tbackground: transparent;\n\tborder: none;\n\tfont: inherit;\n\tmin-height: 19px; /* Required when computing input height at char deletion */\n\theight: 19px;\n\tmax-height: 95px; /* min-height/height x number of lines maximum */\n\tline-height: 19px; /* should match height */\n\toutline: none;\n\tmargin: 5px;\n\tpadding: 0;\n\tresize: none;\n\tflex: 1 0 auto;\n\talign-self: center;\n\ttouch-action: pan-y;\n}\n\n#form #upload-input {\n\tdisplay: none;\n}\n\n#form #upload,\n#form #submit {\n\tcolor: #607992;\n\tfont-size: 14px;\n\theight: 32px;\n\twidth: 32px;\n\tflex: 0 0 auto;\n}\n\n#form #upload[disabled],\n#form #submit[disabled] {\n\topacity: 0.5;\n}\n\n#mentions-popup-container,\n#context-menu-container {\n\tposition: absolute;\n\ttop: 0;\n\tleft: 0;\n\twidth: 100%;\n\theight: 100%;\n\tz-index: 1000;\n\tbackground: transparent;\n}\n\n#context-menu-container.passthrough {\n\tpointer-events: none;\n}\n\n#context-menu-container.passthrough > * {\n\tpointer-events: auto;\n}\n\n.mentions-popup,\n#context-menu,\n.textcomplete-menu {\n\tposition: absolute;\n\tlist-style: none;\n\tmargin: 0;\n\tpadding: 0 6px;\n\tmin-width: 180px;\n\tfont-size: 14px;\n\tbackground-color: #fff;\n\tbox-shadow: 0 3px 12px rgb(0 0 0 / 15%);\n\tborder: 1px solid rgb(0 0 0 / 15%);\n\tborder-radius: 5px;\n\toutline: 0;\n}\n\n.context-menu-divider {\n\theight: 1px;\n\tmargin: 6px 0;\n\tbackground-color: rgb(0 0 0 / 10%);\n}\n\n.context-menu-item,\n.textcomplete-item {\n\tcursor: pointer;\n\tdisplay: block;\n\tpadding: 4px 8px;\n\tcolor: #333;\n\tmargin-top: 6px;\n\tmargin-bottom: 6px;\n\tline-height: 1.4;\n\tborder-radius: 3px;\n\twhite-space: nowrap;\n}\n\n.context-menu-item.active,\n.textcomplete-item:focus,\n.textcomplete-item:hover,\n.textcomplete-menu .active,\n#chat .userlist .user.active {\n\tbackground-color: rgb(0 0 0 / 10%);\n}\n\n.context-menu-item::before,\n.textcomplete-item::before {\n\twidth: 20px;\n\tdisplay: inline-block;\n}\n\n.textcomplete-item a {\n\tcolor: #333;\n}\n\n.textcomplete-item a:hover {\n\ttext-decoration: none;\n}\n\n.emoji {\n\tfont-size: 1.4em;\n\tvertical-align: text-top;\n\tline-height: 1;\n}\n\n.textcomplete-item .emoji {\n\twidth: 32px;\n\ttext-align: center;\n}\n\n.textcomplete-item .irc-bg {\n\tdisplay: block;\n}\n\n/**\n * IRC Message Styling\n * Colours are credit to http://clrs.cc/\n */\n.irc-fg0 { color: #fff; }\n.irc-fg1 { color: #000; }\n.irc-fg2 { color: #001f3f; }\n.irc-fg3 { color: #2ecc40; }\n.irc-fg4 { color: #ff4136; }\n.irc-fg5 { color: #85144b; }\n.irc-fg6 { color: #b10dc9; }\n.irc-fg7 { color: #ff851b; }\n.irc-fg8 { color: #ffdc00; }\n.irc-fg9 { color: #01ff70; }\n.irc-fg10 { color: #39cccc; }\n.irc-fg11 { color: #7fdbff; }\n.irc-fg12 { color: #0074d9; }\n.irc-fg13 { color: #f012be; }\n.irc-fg14 { color: #aaa; }\n.irc-fg15 { color: #ddd; }\n.irc-bg0 { background: #fff; }\n.irc-bg1 { background: #000; }\n.irc-bg2 { background: #001f3f; }\n.irc-bg3 { background: #2ecc40; }\n.irc-bg4 { background: #ff4136; }\n.irc-bg5 { background: #85144b; }\n.irc-bg6 { background: #b10dc9; }\n.irc-bg7 { background: #ff851b; }\n.irc-bg8 { background: #ffdc00; }\n.irc-bg9 { background: #01ff70; }\n.irc-bg10 { background: #39cccc; }\n.irc-bg11 { background: #7fdbff; }\n.irc-bg12 { background: #0074d9; }\n.irc-bg13 { background: #f012be; }\n.irc-bg14 { background: #aaa; }\n.irc-bg15 { background: #ddd; }\n\n/* https://modern.ircdocs.horse/formatting.html#colors-16-98 */\n.irc-fg16 { color: #470000; }\n.irc-fg17 { color: #472100; }\n.irc-fg18 { color: #474700; }\n.irc-fg19 { color: #324700; }\n.irc-fg20 { color: #004700; }\n.irc-fg21 { color: #00472c; }\n.irc-fg22 { color: #004747; }\n.irc-fg23 { color: #002747; }\n.irc-fg24 { color: #000047; }\n.irc-fg25 { color: #2e0047; }\n.irc-fg26 { color: #470047; }\n.irc-fg27 { color: #47002a; }\n.irc-fg28 { color: #740000; }\n.irc-fg29 { color: #743a00; }\n.irc-fg30 { color: #747400; }\n.irc-fg31 { color: #517400; }\n.irc-fg32 { color: #007400; }\n.irc-fg33 { color: #007449; }\n.irc-fg34 { color: #007474; }\n.irc-fg35 { color: #004074; }\n.irc-fg36 { color: #000074; }\n.irc-fg37 { color: #4b0074; }\n.irc-fg38 { color: #740074; }\n.irc-fg39 { color: #740045; }\n.irc-fg40 { color: #b50000; }\n.irc-fg41 { color: #b56300; }\n.irc-fg42 { color: #b5b500; }\n.irc-fg43 { color: #7db500; }\n.irc-fg44 { color: #00b500; }\n.irc-fg45 { color: #00b571; }\n.irc-fg46 { color: #00b5b5; }\n.irc-fg47 { color: #0063b5; }\n.irc-fg48 { color: #0000b5; }\n.irc-fg49 { color: #7500b5; }\n.irc-fg50 { color: #b500b5; }\n.irc-fg51 { color: #b5006b; }\n.irc-fg52 { color: #f00; }\n.irc-fg53 { color: #ff8c00; }\n.irc-fg54 { color: #ff0; }\n.irc-fg55 { color: #b2ff00; }\n.irc-fg56 { color: #0f0; }\n.irc-fg57 { color: #00ffa0; }\n.irc-fg58 { color: #0ff; }\n.irc-fg59 { color: #008cff; }\n.irc-fg60 { color: #00f; }\n.irc-fg61 { color: #a500ff; }\n.irc-fg62 { color: #f0f; }\n.irc-fg63 { color: #ff0098; }\n.irc-fg64 { color: #ff5959; }\n.irc-fg65 { color: #ffb459; }\n.irc-fg66 { color: #ffff71; }\n.irc-fg67 { color: #cfff60; }\n.irc-fg68 { color: #6fff6f; }\n.irc-fg69 { color: #65ffc9; }\n.irc-fg70 { color: #6dffff; }\n.irc-fg71 { color: #59b4ff; }\n.irc-fg72 { color: #5959ff; }\n.irc-fg73 { color: #c459ff; }\n.irc-fg74 { color: #f6f; }\n.irc-fg75 { color: #ff59bc; }\n.irc-fg76 { color: #ff9c9c; }\n.irc-fg77 { color: #ffd39c; }\n.irc-fg78 { color: #ffff9c; }\n.irc-fg79 { color: #e2ff9c; }\n.irc-fg80 { color: #9cff9c; }\n.irc-fg81 { color: #9cffdb; }\n.irc-fg82 { color: #9cffff; }\n.irc-fg83 { color: #9cd3ff; }\n.irc-fg84 { color: #9c9cff; }\n.irc-fg85 { color: #dc9cff; }\n.irc-fg86 { color: #ff9cff; }\n.irc-fg87 { color: #ff94d3; }\n.irc-fg88 { color: #000; }\n.irc-fg89 { color: #131313; }\n.irc-fg90 { color: #282828; }\n.irc-fg91 { color: #363636; }\n.irc-fg92 { color: #4d4d4d; }\n.irc-fg93 { color: #656565; }\n.irc-fg94 { color: #818181; }\n.irc-fg95 { color: #9f9f9f; }\n.irc-fg96 { color: #bcbcbc; }\n.irc-fg97 { color: #e2e2e2; }\n.irc-fg98 { color: #fff; }\n.irc-bg16 { background-color: #470000; }\n.irc-bg17 { background-color: #472100; }\n.irc-bg18 { background-color: #474700; }\n.irc-bg19 { background-color: #324700; }\n.irc-bg20 { background-color: #004700; }\n.irc-bg21 { background-color: #00472c; }\n.irc-bg22 { background-color: #004747; }\n.irc-bg23 { background-color: #002747; }\n.irc-bg24 { background-color: #000047; }\n.irc-bg25 { background-color: #2e0047; }\n.irc-bg26 { background-color: #470047; }\n.irc-bg27 { background-color: #47002a; }\n.irc-bg28 { background-color: #740000; }\n.irc-bg29 { background-color: #743a00; }\n.irc-bg30 { background-color: #747400; }\n.irc-bg31 { background-color: #517400; }\n.irc-bg32 { background-color: #007400; }\n.irc-bg33 { background-color: #007449; }\n.irc-bg34 { background-color: #007474; }\n.irc-bg35 { background-color: #004074; }\n.irc-bg36 { background-color: #000074; }\n.irc-bg37 { background-color: #4b0074; }\n.irc-bg38 { background-color: #740074; }\n.irc-bg39 { background-color: #740045; }\n.irc-bg40 { background-color: #b50000; }\n.irc-bg41 { background-color: #b56300; }\n.irc-bg42 { background-color: #b5b500; }\n.irc-bg43 { background-color: #7db500; }\n.irc-bg44 { background-color: #00b500; }\n.irc-bg45 { background-color: #00b571; }\n.irc-bg46 { background-color: #00b5b5; }\n.irc-bg47 { background-color: #0063b5; }\n.irc-bg48 { background-color: #0000b5; }\n.irc-bg49 { background-color: #7500b5; }\n.irc-bg50 { background-color: #b500b5; }\n.irc-bg51 { background-color: #b5006b; }\n.irc-bg52 { background-color: #f00; }\n.irc-bg53 { background-color: #ff8c00; }\n.irc-bg54 { background-color: #ff0; }\n.irc-bg55 { background-color: #b2ff00; }\n.irc-bg56 { background-color: #0f0; }\n.irc-bg57 { background-color: #00ffa0; }\n.irc-bg58 { background-color: #0ff; }\n.irc-bg59 { background-color: #008cff; }\n.irc-bg60 { background-color: #00f; }\n.irc-bg61 { background-color: #a500ff; }\n.irc-bg62 { background-color: #f0f; }\n.irc-bg63 { background-color: #ff0098; }\n.irc-bg64 { background-color: #ff5959; }\n.irc-bg65 { background-color: #ffb459; }\n.irc-bg66 { background-color: #ffff71; }\n.irc-bg67 { background-color: #cfff60; }\n.irc-bg68 { background-color: #6fff6f; }\n.irc-bg69 { background-color: #65ffc9; }\n.irc-bg70 { background-color: #6dffff; }\n.irc-bg71 { background-color: #59b4ff; }\n.irc-bg72 { background-color: #5959ff; }\n.irc-bg73 { background-color: #c459ff; }\n.irc-bg74 { background-color: #f6f; }\n.irc-bg75 { background-color: #ff59bc; }\n.irc-bg76 { background-color: #ff9c9c; }\n.irc-bg77 { background-color: #ffd39c; }\n.irc-bg78 { background-color: #ffff9c; }\n.irc-bg79 { background-color: #e2ff9c; }\n.irc-bg80 { background-color: #9cff9c; }\n.irc-bg81 { background-color: #9cffdb; }\n.irc-bg82 { background-color: #9cffff; }\n.irc-bg83 { background-color: #9cd3ff; }\n.irc-bg84 { background-color: #9c9cff; }\n.irc-bg85 { background-color: #dc9cff; }\n.irc-bg86 { background-color: #ff9cff; }\n.irc-bg87 { background-color: #ff94d3; }\n.irc-bg88 { background-color: #000; }\n.irc-bg89 { background-color: #131313; }\n.irc-bg90 { background-color: #282828; }\n.irc-bg91 { background-color: #363636; }\n.irc-bg92 { background-color: #4d4d4d; }\n.irc-bg93 { background-color: #656565; }\n.irc-bg94 { background-color: #818181; }\n.irc-bg95 { background-color: #9f9f9f; }\n.irc-bg96 { background-color: #bcbcbc; }\n.irc-bg97 { background-color: #e2e2e2; }\n.irc-bg98 { background-color: #fff; }\n\n.irc-bold {\n\tfont-weight: bold;\n}\n\n.irc-underline {\n\ttext-decoration: underline;\n}\n\n.irc-strikethrough {\n\ttext-decoration: line-through;\n}\n\n.irc-underline.irc-strikethrough {\n\ttext-decoration: underline line-through;\n}\n\n.irc-italic {\n\tfont-style: italic;\n}\n\n.tooltipped::after {\n\tfont-size: 12px;\n}\n\n@media (min-width: 480px) {\n\t/* Fade out for long usernames */\n\t#chat .from {\n\t\tpadding-left: 10px;\n\t\tmask-image: linear-gradient(to left, transparent, black 10px);\n\t}\n}\n\n@media (max-width: 768px) {\n\t/**\n\t * TODO Replace this with `@media (hover: hover)` when Firefox supports it\n\t * See:\n\t * - http://stackoverflow.com/a/28058919/1935861\n\t * - http://caniuse.com/#feat=css-media-interaction\n\t * - https://www.w3.org/TR/mediaqueries-4/\n\t * - https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover\n\t */\n\t.tooltipped-no-touch:hover::before,\n\t.tooltipped-no-touch:hover::after {\n\t\tvisibility: hidden;\n\t\topacity: 0;\n\t}\n\n\t#sidebar .logo-container {\n\t\tmargin-top: 5px;\n\t}\n\n\t.channel-list-item,\n\t#sidebar .empty,\n\t.window label,\n\t.header .topic,\n\t#settings .error,\n\t#help .help-item,\n\t#loading,\n\t#context-menu,\n\t#form #input,\n\t.textcomplete-menu,\n\t.messages .msg {\n\t\tfont-size: 15px;\n\t}\n\n\t#sidebar {\n\t\tdisplay: flex;\n\t\tbackground: var(--body-bg-color);\n\t\theight: 100%;\n\t\tposition: absolute;\n\t\tleft: -220px;\n\t\tz-index: 10;\n\t\ttransition: transform 160ms;\n\t\ttransform: translateZ(0);\n\t}\n\n\t#sidebar-overlay {\n\t\tposition: fixed;\n\t\ttop: 0;\n\t\tbottom: 0;\n\t\tleft: 0;\n\t\tright: 0;\n\t\tbackground: var(--overlay-bg-color);\n\t\topacity: 0;\n\t\tvisibility: hidden;\n\t\ttransition: opacity 160ms, visibility 160ms;\n\t\tz-index: 9;\n\t}\n\n\t#viewport.menu-open #sidebar-overlay {\n\t\topacity: 1;\n\t}\n\n\t#viewport.menu-open #sidebar {\n\t\ttransform: translate3d(220px, 0, 0);\n\t}\n\n\t#viewport.menu-dragging #sidebar-overlay,\n\t#viewport.menu-dragging #sidebar {\n\t\ttransition: none;\n\t}\n\n\t#viewport.menu-open #sidebar,\n\t#viewport.menu-dragging #sidebar {\n\t\tbox-shadow: 0 0 25px 0 rgb(0 0 0 / 50%);\n\t}\n\n\t#viewport.menu-open #sidebar-overlay,\n\t#viewport.menu-dragging #sidebar-overlay {\n\t\tvisibility: visible;\n\t}\n\n\t/* On mobile display, channel list button stays at the top */\n\t#viewport .lt {\n\t\tposition: relative;\n\t}\n\n\t#chat .userlist {\n\t\tbackground-color: var(--window-bg-color);\n\t\theight: 100%;\n\t\tposition: absolute;\n\t\tright: 0;\n\t\ttransform: translateX(180px);\n\t\ttransition: transform 0.2s;\n\t\tz-index: 1;\n\t}\n\n\t#viewport.userlist-open #chat .userlist {\n\t\ttransform: translateX(0);\n\t}\n\n\t#chat .header .title {\n\t\tpadding-left: 6px;\n\t}\n\n\t#chat .toggle-content .thumb {\n\t\tmax-height: 58px;\n\t\tmax-width: 104px;\n\t}\n}\n\n@media (max-width: 479px) {\n\t.container {\n\t\tmax-width: 100%;\n\t\tmargin: 0;\n\t}\n\n\t#sign-in .btn {\n\t\twidth: 100%;\n\t}\n\n\t.input {\n\t\tmargin-bottom: 2px;\n\t}\n\n\t#connect .connect-row {\n\t\tflex-direction: column;\n\t}\n\n\t#connect .connect-row > .input,\n\t#connect .connect-row > .input-wrap {\n\t\tflex-grow: 1;\n\t}\n\n\t#help .help-version-title {\n\t\tflex-direction: column;\n\t}\n\n\t#chat .messages {\n\t\tdisplay: block;\n\t\tpadding: 5px 0;\n\t}\n\n\t#chat .msg {\n\t\tdisplay: block;\n\t\tpadding: 2px 10px;\n\t}\n\n\t#chat .msg[data-type=\"condensed\"] .msg {\n\t\tpadding: 2px 0;\n\t}\n\n\t#chat .time,\n\t#chat .from,\n\t#chat .content {\n\t\tborder: 0;\n\t\tdisplay: inline;\n\t\tpadding: 0;\n\t}\n\n\t#chat .from::after {\n\t\t/* Add a space because mobile view changes to block display without paddings */\n\t\tcontent: \" \";\n\t\twhite-space: pre;\n\t}\n\n\t#chat .chat-view[data-type=\"channel\"] .msg.highlight {\n\t\tpadding-left: 5px;\n\t}\n\n\t#chat .chat-view[data-type=\"channel\"] .msg.highlight .time {\n\t\tpadding-left: 0;\n\t}\n\n\t#chat .condensed-summary .time,\n\t#chat .condensed-summary .from {\n\t\tdisplay: none;\n\t}\n\n\t#help .help-item .subject {\n\t\tdisplay: inline-block;\n\t\tpadding-bottom: 4px;\n\t}\n\n\t#help .help-item .description {\n\t\tdisplay: block;\n\t}\n}\n\n::-webkit-scrollbar {\n\twidth: 8px;\n\tbackground-color: rgb(0 0 0 / 0%);\n}\n\n::-webkit-scrollbar:hover {\n\tbackground-color: rgb(0 0 0 / 9%);\n}\n\n::-webkit-scrollbar-thumb:vertical {\n\tbackground: rgb(0 0 0 / 50%);\n\tborder-radius: 100px;\n}\n\n::-webkit-scrollbar-thumb:vertical:active {\n\tbackground: rgb(0 0 0 / 60%);\n}\n\n/* Image viewer and drag-and-drop overlay */\n\n#confirm-dialog-overlay,\n#upload-overlay,\n#image-viewer,\n#image-viewer .open-btn,\n#image-viewer .close-btn {\n\t/* Vertically and horizontally center stuff */\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: center;\n\tjustify-content: center;\n}\n\n#confirm-dialog-overlay,\n#upload-overlay,\n#image-viewer {\n\tposition: fixed;\n\ttop: 0;\n\tbottom: 0;\n\tleft: 0;\n\tright: 0;\n\tbackground: var(--overlay-bg-color);\n\tvisibility: hidden;\n\topacity: 0;\n\ttransition: opacity 0.2s, visibility 0.2s;\n\tz-index: 999;\n\tuser-select: none;\n}\n\n#confirm-dialog-overlay.opened,\n#upload-overlay.is-dragover,\n#image-viewer.opened {\n\tvisibility: visible;\n\topacity: 1;\n}\n\n#confirm-dialog-overlay,\n#image-viewer {\n\tbackground: rgb(0 0 0 / 90%);\n}\n\n#image-viewer .close-btn,\n#image-viewer .open-btn,\n#image-viewer .previous-image-btn,\n#image-viewer .next-image-btn {\n\tposition: fixed;\n\ttop: 0;\n\twidth: 2em;\n\tfont-size: 36px;\n\tcolor: white;\n\topacity: 0.6;\n\ttransition: 0.2s opacity;\n}\n\n#image-viewer .close-btn {\n\tright: 0;\n\theight: 2em;\n\tz-index: 1002;\n}\n\n#image-viewer .close-btn::before {\n\tcontent: \"×\";\n}\n\n#image-viewer .open-btn {\n\tright: 0;\n\tbottom: 0;\n\ttop: auto;\n\theight: 2em;\n\tz-index: 1002;\n}\n\n#image-viewer .previous-image-btn,\n#image-viewer .next-image-btn {\n\tbottom: 0;\n\tz-index: 1001;\n}\n\n#image-viewer .previous-image-btn {\n\tleft: 0;\n}\n\n#image-viewer .next-image-btn {\n\tright: 0;\n}\n\n#image-viewer .close-btn:hover,\n#image-viewer .previous-image-btn:hover,\n#image-viewer .next-image-btn:hover {\n\topacity: 1;\n}\n\n#image-viewer > img {\n\tcursor: grab;\n\tposition: absolute;\n\ttransform-origin: 50% 50%;\n\n\t/* Checkered background for transparent images */\n\tbackground-position: 0 0, 10px 10px;\n\tbackground-size: 20px 20px;\n\tbackground-image:\n\t\tlinear-gradient(45deg, #eee 25%, rgb(0 0 0 / 0%) 25%, rgb(0 0 0 / 0%) 75%, #eee 75%, #eee 100%),\n\t\tlinear-gradient(45deg, #eee 25%, #fff 25%, #fff 75%, #eee 75%, #eee 100%);\n}\n\n/* Correctly handle multiple successive whitespace characters.\n For example: user has quit ( ===> L O L <=== ) */\n\n.header .topic,\n#chat .msg[data-type=\"action\"] .content,\n#chat .msg[data-type=\"message\"] .content,\n#chat .msg[data-type=\"monospace_block\"] .content,\n#chat .msg[data-type=\"notice\"] .content,\n#chat .ctcp-message,\n#chat .part-reason,\n#chat .quit-reason,\n#chat .new-topic,\n#chat table.channel-list .topic {\n\twhite-space: pre-wrap;\n}\n\n.chat-view[data-type=\"search-results\"] .search-status {\n\tdisplay: flex;\n\theight: 100%;\n\tjustify-content: center;\n\talign-items: center;\n}\n","\n\t\n\t\tYou are not connected to any networks yet.\n\t\n\t\n\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\tNo results found.\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t 0\"\n\t\t\t\t\t\t\t:key=\"channel.id\"\n\t\t\t\t\t\t\t:channel=\"channel\"\n\t\t\t\t\t\t\t:network=\"network\"\n\t\t\t\t\t\t\t:active=\"\n\t\t\t\t\t\t\t\t$store.state.activeChannel &&\n\t\t\t\t\t\t\t\tchannel === $store.state.activeChannel.channel\n\t\t\t\t\t\t\t\"\n\t\t\t\t\t\t/>\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\n\n\n\n\n","\n\t\n\t\t\n\t\t\t\n\t\t\t\t{{ data.title }}\n\t\t\t\t{{ data.text }}\n\t\t\t\n\t\t\t\n\t\t\t\tCancel\n\t\t\t\t{{ data.button }}\n\t\t\t\n\t\t\n\t\n\n\n\n\n\n","\n\t\n\t\t\n\t\t\t\n\t\t\t\tRecent mentions\n\t\t\t\t\n\t\t\t\t\tDismiss all\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\tLoading…\n\t\t\t\tYou have no recent mentions.\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\tin {{ message.channel.channel.name }} on\n\t\t\t\t\t\t\t\t\t{{ message.channel.network.name }}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t in unknown channel \n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t{{ messageTime(message.time) }}\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\n\n\n\n\n","\n\t\n\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tEdit {{ defaults.name }}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tConnect\n\t\t\t\t\t\n\t\t\t\t\t\tto {{ defaults.name }}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\tNetwork settings\n\t\t\t\t\n\t\t\t\t\tName\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tServer\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t:\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tPassword\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tUse secure connection (TLS)\n\t\t\t\t\t\t\t🔒 STS\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tOnly allow trusted certificates\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\tProxy Settings\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\tEnable Proxy\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tSOCKS Address\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t:\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\n\t\t\t\t\t\n\t\t\t\t\t\tProxy username\n\t\t\t\t\t\t\n\t\t\t\t\t\n\n\t\t\t\t\t\n\t\t\t\t\t\tProxy password\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\tNetwork settings\n\t\t\t\t\n\t\t\t\t\tName\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tPassword\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\tUser preferences\n\t\t\t\n\t\t\t\tNick\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tUsername\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\tReal name\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\tLeave message\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tCommands\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tChannels\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tI have a password\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tPassword\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\tAuthentication\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tNo authentication\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tUsername + password (SASL PLAIN)\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tClient certificate (SASL EXTERNAL)\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tAccount\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tPassword\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tThe Lounge automatically generates and manages the client certificate.\n\t\t\t\t\t\n\t\t\t\t\t\tOn the IRC server, you will need to tell the services to attach the\n\t\t\t\t\t\tcertificate fingerprint (certfp) to your account, for example:\n\t\t\t\t\t\n\t\t\t\t\t/msg NickServ CERT ADD\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tSave network\n\t\t\t\t\tConnect\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\n\n\n\n\n","\n\t\n\t\t\n\t\t\t{{ session.agent }}\n\n\t\t\t{{\n\t\t\t\tsession.ip\n\t\t\t}}\n\n\t\t\t 1\" class=\"session-usage\">\n\t\t\t\tActive in {{ session.active }} browsers\n\t\t\t\n\t\t\t\n\t\t\t\tLast used on {{ lastUse }}\n\t\t\t\n\t\t\n\t\t\n\t\t\t\n\t\t\t\tSign out\n\t\t\t\tRevoke\n\t\t\t\n\t\t\n\t\n\n\n\n\n\n","\n\t\n\t\t\n\t\t\t\n\t\t\n\t\t\n\t\t\tSettings\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tAdvanced settings\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tNative app\n\t\t\t\t\n\t\t\t\t\tAdd The Lounge to Home screen\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tOpen irc:// URLs with The Lounge\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tSettings synchronisation\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tSynchronize settings with other clients\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tWarning: Checking this box will override the settings\n\t\t\t\t\t\t\tof this client with those stored on the server.\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tUse the button below to enable synchronization, and override any\n\t\t\t\t\t\t\tsettings already synced to the server.\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tSync settings and enable\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tWarning: No settings have been synced before. Enabling\n\t\t\t\t\t\t\tthis will sync all settings of this client as the base for other\n\t\t\t\t\t\t\tclients.\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\tMessages\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tShow MOTD\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tInclude seconds in timestamp\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tUse 12-hour timestamps\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\tAutomatic away message\n\n\t\t\t\t\n\t\t\t\t\tAutomatic away message\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\tStatus messages\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tShow all status messages individually\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tCondense status messages together\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tHide all status messages\n\t\t\t\t\n\t\t\t\n\t\t\tVisual Aids\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tEnable colored nicknames\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tEnable autocomplete\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tNick autocomplete postfix\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\tTheme\n\t\t\t\n\t\t\t\tTheme\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{{ theme.displayName }}\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tLink previews\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tAuto-expand media\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tAuto-expand websites\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tFile uploads\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\tAttempt to remove metadata from images before uploading\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tPush Notifications\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tUnsubscribe from push notifications\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tLoading…\n\t\t\t\t\t\t\n\t\t\t\t\t\t Subscribe to push notifications \n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tWarning: Push notifications are only supported over HTTPS\n\t\t\t\t\t\tconnections.\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tWarning:\n\t\t\t\t\t\tPush notifications are not supported by your browser.\n\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tSafari does\n\t\t\t\t\t\t\tnot support the web push notification specification, and because all browsers on iOS use Safari under the hood, The Lounge\n\t\t\t\t\t\t\tis unable to provide push notifications on iOS devices.\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\tBrowser Notifications\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tEnable browser notifications\n\t\t\t\t\t\n\t\t\t\t\t\tWarning: Notifications are not supported by your browser.\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tWarning: Notifications are only supported over HTTPS\n\t\t\t\t\t\tconnections.\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tWarning: Notifications are blocked by your browser.\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tEnable notification sound\n\t\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\tPlay sound\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tEnable notification for all messages\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tCustom highlights\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tHighlight exceptions\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tChange password\n\t\t\t\t\n\t\t\t\t\t Enter current password \n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t Enter desired new password \n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t Repeat new password \n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\tSuccessfully updated your password\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t{{ passwordErrors[passwordChangeStatus.error] }}\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\tChange password\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tCustom Stylesheet\n\t\t\t\t\n\t\t\t\t\tCustom stylesheet. You can override any style with CSS here.\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\n\t\t\t\n\t\t\t\tSessions\n\n\t\t\t\tCurrent session\n\t\t\t\t\n\n\t\t\t\t 0\">\n\t\t\t\t\tActive sessions\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\tOther sessions\n\t\t\t\tLoading…\n\t\t\t\t\n\t\t\t\t\tYou are not currently logged in to any other device.\n\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\n\n\n\n\n","\n\t\n\t\t\n\t\t\t\n\t\t\n\t\t\n\t\n\n\n\n\n\n","\n\t\n\t\t\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\tSearching in {{ channel.name }} for\n\t\t\t\t\t{{ $route.query.q }}\n\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\tLoading…\n\t\t\t\t\t\t\t\tShow older messages\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tSearching…\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\tNo results found.\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\n\t\t\t\n\t\t\n\t\n\n\n\n\n\n"],"names":[],"sourceRoot":""} diff --git a/aprsd/web/chat/static/css/tabs.css b/aprsd/web/chat/static/css/tabs.css new file mode 100644 index 0000000..755c943 --- /dev/null +++ b/aprsd/web/chat/static/css/tabs.css @@ -0,0 +1,41 @@ +* {box-sizing: border-box} + +/* Style the tab */ +.tab { + border: 1px solid #ccc; + background-color: #f1f1f1; + height: 450px; +} + +/* Style the buttons inside the tab */ +.tab div { + display: block; + background-color: inherit; + color: black; + padding: 10px; + width: 100%; + border: none; + outline: none; + text-align: left; + cursor: pointer; + transition: 0.3s; + font-size: 17px; +} + +/* Change background color of buttons on hover */ +.tab div:hover { + background-color: #ddd; +} + +/* Create an active/current "tab button" class */ +.tab div.active { + background-color: #ccc; +} + +/* Style the tab content */ +.tabcontent { + border: 1px solid #ccc; + height: 450px; + overflow-y: scroll; + background-color: white; +} diff --git a/aprsd/web/chat/static/images/Untitled.png b/aprsd/web/chat/static/images/Untitled.png new file mode 100644 index 0000000..666fbc4 Binary files /dev/null and b/aprsd/web/chat/static/images/Untitled.png differ diff --git a/aprsd/web/chat/static/images/aprs-symbols-16-0.png b/aprsd/web/chat/static/images/aprs-symbols-16-0.png new file mode 100644 index 0000000..81eec3d Binary files /dev/null and b/aprsd/web/chat/static/images/aprs-symbols-16-0.png differ diff --git a/aprsd/web/chat/static/images/aprs-symbols-16-1.png b/aprsd/web/chat/static/images/aprs-symbols-16-1.png new file mode 100644 index 0000000..5af31d9 Binary files /dev/null and b/aprsd/web/chat/static/images/aprs-symbols-16-1.png differ diff --git a/aprsd/web/chat/static/images/aprs-symbols-64-0.png b/aprsd/web/chat/static/images/aprs-symbols-64-0.png new file mode 100644 index 0000000..81eec3d Binary files /dev/null and b/aprsd/web/chat/static/images/aprs-symbols-64-0.png differ diff --git a/aprsd/web/chat/static/images/aprs-symbols-64-1.png b/aprsd/web/chat/static/images/aprs-symbols-64-1.png new file mode 100644 index 0000000..5af31d9 Binary files /dev/null and b/aprsd/web/chat/static/images/aprs-symbols-64-1.png differ diff --git a/aprsd/web/chat/static/images/aprs-symbols-64-2.png b/aprsd/web/chat/static/images/aprs-symbols-64-2.png new file mode 100644 index 0000000..1f940d4 Binary files /dev/null and b/aprsd/web/chat/static/images/aprs-symbols-64-2.png differ diff --git a/aprsd/web/chat/static/js/main.js b/aprsd/web/chat/static/js/main.js new file mode 100644 index 0000000..dc27310 --- /dev/null +++ b/aprsd/web/chat/static/js/main.js @@ -0,0 +1,44 @@ +function aprs_img(item, x_offset, y_offset) { + var x = x_offset * -16; + if (y_offset > 5) { + y_offset = 5; + } + var y = y_offset * -16; + var loc = x + 'px '+ y + 'px' + item.css('background-position', loc); +} + +function show_aprs_icon(item, symbol) { + var offset = ord(symbol) - 33; + var col = Math.floor(offset / 16); + var row = offset % 16; + //console.log("'" + symbol+"' off: "+offset+" row: "+ row + " col: " + col) + aprs_img(item, row, col); +} + +function ord(str){return str.charCodeAt(0);} + +function update_stats( data ) { + $("#version").text( data["stats"]["aprsd"]["version"] ); + $("#aprs_connection").html( data["aprs_connection"] ); + $("#uptime").text( "uptime: " + data["stats"]["aprsd"]["uptime"] ); + short_time = data["time"].split(/\s(.+)/)[1]; +} + + +function start_update() { + + (function statsworker() { + $.ajax({ + url: "/stats", + type: 'GET', + dataType: 'json', + success: function(data) { + update_stats(data); + }, + complete: function() { + setTimeout(statsworker, 10000); + } + }); + })(); +} diff --git a/aprsd/web/chat/static/js/send-message.js b/aprsd/web/chat/static/js/send-message.js new file mode 100644 index 0000000..d6b20c4 --- /dev/null +++ b/aprsd/web/chat/static/js/send-message.js @@ -0,0 +1,215 @@ +var cleared = false; +var callsign_list = {}; +var message_list = {}; + +function size_dict(d){c=0; for (i in d) ++c; return c} + +function init_chat() { + const socket = io("/sendmsg"); + socket.on('connect', function () { + console.log("Connected to socketio"); + }); + socket.on('connected', function(msg) { + console.log("Connected!"); + console.log(msg); + }); + + socket.on("sent", function(msg) { + if (cleared == false) { + var msgsdiv = $("#msgsTabsDiv"); + msgsdiv.html('') + cleared = true + } + sent_msg(msg); + }); + + socket.on("ack", function(msg) { + update_msg(msg); + }); + + socket.on("new", function(msg) { + if (cleared == false) { + var msgsdiv = $("#msgsTabsDiv"); + msgsdiv.html('') + cleared = true + } + from_msg(msg); + }); + + $("#sendform").submit(function(event) { + event.preventDefault(); + msg = {'to': $('#to_call').val(), + 'message': $('#message').val(), + } + socket.emit("send", msg); + $('#message').val(''); + }); +} + + +function add_callsign(callsign) { + /* Ensure a callsign exists in the left hand nav */ + + if (callsign in callsign_list) { + return false + } + + var callsignTabs = $("#callsignTabs"); + tab_name = tab_string(callsign); + tab_content = tab_content_name(callsign); + divname = content_divname(callsign); + + item_html = ''+callsign+''; + callsignTabs.append(item_html); + callsign_list[callsign] = true; + return true +} + +function append_message(callsign, msg, msg_html) { + new_callsign = false + if (!message_list.hasOwnProperty(callsign)) { + message_list[callsign] = new Array(); + } + message_list[callsign].push(msg); + + // Find the right div to place the html + new_callsign = add_callsign(callsign); + append_message_html(callsign, msg_html, new_callsign); + if (new_callsign) { + //click on the new tab + click_div = '#'+tab_string(callsign); + $(click_div).click(); + } +} + +function tab_string(callsign) { + return "msgs"+callsign; +} + +function tab_content_name(callsign) { + return tab_string(callsign)+"Content"; +} + +function content_divname(callsign) { + return "#"+tab_content_name(callsign); +} + +function append_message_html(callsign, msg_html, new_callsign) { + var msgsTabs = $('#msgsTabsDiv'); + divname_str = tab_content_name(callsign); + divname = content_divname(callsign); + if (new_callsign) { + // we have to add a new DIV + msg_div_html = ''+msg_html+''; + msgsTabs.append(msg_div_html); + } else { + var msgDiv = $(divname); + msgDiv.append(msg_html); + } + + $(divname).animate({scrollTop: $(divname)[0].scrollHeight}, "slow"); +} + +function create_message_html(time, from, to, message, ack) { + msg_html = ''; + msg_html += ''+time+''; + msg_html += ''; + msg_html += ''+from+''; + if (ack) { + msg_html += ''; + } else { + msg_html += ''; + } + msg_html += '> '; + msg_html += ''; + msg_html += ''+message+''; + msg_html += ''; + + return msg_html +} + +function sent_msg(msg) { + var msgsdiv = $("#sendMsgsDiv"); + + ts_str = msg["ts"].toString(); + ts = ts_str.split(".")[0]*1000; + id = ts_str.split('.')[0] + ack_id = "ack_" + id + + var d = new Date(ts).toLocaleDateString("en-US") + var t = new Date(ts).toLocaleTimeString("en-US") + + msg_html = create_message_html(t, msg['from'], msg['to'], msg['message'], ack_id); + append_message(msg['to'], msg, msg_html); +} + +function from_msg(msg) { + var msgsdiv = $("#sendMsgsDiv"); + + // We have an existing entry + ts_str = msg["ts"].toString(); + ts = ts_str.split(".")[0]*1000; + id = ts_str.split('.')[0] + ack_id = "ack_" + id + + var d = new Date(ts).toLocaleDateString("en-US") + var t = new Date(ts).toLocaleTimeString("en-US") + + from = msg['from'] + msg_html = create_message_html(t, from, false, msg['message'], false); + append_message(from, msg, msg_html); +} + +function update_msg(msg) { + var msgsdiv = $("#sendMsgsDiv"); + // We have an existing entry + ts_str = msg["ts"].toString(); + id = ts_str.split('.')[0] + pretty_id = "pretty_" + id + loader_id = "loader_" + id + ack_id = "ack_" + id + span_id = "span_" + id + + + + if (msg['ack'] == true) { + var loader_div = $('#' + loader_id); + var ack_div = $('#' + ack_id); + loader_div.removeClass('ui active inline loader'); + loader_div.addClass('ui disabled loader'); + ack_div.removeClass('thumbs up outline icon'); + ack_div.addClass('thumbs up outline icon'); + } + + $('.ui.accordion').accordion('refresh'); +} + +function callsign_select(callsign) { + var tocall = $("#to_call"); + tocall.val(callsign); +} + +function reset_Tabs() { + tabcontent = document.getElementsByClassName("tabcontent"); + for (i = 0; i < tabcontent.length; i++) { + tabcontent[i].style.display = "none"; + } +} + +function openCallsign(evt, callsign) { + var i, tabcontent, tablinks; + + tab_content = tab_content_name(callsign); + + tabcontent = document.getElementsByClassName("tabcontent"); + for (i = 0; i < tabcontent.length; i++) { + tabcontent[i].style.display = "none"; + } + tablinks = document.getElementsByClassName("tablinks"); + for (i = 0; i < tablinks.length; i++) { + tablinks[i].className = tablinks[i].className.replace(" active", ""); + } + document.getElementById(tab_content).style.display = "block"; + evt.target.className += " active"; + callsign_select(callsign); +} diff --git a/aprsd/web/chat/static/js/tabs.js b/aprsd/web/chat/static/js/tabs.js new file mode 100644 index 0000000..97cd2f8 --- /dev/null +++ b/aprsd/web/chat/static/js/tabs.js @@ -0,0 +1,28 @@ +function openTab(evt, tabName) { + // Declare all variables + var i, tabcontent, tablinks; + + if (typeof tabName == 'undefined') { + return + } + + // Get all elements with class="tabcontent" and hide them + tabcontent = document.getElementsByClassName("tabcontent"); + for (i = 0; i < tabcontent.length; i++) { + tabcontent[i].style.display = "none"; + } + + // Get all elements with class="tablinks" and remove the class "active" + tablinks = document.getElementsByClassName("tablinks"); + for (i = 0; i < tablinks.length; i++) { + tablinks[i].className = tablinks[i].className.replace(" active", ""); + } + + // Show the current tab, and add an "active" class to the button that opened the tab + document.getElementById(tabName).style.display = "block"; + if (typeof evt.currentTarget == 'undefined') { + return + } else { + evt.currentTarget.className += " active"; + } +} diff --git a/aprsd/web/chat/static/json-viewer/jquery.json-viewer.css b/aprsd/web/chat/static/json-viewer/jquery.json-viewer.css new file mode 100644 index 0000000..57aa450 --- /dev/null +++ b/aprsd/web/chat/static/json-viewer/jquery.json-viewer.css @@ -0,0 +1,57 @@ +/* Root element */ +.json-document { + padding: 1em 2em; +} + +/* Syntax highlighting for JSON objects */ +ul.json-dict, ol.json-array { + list-style-type: none; + margin: 0 0 0 1px; + border-left: 1px dotted #ccc; + padding-left: 2em; +} +.json-string { + color: #0B7500; +} +.json-literal { + color: #1A01CC; + font-weight: bold; +} + +/* Toggle button */ +a.json-toggle { + position: relative; + color: inherit; + text-decoration: none; +} +a.json-toggle:focus { + outline: none; +} +a.json-toggle:before { + font-size: 1.1em; + color: #c0c0c0; + content: "\25BC"; /* down arrow */ + position: absolute; + display: inline-block; + width: 1em; + text-align: center; + line-height: 1em; + left: -1.2em; +} +a.json-toggle:hover:before { + color: #aaa; +} +a.json-toggle.collapsed:before { + /* Use rotated down arrow, prevents right arrow appearing smaller than down arrow in some browsers */ + transform: rotate(-90deg); +} + +/* Collapsable placeholder links */ +a.json-placeholder { + color: #aaa; + padding: 0 1em; + text-decoration: none; +} +a.json-placeholder:hover { + text-decoration: underline; +} diff --git a/aprsd/web/chat/static/json-viewer/jquery.json-viewer.js b/aprsd/web/chat/static/json-viewer/jquery.json-viewer.js new file mode 100644 index 0000000..611411b --- /dev/null +++ b/aprsd/web/chat/static/json-viewer/jquery.json-viewer.js @@ -0,0 +1,158 @@ +/** + * jQuery json-viewer + * @author: Alexandre Bodelot + * @link: https://github.com/abodelot/jquery.json-viewer + */ +(function($) { + + /** + * Check if arg is either an array with at least 1 element, or a dict with at least 1 key + * @return boolean + */ + function isCollapsable(arg) { + return arg instanceof Object && Object.keys(arg).length > 0; + } + + /** + * Check if a string represents a valid url + * @return boolean + */ + function isUrl(string) { + var urlRegexp = /^(https?:\/\/|ftps?:\/\/)?([a-z0-9%-]+\.){1,}([a-z0-9-]+)?(:(\d{1,5}))?(\/([a-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+)?)?$/i; + return urlRegexp.test(string); + } + + /** + * Transform a json object into html representation + * @return string + */ + function json2html(json, options) { + var html = ''; + if (typeof json === 'string') { + // Escape tags and quotes + json = json + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"'); + + if (options.withLinks && isUrl(json)) { + html += '' + json + ''; + } else { + // Escape double quotes in the rendered non-URL string. + json = json.replace(/"/g, '\\"'); + html += '"' + json + '"'; + } + } else if (typeof json === 'number') { + html += '' + json + ''; + } else if (typeof json === 'boolean') { + html += '' + json + ''; + } else if (json === null) { + html += 'null'; + } else if (json instanceof Array) { + if (json.length > 0) { + html += '['; + for (var i = 0; i < json.length; ++i) { + html += ''; + // Add toggle button if item is collapsable + if (isCollapsable(json[i])) { + html += ''; + } + html += json2html(json[i], options); + // Add comma if item is not last + if (i < json.length - 1) { + html += ','; + } + html += ''; + } + html += ']'; + } else { + html += '[]'; + } + } else if (typeof json === 'object') { + var keyCount = Object.keys(json).length; + if (keyCount > 0) { + html += '{'; + for (var key in json) { + if (Object.prototype.hasOwnProperty.call(json, key)) { + html += ''; + var keyRepr = options.withQuotes ? + '"' + key + '"' : key; + // Add toggle button if item is collapsable + if (isCollapsable(json[key])) { + html += '' + keyRepr + ''; + } else { + html += keyRepr; + } + html += ': ' + json2html(json[key], options); + // Add comma if item is not last + if (--keyCount > 0) { + html += ','; + } + html += ''; + } + } + html += '}'; + } else { + html += '{}'; + } + } + return html; + } + + /** + * jQuery plugin method + * @param json: a javascript object + * @param options: an optional options hash + */ + $.fn.jsonViewer = function(json, options) { + // Merge user options with default options + options = Object.assign({}, { + collapsed: false, + rootCollapsable: true, + withQuotes: false, + withLinks: true + }, options); + + // jQuery chaining + return this.each(function() { + + // Transform to HTML + var html = json2html(json, options); + if (options.rootCollapsable && isCollapsable(json)) { + html = '' + html; + } + + // Insert HTML in target DOM element + $(this).html(html); + $(this).addClass('json-document'); + + // Bind click on toggle buttons + $(this).off('click'); + $(this).on('click', 'a.json-toggle', function() { + var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array'); + target.toggle(); + if (target.is(':visible')) { + target.siblings('.json-placeholder').remove(); + } else { + var count = target.children('li').length; + var placeholder = count + (count > 1 ? ' items' : ' item'); + target.after('' + placeholder + ''); + } + return false; + }); + + // Simulate click on toggle button when placeholder is clicked + $(this).on('click', 'a.json-placeholder', function() { + $(this).siblings('a.json-toggle').click(); + return false; + }); + + if (options.collapsed == true) { + // Trigger click to collapse all nodes + $(this).find('a.json-toggle').click(); + } + }); + }; +})(jQuery); diff --git a/aprsd/web/chat/templates/index.html b/aprsd/web/chat/templates/index.html new file mode 100644 index 0000000..7f731bd --- /dev/null +++ b/aprsd/web/chat/templates/index.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + APRSD WebChat {{ version }} + + + + + {{ callsign }} + connected to + {{ aprs_connection|safe }} + + + + NONE + + + + + Send Message + + + + Callsign + + + + + + + Message + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dev-requirements.in b/dev-requirements.in index e55ac9e..ad4e559 100644 --- a/dev-requirements.in +++ b/dev-requirements.in @@ -10,3 +10,5 @@ pip-tools pytest pytest-cov gray +pip==22.0.4 +pip-tools==5.4.0 diff --git a/dev-requirements.txt b/dev-requirements.txt index 42c4582..0a6d9dc 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,257 +1,99 @@ # -# This file is autogenerated by pip-compile with python 3.8 +# This file is autogenerated by pip-compile # To update, run: # # pip-compile dev-requirements.in # -add-trailing-comma==2.1.0 - # via gray -alabaster==0.7.12 - # via sphinx -appdirs==1.4.4 - # via black -attrs==21.2.0 - # via - # jsonschema - # pytest -autoflake==1.4 - # via gray -babel==2.9.1 - # via sphinx -backports-entry-points-selectable==1.1.0 - # via virtualenv -black==21.7b0 - # via gray -bleach==4.1.0 - # via readme-renderer -build==0.9.0 - # via pip-tools -certifi==2021.5.30 - # via requests -cfgv==3.3.1 - # via pre-commit -charset-normalizer==2.0.4 - # via requests -click==8.0.1 - # via - # black - # pip-tools -colorama==0.4.4 - # via twine -colorlog==6.4.1 - # via prettylog -configargparse==1.5.2 - # via gray -coverage==5.5 - # via pytest-cov -distlib==0.3.2 - # via virtualenv -docutils==0.17.1 - # via - # readme-renderer - # sphinx -fast-json==0.3.2 - # via prettylog -filelock==3.0.12 - # via - # tox - # virtualenv -fixit==0.1.4 - # via gray -flake8==3.9.2 - # via - # -r dev-requirements.in - # fixit - # flake8-polyfill - # pep8-naming -flake8-polyfill==1.0.2 - # via pep8-naming -gray==0.10.1 - # via -r dev-requirements.in -identify==2.2.13 - # via pre-commit -idna==3.2 - # via requests -imagesize==1.2.0 - # via sphinx -importlib-metadata==4.7.1 - # via - # keyring - # twine -importlib-resources==5.2.2 - # via fixit -iniconfig==1.1.1 - # via pytest -isort==5.9.3 - # via - # -r dev-requirements.in - # gray -jinja2==3.0.1 - # via sphinx -jsonschema==3.2.0 - # via fixit -keyring==23.1.0 - # via twine -libcst==0.3.20 - # via fixit -markupsafe==2.0.1 - # via jinja2 -mccabe==0.6.1 - # via flake8 -mypy==0.910 - # via -r dev-requirements.in -mypy-extensions==0.4.3 - # via - # black - # mypy - # typing-inspect -nodeenv==1.6.0 - # via pre-commit -packaging==21.0 - # via - # bleach - # build - # pytest - # sphinx - # tox -pathspec==0.9.0 - # via black -pep517==0.11.0 - # via build -pep8-naming==0.12.1 - # via -r dev-requirements.in -pip-tools==6.9.0 - # via -r dev-requirements.in -pkginfo==1.7.1 - # via twine -platformdirs==2.2.0 - # via virtualenv -pluggy==1.0.0 - # via - # pytest - # tox -pre-commit==2.14.0 - # via -r dev-requirements.in -prettylog==0.3.0 - # via gray -py==1.10.0 - # via - # pytest - # tox -pycodestyle==2.7.0 - # via flake8 -pyflakes==2.3.1 - # via - # autoflake - # flake8 -pygments==2.10.0 - # via - # readme-renderer - # sphinx -pyparsing==2.4.7 - # via packaging -pyrsistent==0.18.0 - # via jsonschema -pytest==6.2.5 - # via - # -r dev-requirements.in - # pytest-cov -pytest-cov==2.12.1 - # via -r dev-requirements.in -pytz==2021.1 - # via babel -pyupgrade==2.24.0 - # via gray -pyyaml==5.4.1 - # via - # fixit - # libcst - # pre-commit -readme-renderer==29.0 - # via twine -regex==2021.8.27 - # via black -requests==2.26.0 - # via - # requests-toolbelt - # sphinx - # twine -requests-toolbelt==0.9.1 - # via twine -rfc3986==1.5.0 - # via twine -six==1.16.0 - # via - # bleach - # jsonschema - # readme-renderer - # tox - # virtualenv -snowballstemmer==2.1.0 - # via sphinx -sphinx==4.1.2 - # via -r dev-requirements.in -sphinxcontrib-applehelp==1.0.2 - # via sphinx -sphinxcontrib-devhelp==1.0.2 - # via sphinx -sphinxcontrib-htmlhelp==2.0.0 - # via sphinx -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.3 - # via sphinx -sphinxcontrib-serializinghtml==1.1.5 - # via sphinx -tokenize-rt==4.1.0 - # via - # add-trailing-comma - # pyupgrade -toml==0.10.2 - # via - # mypy - # pre-commit - # pytest - # pytest-cov - # tox -tomli==1.2.1 - # via - # black - # build - # pep517 -tox==3.24.3 - # via -r dev-requirements.in -tqdm==4.62.2 - # via twine -twine==3.4.2 - # via -r dev-requirements.in -typing-extensions==3.10.0.0 - # via - # libcst - # mypy - # typing-inspect -typing-inspect==0.7.1 - # via libcst -ujson==4.1.0 - # via fast-json -unify==0.5 - # via gray -untokenize==0.1.1 - # via unify -urllib3==1.26.6 - # via requests -virtualenv==20.7.2 - # via - # pre-commit - # tox -webencodings==0.5.1 - # via bleach -wheel==0.37.0 - # via pip-tools -zipp==3.5.0 - # via - # importlib-metadata - # importlib-resources +add-trailing-comma==2.3.0 # via gray +alabaster==0.7.12 # via sphinx +attrs==22.1.0 # via jsonschema, pytest +autoflake==1.7.7 # via gray +babel==2.11.0 # via sphinx +black==22.10.0 # via gray +bleach==5.0.1 # via readme-renderer +certifi==2022.9.24 # via requests +cfgv==3.3.1 # via pre-commit +charset-normalizer==2.1.1 # via requests +click==8.1.3 # via black, pip-tools +colorlog==6.7.0 # via prettylog +commonmark==0.9.1 # via rich +configargparse==1.5.3 # via gray +coverage[toml]==6.5.0 # via pytest-cov +distlib==0.3.6 # via virtualenv +docutils==0.19 # via readme-renderer, sphinx +exceptiongroup==1.0.4 # via pytest +fast-json==0.3.2 # via prettylog +filelock==3.8.0 # via tox, virtualenv +fixit==0.1.4 # via gray +flake8==5.0.4 # via -r dev-requirements.in, fixit, pep8-naming +gray==0.12.0 # via -r dev-requirements.in +identify==2.5.9 # via pre-commit +idna==3.4 # via requests +imagesize==1.4.1 # via sphinx +importlib-metadata==5.0.0 # via keyring, sphinx, twine +importlib-resources==5.10.0 # via fixit, jsonschema +iniconfig==1.1.1 # via pytest +isort==5.10.1 # via -r dev-requirements.in, gray +jaraco.classes==3.2.3 # via keyring +jinja2==3.1.2 # via sphinx +jsonschema==4.17.1 # via fixit +keyring==23.11.0 # via twine +libcst==0.4.9 # via fixit +markupsafe==2.1.1 # via jinja2 +mccabe==0.7.0 # via flake8 +more-itertools==9.0.0 # via jaraco.classes +mypy-extensions==0.4.3 # via black, mypy, typing-inspect +mypy==0.991 # via -r dev-requirements.in +nodeenv==1.7.0 # via pre-commit +packaging==21.3 # via pytest, sphinx, tox +pathspec==0.10.2 # via black +pep8-naming==0.13.2 # via -r dev-requirements.in +pip-tools==5.4.0 # via -r dev-requirements.in +pkginfo==1.8.3 # via twine +pkgutil-resolve-name==1.3.10 # via jsonschema +platformdirs==2.5.4 # via black, virtualenv +pluggy==1.0.0 # via pytest, tox +pre-commit==2.20.0 # via -r dev-requirements.in +prettylog==0.3.0 # via gray +py==1.11.0 # via tox +pycodestyle==2.9.1 # via flake8 +pyflakes==2.5.0 # via autoflake, flake8 +pygments==2.13.0 # via readme-renderer, rich, sphinx +pyparsing==3.0.9 # via packaging +pyrsistent==0.19.2 # via jsonschema +pytest-cov==4.0.0 # via -r dev-requirements.in +pytest==7.2.0 # via -r dev-requirements.in, pytest-cov +pytz==2022.6 # via babel +pyupgrade==3.2.2 # via gray +pyyaml==6.0 # via fixit, libcst, pre-commit +readme-renderer==37.3 # via twine +requests-toolbelt==0.10.1 # via twine +requests==2.28.1 # via requests-toolbelt, sphinx, twine +rfc3986==2.0.0 # via twine +rich==12.6.0 # via twine +six==1.16.0 # via bleach, pip-tools, tox +snowballstemmer==2.2.0 # via sphinx +sphinx==5.3.0 # via -r dev-requirements.in +sphinxcontrib-applehelp==1.0.2 # via sphinx +sphinxcontrib-devhelp==1.0.2 # via sphinx +sphinxcontrib-htmlhelp==2.0.0 # via sphinx +sphinxcontrib-jsmath==1.0.1 # via sphinx +sphinxcontrib-qthelp==1.0.3 # via sphinx +sphinxcontrib-serializinghtml==1.1.5 # via sphinx +tokenize-rt==5.0.0 # via add-trailing-comma, pyupgrade +toml==0.10.2 # via pre-commit +tomli==2.0.1 # via autoflake, black, coverage, mypy, pytest, tox +tox==3.27.1 # via -r dev-requirements.in +twine==4.0.1 # via -r dev-requirements.in +typing-extensions==4.4.0 # via black, libcst, mypy, rich, typing-inspect +typing-inspect==0.8.0 # via libcst +ujson==5.5.0 # via fast-json +unify==0.5 # via gray +untokenize==0.1.1 # via unify +urllib3==1.26.12 # via requests, twine +virtualenv==20.16.7 # via pre-commit, tox +webencodings==0.5.1 # via bleach +zipp==3.10.0 # via importlib-metadata, importlib-resources # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/requirements.in b/requirements.in index 9b524b6..c854366 100644 --- a/requirements.in +++ b/requirements.in @@ -1,20 +1,19 @@ -aioax25>=0.0.10 aprslib>=0.7.0 click click-completion -flask +flask==2.1.2 +werkzeug==2.1.2 flask-classful flask-httpauth imapclient -opencage pluggy pbr pyyaml # Allowing a newer version can lead to a conflict with # requests. py3-validate-email -pytz requests +pytz six thesmuggler update_checker @@ -24,3 +23,7 @@ tabulate rich # For the list-plugins pypi.org search scraping beautifulsoup4 +wrapt +# kiss3 uses attrs +kiss3 +attrs==22.1.0 diff --git a/requirements.txt b/requirements.txt index 5ce8a57..5fadfa4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,130 +1,55 @@ # -# This file is autogenerated by pip-compile with python 3.8 +# This file is autogenerated by pip-compile # To update, run: # # pip-compile requirements.in # -aioax25==0.0.10 - # via -r requirements.in -aprslib==0.7.0 - # via -r requirements.in -backoff==1.11.1 - # via opencage -beautifulsoup4==4.10.0 - # via -r requirements.in -bidict==0.21.2 - # via python-socketio -certifi==2021.5.30 - # via requests -cffi==1.14.6 - # via cryptography -charset-normalizer==2.0.4 - # via requests -click==8.0.1 - # via - # -r requirements.in - # click-completion - # flask -click-completion==0.5.2 - # via -r requirements.in -colorama==0.4.4 - # via rich -commonmark==0.9.1 - # via rich -contexter==0.1.4 - # via signalslot -cryptography==3.4.7 - # via pyopenssl -dnspython==2.1.0 - # via - # eventlet - # py3-validate-email -eventlet==0.33.1 - # via -r requirements.in -filelock==3.0.12 - # via py3-validate-email -flask==2.0.1 - # via - # -r requirements.in - # flask-classful - # flask-httpauth - # flask-socketio -flask-classful==0.14.2 - # via -r requirements.in -flask-httpauth==4.4.0 - # via -r requirements.in -flask-socketio==5.1.1 - # via -r requirements.in -greenlet==1.1.1 - # via eventlet -idna==3.2 - # via - # py3-validate-email - # requests -imapclient==2.2.0 - # via -r requirements.in -itsdangerous==2.0.1 - # via flask -jinja2==3.0.1 - # via - # click-completion - # flask -markupsafe==2.0.1 - # via jinja2 -opencage==2.0.0 - # via -r requirements.in -pbr==5.6.0 - # via -r requirements.in -pluggy==1.0.0 - # via -r requirements.in -py3-validate-email==1.0.1 - # via -r requirements.in -pycparser==2.20 - # via cffi -pygments==2.10.0 - # via rich -pyopenssl==20.0.1 - # via opencage -pyserial==3.5 - # via aioax25 -python-engineio==4.2.1 - # via python-socketio -python-socketio==5.4.0 - # via flask-socketio -pytz==2021.1 - # via -r requirements.in -pyyaml==5.4.1 - # via -r requirements.in -requests==2.26.0 - # via - # -r requirements.in - # opencage - # update-checker -rich==10.15.2 - # via -r requirements.in -shellingham==1.4.0 - # via click-completion -signalslot==0.1.2 - # via aioax25 -six==1.16.0 - # via - # -r requirements.in - # click-completion - # eventlet - # imapclient - # pyopenssl - # signalslot -soupsieve==2.3.1 - # via beautifulsoup4 -tabulate==0.8.9 - # via -r requirements.in -thesmuggler==1.0.1 - # via -r requirements.in -update-checker==0.18.0 - # via -r requirements.in -urllib3==1.26.6 - # via requests -weakrefmethod==1.0.3 - # via signalslot -werkzeug==2.0.0 - # via flask +aprslib==0.7.2 # via -r requirements.in +attrs==22.1.0 # via -r requirements.in, ax253, kiss3 +ax253==0.1.5.post1 # via kiss3 +beautifulsoup4==4.11.1 # via -r requirements.in +bidict==0.22.0 # via python-socketio +bitarray==2.6.0 # via ax253, kiss3 +certifi==2022.9.24 # via requests +charset-normalizer==2.1.1 # via requests +click-completion==0.5.2 # via -r requirements.in +click==8.1.3 # via -r requirements.in, click-completion, flask +commonmark==0.9.1 # via rich +dnspython==2.2.1 # via eventlet, py3-validate-email +eventlet==0.33.2 # via -r requirements.in +filelock==3.8.0 # via py3-validate-email +flask-classful==0.14.2 # via -r requirements.in +flask-httpauth==4.7.0 # via -r requirements.in +flask-socketio==5.3.2 # via -r requirements.in +flask==2.1.2 # via -r requirements.in, flask-classful, flask-httpauth, flask-socketio +greenlet==2.0.1 # via eventlet +idna==3.4 # via py3-validate-email, requests +imapclient==2.3.1 # via -r requirements.in +importlib-metadata==5.0.0 # via ax253, flask, kiss3 +itsdangerous==2.1.2 # via flask +jinja2==3.1.2 # via click-completion, flask +kiss3==8.0.0 # via -r requirements.in +markupsafe==2.1.1 # via jinja2 +pbr==5.11.0 # via -r requirements.in +pluggy==1.0.0 # via -r requirements.in +py3-validate-email==1.0.5.post1 # via -r requirements.in +pygments==2.13.0 # via rich +pyserial-asyncio==0.6 # via kiss3 +pyserial==3.5 # via pyserial-asyncio +python-engineio==4.3.4 # via python-socketio +python-socketio==5.7.2 # via flask-socketio +pytz==2022.6 # via -r requirements.in +pyyaml==6.0 # via -r requirements.in +requests==2.28.1 # via -r requirements.in, update-checker +rich==12.6.0 # via -r requirements.in +shellingham==1.5.0 # via click-completion +six==1.16.0 # via -r requirements.in, click-completion, eventlet, imapclient +soupsieve==2.3.2.post1 # via beautifulsoup4 +tabulate==0.9.0 # via -r requirements.in +thesmuggler==1.0.1 # via -r requirements.in +typing-extensions==4.4.0 # via rich +update_checker==0.18.0 # via -r requirements.in +urllib3==1.26.12 # via requests +werkzeug==2.1.2 # via -r requirements.in, flask +wrapt==1.14.1 # via -r requirements.in +zipp==3.10.0 # via importlib-metadata diff --git a/tests/cmds/test_dev.py b/tests/cmds/test_dev.py index fd2b20a..16b078a 100644 --- a/tests/cmds/test_dev.py +++ b/tests/cmds/test_dev.py @@ -15,7 +15,10 @@ F = t.TypeVar("F", bound=t.Callable[..., t.Any]) class TestDevTestPluginCommand(unittest.TestCase): def _build_config(self, login=None, password=None): - config = {"aprs": {}} + config = { + "aprs": {}, + "aprsd": {"trace": False}, + } if login: config["aprs"]["login"] = login @@ -25,7 +28,7 @@ class TestDevTestPluginCommand(unittest.TestCase): return aprsd_config.Config(config) @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_login(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no login and config.""" @@ -43,7 +46,7 @@ class TestDevTestPluginCommand(unittest.TestCase): assert "Must set --aprs_login or APRS_LOGIN" in result.output @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_plugin_arg(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no login and config.""" diff --git a/tests/cmds/test_send_message.py b/tests/cmds/test_send_message.py index e67e8f1..5e4d682 100644 --- a/tests/cmds/test_send_message.py +++ b/tests/cmds/test_send_message.py @@ -15,7 +15,10 @@ F = t.TypeVar("F", bound=t.Callable[..., t.Any]) class TestSendMessageCommand(unittest.TestCase): def _build_config(self, login=None, password=None): - config = {"aprs": {}} + config = { + "aprs": {}, + "aprsd": {"trace": False}, + } if login: config["aprs"]["login"] = login @@ -25,7 +28,7 @@ class TestSendMessageCommand(unittest.TestCase): return aprsd_config.Config(config) @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_login(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no login and config.""" @@ -43,7 +46,7 @@ class TestSendMessageCommand(unittest.TestCase): assert "Must set --aprs_login or APRS_LOGIN" in result.output @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_password(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no password and config.""" @@ -58,7 +61,7 @@ class TestSendMessageCommand(unittest.TestCase): assert "Must set --aprs-password or APRS_PASSWORD" in result.output @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_tocallsign(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no tocallsign.""" @@ -76,7 +79,7 @@ class TestSendMessageCommand(unittest.TestCase): assert "Error: Missing argument 'TOCALLSIGN'" in result.output @mock.patch("aprsd.config.parse_config") - @mock.patch("aprsd.log.setup_logging") + @mock.patch("aprsd.logging.log.setup_logging") def test_no_command(self, mock_logging, mock_parse_config): """Make sure we get an error if there is no command.""" diff --git a/tests/plugins/test_time.py b/tests/plugins/test_time.py index 52616a4..befba45 100644 --- a/tests/plugins/test_time.py +++ b/tests/plugins/test_time.py @@ -2,8 +2,8 @@ from unittest import mock import pytz -from aprsd.fuzzyclock import fuzzy from aprsd.plugins import time as time_plugin +from aprsd.utils import fuzzy from .. import fake, test_plugin diff --git a/tox.ini b/tox.ini index 5a28948..021f176 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,9 @@ minversion = 2.9.0 skipdist = True skip_missing_interpreters = true envlist = pre-commit,pep8,py{36,37,38,39} +#requires = tox-pipenv +# pip==22.0.4 +# pip-tools==5.4.0 # Activate isolated build environment. tox will use a virtual environment # to build a source distribution from the source tree. For build tools and
{{ data.text }}
Loading…
You have no recent mentions.
The Lounge automatically generates and manages the client certificate.
\n\t\t\t\t\t\tOn the IRC server, you will need to tell the services to attach the\n\t\t\t\t\t\tcertificate fingerprint (certfp) to your account, for example:\n\t\t\t\t\t
/msg NickServ CERT ADD
1\" class=\"session-usage\">\n\t\t\t\tActive in {{ session.active }} browsers\n\t\t\t
\n\t\t\t\tLast used on {{ lastUse }}\n\t\t\t
\n\t\t\t\t\t\t\tWarning: Checking this box will override the settings\n\t\t\t\t\t\t\tof this client with those stored on the server.\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\tUse the button below to enable synchronization, and override any\n\t\t\t\t\t\t\tsettings already synced to the server.\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\tWarning: No settings have been synced before. Enabling\n\t\t\t\t\t\t\tthis will sync all settings of this client as the base for other\n\t\t\t\t\t\t\tclients.\n\t\t\t\t\t\t
\n\t\t\t\t\tYou are not currently logged in to any other device.\n\t\t\t\t