diff --git a/package-lock.json b/package-lock.json
index f1d303a0..0790becd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"autoprefixer": "~10.4.13",
"postcss": "~8.4.20",
"postcss-dark-theme-class": "~0.7.3",
+ "twitter-text": "~3.1.0",
"vite": "~4.0.3",
"vite-plugin-pwa": "~0.14.0",
"workbox-cacheable-response": "~6.5.4",
@@ -2930,6 +2931,14 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
},
+ "node_modules/core-js": {
+ "version": "2.6.12",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
+ "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
+ "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
+ "dev": true,
+ "hasInstallScript": true
+ },
"node_modules/core-js-compat": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz",
@@ -5075,6 +5084,30 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
},
+ "node_modules/twemoji-parser": {
+ "version": "11.0.2",
+ "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-11.0.2.tgz",
+ "integrity": "sha512-5kO2XCcpAql6zjdLwRwJjYvAZyDy3+Uj7v1ipBzLthQmDL7Ce19bEqHr3ImSNeoSW2OA8u02XmARbXHaNO8GhA==",
+ "dev": true
+ },
+ "node_modules/twitter-text": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/twitter-text/-/twitter-text-3.1.0.tgz",
+ "integrity": "sha512-nulfUi3FN6z0LUjYipJid+eiwXvOLb8Ass7Jy/6zsXmZK3URte043m8fL3FyDzrK+WLpyqhHuR/TcARTN/iuGQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.3.1",
+ "core-js": "^2.5.0",
+ "punycode": "1.4.1",
+ "twemoji-parser": "^11.0.2"
+ }
+ },
+ "node_modules/twitter-text/node_modules/punycode": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
+ "dev": true
+ },
"node_modules/type-fest": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
@@ -7752,6 +7785,12 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
},
+ "core-js": {
+ "version": "2.6.12",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
+ "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
+ "dev": true
+ },
"core-js-compat": {
"version": "3.26.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz",
@@ -9316,6 +9355,32 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
},
+ "twemoji-parser": {
+ "version": "11.0.2",
+ "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-11.0.2.tgz",
+ "integrity": "sha512-5kO2XCcpAql6zjdLwRwJjYvAZyDy3+Uj7v1ipBzLthQmDL7Ce19bEqHr3ImSNeoSW2OA8u02XmARbXHaNO8GhA==",
+ "dev": true
+ },
+ "twitter-text": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/twitter-text/-/twitter-text-3.1.0.tgz",
+ "integrity": "sha512-nulfUi3FN6z0LUjYipJid+eiwXvOLb8Ass7Jy/6zsXmZK3URte043m8fL3FyDzrK+WLpyqhHuR/TcARTN/iuGQ==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.3.1",
+ "core-js": "^2.5.0",
+ "punycode": "1.4.1",
+ "twemoji-parser": "^11.0.2"
+ },
+ "dependencies": {
+ "punycode": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
+ "dev": true
+ }
+ }
+ },
"type-fest": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
diff --git a/package.json b/package.json
index ff907812..7ef73a72 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"autoprefixer": "~10.4.13",
"postcss": "~8.4.20",
"postcss-dark-theme-class": "~0.7.3",
+ "twitter-text": "~3.1.0",
"vite": "~4.0.3",
"vite-plugin-pwa": "~0.14.0",
"workbox-cacheable-response": "~6.5.4",
diff --git a/scripts/extract-url.js b/scripts/extract-url.js
new file mode 100644
index 00000000..b1ab1335
--- /dev/null
+++ b/scripts/extract-url.js
@@ -0,0 +1,48 @@
+import fs from 'fs';
+import regexSupplant from 'twitter-text/dist/lib/regexSupplant.js';
+import validDomain from 'twitter-text/dist/regexp/validDomain.js';
+import validPortNumber from 'twitter-text/dist/regexp/validPortNumber.js';
+import validUrlPath from 'twitter-text/dist/regexp/validUrlPath.js';
+import validUrlPrecedingChars from 'twitter-text/dist/regexp/validUrlPrecedingChars.js';
+import validUrlQueryChars from 'twitter-text/dist/regexp/validUrlQueryChars.js';
+import validUrlQueryEndingChars from 'twitter-text/dist/regexp/validUrlQueryEndingChars.js';
+
+// The difference with twitter-text's extractURL is that the protocol isn't
+// optional.
+
+const urlRegex = regexSupplant(
+ '(' + // $1 total match
+ '(#{validUrlPrecedingChars})' + // $2 Preceeding chracter
+ '(' + // $3 URL
+ '(https?:\\/\\/)' + // $4 Protocol (optional) <-- THIS IS THE DIFFERENCE, MISSING '?' AFTER PROTOCOL
+ '(#{validDomain})' + // $5 Domain(s)
+ '(?::(#{validPortNumber}))?' + // $6 Port number (optional)
+ '(\\/#{validUrlPath}*)?' + // $7 URL Path
+ '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $8 Query String
+ ')' +
+ ')',
+ {
+ validUrlPrecedingChars,
+ validDomain,
+ validPortNumber,
+ validUrlPath,
+ validUrlQueryChars,
+ validUrlQueryEndingChars,
+ },
+ 'gi',
+);
+
+const filePath = 'src/data/url-regex.json';
+fs.writeFile(
+ filePath,
+ JSON.stringify({
+ source: urlRegex.source,
+ flags: urlRegex.flags,
+ }),
+ (err) => {
+ if (err) {
+ console.error(err);
+ }
+ console.log(`Wrote ${filePath}`);
+ },
+);
diff --git a/src/app.css b/src/app.css
index e3732225..e918cc9e 100644
--- a/src/app.css
+++ b/src/app.css
@@ -658,6 +658,65 @@ button.carousel-dot[disabled].active {
background-color: var(--link-color);
}
+/* DONUT METER */
+
+meter.donut {
+ appearance: none;
+}
+
+meter.donut:is(
+ ::-webkit-progress-inner-element,
+ ::-webkit-progress-bar,
+ ::-webkit-progress-value,
+ ::-webkit-meter-bar,
+ ::-webkit-meter-optimum-value,
+ ::-webkit-meter-suboptimum-value,
+ ::-webkit-meter-even-less-good-value
+ ) {
+ display: none;
+}
+
+meter.donut:is(::-moz-progress-bar, ::-moz-meter-bar) {
+ background: transparent;
+}
+
+meter.donut {
+ position: relative;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ --fill: calc(var(--percentage) * 1%);
+ --color: var(--link-color);
+ --middle-circle: radial-gradient(
+ circle at 50% 50%,
+ var(--bg-faded-color) 10px,
+ transparent 10px
+ );
+ background-image: var(--middle-circle),
+ conic-gradient(var(--color) var(--fill), var(--bg-faded-blur-color) 0);
+}
+meter.donut.warning {
+ --color: var(--orange-color);
+}
+meter.donut.danger {
+ --color: var(--red-color);
+}
+meter.donut.explode {
+ background-image: none;
+}
+meter.donut:is(.warning, .danger, .explode):after {
+ content: attr(data-left);
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 12px;
+ color: var(--text-insignificant-color);
+}
+meter.donut:is(.danger, .explode):after {
+ color: var(--red-color);
+}
+
@media (min-width: 40em) {
html,
body {
diff --git a/src/components/compose.jsx b/src/components/compose.jsx
index b14ba6ae..64b0789d 100644
--- a/src/components/compose.jsx
+++ b/src/components/compose.jsx
@@ -4,6 +4,7 @@ import '@github/text-expander-element';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import stringLength from 'string-length';
+import urlRegex from '../data/url-regex';
import emojifyText from '../utils/emojify-text';
import openCompose from '../utils/open-compose';
import store from '../utils/store';
@@ -380,6 +381,20 @@ function Compose({
});
}, []);
+ const [charCount, setCharCount] = useState(
+ textareaRef.current?.value?.length +
+ spoilerTextRef.current?.value?.length || 0,
+ );
+ const leftChars = maxCharacters - charCount;
+ const getCharCount = () => {
+ const { value } = textareaRef.current;
+ const { value: spoilerText } = spoilerTextRef.current;
+ return stringLength(countableText(value)) + stringLength(spoilerText);
+ };
+ const updateCharCount = () => {
+ setCharCount(getCharCount());
+ };
+
return (
@@ -543,6 +558,7 @@ function Compose({
sensitive = sensitive === 'on'; // checkboxes return "on" if checked
// Validation
+ /* Let the backend validate this
if (stringLength(status) > maxCharacters) {
alert(`Status is too long! Max characters: ${maxCharacters}`);
return;
@@ -556,6 +572,7 @@ function Compose({
);
return;
}
+ */
if (poll) {
if (poll.options.length < 2) {
alert('Poll must have at least 2 options');
@@ -664,6 +681,9 @@ function Compose({
opacity: sensitive ? 1 : 0,
pointerEvents: sensitive ? 'auto' : 'none',
}}
+ onInput={() => {
+ updateCharCount();
+ }}
/>