New: char count for Compose field

Uses pre-compiled regex for perf
This commit is contained in:
Lim Chee Aun 2022-12-23 16:45:02 +08:00
parent a2e55eca90
commit 13a347ce37
6 changed files with 226 additions and 0 deletions

65
package-lock.json generated
View file

@ -29,6 +29,7 @@
"autoprefixer": "~10.4.13", "autoprefixer": "~10.4.13",
"postcss": "~8.4.20", "postcss": "~8.4.20",
"postcss-dark-theme-class": "~0.7.3", "postcss-dark-theme-class": "~0.7.3",
"twitter-text": "~3.1.0",
"vite": "~4.0.3", "vite": "~4.0.3",
"vite-plugin-pwa": "~0.14.0", "vite-plugin-pwa": "~0.14.0",
"workbox-cacheable-response": "~6.5.4", "workbox-cacheable-response": "~6.5.4",
@ -2930,6 +2931,14 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true "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": { "node_modules/core-js-compat": {
"version": "3.26.1", "version": "3.26.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz", "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", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" "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": { "node_modules/type-fest": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
@ -7752,6 +7785,12 @@
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true "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": { "core-js-compat": {
"version": "3.26.1", "version": "3.26.1",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.1.tgz", "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", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
"integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" "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": { "type-fest": {
"version": "0.16.0", "version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",

View file

@ -31,6 +31,7 @@
"autoprefixer": "~10.4.13", "autoprefixer": "~10.4.13",
"postcss": "~8.4.20", "postcss": "~8.4.20",
"postcss-dark-theme-class": "~0.7.3", "postcss-dark-theme-class": "~0.7.3",
"twitter-text": "~3.1.0",
"vite": "~4.0.3", "vite": "~4.0.3",
"vite-plugin-pwa": "~0.14.0", "vite-plugin-pwa": "~0.14.0",
"workbox-cacheable-response": "~6.5.4", "workbox-cacheable-response": "~6.5.4",

48
scripts/extract-url.js Normal file
View file

@ -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}`);
},
);

View file

@ -658,6 +658,65 @@ button.carousel-dot[disabled].active {
background-color: var(--link-color); 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) { @media (min-width: 40em) {
html, html,
body { body {

View file

@ -4,6 +4,7 @@ import '@github/text-expander-element';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import stringLength from 'string-length'; import stringLength from 'string-length';
import urlRegex from '../data/url-regex';
import emojifyText from '../utils/emojify-text'; import emojifyText from '../utils/emojify-text';
import openCompose from '../utils/open-compose'; import openCompose from '../utils/open-compose';
import store from '../utils/store'; 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 ( return (
<div id="compose-container" class={standalone ? 'standalone' : ''}> <div id="compose-container" class={standalone ? 'standalone' : ''}>
<div class="compose-top"> <div class="compose-top">
@ -543,6 +558,7 @@ function Compose({
sensitive = sensitive === 'on'; // checkboxes return "on" if checked sensitive = sensitive === 'on'; // checkboxes return "on" if checked
// Validation // Validation
/* Let the backend validate this
if (stringLength(status) > maxCharacters) { if (stringLength(status) > maxCharacters) {
alert(`Status is too long! Max characters: ${maxCharacters}`); alert(`Status is too long! Max characters: ${maxCharacters}`);
return; return;
@ -556,6 +572,7 @@ function Compose({
); );
return; return;
} }
*/
if (poll) { if (poll) {
if (poll.options.length < 2) { if (poll.options.length < 2) {
alert('Poll must have at least 2 options'); alert('Poll must have at least 2 options');
@ -664,6 +681,9 @@ function Compose({
opacity: sensitive ? 1 : 0, opacity: sensitive ? 1 : 0,
pointerEvents: sensitive ? 'auto' : 'none', pointerEvents: sensitive ? 'auto' : 'none',
}} }}
onInput={() => {
updateCharCount();
}}
/> />
<label <label
class="toolbar-button" class="toolbar-button"
@ -738,6 +758,7 @@ function Compose({
e.target.style.height = value e.target.style.height = value
? scrollHeight + offset + 'px' ? scrollHeight + offset + 'px'
: null; : null;
updateCharCount();
}} }}
style={{ style={{
maxHeight: `${maxCharacters / 50}em`, maxHeight: `${maxCharacters / 50}em`,
@ -848,6 +869,27 @@ function Compose({
</button>{' '} </button>{' '}
<div class="spacer" /> <div class="spacer" />
{uiState === 'loading' && <Loader abrupt />}{' '} {uiState === 'loading' && <Loader abrupt />}{' '}
{uiState !== 'loading' && charCount > maxCharacters / 2 && (
<>
<meter
class={`donut ${
leftChars <= -10
? 'explode'
: leftChars <= 0
? 'danger'
: leftChars <= 20
? 'warning'
: ''
}`}
value={charCount}
max={maxCharacters}
data-left={leftChars}
style={{
'--percentage': (charCount / maxCharacters) * 100,
}}
/>{' '}
</>
)}
<button type="submit" class="large" disabled={uiState === 'loading'}> <button type="submit" class="large" disabled={uiState === 'loading'}>
{replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'} {replyToStatus ? 'Reply' : editStatus ? 'Update' : 'Post'}
</button> </button>
@ -1065,4 +1107,14 @@ function encodeHTML(str) {
}); });
} }
// https://github.com/mastodon/mastodon/blob/c4a429ed47e85a6bbf0d470a41cc2f64cf120c19/app/javascript/mastodon/features/compose/util/counter.js
const urlRegexObj = new RegExp(urlRegex.source, urlRegex.flags);
const usernameRegex = /(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/gi;
const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
function countableText(inputText) {
return inputText
.replace(urlRegexObj, urlPlaceholder)
.replace(usernameRegex, '$1@$3');
}
export default Compose; export default Compose;

1
src/data/url-regex.json Normal file

File diff suppressed because one or more lines are too long