diff --git a/web/source/css/base.css b/web/source/css/base.css index 1818d532b..760189be3 100644 --- a/web/source/css/base.css +++ b/web/source/css/base.css @@ -290,7 +290,7 @@ section.error { font-weight: bold; } -input, select, textarea { +input, select, textarea, .input { box-sizing: border-box; border: 0.15rem solid $input-border; border-radius: 0.1rem; diff --git a/web/source/package.json b/web/source/package.json index eb64e23be..02ae79f03 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -12,11 +12,13 @@ }, "dependencies": { "@reduxjs/toolkit": "^1.8.6", + "ariakit": "^2.0.0-next.41", "bluebird": "^3.7.2", "dotty": "^0.1.2", "is-valid-domain": "^0.1.6", "js-file-download": "^0.4.12", "langs": "^2.0.0", + "match-sorter": "^6.3.1", "modern-normalize": "^1.1.0", "photoswipe": "^5.3.3", "photoswipe-dynamic-caption-plugin": "^1.2.7", @@ -28,6 +30,8 @@ "redux-devtools-extension": "^2.13.9", "redux-persist": "^6.0.0", "skulk": "^0.0.6", + "split-filter-n": "^1.1.3", + "syncpipe": "^1.0.0", "wouter": "^2.8.0-alpha.2" }, "devDependencies": { diff --git a/web/source/settings/admin/emoji/new-emoji.js b/web/source/settings/admin/emoji/new-emoji.js index e5bc8893d..65dc52132 100644 --- a/web/source/settings/admin/emoji/new-emoji.js +++ b/web/source/settings/admin/emoji/new-emoji.js @@ -20,30 +20,34 @@ const Promise = require('bluebird'); const React = require("react"); +const { matchSorter } = require("match-sorter"); const FakeToot = require("../../components/fake-toot"); const MutateButton = require("../../components/mutation-button"); +const ComboBox = require("../../components/combo-box"); -const { +const { useTextInput, - useFileInput + useFileInput, + useComboBoxInput } = require("../../components/form"); const query = require("../../lib/query"); +const syncpipe = require('syncpipe'); -module.exports = function NewEmojiForm({emoji}) { +module.exports = function NewEmojiForm({ emoji, emojiByCategory }) { const emojiCodes = React.useMemo(() => { return new Set(emoji.map((e) => e.shortcode)); }, [emoji]); const [addEmoji, result] = query.useAddEmojiMutation(); - const [onFileChange, resetFile, {image, imageURL, imageInfo}] = useFileInput("image", { + const [onFileChange, resetFile, { image, imageURL, imageInfo }] = useFileInput("image", { withPreview: true, maxSize: 50 * 1024 }); - const [onShortcodeChange, resetShortcode, {shortcode, setShortcode, shortcodeRef}] = useTextInput("shortcode", { + const [onShortcodeChange, resetShortcode, { shortcode, setShortcode, shortcodeRef }] = useTextInput("shortcode", { validator: function validateShortcode(code) { return emojiCodes.has(code) ? "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, + <> + + {categoryName} + + ]) + ]); + }, [emojiByCategory, category]); + React.useEffect(() => { if (shortcode.length == 0) { if (image != undefined) { @@ -58,6 +79,9 @@ module.exports = function NewEmojiForm({emoji}) { 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 }, [image]); @@ -69,11 +93,13 @@ module.exports = function NewEmojiForm({emoji}) { Promise.try(() => { return addEmoji({ image, - shortcode + shortcode, + category }); }).then(() => { resetFile(); resetShortcode(); + resetCategory(); }); } @@ -125,8 +151,15 @@ module.exports = function NewEmojiForm({emoji}) { value={shortcode} /> - - + + + + ); diff --git a/web/source/settings/admin/emoji/overview.js b/web/source/settings/admin/emoji/overview.js index 028276da2..15891a5ec 100644 --- a/web/source/settings/admin/emoji/overview.js +++ b/web/source/settings/admin/emoji/overview.js @@ -20,7 +20,7 @@ const React = require("react"); const {Link} = require("wouter"); -const defaultValue = require('default-value'); +const splitFilterN = require("split-filter-n"); const NewEmojiForm = require("./new-emoji"); @@ -30,11 +30,18 @@ const base = "/settings/admin/custom-emoji"; module.exports = function EmojiOverview() { const { - data: emoji, + data: emoji = [], isLoading, error } = 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 ( <>

Custom Emoji

@@ -44,33 +51,21 @@ module.exports = function EmojiOverview() { {isLoading ? "Loading..." : <> - - + + } ); }; -function EmojiList({emoji}) { - 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]); - +function EmojiList({emoji, emojiByCategory}) { return (

Overview

- {emoji.length == 0 && "No local emoji yet"} - {Object.entries(byCategory).map(([category, entries]) => { + {emoji.length == 0 && "No local emoji yet, add one below"} + {Object.entries(emojiByCategory).map(([category, entries]) => { return ; })}
diff --git a/web/source/settings/components/combo-box.jsx b/web/source/settings/components/combo-box.jsx new file mode 100644 index 000000000..1e6293890 --- /dev/null +++ b/web/source/settings/components/combo-box.jsx @@ -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 . +*/ + +"use strict"; + +const React = require("react"); + +const { + Combobox, + ComboboxItem, + ComboboxPopover, +} = require("ariakit/combobox"); + +module.exports = function ComboBox({state, items, label, placeHolder}) { + return ( +
+ + + {items.map(([key, value]) => ( + + {value} + + ))} + +
+ ); +}; \ No newline at end of file diff --git a/web/source/settings/components/form/combobox.jsx b/web/source/settings/components/form/combobox.jsx new file mode 100644 index 000000000..6ab235ed3 --- /dev/null +++ b/web/source/settings/components/form/combobox.jsx @@ -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 . +*/ + +"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, + } + ]; +}; \ No newline at end of file diff --git a/web/source/settings/components/form/index.js b/web/source/settings/components/form/index.js index 5edc52364..e226a4b04 100644 --- a/web/source/settings/components/form/index.js +++ b/web/source/settings/components/form/index.js @@ -32,5 +32,6 @@ function makeHook(func) { module.exports = { useTextInput: makeHook(require("./text")), - useFileInput: makeHook(require("./file")) + useFileInput: makeHook(require("./file")), + useComboBoxInput: makeHook(require("./combobox")) }; \ No newline at end of file diff --git a/web/source/settings/redux/reducers/user.js b/web/source/settings/redux/reducers/user.js index b4463c9f9..861f519d1 100644 --- a/web/source/settings/redux/reducers/user.js +++ b/web/source/settings/redux/reducers/user.js @@ -20,7 +20,6 @@ const { createSlice } = require("@reduxjs/toolkit"); const d = require("dotty"); -const defaultValue = require("default-value"); module.exports = createSlice({ name: "user", @@ -30,10 +29,10 @@ module.exports = createSlice({ }, reducers: { setAccount: (state, { payload }) => { - payload.source = defaultValue(payload.source, {}); - payload.source.language = defaultValue(payload.source.language.toUpperCase(), "EN"); - payload.source.status_format = defaultValue(payload.source.status_format, "plain"); - payload.source.sensitive = defaultValue(payload.source.sensitive, false); + payload.source = payload.source ?? {}; + payload.source.language = payload.source.language.toUpperCase() ?? "EN"; + payload.source.status_format = payload.source.status_format ?? "plain"; + payload.source.sensitive = payload.source.sensitive ?? false; state.profile = payload; // /user/settings only needs a copy of the 'source' obj diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 93e52f680..3af52337a 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -502,4 +502,62 @@ span.form-info { .instance-list .filter { 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%); } \ No newline at end of file diff --git a/web/source/yarn.lock b/web/source/yarn.lock index 458732503..8fed42cea 100644 --- a/web/source/yarn.lock +++ b/web/source/yarn.lock @@ -991,6 +991,18 @@ dependencies: 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": version "0.11.7" 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" 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: version "1.1.1" 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" 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: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -4593,6 +4626,11 @@ regjsparser@^0.9.1: dependencies: 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: version "1.0.0" 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" 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" resolved "https://registry.yarnpkg.com/split-filter-n/-/split-filter-n-1.1.3.tgz#c983ae1e52402e70071f711a7af767a91f09f740" integrity sha512-EU0EjvBI/mYBQMSAHq+ua/YNCuThuDjbU5h036k01+xieFW1aNvLNKb90xLihXIz5xJQX4VkEKan4LjSIyv7lg==