[frogend] Emoji categories (#1051)

* emoji category combobox

* emoji categorizing

* dropdown entry separation

* emoji filtering/sorting

* add some explaining comments

* remove unneeded default-value code

* remove wrongly created package.json

* configurable ComboBox label+placeHolder
This commit is contained in:
f0x52 2022-11-16 17:05:49 +01:00 committed by GitHub
parent 940abc279c
commit aa5c4e065c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 249 additions and 35 deletions

View file

@ -290,7 +290,7 @@ section.error {
font-weight: bold; font-weight: bold;
} }
input, select, textarea { input, select, textarea, .input {
box-sizing: border-box; box-sizing: border-box;
border: 0.15rem solid $input-border; border: 0.15rem solid $input-border;
border-radius: 0.1rem; border-radius: 0.1rem;

View file

@ -12,11 +12,13 @@
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^1.8.6", "@reduxjs/toolkit": "^1.8.6",
"ariakit": "^2.0.0-next.41",
"bluebird": "^3.7.2", "bluebird": "^3.7.2",
"dotty": "^0.1.2", "dotty": "^0.1.2",
"is-valid-domain": "^0.1.6", "is-valid-domain": "^0.1.6",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"langs": "^2.0.0", "langs": "^2.0.0",
"match-sorter": "^6.3.1",
"modern-normalize": "^1.1.0", "modern-normalize": "^1.1.0",
"photoswipe": "^5.3.3", "photoswipe": "^5.3.3",
"photoswipe-dynamic-caption-plugin": "^1.2.7", "photoswipe-dynamic-caption-plugin": "^1.2.7",
@ -28,6 +30,8 @@
"redux-devtools-extension": "^2.13.9", "redux-devtools-extension": "^2.13.9",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"skulk": "^0.0.6", "skulk": "^0.0.6",
"split-filter-n": "^1.1.3",
"syncpipe": "^1.0.0",
"wouter": "^2.8.0-alpha.2" "wouter": "^2.8.0-alpha.2"
}, },
"devDependencies": { "devDependencies": {

View file

@ -20,30 +20,34 @@
const Promise = require('bluebird'); const Promise = require('bluebird');
const React = require("react"); const React = require("react");
const { matchSorter } = require("match-sorter");
const FakeToot = require("../../components/fake-toot"); const FakeToot = require("../../components/fake-toot");
const MutateButton = require("../../components/mutation-button"); const MutateButton = require("../../components/mutation-button");
const ComboBox = require("../../components/combo-box");
const { const {
useTextInput, useTextInput,
useFileInput useFileInput,
useComboBoxInput
} = require("../../components/form"); } = require("../../components/form");
const query = require("../../lib/query"); const query = require("../../lib/query");
const syncpipe = require('syncpipe');
module.exports = function NewEmojiForm({emoji}) { module.exports = function NewEmojiForm({ emoji, emojiByCategory }) {
const emojiCodes = React.useMemo(() => { const emojiCodes = React.useMemo(() => {
return new Set(emoji.map((e) => e.shortcode)); return new Set(emoji.map((e) => e.shortcode));
}, [emoji]); }, [emoji]);
const [addEmoji, result] = query.useAddEmojiMutation(); const [addEmoji, result] = query.useAddEmojiMutation();
const [onFileChange, resetFile, {image, imageURL, imageInfo}] = useFileInput("image", { const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", {
withPreview: true, withPreview: true,
maxSize: 50 * 1024 maxSize: 50 * 1024
}); });
const [onShortcodeChange, resetShortcode, {shortcode, setShortcode, shortcodeRef}] = useTextInput("shortcode", { const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", {
validator: function validateShortcode(code) { validator: function validateShortcode(code) {
return emojiCodes.has(code) return emojiCodes.has(code)
? "Shortcode already in use" ? "Shortcode already in use"
@ -51,6 +55,23 @@ module.exports = function NewEmojiForm({emoji}) {
} }
}); });
const [categoryState, resetCategory, { category }] = useComboBoxInput("category");
// data used by the ComboBox element to select an emoji category
const categoryItems = React.useMemo(() => {
return syncpipe(emojiByCategory, [
(_) => Object.keys(_), // just emoji category names
(_) => matchSorter(_, category), // sorted by complex algorithm
(_) => _.map((categoryName) => [ // map to input value, and selectable element with icon
categoryName,
<>
<img src={emojiByCategory[categoryName][0].static_url} aria-hidden="true"></img>
{categoryName}
</>
])
]);
}, [emojiByCategory, category]);
React.useEffect(() => { React.useEffect(() => {
if (shortcode.length == 0) { if (shortcode.length == 0) {
if (image != undefined) { if (image != undefined) {
@ -58,6 +79,9 @@ module.exports = function NewEmojiForm({emoji}) {
setShortcode(name); setShortcode(name);
} }
} }
// we explicitly don't want to add 'shortcode' as a dependency here
// because we only want this to update to the filename if the field is empty
// at the moment the file is selected, not some time after when the field is emptied
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [image]); }, [image]);
@ -69,11 +93,13 @@ module.exports = function NewEmojiForm({emoji}) {
Promise.try(() => { Promise.try(() => {
return addEmoji({ return addEmoji({
image, image,
shortcode shortcode,
category
}); });
}).then(() => { }).then(() => {
resetFile(); resetFile();
resetShortcode(); resetShortcode();
resetCategory();
}); });
} }
@ -125,8 +151,15 @@ module.exports = function NewEmojiForm({emoji}) {
value={shortcode} value={shortcode}
/> />
</div> </div>
<MutateButton text="Upload emoji" result={result}/> <ComboBox
state={categoryState}
items={categoryItems}
label="Category"
placeHolder="e.g., reactions"
/>
<MutateButton text="Upload emoji" result={result} />
</form> </form>
</div> </div>
); );

View file

@ -20,7 +20,7 @@
const React = require("react"); const React = require("react");
const {Link} = require("wouter"); const {Link} = require("wouter");
const defaultValue = require('default-value'); const splitFilterN = require("split-filter-n");
const NewEmojiForm = require("./new-emoji"); const NewEmojiForm = require("./new-emoji");
@ -30,11 +30,18 @@ const base = "/settings/admin/custom-emoji";
module.exports = function EmojiOverview() { module.exports = function EmojiOverview() {
const { const {
data: emoji, data: emoji = [],
isLoading, isLoading,
error error
} = query.useGetAllEmojiQuery({filter: "domain:local"}); } = query.useGetAllEmojiQuery({filter: "domain:local"});
// split all emoji over an object keyed by the category names (or Unsorted)
const emojiByCategory = React.useMemo(() => splitFilterN(
emoji,
[],
(entry) => entry.category ?? "Unsorted"
), [emoji]);
return ( return (
<> <>
<h1>Custom Emoji</h1> <h1>Custom Emoji</h1>
@ -44,33 +51,21 @@ module.exports = function EmojiOverview() {
{isLoading {isLoading
? "Loading..." ? "Loading..."
: <> : <>
<EmojiList emoji={emoji}/> <EmojiList emoji={emoji} emojiByCategory={emojiByCategory}/>
<NewEmojiForm emoji={emoji}/> <NewEmojiForm emoji={emoji} emojiByCategory={emojiByCategory}/>
</> </>
} }
</> </>
); );
}; };
function EmojiList({emoji}) { function EmojiList({emoji, emojiByCategory}) {
const byCategory = React.useMemo(() => {
const categories = {};
emoji.forEach((emoji) => {
let cat = defaultValue(emoji.category, "Unsorted");
categories[cat] = defaultValue(categories[cat], []);
categories[cat].push(emoji);
});
return categories;
}, [emoji]);
return ( return (
<div> <div>
<h2>Overview</h2> <h2>Overview</h2>
<div className="list emoji-list"> <div className="list emoji-list">
{emoji.length == 0 && "No local emoji yet"} {emoji.length == 0 && "No local emoji yet, add one below"}
{Object.entries(byCategory).map(([category, entries]) => { {Object.entries(emojiByCategory).map(([category, entries]) => {
return <EmojiCategory key={category} category={category} entries={entries}/>; return <EmojiCategory key={category} category={category} entries={entries}/>;
})} })}
</div> </div>

View file

@ -0,0 +1,49 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const {
Combobox,
ComboboxItem,
ComboboxPopover,
} = require("ariakit/combobox");
module.exports = function ComboBox({state, items, label, placeHolder}) {
return (
<div className="form-field combobox-wrapper">
<label>
{label}
<Combobox
state={state}
placeholder={placeHolder}
className="combobox input"
/>
</label>
<ComboboxPopover state={state} className="popover">
{items.map(([key, value]) => (
<ComboboxItem className="combobox-item" key={key} value={key}>
{value}
</ComboboxItem>
))}
</ComboboxPopover>
</div>
);
};

View file

@ -0,0 +1,37 @@
/*
GoToSocial
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
"use strict";
const { useComboboxState } = require("ariakit/combobox");
module.exports = function useComboBoxInput({name, Name}, {validator} = {}) {
const state = useComboboxState({ gutter: 0, sameWidth: true });
function reset() {
state.value = "";
}
return [
state,
reset,
{
[name]: state.value,
}
];
};

View file

@ -32,5 +32,6 @@ function makeHook(func) {
module.exports = { module.exports = {
useTextInput: makeHook(require("./text")), useTextInput: makeHook(require("./text")),
useFileInput: makeHook(require("./file")) useFileInput: makeHook(require("./file")),
useComboBoxInput: makeHook(require("./combobox"))
}; };

View file

@ -20,7 +20,6 @@
const { createSlice } = require("@reduxjs/toolkit"); const { createSlice } = require("@reduxjs/toolkit");
const d = require("dotty"); const d = require("dotty");
const defaultValue = require("default-value");
module.exports = createSlice({ module.exports = createSlice({
name: "user", name: "user",
@ -30,10 +29,10 @@ module.exports = createSlice({
}, },
reducers: { reducers: {
setAccount: (state, { payload }) => { setAccount: (state, { payload }) => {
payload.source = defaultValue(payload.source, {}); payload.source = payload.source ?? {};
payload.source.language = defaultValue(payload.source.language.toUpperCase(), "EN"); payload.source.language = payload.source.language.toUpperCase() ?? "EN";
payload.source.status_format = defaultValue(payload.source.status_format, "plain"); payload.source.status_format = payload.source.status_format ?? "plain";
payload.source.sensitive = defaultValue(payload.source.sensitive, false); payload.source.sensitive = payload.source.sensitive ?? false;
state.profile = payload; state.profile = payload;
// /user/settings only needs a copy of the 'source' obj // /user/settings only needs a copy of the 'source' obj

View file

@ -502,4 +502,62 @@ span.form-info {
.instance-list .filter { .instance-list .filter {
flex-direction: column; flex-direction: column;
} }
}
.combobox-wrapper {
display: flex;
flex-direction: column;
input[aria-expanded="true"] {
border-bottom: none;
}
}
.combobox {
height: 2.5rem;
font-size: 1rem;
line-height: 1.5rem;
}
.popover {
position: relative;
z-index: 50;
display: flex;
max-height: min(var(--popover-available-height,300px),300px);
flex-direction: column;
overflow: auto;
overscroll-behavior: contain;
border: 0.15rem solid $orange2;
background: $bg-accent;
}
.combobox-item {
display: flex;
cursor: pointer;
scroll-margin: 0.5rem;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
line-height: 1.5rem;
border-bottom: 0.15rem solid $gray3;
&:last-child {
border: none;
}
img {
height: 1.5rem;
width: 1.5rem;
object-fit: contain;
}
}
.combobox-item:hover {
background: $button-hover-bg;
color: $button-fg;
}
.combobox-item[data-active-item] {
background: $button-hover-bg;
color: hsl(204 20% 100%);
} }

View file

@ -991,6 +991,18 @@
dependencies: dependencies:
fs-monkey "0.4.0" fs-monkey "0.4.0"
"@floating-ui/core@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.0.1.tgz#00e64d74e911602c8533957af0cce5af6b2e93c8"
integrity sha512-bO37brCPfteXQfFY0DyNDGB3+IMe4j150KFQcgJ5aBP295p9nBGeHEs/p0czrRbtlHq4Px/yoPXO/+dOCcF4uA==
"@floating-ui/dom@^1.0.0":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.0.4.tgz#cc0f2a03db7193b1b932b90d09c5c81235682a60"
integrity sha512-maYJRv+sAXTy4K9mzdv0JPyNW5YPVHrqtY90tEdI6XNpuLOP26Ci2pfwPsKBA/Wh4Z3FX5sUrtUFTdMYj9v+ug==
dependencies:
"@floating-ui/core" "^1.0.1"
"@humanwhocodes/config-array@^0.11.6": "@humanwhocodes/config-array@^0.11.6":
version "0.11.7" version "0.11.7"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.7.tgz#38aec044c6c828f6ed51d5d7ae3d9b9faf6dbb0f" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.7.tgz#38aec044c6c828f6ed51d5d7ae3d9b9faf6dbb0f"
@ -1623,6 +1635,19 @@ argparse@^2.0.1:
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
ariakit-utils@0.17.0-next.26:
version "0.17.0-next.26"
resolved "https://registry.yarnpkg.com/ariakit-utils/-/ariakit-utils-0.17.0-next.26.tgz#585ccf021f9271c4ac0be2753ccf49bbd88bfaae"
integrity sha512-Su1MA0nWcMKI/lPS++jgkXek6Z+Az4SGOTIjGz2mn7kN6pSRO3Xm4gW/6gLpbu0kmVd+MYNSpmEvFnJUf/udhA==
ariakit@^2.0.0-next.41:
version "2.0.0-next.41"
resolved "https://registry.yarnpkg.com/ariakit/-/ariakit-2.0.0-next.41.tgz#ea23521c18c30dd5daf3f48976f879710d968dca"
integrity sha512-79ACgnIofsC7ULirjz/dqjNCwUW9TmX7ULdCqHHrpJP1H+lw9vtpWv4eeuRAII2lsyfNtXmLnY6qZ1ZV3ucxmA==
dependencies:
"@floating-ui/dom" "^1.0.0"
ariakit-utils "0.17.0-next.26"
array-flatten@1.1.1: array-flatten@1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
@ -3759,6 +3784,14 @@ map-obj@^4.1.0:
resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a" resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.3.0.tgz#9304f906e93faae70880da102a9f1df0ea8bb05a"
integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ== integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==
match-sorter@^6.3.1:
version "6.3.1"
resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-6.3.1.tgz#98cc37fda756093424ddf3cbc62bfe9c75b92bda"
integrity sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==
dependencies:
"@babel/runtime" "^7.12.5"
remove-accents "0.4.2"
md5.js@^1.3.4: md5.js@^1.3.4:
version "1.3.5" version "1.3.5"
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@ -4593,6 +4626,11 @@ regjsparser@^0.9.1:
dependencies: dependencies:
jsesc "~0.5.0" jsesc "~0.5.0"
remove-accents@0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5"
integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==
requires-port@^1.0.0: requires-port@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
@ -4894,7 +4932,7 @@ sourcemap-codec@^1.4.1:
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
split-filter-n@^1.1.2: split-filter-n@^1.1.2, split-filter-n@^1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/split-filter-n/-/split-filter-n-1.1.3.tgz#c983ae1e52402e70071f711a7af767a91f09f740" resolved "https://registry.yarnpkg.com/split-filter-n/-/split-filter-n-1.1.3.tgz#c983ae1e52402e70071f711a7af767a91f09f740"
integrity sha512-EU0EjvBI/mYBQMSAHq+ua/YNCuThuDjbU5h036k01+xieFW1aNvLNKb90xLihXIz5xJQX4VkEKan4LjSIyv7lg== integrity sha512-EU0EjvBI/mYBQMSAHq+ua/YNCuThuDjbU5h036k01+xieFW1aNvLNKb90xLihXIz5xJQX4VkEKan4LjSIyv7lg==