diff --git a/README.md b/README.md index a354f01..08152f8 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ feature discussions. - [X] refactor login/sessions/totp - [X] chat rooms - [X] chat messages + - [ ] clean up after initial implementation + - [X] split the monolithic talk.html up - [ ] chat message processing - - [ ] auto-link urls - - [ ] use this regex?: `(?:(?[a-zA-Z]+):\/\/)?(?:(?(?\S.+)\:(?.+))\@)?(?(?[-a-zA-Z0-9\.]{2,256}\.(?[-a-zA-Z0-9]{2,256}))(?:\:(?[0-9]{1,6}))?)\b(?[-a-zA-Z0-9@:%_{}\[\]<>\(\)\+.~#&//=]*)(?:\?(?[a-zA-Z0-9!$%&<>()*+,-\.\/\:\;\=\?\@_~]+))?(?:#(?[a-zA-Z0-9!$&'()*+,-\.\/\:\;\=\?\@_~]+))?` + - [X] auto-link urls + - [X] use this regex: `(?:(?[a-zA-Z]+):\/\/)?(?:(?(?\S.+)\:(?.+))\@)?(?(?:(?[-a-zA-Z0-9\.]+)\.)?(?[-a-zA-Z0-9]+?\.(?[-a-zA-Z0-9]{2,64}))(?:\:(?[0-9]{1,6}))?)\b(?[-a-zA-Z0-9@:%_{}\[\]<>\(\)\+.~&\/="]*)(?:\?(?[a-zA-Z0-9!$%&<>()*+,-\.\/\:\;\=\?\@_~"]+))?(?:#(?[a-zA-Z0-9!$&'()*+,-\.\/\:\;\=\?\@_~"]*?))?` - [ ] preview cards for links - [ ] embedded video for - [ ] youtube @@ -26,13 +28,13 @@ feature discussions. - [ ] tidal - [ ] spotify - [ ] youtube (any way to differentiate for yt music?) - - [ ] punycode urls before url extraction? (see: https://stackoverflow.com/a/26618995) + - [X] punycode urls before url extraction? (see: https://stackoverflow.com/a/26618995) - [ ] gif support - [ ] start/stop gif control - [ ] hide control - [ ] inline image support - [ ] hide control - - [ ] try to select immediate sibling messages from the same user and hide mulitple avatars + - [X] try to select immediate sibling messages from the same user and hide mulitple avatars - [X] user profile page - [X] logout button - [ ] profile editing @@ -54,6 +56,8 @@ feature discussions. - [X] smart forms - [X] use the api for forms so requests will be authenticated - [X] support multiple methods + - [X] add `on_parsed` to allow injecting additional data + - [X] refine `on_response`/`on_reply` - [ ] media uploads - [ ] local upload support (keep it simple for small instances) - [ ] S3 support (then self-host with your friends: https://garagehq.deuxfleurs.fr/) diff --git a/public/api/rooms/:room_id/events/index.ts b/public/api/rooms/:room_id/events/index.ts index 9a1cc97..d9bdd2f 100644 --- a/public/api/rooms/:room_id/events/index.ts +++ b/public/api/rooms/:room_id/events/index.ts @@ -78,7 +78,9 @@ export async function GET(request: Request, meta: Record): Promise< 'Cache-Control': 'no-cache, must-revalidate' }; - const results = (await events.all(options)).map((entry) => entry.load()); + const results = (await events.all(options)) + .map((entry) => entry.load()) + .sort((lhs_item, rhs_item) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created)); // long-polling support if (results.length === 0 && meta.query.wait) { @@ -104,6 +106,7 @@ export async function GET(request: Request, meta: Record): Promise< events.on('create', on_create); request.signal.addEventListener('abort', () => { events.off('create', on_create); + reject(new Error('request aborted')); }); Deno.addSignalListener('SIGINT', () => { events.off('create', on_create); diff --git a/public/base.css b/public/base.css index 8116945..ff451e9 100644 --- a/public/base.css +++ b/public/base.css @@ -125,6 +125,60 @@ body { border-right: 4px solid #444; } +form div { + position: relative; + display: flex; + margin-bottom: 1em; +} + +form label { + position: absolute; + top: 10px; + font-size: 30px; + margin: 10px; + padding: 0 10px; + background-color: var(--bg); + -webkit-transition: + top 0.2s ease-in-out, + font-size 0.2s ease-in-out; + transition: + top 0.2s ease-in-out, + font-size 0.2s ease-in-out; +} + +form input:focus ~ label, +form input:valid ~ label { + top: -25px; + font-size: 20px; +} + +form input { + width: 100%; + padding: 20px; + border: 1px solid var(--text); + font-size: 20px; + background-color: var(--bg); + color: var(--text); +} + +form input:focus { + outline: none; +} + +form button { + width: 100%; + padding: 20px; + border: 1px solid var(--text); + font-size: 20px; + background-color: var(--bg); + color: var(--text); + margin-top: 1rem; +} + +form button.primary { + background-color: var(--accent); +} + button { background: inherit; color: inherit; @@ -141,12 +195,14 @@ button.primary { [data-requires-permission] { visibility: hidden; opacity: 0; + height: 0; } body[data-perms*="users.write"] [data-requires-permission="users.write"], body[data-perms*="rooms.create"] [data-requires-permission="rooms.create"] { visibility: visible; opacity: 1; + height: unset; } /* ICONS */ @@ -162,8 +218,42 @@ body[data-perms*="rooms.create"] [data-requires-permission="rooms.create"] { stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 400; + + margin: 0 auto; } +/* ICON - ADD (with box) */ +.icon.add { + box-sizing: border-box; + position: relative; + display: block; + width: 22px; + height: 22px; + border: 2px solid; + transform: scale(var(--icon-scale, 1)); + border-radius: 4px; +} +.icon.add::after, +.icon.add::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 10px; + height: 2px; + background: currentColor; + border-radius: 5px; + top: 8px; + left: 4px; +} +.icon.add::after { + width: 2px; + height: 10px; + top: 4px; + left: 8px; +} + + /* ICON - ATTACHMENT */ .icon.attachment { box-sizing: border-box; diff --git a/public/js/datetimeutils.js b/public/js/datetimeutils.js index 560899f..c84907e 100644 --- a/public/js/datetimeutils.js +++ b/public/js/datetimeutils.js @@ -15,5 +15,7 @@ function datetime_to_local(input) { timeStyle: "short", hour12: true, }), + + value: local_datetime.valueOf(), }; } diff --git a/public/js/external/punycode.js b/public/js/external/punycode.js new file mode 100644 index 0000000..26e0c09 --- /dev/null +++ b/public/js/external/punycode.js @@ -0,0 +1,443 @@ +// https://github.com/mathiasbynens/punycode.js + +"use strict"; + +function get_punycode() { + /** Highest positive signed 32-bit float value */ + const maxInt = 2147483647; // aka. 0x7FFFFFFF or 2^31-1 + + /** Bootstring parameters */ + const base = 36; + const tMin = 1; + const tMax = 26; + const skew = 38; + const damp = 700; + const initialBias = 72; + const initialN = 128; // 0x80 + const delimiter = "-"; // '\x2D' + + /** Regular expressions */ + const regexPunycode = /^xn--/; + const regexNonASCII = /[^\0-\x7F]/; // Note: U+007F DEL is excluded too. + const regexSeparators = /[\x2E\u3002\uFF0E\uFF61]/g; // RFC 3490 separators + + /** Error messages */ + const errors = { + overflow: "Overflow: input needs wider integers to process", + "not-basic": "Illegal input >= 0x80 (not a basic code point)", + "invalid-input": "Invalid input", + }; + + /** Convenience shortcuts */ + const baseMinusTMin = base - tMin; + const floor = Math.floor; + const stringFromCharCode = String.fromCharCode; + + /*--------------------------------------------------------------------------*/ + + /** + * A generic error utility function. + * @private + * @param {String} type The error type. + * @returns {Error} Throws a `RangeError` with the applicable error message. + */ + function error(type) { + throw new RangeError(errors[type]); + } + + /** + * A generic `Array#map` utility function. + * @private + * @param {Array} array The array to iterate over. + * @param {Function} callback The function that gets called for every array + * item. + * @returns {Array} A new array of values returned by the callback function. + */ + function map(array, callback) { + const result = []; + let length = array.length; + while (length--) { + result[length] = callback(array[length]); + } + return result; + } + + /** + * A simple `Array#map`-like wrapper to work with domain name strings or email + * addresses. + * @private + * @param {String} domain The domain name or email address. + * @param {Function} callback The function that gets called for every + * character. + * @returns {String} A new string of characters returned by the callback + * function. + */ + function mapDomain(domain, callback) { + const parts = domain.split("@"); + let result = ""; + if (parts.length > 1) { + // In email addresses, only the domain name should be punycoded. Leave + // the local part (i.e. everything up to `@`) intact. + result = parts[0] + "@"; + domain = parts[1]; + } + // Avoid `split(regex)` for IE8 compatibility. See #17. + domain = domain.replace(regexSeparators, "\x2E"); + const labels = domain.split("."); + const encoded = map(labels, callback).join("."); + return result + encoded; + } + + /** + * Creates an array containing the numeric code points of each Unicode + * character in the string. While JavaScript uses UCS-2 internally, + * this function will convert a pair of surrogate halves (each of which + * UCS-2 exposes as separate characters) into a single code point, + * matching UTF-16. + * @see `punycode.ucs2.encode` + * @see + * @memberOf punycode.ucs2 + * @name decode + * @param {String} string The Unicode input string (UCS-2). + * @returns {Array} The new array of code points. + */ + function ucs2decode(string) { + const output = []; + let counter = 0; + const length = string.length; + while (counter < length) { + const value = string.charCodeAt(counter++); + if (value >= 0xd800 && value <= 0xdbff && counter < length) { + // It's a high surrogate, and there is a next character. + const extra = string.charCodeAt(counter++); + if ((extra & 0xfc00) == 0xdc00) { + // Low surrogate. + output.push(((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000); + } else { + // It's an unmatched surrogate; only append this code unit, in case the + // next code unit is the high surrogate of a surrogate pair. + output.push(value); + counter--; + } + } else { + output.push(value); + } + } + return output; + } + + /** + * Creates a string based on an array of numeric code points. + * @see `punycode.ucs2.decode` + * @memberOf punycode.ucs2 + * @name encode + * @param {Array} codePoints The array of numeric code points. + * @returns {String} The new Unicode string (UCS-2). + */ + const ucs2encode = (codePoints) => String.fromCodePoint(...codePoints); + + /** + * Converts a basic code point into a digit/integer. + * @see `digitToBasic()` + * @private + * @param {Number} codePoint The basic numeric code point value. + * @returns {Number} The numeric value of a basic code point (for use in + * representing integers) in the range `0` to `base - 1`, or `base` if + * the code point does not represent a value. + */ + const basicToDigit = function (codePoint) { + if (codePoint >= 0x30 && codePoint < 0x3a) { + return 26 + (codePoint - 0x30); + } + if (codePoint >= 0x41 && codePoint < 0x5b) { + return codePoint - 0x41; + } + if (codePoint >= 0x61 && codePoint < 0x7b) { + return codePoint - 0x61; + } + return base; + }; + + /** + * Converts a digit/integer into a basic code point. + * @see `basicToDigit()` + * @private + * @param {Number} digit The numeric value of a basic code point. + * @returns {Number} The basic code point whose value (when used for + * representing integers) is `digit`, which needs to be in the range + * `0` to `base - 1`. If `flag` is non-zero, the uppercase form is + * used; else, the lowercase form is used. The behavior is undefined + * if `flag` is non-zero and `digit` has no uppercase form. + */ + const digitToBasic = function (digit, flag) { + // 0..25 map to ASCII a..z or A..Z + // 26..35 map to ASCII 0..9 + return digit + 22 + 75 * (digit < 26) - ((flag != 0) << 5); + }; + + /** + * Bias adaptation function as per section 3.4 of RFC 3492. + * https://tools.ietf.org/html/rfc3492#section-3.4 + * @private + */ + const adapt = function (delta, numPoints, firstTime) { + let k = 0; + delta = firstTime ? floor(delta / damp) : delta >> 1; + delta += floor(delta / numPoints); + for (; /* no initialization */ delta > (baseMinusTMin * tMax) >> 1; k += base) { + delta = floor(delta / baseMinusTMin); + } + return floor(k + ((baseMinusTMin + 1) * delta) / (delta + skew)); + }; + + /** + * Converts a Punycode string of ASCII-only symbols to a string of Unicode + * symbols. + * @memberOf punycode + * @param {String} input The Punycode string of ASCII-only symbols. + * @returns {String} The resulting string of Unicode symbols. + */ + const decode = function (input) { + // Don't use UCS-2. + const output = []; + const inputLength = input.length; + let i = 0; + let n = initialN; + let bias = initialBias; + + // Handle the basic code points: let `basic` be the number of input code + // points before the last delimiter, or `0` if there is none, then copy + // the first basic code points to the output. + + let basic = input.lastIndexOf(delimiter); + if (basic < 0) { + basic = 0; + } + + for (let j = 0; j < basic; ++j) { + // if it's not a basic code point + if (input.charCodeAt(j) >= 0x80) { + error("not-basic"); + } + output.push(input.charCodeAt(j)); + } + + // Main decoding loop: start just after the last delimiter if any basic code + // points were copied; start at the beginning otherwise. + + for ( + let index = basic > 0 ? basic + 1 : 0; + index < inputLength /* no final expression */; + + ) { + // `index` is the index of the next character to be consumed. + // Decode a generalized variable-length integer into `delta`, + // which gets added to `i`. The overflow checking is easier + // if we increase `i` as we go, then subtract off its starting + // value at the end to obtain `delta`. + const oldi = i; + for (let w = 1, k = base /* no condition */; ; k += base) { + if (index >= inputLength) { + error("invalid-input"); + } + + const digit = basicToDigit(input.charCodeAt(index++)); + + if (digit >= base) { + error("invalid-input"); + } + if (digit > floor((maxInt - i) / w)) { + error("overflow"); + } + + i += digit * w; + const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; + + if (digit < t) { + break; + } + + const baseMinusT = base - t; + if (w > floor(maxInt / baseMinusT)) { + error("overflow"); + } + + w *= baseMinusT; + } + + const out = output.length + 1; + bias = adapt(i - oldi, out, oldi == 0); + + // `i` was supposed to wrap around from `out` to `0`, + // incrementing `n` each time, so we'll fix that now: + if (floor(i / out) > maxInt - n) { + error("overflow"); + } + + n += floor(i / out); + i %= out; + + // Insert `n` at position `i` of the output. + output.splice(i++, 0, n); + } + + return String.fromCodePoint(...output); + }; + + /** + * Converts a string of Unicode symbols (e.g. a domain name label) to a + * Punycode string of ASCII-only symbols. + * @memberOf punycode + * @param {String} input The string of Unicode symbols. + * @returns {String} The resulting Punycode string of ASCII-only symbols. + */ + const encode = function (input) { + const output = []; + + // Convert the input in UCS-2 to an array of Unicode code points. + input = ucs2decode(input); + + // Cache the length. + const inputLength = input.length; + + // Initialize the state. + let n = initialN; + let delta = 0; + let bias = initialBias; + + // Handle the basic code points. + for (const currentValue of input) { + if (currentValue < 0x80) { + output.push(stringFromCharCode(currentValue)); + } + } + + const basicLength = output.length; + let handledCPCount = basicLength; + + // `handledCPCount` is the number of code points that have been handled; + // `basicLength` is the number of basic code points. + + // Finish the basic string with a delimiter unless it's empty. + if (basicLength) { + output.push(delimiter); + } + + // Main encoding loop: + while (handledCPCount < inputLength) { + // All non-basic code points < n have been handled already. Find the next + // larger one: + let m = maxInt; + for (const currentValue of input) { + if (currentValue >= n && currentValue < m) { + m = currentValue; + } + } + + // Increase `delta` enough to advance the decoder's state to , + // but guard against overflow. + const handledCPCountPlusOne = handledCPCount + 1; + if (m - n > floor((maxInt - delta) / handledCPCountPlusOne)) { + error("overflow"); + } + + delta += (m - n) * handledCPCountPlusOne; + n = m; + + for (const currentValue of input) { + if (currentValue < n && ++delta > maxInt) { + error("overflow"); + } + if (currentValue === n) { + // Represent delta as a generalized variable-length integer. + let q = delta; + for (let k = base /* no condition */; ; k += base) { + const t = k <= bias ? tMin : k >= bias + tMax ? tMax : k - bias; + if (q < t) { + break; + } + const qMinusT = q - t; + const baseMinusT = base - t; + output.push( + stringFromCharCode(digitToBasic(t + (qMinusT % baseMinusT), 0)), + ); + q = floor(qMinusT / baseMinusT); + } + + output.push(stringFromCharCode(digitToBasic(q, 0))); + bias = adapt(delta, handledCPCountPlusOne, handledCPCount === basicLength); + delta = 0; + ++handledCPCount; + } + } + + ++delta; + ++n; + } + return output.join(""); + }; + + /** + * Converts a Punycode string representing a domain name or an email address + * to Unicode. Only the Punycoded parts of the input will be converted, i.e. + * it doesn't matter if you call it on a string that has already been + * converted to Unicode. + * @memberOf punycode + * @param {String} input The Punycoded domain name or email address to + * convert to Unicode. + * @returns {String} The Unicode representation of the given Punycode + * string. + */ + const toUnicode = function (input) { + return mapDomain(input, function (string) { + return regexPunycode.test(string) ? decode(string.slice(4).toLowerCase()) : string; + }); + }; + + /** + * Converts a Unicode string representing a domain name or an email address to + * Punycode. Only the non-ASCII parts of the domain name will be converted, + * i.e. it doesn't matter if you call it with a domain that's already in + * ASCII. + * @memberOf punycode + * @param {String} input The domain name or email address to convert, as a + * Unicode string. + * @returns {String} The Punycode representation of the given domain name or + * email address. + */ + const toASCII = function (input) { + return mapDomain(input, function (string) { + return regexNonASCII.test(string) ? "xn--" + encode(string) : string; + }); + }; + + /*--------------------------------------------------------------------------*/ + + /** Define the public API */ + const punycode = { + /** + * A string representing the current Punycode.js version number. + * @memberOf punycode + * @type String + */ + version: "2.3.1", + /** + * An object of methods to convert from JavaScript's internal character + * representation (UCS-2) to Unicode code points, and back. + * @see + * @memberOf punycode + * @type Object + */ + ucs2: { + decode: ucs2decode, + encode: ucs2encode, + }, + decode: decode, + encode: encode, + toASCII: toASCII, + toUnicode: toUnicode, + }; + + //module.exports = punycode; + return punycode; +} diff --git a/public/js/smartforms.js b/public/js/smartforms.js index ef4f23c..9c4ca1f 100644 --- a/public/js/smartforms.js +++ b/public/js/smartforms.js @@ -2,10 +2,17 @@ document.addEventListener("DOMContentLoaded", () => { /* make all forms semi-smart */ const forms = document.querySelectorAll("form[data-smart]"); for (const form of forms) { - const script = form.querySelector("script"); - - form.onsubmit = async (event) => { + async function on_submit(event) { event.preventDefault(); + form.disabled = true; + + if (form.on_submit) { + const result = await form.on_submit(event); + if (result === false) { + form.disabled = false; + return; + } + } const url = form.action; const method = form.dataset.method ?? "POST"; @@ -24,13 +31,11 @@ document.addEventListener("DOMContentLoaded", () => { current[elements.slice(elements.length - 1).shift()] = value; } - console.dir({ - method, - form, - json, - }); + if (form.on_parsed) { + await form.on_parsed(json); + } + try { - // TODO: send session header const options = { method, headers: { @@ -56,21 +61,30 @@ document.addEventListener("DOMContentLoaded", () => { return; } - const response_body = await response.json(); if (form.on_response) { - return form.on_response(response_body); + await form.on_response(response); + } + + const response_body = await response.json(); + if (form.on_reply) { + return form.on_reply(response_body); } } catch (error) { console.dir({ error, }); - if (form.onerror) { - return form.onerror(error); + if (form.on_error) { + return form.on_error(error); } alert(error); + } finally { + form.disabled = false; } - }; + } + + form.addEventListener("submit", on_submit); + //form.onsubmit = on_submit; } }); diff --git a/public/signup_login_wall.html b/public/signup_login_wall.html index 3267114..431d3ff 100644 --- a/public/signup_login_wall.html +++ b/public/signup_login_wall.html @@ -31,59 +31,7 @@ #signup-login-wall form { width: 100%; - } - - form div { - position: relative; - display: flex; - margin-bottom: 1em; - } - - form label { - position: absolute; - top: 10px; - font-size: 30px; - margin: 10px; - padding: 0 10px; - background-color: var(--bg); - -webkit-transition: - top 0.2s ease-in-out, - font-size 0.2s ease-in-out; - transition: - top 0.2s ease-in-out, - font-size 0.2s ease-in-out; - } - - form input:focus ~ label, - form input:valid ~ label { - top: -25px; - font-size: 20px; - } - - form input { - width: 100%; - padding: 20px; - border: 1px solid var(--text); - font-size: 20px; - background-color: var(--bg); - color: var(--text); - } - - form input:focus { - outline: none; - } - - form button { - width: 100%; - padding: 20px; - border: 1px solid var(--text); - font-size: 20px; - background-color: var(--bg); - color: var(--text); - } - - form button.primary { - background-color: var(--accent); + padding: 1rem; } @@ -101,17 +49,11 @@
Log In
-
+ - -
-
-
- - - - - -
-
-
- diff --git a/public/tabs/talk/talk.css b/public/tabs/talk/talk.css new file mode 100644 index 0000000..ec0e0a5 --- /dev/null +++ b/public/tabs/talk/talk.css @@ -0,0 +1,181 @@ +#talk .tab-content { + display: grid; + grid-template-columns: auto 1fr; +} + +.room-list { + margin: 1rem 0; + list-style-type: none; +} + +.room-list > li.room a:before { + position: absolute; + left: -1.75rem; + top: 0; + font-weight: bold; + font-size: x-large; + content: "#"; + color: var(--text); +} + +.room-list > li.room a { + position: relative; + display: block; + width: 100%; + min-height: 1.5rem; + line-height: 1.5rem; + font-weight: bold; + font-size: large; + margin-left: 1.75rem; + text-decoration: none; +} + +.room-list > li.room.active a { + color: var(--accent); +} + +#talk .sidebar { + position: relative; + width: min-content; + min-width: 10rem; + max-width: 32rem; + overflow-x: scroll; + padding: 0.5rem; +} + +#talk .sidebar .title { + text-transform: uppercase; + font-size: small; + font-weight: bold; + line-height: 2rem; +} + +#talk #room-chat-container { + position: relative; +} + +#talk #room-chat-content { + overflow-y: scroll; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 5rem; +} + +#talk #room-chat-entry-container { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 5rem; + padding: 1rem; +} + +#talk #room-chat-entry-container form { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: row; + padding: 0.75rem; +} + +#talk #room-chat-entry-container form button { + width: inherit; + padding: inherit; + margin: 0 1rem; +} + +#talk #room-chat-entry-container form textarea { + flex-grow: 1; + background: inherit; + color: inherit; +} + +#talk .message-container { + transition: all 0.33s; + background: rgba(255, 255, 255, 0.03); + margin-top: 0.75rem; + padding: 2px; + border-radius: 4px; +} + +#talk .message-container.user-tick.time-tick + .message-container.user-tick.time-tick, +#talk .message-container.user-tick.time-tock + .message-container.user-tick.time-tock, +#talk .message-container.user-tock.time-tick + .message-container.user-tock.time-tick, +#talk .message-container.user-tock.time-tock + .message-container.user-tock.time-tock { + margin-top: 0; + padding: 0 2px; +} + +#talk + .message-container.user-tick.time-tick + + .message-container.user-tick.time-tick + .info-container, +#talk + .message-container.user-tick.time-tock + + .message-container.user-tick.time-tock + .info-container, +#talk + .message-container.user-tock.time-tick + + .message-container.user-tock.time-tick + .info-container, +#talk + .message-container.user-tock.time-tock + + .message-container.user-tock.time-tock + .info-container { + opacity: 0; + visibility: hidden; + height: 0; + margin: 0; +} + +#talk .message-container.sending { + opacity: 0.75; +} + +#talk .message-container .info-container { + display: flex; + margin-bottom: -1.5rem; + height: 3.75rem; +} + +#talk .message-container .info-container .avatar-container { + display: inline-block; + margin: 0 4px; + width: 3rem; + height: 3rem; + border-radius: 16%; + overflow: hidden; +} + +#talk .message-container .info-container .avatar-container img { + width: 100%; +} + +#talk .message-container .info-container .username-container { + margin: 0 4px; + font-weight: bold; +} + +#talk .message-container .info-container .datetime-container { + margin: 0 4px; +} + +#talk .message-container .info-container .datetime-container .long { + font-size: x-small; + text-transform: uppercase; +} + +#talk .message-container .info-container .datetime-container .short { + font-size: xx-small; + visibility: hidden; + display: none; +} + +#talk .message-container .message-content-container { + padding-left: 8rem; +} diff --git a/public/tabs/talk/talk.html b/public/tabs/talk/talk.html new file mode 100644 index 0000000..132caf4 --- /dev/null +++ b/public/tabs/talk/talk.html @@ -0,0 +1,175 @@ +
+ + +
+ + + + +
+
+
+
+ + + + + +
+
+
+
+
diff --git a/public/tabs/talk/talk.js b/public/tabs/talk/talk.js new file mode 100644 index 0000000..5a34557 --- /dev/null +++ b/public/tabs/talk/talk.js @@ -0,0 +1,266 @@ +const URL_MATCHING_REGEX = + /(?:(?[a-zA-Z]+):\/\/)?(?:(?(?\S.+)\:(?.+))\@)?(?(?:(?[-a-zA-Z0-9\.]+)\.)?(?[-a-zA-Z0-9]+?\.(?[-a-zA-Z0-9]{2,64}))(?:\:(?[0-9]{1,6}))?)\b(?[-a-zA-Z0-9@:%_{}\[\]<>\(\)\+.~&\/="]*)(?:\?(?[a-zA-Z0-9!$%&<>()*+,-\.\/\:\;\=\?\@_~"]+))?(?:#(?[a-zA-Z0-9!$&'()*+,-\.\/\:\;\=\?\@_~"]*?))?/gm; + +const URL_MATCH_HANDLERS = [ + (match) => { + // console.dir(match); + return; + }, + + // url + (match) => { + // TODO: punycoding if something has unicode? + // const punycode = get_punycode(); + // const punycoded_url = punycode.encode(match[0]); + const groups = match.groups ?? {}; + + return `${match[0]}`; + }, +]; + +function message_text_to_html(input) { + let html_message = input; + let match; + while ((match = URL_MATCHING_REGEX.exec(input)) !== null) { + for (const handler of URL_MATCH_HANDLERS) { + const result = handler(match); + if (typeof result === "string") { + html_message = html_message.replace(match[0], result); + } + } + } + + return html_message; +} + +const time_tick_tock_timeout = 60 * 1000; // 1 minute +let last_event_datetime_value = 0; +let time_tick_tock_class = "time-tock"; + +let last_creator_id = null; +let user_tick_tock_class = "user-tock"; +function render_text_event(room_chat_content, event, creator, existing_element) { + const event_datetime = datetime_to_local(event.timestamps.created); + + if (event_datetime.value - last_event_datetime_value > time_tick_tock_timeout) { + time_tick_tock_class = time_tick_tock_class === "time-tick" ? "time-tock" : "time-tick"; + } + last_event_datetime_value = event_datetime.value; + + if (last_creator_id !== creator.id) { + user_tick_tock_class = user_tick_tock_class === "user-tick" ? "user-tock" : "user-tick"; + last_creator_id = creator.id; + } + + const html_content = `
+
+
+ user avatar +
+
+ ${creator.username ?? "unknown"} +
+
+ ${event_datetime.long} + ${event_datetime.short} +
+
+
${message_text_to_html(event.data.message)}
+
`; + + if (existing_element) { + const template = document.createElement("template"); + template.innerHTML = html_content; + existing_element.replaceWith(template.content.firstChild); + } else { + room_chat_content.insertAdjacentHTML("beforeend", html_content); + } +} + +async function get_new_room_element() { + const existing_new_room_element = document.getElementById("new-room"); + if (existing_new_room_element) { + return existing_new_room_element; + } + + const room_list = document.getElementById("room-list"); + room_list.insertAdjacentHTML( + "beforeend", + `
  • new room
  • `, + ); + await new Promise((resolve) => setTimeout(resolve, 1)); + + const new_room_element = document.getElementById("new-room"); + return new_room_element; +} + +const users = {}; +async function append_room_events(events) { + const room_chat_content = document.getElementById("room-chat-content"); + let last_message_id = room_chat_content.dataset.last_message_id ?? ""; + for (const event of events) { + // if the last message is undefined, it becomes this event, otherwise, if this event's id is newer, + // it becomes the latest message + last_message_id = + event.id > last_message_id && event.id.indexOf("TEMP") !== 0 + ? event.id + : last_message_id; + + // if the last message has been updated, update the content's dataset to reflect that + if (last_message_id !== room_chat_content.dataset.last_message_id) { + room_chat_content.dataset.last_message_id = last_message_id; + } + + users[event.creator_id] = + users[event.creator_id] ?? + (await (await api.fetch(`/api/users/${event.creator_id}`)).json()); + + const existing_element = + document.getElementById(`chat-${event.id}`) ?? + (event.meta?.temp_id + ? document.getElementById(`chat-${event.meta.temp_id}`) + : undefined); + render_text_event(room_chat_content, event, users[event.creator_id], existing_element); + } + + room_chat_content.scrollTop = room_chat_content.scrollHeight; +} + +// TODO: we need some abortcontroller handling here or something +// similar for when we change rooms, this is the most basic +// first pass outline +let room_polling_request_abort_controller = null; +async function poll_for_new_events() { + const room_chat_content = document.getElementById("room-chat-content"); + const room_id = room_chat_content.dataset.room_id; + const last_message_id = room_chat_content.dataset.last_message_id; + + if (!room_id) { + return; + } + + const message_polling_url = `/api/rooms/${room_id}/events?type=chat&limit=100&sort=newest&wait=true${last_message_id ? `&after_id=${last_message_id}` : ""}`; + + room_polling_request_abort_controller = + room_polling_request_abort_controller || new AbortController(); + + api.fetch(message_polling_url, { + signal: room_polling_request_abort_controller.signal, + }) + .then(async (new_events_response) => { + const new_events = (await new_events_response.json()).reverse(); + await append_room_events(new_events.toReversed()); + poll_for_new_events(room_id); + }) + .catch((error) => { + // TODO: poll again? back off? + console.error(error); + }); +} + +async function load_room(room_id) { + const room_chat_content = document.getElementById("room-chat-content"); + + if (room_polling_request_abort_controller) { + room_polling_request_abort_controller.abort(); + room_polling_request_abort_controller = null; + delete room_chat_content.dataset.last_message_id; + } + + const room_response = await api.fetch(`/api/rooms/${room_id}`); + if (!room_response.ok) { + const error = await room_response.json(); + alert(error.message ?? JSON.stringify(error)); + return; + } + + const room = await room_response.json(); + + room_chat_content.dataset.room_id = room.id; + room_chat_content.innerHTML = ""; + + const room_selectors = document.querySelectorAll("li.room"); + for (const room_selector of room_selectors) { + room_selector.classList.remove("active"); + if (room_selector.id === `room-selector-${room_id}`) { + room_selector.classList.add("active"); + } + } + + const events_response = await api.fetch( + `/api/rooms/${room_id}/events?type=chat&limit=100&sort=newest`, + ); + if (!events_response.ok) { + const error = await events_response.json(); + alert(error.message ?? JSON.stringify(error)); + return; + } + + const events = (await events_response.json()).reverse(); + + await append_room_events(events); + poll_for_new_events(room_id); +} + +let last_room_update = undefined; +async function update_chat_rooms() { + const now = new Date(); + const time_since_last_update = now - (last_room_update ?? 0); + if (time_since_last_update < 5_000) { + return; + } + + const rooms_response = await api.fetch("/api/rooms"); + if (rooms_response.ok) { + const room_list = document.getElementById("room-list"); + room_list.innerHTML = ""; + const rooms = await rooms_response.json(); + for (const room of rooms) { + room_list.insertAdjacentHTML( + "beforeend", + `
  • ${room.name}
  • `, + ); + } + + last_room_update = now; + } +} +window.addEventListener("locationchange", update_chat_rooms); + +function check_for_room_in_url() { + const user_json = document.body.dataset.user; + if (!user_json) { + return; + } + + const hash = window.location.hash; + const talk_in_url = hash.indexOf("#/talk") === 0; + if (!talk_in_url) { + return; + } + + const first_room_id = document.querySelector("li.room")?.id.substring(14); + + // #/talk/room/{room_id} + // ^ 12 + const room_id = hash.substring(12) || first_room_id; + + if (!room_id) { + setTimeout(check_for_room_in_url, 100); + return; + } + + const room_chat_container = document.getElementById("room-chat-container"); + + if (room_chat_container.dataset.room_id !== room_id) { + window.location.hash = `/talk/room/${room_id}`; + room_chat_container.dataset.room_id = room_id; + load_room(room_id); + } +} +window.addEventListener("locationchange", check_for_room_in_url); + +document.addEventListener("DOMContentLoaded", async () => { + await update_chat_rooms(); + check_for_room_in_url(); +});