diff --git a/README.md b/README.md index f1e554d..29be680 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,7 @@ The canonical location of this repo is https://git.076.ne.jp/ninya9k/anonstream. These mirrors also exist: * https://gitlab.com/ninya9k/anonstream * https://github.com/ninya9k/anonstream + +## Credits + +* [/anonstream/static/settings.svg](https://git.076.ne.jp/ninya9k/anonstream/src/branch/master/anonstream/static/settings.svg): [setting](https://thenounproject.com/icon/setting-685325/) by [ulimicon](https://thenounproject.com/unlimicon/) is licensed under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/). diff --git a/anonstream/routes/nojs.py b/anonstream/routes/nojs.py index 8cfcc22..3391b0a 100644 --- a/anonstream/routes/nojs.py +++ b/anonstream/routes/nojs.py @@ -141,7 +141,7 @@ async def nojs_submit_appearance(user): # Collect form data name = form.get('name', '').strip() - if len(name) == 0 or name == get_default_name(user): + if len(name) == 0: name = None color = form.get('color', '') diff --git a/anonstream/static/anonstream.js b/anonstream/static/anonstream.js index 474ecf1..bd1e1fd 100644 --- a/anonstream/static/anonstream.js +++ b/anonstream/static/anonstream.js @@ -28,11 +28,15 @@ const jsmarkup_chat_form = `\
- Not connected to chat + + Not connected to chat + × +
+
+ +
+ Name: + + + Tripcode: + +
+
+ +
`; const insert_jsmarkup = () => {jsmarkup_info_float_viewership @@ -115,6 +130,30 @@ const show_notice = (text) => { chat_form.dataset.notice = ""; } +/* override chat form settings input */ +const chat_appearance_form = document.getElementById("appearance-form_js"); +const chat_appearance_form_result = document.getElementById("appearance-form_js__row__result"); +const chat_form_settings = document.getElementById("chat-form_js__settings"); +chat_form_settings.addEventListener("click", (event) => { + event.preventDefault(); + if (chat_appearance_form.dataset.hidden === undefined) { + chat_appearance_form.dataset.hidden = ""; + chat_form_settings.style.backgroundColor = ""; + chat_appearance_form_result.innerText = ""; + if (!chat_appearance_form_submit.disabled) { + chat_appearance_form.reset(); + } + } else { + chat_appearance_form.removeAttribute("data-hidden"); + chat_form_settings.style.backgroundColor = "#4f4f53"; + } +}); + +/* appearance form */ +const chat_appearance_form_name = document.getElementById("appearance-form_js__name"); +const chat_appearance_form_color = document.getElementById("appearance-form_js__color"); +const chat_appearance_form_password = document.getElementById("appearance-form_js__password"); + /* create websocket */ const info_title = document.getElementById("info_js__title"); const info_viewership = document.getElementById("info_js__float__viewership"); @@ -523,6 +562,7 @@ const on_websocket_message = (event) => { case "error": console.log("ws error", receipt); chat_form_submit.disabled = false; + chat_appearance_form_submit.disabled = false; break; case "init": @@ -576,6 +616,14 @@ const on_websocket_message = (event) => { update_user_tripcodes(); update_users_list() + // appearance form default values + const user = users[TOKEN_HASH]; + if (user.name !== null) { + chat_appearance_form_name.setAttribute("value", user.name); + } + chat_appearance_form_name.setAttribute("placeholder", default_name[user.broadcaster]); + chat_appearance_form_color.setAttribute("value", user.color); + // insert new messages const last = chat_messages.children.length == 0 ? null : chat_messages.children[chat_messages.children.length - 1]; const last_seq = last === null ? null : parseInt(last.dataset.seq); @@ -666,6 +714,41 @@ const on_websocket_message = (event) => { receipt.digest === null ? disable_captcha() : enable_captcha(receipt.digest); break; + case "appearance": + console.log("ws appearance", receipt); + + if (receipt.errors === undefined) { + if (receipt.name !== null) { + chat_appearance_form_name.setAttribute("value", receipt.name); + } + chat_appearance_form_color.setAttribute("value", receipt.color); + chat_appearance_form_result.innerHTML = receipt.result; + } else { + const ul = document.createElement("ul"); + for (const error of receipt.errors) { + const li = document.createElement("li"); + li.innerText = error[0]; + for (const tuple of error.slice(1)) { + const mark = document.createElement("mark"); + mark.innerText = tuple[0]; + li.insertAdjacentText("beforeend", " "); + li.insertAdjacentElement("beforeend", mark); + li.insertAdjacentText("beforeend", tuple[1]); + } + ul.insertAdjacentElement("beforeend", li); + } + const result = document.createElement("div"); + result.innerText = "Errors:"; + result.insertAdjacentElement("beforeend", ul); + chat_appearance_form_result.innerHTML = result.innerHTML; + } + + chat_appearance_form_submit.disabled = false; + chat_appearance_form.removeAttribute("data-hidden"); + chat_form_settings.style.backgroundColor = "#4f4f53"; + + break; + default: console.log("incomprehensible websocket message", receipt); } @@ -680,13 +763,13 @@ const connect_websocket = () => { return; } chat_live_ball.style.borderColor = "gold"; - chat_live_status.innerHTML = "Waiting... Connecting to chat..."; + chat_live_status.innerHTML = "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.innerHTML = "Connected to chat"; + chat_live_status.innerHTML = "Connected to chat"; // 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. @@ -702,7 +785,7 @@ const connect_websocket = () => { console.log("websocket close", event); chat_form_submit.disabled = true; chat_live_ball.style.borderColor = "maroon"; - chat_live_status.innerHTML = "Failed to connect Disconnected from chat"; + chat_live_status.innerHTML = "Disconnected from chat×"; if (!ws.successor) { ws.successor = true; setTimeout(connect_websocket, websocket_backoff); @@ -745,6 +828,18 @@ chat_form.addEventListener("submit", (event) => { ws.send(JSON.stringify(payload)); }); +/* override js-only appearance form */ +const chat_appearance_form_submit = document.getElementById("appearance-form_js__row__submit"); +chat_appearance_form.addEventListener("submit", (event) => { + event.preventDefault(); + const form = Object.fromEntries(new FormData(chat_appearance_form)); + const payload = {type: "appearance", form: form}; + chat_appearance_form_submit.disabled = true; + chat_appearance_form_password.value = ""; + chat_appearance_form_result.innerText = ""; + 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; diff --git a/anonstream/static/settings.svg b/anonstream/static/settings.svg new file mode 100644 index 0000000..ace59e8 --- /dev/null +++ b/anonstream/static/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/anonstream/static/style.css b/anonstream/static/style.css index 27187c3..e9a3561 100644 --- a/anonstream/static/style.css +++ b/anonstream/static/style.css @@ -258,19 +258,19 @@ noscript { #chat-users_nojs { height: 100%; } +#chat__form { + position: relative; +} #chat-form_js { display: grid; - grid-template-columns: 1fr min-content min-content 5rem; + grid-template-columns: 1fr min-content min-content min-content 5rem; grid-template-rows: auto var(--button-height); grid-gap: 0.375rem; padding: 0 0.5rem 0.5rem 0.5rem; position: relative; } -#chat-form_js__submit { - grid-column: 2 / span 1; -} #chat-form_js__comment { - grid-column: 1 / span 4; + grid-column: 1 / span 5; background-color: #434347; border-radius: 4px; border: 2px solid transparent; @@ -299,9 +299,20 @@ noscript { #chat-form_js__captcha-answer { width: 8ch; } -#chat-form_js__submit { +#chat-form_js__settings { + align-self: center; + padding: 5px; + box-sizing: border-box; + border-radius: 3px; + color: var(--text-color); grid-column: 4; } +#chat-form_js__settings:hover { + background-color: #434347; +} +#chat-form_js__submit { + grid-column: 5; +} #chat-form_js:not([data-captcha]) > #chat-form_js__captcha-image, #chat-form_js:not([data-captcha]) > #chat-form_js__captcha-answer { display: none; @@ -312,9 +323,10 @@ noscript { #chat-form_js__notice { position: absolute; width: 100%; - background: linear-gradient(#2323277f 25%, #232327); + background: linear-gradient(#23232700, #2323277f 8%, #232327); height: 100%; display: grid; + z-index: 1; } #chat-form_js__notice__button { color: inherit; @@ -336,6 +348,51 @@ noscript { #chat-form_nojs { height: 13ch; } +#appearance-form_js { + position: absolute; + bottom: 3rem; + padding: 0.5rem; + margin: 0 1rem; + width: calc(100% - 2rem); + box-sizing: border-box; + background: #343437df; + border: 2px outset #434347; + border-radius: 4px; + display: grid; + grid-template-columns: min-content 1fr min-content; + grid-template-rows: 1fr 1fr auto; + grid-gap: 0.375rem; +} +#appearance-form_js[data-hidden] { + display: none; +} +#appearance-form_js__label-name, +#appearance-form_js__label-tripcode { + align-self: center; +} +#appearance-form_js__name, +#appearance-form_js__password { + min-width: 12ch; +} +#appearance-form_js__row { + grid-column: 1 / span 3; + grid-row: 3; + display: grid; + grid-template-columns: auto 4rem; + align-items: end; +} +#appearance-form_js__row__result { + font-weight: bold; + font-size: 11pt; +} +#appearance-form_js__row__result > ul { + margin: 0; + padding-left: 1.125rem; + font-size: 10pt; +} +#appearance-form_js__row__submit { + min-height: 1.75rem; +} #chat-live { position: relative; font-size: 9pt; diff --git a/anonstream/user.py b/anonstream/user.py index 0be76d9..180a9b7 100644 --- a/anonstream/user.py +++ b/anonstream/user.py @@ -4,7 +4,7 @@ from math import inf from quart import current_app from anonstream.wrappers import try_except_log, with_timestamp -from anonstream.helpers.user import get_presence, Presence +from anonstream.helpers.user import get_default_name, get_presence, Presence from anonstream.helpers.captcha import check_captcha_digest, Answer from anonstream.helpers.tripcode import generate_tripcode from anonstream.utils.colour import color_to_colour, get_contrast, NotAColor @@ -69,6 +69,8 @@ def try_change_appearance(user, name, color, password, want_tripcode): def change_name(user, name, dry_run=False): if dry_run: + if name == get_default_name(user): + name = None if name is not None: if len(name) == 0: raise BadAppearance('Name was empty') @@ -91,7 +93,7 @@ def change_color(user, color, dry_run=False): if contrast < min_contrast: raise BadAppearance( 'Colour had insufficient contrast:', - (f'{contrast:.2f}', f'/{min_contrast}'), + (f'{contrast:.2f}', f'/{min_contrast:.2f}'), ) else: user['color'] = color diff --git a/anonstream/utils/websocket.py b/anonstream/utils/websocket.py index 1dcaec0..25b01b4 100644 --- a/anonstream/utils/websocket.py +++ b/anonstream/utils/websocket.py @@ -1,3 +1,7 @@ +from enum import Enum + +WS = Enum('WS', names=('MESSAGE, CAPTCHA, APPEARANCE')) + class Malformed(Exception): pass @@ -19,13 +23,27 @@ def parse_websocket_data(receipt): comment = get(str, form, 'comment') digest = get(str, form, 'captcha-digest', '') answer = get(str, form, 'captcha-answer', '') - return nonce, comment, digest, answer + return WS.MESSAGE, (nonce, comment, digest, answer) case 'appearance': - raise NotImplemented + form = get(dict, receipt, 'form') + name = get(str, form, 'name').strip() + if len(name) == 0: + name = None + color = get(str, form, 'color') + password = get(str, form, 'password') + #match get(str | None, form, 'want-tripcode'): + # case '0': + # want_tripcode = False + # case '1': + # want_tripcode = True + # case _: + # want_tripcode = None + want_tripcode = bool(password) + return WS.APPEARANCE, (name, color, password, want_tripcode) case 'captcha': - return None + return WS.CAPTCHA, () case _: raise Malformed('malformed type') diff --git a/anonstream/websocket.py b/anonstream/websocket.py index 75e2d07..41e53d6 100644 --- a/anonstream/websocket.py +++ b/anonstream/websocket.py @@ -6,9 +6,9 @@ from quart import current_app, websocket from anonstream.stream import get_stream_title, get_stream_uptime_and_viewership from anonstream.captcha import get_random_captcha_digest_for from anonstream.chat import get_all_messages_for_websocket, add_chat_message, Rejected -from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha +from anonstream.user import get_all_users_for_websocket, see, verify, deverify, BadCaptcha, try_change_appearance from anonstream.utils.chat import generate_nonce -from anonstream.utils.websocket import parse_websocket_data, Malformed +from anonstream.utils.websocket import parse_websocket_data, Malformed, WS CONFIG = current_app.config @@ -41,7 +41,7 @@ async def websocket_inbound(queue, user): finally: see(user) try: - parsed = parse_websocket_data(receipt) + receipt_type, parsed = parse_websocket_data(receipt) except Malformed as e: error , *_ = e.args payload = { @@ -49,12 +49,14 @@ async def websocket_inbound(queue, user): 'because': error, } else: - match parsed: - case [nonce, comment, digest, answer]: - payload = handle_inbound_message(user, *parsed) - - case None: - payload = handle_inbound_captcha(user) + match receipt_type: + case WS.MESSAGE: + handle = handle_inbound_message + case WS.APPEARANCE: + handle = handle_inbound_appearance + case WS.CAPTCHA: + handle = handle_inbound_captcha + payload = handle(user, *parsed) queue.put_nowait(payload) @@ -64,6 +66,22 @@ def handle_inbound_captcha(user): 'digest': get_random_captcha_digest_for(user), } +def handle_inbound_appearance(user, name, color, password, want_tripcode): + errors = try_change_appearance(user, name, color, password, want_tripcode) + if errors: + return { + 'type': 'appearance', + 'errors': [error.args for error in errors], + } + else: + return { + 'type': 'appearance', + 'result': 'Changed appearance', + 'name': user['name'], + 'color': user['color'], + #'tripcode': user['tripcode'], + } + def handle_inbound_message(user, nonce, comment, digest, answer): try: verification_happened = verify(user, digest, answer)