From e77862f4ffeb9b8fe62b8ec1d4f311de2b979a9f Mon Sep 17 00:00:00 2001 From: n9k Date: Wed, 16 Feb 2022 09:55:30 +0000 Subject: [PATCH] Nojs chat, store all user names/colors in js, forget about inactive users Project structure evolving a bit --- anonstream/__init__.py | 27 +++-- anonstream/chat.py | 34 ++++-- anonstream/routes.py | 121 -------------------- anonstream/routes/__init__.py | 3 + anonstream/routes/core.py | 29 +++++ anonstream/routes/nojs.py | 71 ++++++++++++ anonstream/routes/websocket.py | 32 ++++++ anonstream/routes/wrappers.py | 93 ++++++++++++++++ anonstream/static/anonstream.js | 165 +++++++++++++++++++++++----- anonstream/static/style.css | 25 +++-- anonstream/templates/home.html | 2 +- anonstream/templates/nojs_chat.html | 59 ++++++++++ anonstream/templates/nojs_form.html | 57 +++++++--- anonstream/templates/nojs_info.html | 3 +- anonstream/users.py | 58 ++++++++++ anonstream/utils/__init__.py | 2 + anonstream/utils/chat.py | 21 ++-- anonstream/utils/users.py | 19 +++- anonstream/utils/websocket.py | 11 +- anonstream/websocket.py | 29 +++-- anonstream/wrappers.py | 53 ++------- config.toml | 10 ++ 22 files changed, 660 insertions(+), 264 deletions(-) delete mode 100644 anonstream/routes.py create mode 100644 anonstream/routes/__init__.py create mode 100644 anonstream/routes/core.py create mode 100644 anonstream/routes/nojs.py create mode 100644 anonstream/routes/websocket.py create mode 100644 anonstream/routes/wrappers.py create mode 100644 anonstream/templates/nojs_chat.html create mode 100644 anonstream/utils/__init__.py diff --git a/anonstream/__init__.py b/anonstream/__init__.py index dd86639..98afe40 100644 --- a/anonstream/__init__.py +++ b/anonstream/__init__.py @@ -18,14 +18,25 @@ async def create_app(): print('Broadcaster password:', auth_password) app = Quart('anonstream') - app.config['SECRET_KEY'] = config['secret_key'].encode() - app.config['AUTH_USERNAME'] = config['auth']['username'] - app.config['AUTH_PWHASH'] = auth_pwhash - app.config['AUTH_TOKEN'] = generate_token() - app.config['DEFAULT_HOST_NAME'] = config['names']['broadcaster'] - app.config['DEFAULT_ANON_NAME'] = config['names']['anonymous'] - app.config['LIMIT_NOTICES'] = config['limits']['notices'] - app.chat = OrderedDict() + app.config.update({ + 'SECRET_KEY': config['secret_key'].encode(), + 'AUTH_USERNAME': config['auth']['username'], + 'AUTH_PWHASH': auth_pwhash, + 'AUTH_TOKEN': generate_token(), + 'DEFAULT_HOST_NAME': config['names']['broadcaster'], + 'DEFAULT_ANON_NAME': config['names']['anonymous'], + 'MAX_NOTICES': config['limits']['notices'], + 'MAX_CHAT_STORAGE': config['limits']['chat_storage'], + 'MAX_CHAT_SCROLLBACK': config['limits']['chat_scrollback'], + 'USER_CHECKUP_PERIOD': config['ratelimits']['user_absence'], + 'CAPTCHA_CHECKUP_PERIOD': config['ratelimits']['captcha_expiry'], + 'THRESHOLD_IDLE': config['thresholds']['idle'], + 'THRESHOLD_ABSENT': config['thresholds']['absent'], + }) + + assert app.config['THRESHOLD_ABSENT'] >= app.config['THRESHOLD_IDLE'] + + app.chat = {'messages': OrderedDict(), 'nonce_hashes': set()} app.users = {} app.websockets = set() app.segments_directory_cache = DirectoryCache(config['stream']['segments_dir']) diff --git a/anonstream/chat.py b/anonstream/chat.py index 68726dc..a28ec41 100644 --- a/anonstream/chat.py +++ b/anonstream/chat.py @@ -1,7 +1,11 @@ +import time from datetime import datetime from quart import escape +from anonstream.users import users_for_websocket +from anonstream.utils.chat import generate_nonce_hash + class Rejected(Exception): pass @@ -9,18 +13,31 @@ async def broadcast(websockets, payload): for queue in websockets: await queue.put(payload) -async def add_chat_message(chat, websockets, token, message_id, comment): +async def add_chat_message(chat, users, websockets, secret, user, nonce, comment): # check message + nonce_hash = generate_nonce_hash(secret, nonce) + if nonce_hash in chat['nonce_hashes']: + raise Rejected('Discarded suspected duplicate message') if len(comment) == 0: raise Rejected('Message was empty') # add message - dt = datetime.utcnow() + timestamp_ms = time.time_ns() // 1_000_000 + timestamp = timestamp_ms // 1000 + try: + last_message = next(reversed(chat['messages'].values())) + except StopIteration: + message_id = timestamp_ms + else: + if timestamp <= last_message['id']: + message_id = last_message['id'] + 1 + dt = datetime.utcfromtimestamp(timestamp) markup = escape(comment) - chat[message_id] = { + chat['messages'][message_id] = { 'id': message_id, - 'token': token, - 'timestamp': int(dt.timestamp()), + 'nonce_hash': nonce_hash, + 'token': user['token'], + 'timestamp': timestamp, 'date': dt.strftime('%Y-%m-%d'), 'time_minutes': dt.strftime('%H:%M'), 'time_seconds': dt.strftime('%H:%M:%S'), @@ -28,13 +45,16 @@ async def add_chat_message(chat, websockets, token, message_id, comment): 'markup': markup, } + # collect nonce hash + chat['nonce_hashes'].add(nonce_hash) + # broadcast message to websockets await broadcast( websockets, payload={ 'type': 'chat', - 'color': '#c7007f', - 'name': 'Anonymous', + 'id': message_id, + 'token_hash': user['token_hash'], 'markup': markup, } ) diff --git a/anonstream/routes.py b/anonstream/routes.py deleted file mode 100644 index a496ed8..0000000 --- a/anonstream/routes.py +++ /dev/null @@ -1,121 +0,0 @@ -import asyncio - -from quart import current_app, request, render_template, make_response, redirect, websocket, url_for - -from anonstream.stream import get_stream_title -from anonstream.segments import CatSegments, Offline -from anonstream.users import get_default_name, add_notice, pop_notice -from anonstream.wrappers import with_user_from, auth_required -from anonstream.websocket import websocket_outbound, websocket_inbound -from anonstream.chat import add_chat_message, Rejected -from anonstream.utils.chat import create_message, generate_nonce, NonceReuse - -@current_app.route('/') -@with_user_from(request) -async def home(user): - return await render_template('home.html', user=user) - -@current_app.route('/stream.mp4') -@with_user_from(request) -async def stream(user): - try: - cat_segments = CatSegments( - directory_cache=current_app.segments_directory_cache, - token=user['token'] - ) - except Offline: - return 'offline', 404 - response = await make_response(cat_segments.stream()) - response.headers['Content-Type'] = 'video/mp4' - response.timeout = None - return response - -@current_app.route('/login') -@auth_required -async def login(): - return redirect('/') - -@current_app.websocket('/live') -@with_user_from(websocket) -async def live(user): - queue = asyncio.Queue() - current_app.websockets.add(queue) - - producer = websocket_outbound(queue) - consumer = websocket_inbound( - queue=queue, - connected_websockets=current_app.websockets, - token=user['token'], - secret=current_app.config['SECRET_KEY'], - chat=current_app.chat, - ) - try: - await asyncio.gather(producer, consumer) - finally: - current_app.websockets.remove(queue) - -@current_app.route('/info.html') -@with_user_from(request) -async def nojs_info(user): - return await render_template( - 'nojs_info.html', - user=user, - title=get_stream_title(), - ) - -@current_app.route('/chat/messages.html') -@with_user_from(request) -async def nojs_chat(user): - return await render_template('nojs_chat.html', user=user) - -@current_app.route('/chat/form.html') -@with_user_from(request) -async def nojs_form(user): - notice_id = request.args.get('notice', type=int) - prefer_chat_form = request.args.get('landing') != 'appearance' - return await render_template( - 'nojs_form.html', - user=user, - notice=pop_notice(user, notice_id), - prefer_chat_form=prefer_chat_form, - nonce=generate_nonce(), - default_name=get_default_name(user), - ) - -@current_app.post('/chat/message') -@with_user_from(request) -async def nojs_submit_message(user): - form = await request.form - comment = form.get('comment', '') - nonce = form.get('nonce', '') - - try: - message_id, _, _ = create_message( - message_ids=current_app.chat.keys(), - secret=current_app.config['SECRET_KEY'], - nonce=nonce, - comment=comment, - ) - except NonceReuse: - notice_id = add_notice(user, 'Discarded suspected duplicate message') - else: - try: - await add_chat_message( - current_app.chat, - current_app.websockets, - user['token'], - message_id, - comment - ) - except Rejected as e: - notice, *_ = e.args - notice_id = add_notice(user, notice) - else: - notice_id = None - - return redirect(url_for('nojs_form', token=user['token'], notice=notice_id)) - -@current_app.post('/chat/appearance') -@with_user_from(request) -async def nojs_submit_appearance(user): - pass diff --git a/anonstream/routes/__init__.py b/anonstream/routes/__init__.py new file mode 100644 index 0000000..4f49ed1 --- /dev/null +++ b/anonstream/routes/__init__.py @@ -0,0 +1,3 @@ +import anonstream.routes.core +import anonstream.routes.websocket +import anonstream.routes.nojs diff --git a/anonstream/routes/core.py b/anonstream/routes/core.py new file mode 100644 index 0000000..419580c --- /dev/null +++ b/anonstream/routes/core.py @@ -0,0 +1,29 @@ +from quart import current_app, request, render_template, redirect, url_for + +from anonstream.segments import CatSegments, Offline +from anonstream.routes.wrappers import with_user_from, auth_required + +@current_app.route('/') +@with_user_from(request) +async def home(user): + return await render_template('home.html', user=user) + +@current_app.route('/stream.mp4') +@with_user_from(request) +async def stream(user): + try: + cat_segments = CatSegments( + directory_cache=current_app.segments_directory_cache, + token=user['token'] + ) + except Offline: + return 'offline', 404 + response = await make_response(cat_segments.stream()) + response.headers['Content-Type'] = 'video/mp4' + response.timeout = None + return response + +@current_app.route('/login') +@auth_required +async def login(): + return redirect(url_for('home')) diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py new file mode 100644 index 0000000..da8aacd --- /dev/null +++ b/anonstream/routes/nojs.py @@ -0,0 +1,71 @@ +from quart import current_app, request, render_template, redirect, url_for + +from anonstream.stream import get_stream_title +from anonstream.users import get_default_name, add_notice, pop_notice +from anonstream.chat import add_chat_message, Rejected +from anonstream.routes.wrappers import with_user_from +from anonstream.utils.chat import generate_nonce + +@current_app.route('/info.html') +@with_user_from(request) +async def nojs_info(user): + return await render_template( + 'nojs_info.html', + user=user, + title=get_stream_title(), + ) + +@current_app.route('/chat/messages.html') +@with_user_from(request) +async def nojs_chat(user): + return await render_template( + 'nojs_chat.html', + user=user, + users=current_app.users, + messages=current_app.chat['messages'].values(), + get_default_name=get_default_name, + ) + +@current_app.route('/chat/form.html') +@with_user_from(request) +async def nojs_form(user): + notice_id = request.args.get('notice', type=int) + prefer_chat_form = request.args.get('landing') != 'appearance' + return await render_template( + 'nojs_form.html', + user=user, + notice=pop_notice(user, notice_id), + prefer_chat_form=prefer_chat_form, + nonce=generate_nonce(), + default_name=get_default_name(user), + ) + +@current_app.post('/chat/message') +@with_user_from(request) +async def nojs_submit_message(user): + form = await request.form + comment = form.get('comment', '') + nonce = form.get('nonce', '') + + try: + await add_chat_message( + chat=current_app.chat, + users=current_app.users, + websockets=current_app.websockets, + secret=current_app.config['SECRET_KEY'], + user=user, + nonce=nonce, + comment=comment, + ) + except Rejected as e: + notice, *_ = e.args + notice_id = add_notice(user, notice) + else: + notice_id = None + + return redirect(url_for('nojs_form', token=user['token'], notice=notice_id)) + +@current_app.post('/chat/appearance') +@with_user_from(request) +async def nojs_submit_appearance(user): + pass diff --git a/anonstream/routes/websocket.py b/anonstream/routes/websocket.py new file mode 100644 index 0000000..76585e3 --- /dev/null +++ b/anonstream/routes/websocket.py @@ -0,0 +1,32 @@ +import asyncio + +from quart import current_app, websocket + +from anonstream.websocket import websocket_outbound, websocket_inbound +from anonstream.routes.wrappers import with_user_from + +@current_app.websocket('/live') +@with_user_from(websocket) +async def live(user): + queue = asyncio.Queue() + current_app.websockets.add(queue) + + producer = websocket_outbound( + queue=queue, + messages=current_app.chat['messages'].values(), + users=current_app.users, + default_host_name=current_app.config['DEFAULT_HOST_NAME'], + default_anon_name=current_app.config['DEFAULT_ANON_NAME'], + ) + consumer = websocket_inbound( + queue=queue, + chat=current_app.chat, + users=current_app.users, + connected_websockets=current_app.websockets, + user=user, + secret=current_app.config['SECRET_KEY'], + ) + try: + await asyncio.gather(producer, consumer) + finally: + current_app.websockets.remove(queue) diff --git a/anonstream/routes/wrappers.py b/anonstream/routes/wrappers.py new file mode 100644 index 0000000..698ab0a --- /dev/null +++ b/anonstream/routes/wrappers.py @@ -0,0 +1,93 @@ +import time +from functools import wraps + +from quart import current_app, request, abort, make_response +from werkzeug.security import check_password_hash + +from anonstream.users import sunset, user_for_websocket +from anonstream.websocket import broadcast +from anonstream.utils.users import generate_token, generate_user + +def check_auth(context): + auth = context.authorization + return ( + auth is not None + and auth.type == "basic" + and auth.username == current_app.config["AUTH_USERNAME"] + and check_password_hash(current_app.config["AUTH_PWHASH"], auth.password) + ) + +def auth_required(f): + @wraps(f) + async def wrapper(*args, **kwargs): + if check_auth(request): + return await f(*args, **kwargs) + hint = 'The broadcaster should log in with the credentials printed ' \ + 'in their terminal.' + body = ( + f'

{hint}

' + if request.authorization is None else + '

Wrong username or password. Refresh the page to try again.

' + f'

{hint}

' + ) + return body, 401, {'WWW-Authenticate': 'Basic'} + + return wrapper + +def with_user_from(context): + def with_user_from_context(f): + @wraps(f) + async def wrapper(*args, **kwargs): + timestamp = int(time.time()) + + # Check if broadcaster + broadcaster = check_auth(context) + if broadcaster: + token = current_app.config['AUTH_TOKEN'] + else: + token = context.args.get('token') or context.cookies.get('token') or generate_token() + + # Remove non-visible absent users + token_hashes = sunset( + messages=current_app.chat['messages'].values(), + users=current_app.users, + ) + if len(token_hashes) > 0: + await broadcast( + current_app.websockets, + payload={ + 'type': 'rem-users', + 'token_hashes': token_hashes, + } + ) + + # Update / create user + user = current_app.users.get(token) + if user is not None: + user['seen']['last'] = timestamp + else: + user = generate_user( + secret=current_app.config['SECRET_KEY'], + token=token, + broadcaster=broadcaster, + timestamp=timestamp, + ) + current_app.users[token] = user + await broadcast( + current_app.websockets, + payload={ + 'type': 'add-user', + 'user': user_for_websocket(user), + } + ) + + # Set cookie + response = await f(user, *args, **kwargs) + if context.cookies.get('token') != token: + response = await make_response(response) + response.headers['Set-Cookie'] = f'token={token}; path=/' + return response + + return wrapper + + return with_user_from_context diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 54cd988..a7bd6a8 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -2,6 +2,7 @@ const token = document.querySelector("body").dataset.token; /* insert js-only markup */ +const jsmarkup_style = '' const jsmarkup_info = '
'; const jsmarkup_info_title = '
'; const jsmarkup_chat_messages = ''; @@ -13,10 +14,14 @@ const jsmarkup_chat_form = `\ Not connected to chat - + `; const insert_jsmarkup = () => { + if (document.getElementById("style_js") === null) { + const parent = document.head; + parent.insertAdjacentHTML("beforeend", jsmarkup_style); + } if (document.getElementById("info_js") === null) { const parent = document.getElementById("info"); parent.insertAdjacentHTML("beforeend", jsmarkup_info); @@ -36,12 +41,75 @@ const insert_jsmarkup = () => { } insert_jsmarkup(); +const stylesheet = document.styleSheets[1]; /* create websocket */ const info_title = document.getElementById("info_js__title"); -const chat_messages_parent = document.getElementById("chat__messages"); const chat_messages = document.getElementById("chat-messages_js"); + +const create_chat_message = (object) => { + const user = users[object.token_hash]; + + const chat_message = document.createElement("li"); + chat_message.classList.add("chat-message"); + chat_message.dataset.id = object.id; + chat_message.dataset.tokenHash = object.token_hash; + + const chat_message_name = document.createElement("span"); + chat_message_name.classList.add("chat-message__name"); + chat_message_name.innerText = user.name || default_name[user.broadcaster]; + //chat_message_name.dataset.color = user.color; // not working in any browser + + const chat_message_markup = document.createElement("span"); + chat_message_markup.classList.add("chat-message__markup"); + chat_message_markup.innerHTML = object.markup; + + chat_message.insertAdjacentElement("beforeend", chat_message_name); + chat_message.insertAdjacentHTML("beforeend", ": "); + chat_message.insertAdjacentElement("beforeend", chat_message_markup); + + return chat_message +} + +let users = {}; +let default_name = {true: "Broadcaster", false: "Anonymous"}; +const equal = (color1, color2) => { + /* comparing css colors is annoying */ + return false; +} +const update_user_styles = () => { + const to_delete = []; + const to_ignore = new Set(); + for (let index = 0; index < stylesheet.cssRules.length; index++) { + const css_rule = stylesheet.cssRules[index]; + const match = css_rule.selectorText.match(/.chat-message\[data-token-hash="([a-z2-7]{26})"\] > .chat-message__name/); + const token_hash = match === null ? null : match[1]; + const user = token_hash === null ? null : users[token_hash]; + if (user === null || user === undefined) { + to_delete.push(index); + } else if (!equal(css_rule.style.color, user.color)) { + to_delete.push(index); + } else { + to_ignore.add(token_hash); + } + } + + for (const token_hash of Object.keys(users)) { + if (!to_ignore.has(token_hash)) { + const user = users[token_hash]; + stylesheet.insertRule( + `.chat-message[data-token-hash="${token_hash}"] > .chat-message__name { color: ${user.color}; }`, + stylesheet.cssRules.length, + ); + } + } + for (const index of to_delete.reverse()) { + stylesheet.deleteRule(index); + } +} + const on_websocket_message = (event) => { + console.log("websocket message", event); const receipt = JSON.parse(event.data); switch (receipt.type) { case "error": @@ -50,8 +118,29 @@ const on_websocket_message = (event) => { case "init": console.log("ws init", receipt); + chat_form_nonce.value = receipt.nonce; info_title.innerText = receipt.title; + + default_name = receipt.default; + users = receipt.users; + update_user_styles(); + + const ids = new Set(receipt.chat.map((message) => {return message.id;})); + for (const chat_message of chat_messages.children) { + if (!ids.has(parseInt(chat_message.dataset.id))) { + console.log('removing', chat_message); + chat_message.remove(); + } + } + const last_id = Math.max(...[...chat_messages.children].map((element) => parseInt(element.dataset.id))); + for (const message of receipt.chat) { + if (message.id > last_id) { + const chat_message = create_chat_message(message); + chat_messages.insertAdjacentElement("beforeend", chat_message); + } + } + break; case "title": @@ -79,33 +168,29 @@ const on_websocket_message = (event) => { case "chat": console.log("ws chat", receipt); - - const chat_message = document.createElement("li"); - chat_message.classList.add("chat-message"); - - const chat_message_name = document.createElement("span"); - chat_message_name.classList.add("chat-message__name"); - chat_message_name.innerText = receipt.name; - //chat_message_name.dataset.color = receipt.color; // not working in any browser - chat_message_name.style.color = receipt.color; - - const chat_message_markup = document.createElement("span"); - chat_message_markup.classList.add("chat-message__markup"); - chat_message_markup.innerHTML = receipt.markup; - - chat_message.insertAdjacentElement("beforeend", chat_message_name); - chat_message.insertAdjacentHTML("beforeend", ": "); - chat_message.insertAdjacentElement("beforeend", chat_message_markup); - + const chat_message = create_chat_message(receipt); chat_messages.insertAdjacentElement("beforeend", chat_message); - chat_messages_parent.scrollTo({ + chat_messages.scrollTo({ left: 0, - top: chat_messages_parent.scrollTopMax, + top: chat_messages.scrollTopMax, behavior: "smooth", }); - break; + case "add-user": + console.log("ws add-user", receipt); + users[receipt.user.token_hash] = receipt.user; + update_user_styles(); + break; + + case "rem-users": + console.log("ws rem-users", receipt); + for (const token_hash of receipt.token_hashes) { + delete users[token_hash]; + } + update_user_styles(); + break; + default: console.log("incomprehensible websocket message", receipt); } @@ -123,13 +208,23 @@ const connect_websocket = () => { chat_live_status.innerText = "Connecting to chat..."; ws = new WebSocket(`ws://${document.domain}:${location.port}/live?token=${encodeURIComponent(token)}`); ws.addEventListener("open", (event) => { + console.log("websocket open", event); chat_form_submit.disabled = false; chat_live_ball.style.borderColor = "green"; chat_live_status.innerText = "Connected to chat"; - websocket_backoff = 2000; // 2 seconds + // When the server is offline, a newly opened websocket can take a second + // to close. This timeout tries to ensure the backoff doesn't instantly + // (erroneously) reset to 2 seconds in that case. + setTimeout(() => { + if (event.target === ws) { + websocket_backoff = 2000; // 2 seconds + } + }, + websocket_backoff + 4000, + ); }); ws.addEventListener("close", (event) => { - console.log("websocket closed", event); + console.log("websocket close", event); chat_form_submit.disabled = true; chat_live_ball.style.borderColor = "maroon"; chat_live_status.innerText = "Disconnected from chat"; @@ -161,3 +256,23 @@ chat_form.addEventListener("submit", (event) => { chat_form_submit.disabled = true; ws.send(JSON.stringify(payload)); }); + +/* when chat is being resized, peg its bottom in place (instead of its top) */ +const track_scroll = (element) => { + chat_messages.dataset.scrollTop = chat_messages.scrollTop; + chat_messages.dataset.scrollTopMax = chat_messages.scrollTopMax; +} +const peg_bottom = (entries) => { + for (const entry of entries) { + const element = entry.target; + const bottom = chat_messages.dataset.scrollTopMax - chat_messages.dataset.scrollTop; + element.scrollTop = chat_messages.scrollTopMax - bottom; + track_scroll(element); + } +} +const resize = new ResizeObserver(peg_bottom); +resize.observe(chat_messages); +chat_messages.addEventListener("scroll", (event) => { + track_scroll(chat_messages); +}); +track_scroll(chat_messages); diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 1685d60..134df0e 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -1,5 +1,5 @@ :root { - --text-color: white; + --text-color: #ddd; --main-bg-color: #18181a; --chat-bg-color: #232327; @@ -40,7 +40,7 @@ a { } iframe { width: 100%; - border: 1px solid red; + border: none; box-sizing: border-box; } noscript { @@ -83,14 +83,13 @@ noscript { #chat__header { text-align: center; padding: 1ch 0; - margin-bottom: 1ch; border-bottom: var(--chat-border); } #chat-form_js { display: grid; - grid-template: auto var(--button-height) / auto 8ch; - grid-gap: 0.75ch; - margin: 1ch; + grid-template: auto var(--button-height) / auto 5rem; + grid-gap: 0.375rem; + margin: 0 1ch 1ch 1ch; } #chat-form_js__submit { grid-column: 2 / span 1; @@ -101,9 +100,10 @@ noscript { border-radius: 4px; border: 2px solid transparent; transition: 0.25s; - max-height: 16ch; + max-height: max(37.5vh, 16ch); min-height: 1.75ch; - padding: 1.5ch; + height: 6ch; + padding: 0.675rem; color: #c3c3c7; resize: vertical; } @@ -118,13 +118,13 @@ noscript { height: 13ch; } #chat__messages { - overflow-y: auto; position: relative; } #chat-messages_js { list-style: none; margin: 0; - padding: 0 1ch; + padding: 0 1ch 1ch; + overflow-y: auto; width: 100%; box-sizing: border-box; max-height: 100%; @@ -145,8 +145,9 @@ noscript { background-color: #434347; } .chat-message__name { + overflow-wrap: anywhere; font-weight: bold; - color: attr("data-color"); + /* color: attr("data-color"); */ cursor: default; } .chat-message__markup { @@ -238,7 +239,7 @@ footer { display: grid; border: none; border-left: var(--chat-border); - min-height: 100vh; + min-height: 100%; } #both:target #info_nojs { height: var(--nojs-info-height); diff --git a/anonstream/templates/home.html b/anonstream/templates/home.html index 05bf27c..e7d67e3 100644 --- a/anonstream/templates/home.html +++ b/anonstream/templates/home.html @@ -12,7 +12,7 @@