diff --git a/extension/data/AppRoot.tsx b/extension/data/AppRoot.tsx index 8c6d8b3e6..e64ec22e3 100644 --- a/extension/data/AppRoot.tsx +++ b/extension/data/AppRoot.tsx @@ -1,9 +1,17 @@ +import {Provider} from 'react-redux'; + +import store from './store'; + import {PageNotificationContainer} from './components/PageNotificationContainer'; +import {TextFeedbackContainer} from './components/TextFeedbackContainer'; export default function () { return ( -
- -
+ +
+ + +
+
); } diff --git a/extension/data/components/TextFeedbackContainer.tsx b/extension/data/components/TextFeedbackContainer.tsx new file mode 100644 index 000000000..f8ae4e9d9 --- /dev/null +++ b/extension/data/components/TextFeedbackContainer.tsx @@ -0,0 +1,31 @@ +import {useSelector} from 'react-redux'; +import {RootState} from '../store'; +import {TextFeedbackLocation} from '../store/textFeedbackSlice'; + +export function TextFeedbackContainer () { + const currentMessage = useSelector((state: RootState) => state.textFeedback.current); + + // TODO: dont judge me for this im just duplicating how it was done before. + // eventually we will have a way to do scoped component styles and this will + // be better + const style = currentMessage && currentMessage.location === TextFeedbackLocation.BOTTOM + ? { + left: '5px', + bottom: '40px', + top: 'auto', + position: 'fixed', + } as const + : { + transform: 'translate(-50%)', + } as const; + + return ( + <> + {currentMessage && ( +
+ {currentMessage.message} +
+ )} + + ); +} diff --git a/extension/data/store/index.ts b/extension/data/store/index.ts new file mode 100644 index 000000000..20edbdeb4 --- /dev/null +++ b/extension/data/store/index.ts @@ -0,0 +1,21 @@ +import {combineReducers, configureStore, ThunkAction, UnknownAction} from '@reduxjs/toolkit'; +import textFeedbackReducer from './textFeedbackSlice'; + +const rootReducer = combineReducers({ + textFeedback: textFeedbackReducer, +}); + +const store = configureStore({ + reducer: rootReducer, +}); +export default store; + +// TS concerns - see https://redux.js.org/usage/usage-with-typescript#define-root-state-and-dispatch-types +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; +export type AppThunk = ThunkAction< + ReturnType, + RootState, + unknown, + UnknownAction +>; diff --git a/extension/data/store/textFeedbackSlice.ts b/extension/data/store/textFeedbackSlice.ts new file mode 100644 index 000000000..4abf2f5b3 --- /dev/null +++ b/extension/data/store/textFeedbackSlice.ts @@ -0,0 +1,61 @@ +import {createSlice, type PayloadAction} from '@reduxjs/toolkit'; +import {type AppThunk} from '.'; + +// TODO: remove `TBui.FEEDBACK_*` constants in favor of this enum +export enum TextFeedbackKind { + NEUTRAL = 'neutral', + POSITIVE = 'positive', + NEGATIVE = 'negative', +} + +// TODO: remove `TBui.DISPLAY_*` constants in favor of this enum +export enum TextFeedbackLocation { + CENTER = 'center', + BOTTOM = 'bottom', +} + +/** A text feedback message to be displayed. */ +export interface TextFeedback { + message: string; + kind: TextFeedbackKind; + location: TextFeedbackLocation; +} + +// alright time for redux shit + +interface TextFeedbackState { + current: TextFeedback | null; +} +export const textFeedbackSlice = createSlice({ + name: 'textFeedback', + initialState: { + current: null, + } as TextFeedbackState, + reducers: { + set (state, action: PayloadAction) { + state.current = action.payload; + }, + clear (state) { + state.current = null; + }, + }, +}); +export default textFeedbackSlice.reducer; +export const {set, clear} = textFeedbackSlice.actions; + +let removeTextFeedbackTimeout: number | null = null; +export const showTextFeedback = (message: TextFeedback, duration = 3000): AppThunk => dispatch => { + // cancel any pending removal from previous messages + if (removeTextFeedbackTimeout) { + clearTimeout(removeTextFeedbackTimeout); + } + + // display the message + dispatch(set(message)); + + // queue the message to be removed after the duration + removeTextFeedbackTimeout = window.setTimeout(() => { + dispatch(clear()); + removeTextFeedbackTimeout = null; + }, duration); +}; diff --git a/extension/data/tbui.js b/extension/data/tbui.js index 960a6d2a8..887ced60d 100644 --- a/extension/data/tbui.js +++ b/extension/data/tbui.js @@ -10,6 +10,9 @@ import * as TBStorage from './tbstorage.js'; import {onDOMAttach} from './util/dom.ts'; import {reactRenderer} from './util/ui_interop.tsx'; +import {showTextFeedback} from './store/textFeedbackSlice.ts'; + +import store from './store/index.ts'; import {icons} from './tbconstants.ts'; export {icons}; @@ -562,53 +565,10 @@ export function mapInput (labels, items) { } export function textFeedback (feedbackText, feedbackKind, displayDuration, displayLocation) { - if (!displayLocation) { - displayLocation = DISPLAY_CENTER; - } - - // Without text we can't give feedback, the feedbackKind is required to avoid problems in the future. - if (feedbackText && feedbackKind) { - // If there is still a previous feedback element on the page we remove it. - $body.find('#tb-feedback-window').remove(); - - // build up the html, not that the class used is directly passed from the function allowing for easy addition of other kinds. - const feedbackElement = TBStorage.purify( - `
${feedbackText}
`, - ); - - // Add the element to the page. - $body.append(feedbackElement); - - // center it nicely, yes this needs to be done like this if you want to make sure it is in the middle of the page where the user is currently looking. - const $feedbackWindow = $body.find('#tb-feedback-window'); - - switch (displayLocation) { - case DISPLAY_CENTER: - { - const feedbackLeftMargin = $feedbackWindow.outerWidth() / 2; - const feedbackTopMargin = $feedbackWindow.outerHeight() / 2; - - $feedbackWindow.css({ - 'margin-left': `-${feedbackLeftMargin}px`, - 'margin-top': `-${feedbackTopMargin}px`, - }); - } - break; - case DISPLAY_BOTTOM: - { - $feedbackWindow.css({ - left: '5px', - bottom: '40px', - top: 'auto', - position: 'fixed', - }); - } - break; - } - - // And fade out nicely after 3 seconds. - $feedbackWindow.delay(displayDuration ? displayDuration : 3000).fadeOut(); - } + store.dispatch(showTextFeedback( + {message: feedbackText, kind: feedbackKind, location: displayLocation || DISPLAY_CENTER}, + displayDuration || 3000, + )); } // Our awesome long load spinner that ended up not being a spinner at all. It will attend the user to ongoing background operations with a warning when leaving the page. diff --git a/package-lock.json b/package-lock.json index daae3f5aa..549ef69a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "moderator-toolbox-for-reddit", "license": "Apache-2.0", "dependencies": { + "@reduxjs/toolkit": "^2.1.0", "codemirror": "^5.65.15", "dompurify": "^3.0.5", "iter-ops": "^3.1.1", @@ -14,6 +15,7 @@ "pako": "^0.2.6", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-redux": "^9.1.0", "snuownd": "github:gamefreak/snuownd#533e8dcb67fe8e4ddc83fb8317ed0d10c25b6fcb", "timeago": "^1.6.7", "tinycolor2": "^1.6.0", @@ -472,6 +474,29 @@ "node": ">= 8" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.1.0.tgz", + "integrity": "sha512-nfJ/b4ZhzUevQ1ZPKjlDL6CMYxO4o7ZL7OSsvSOxzT/EN11LsBDgTqP7aedHtBrFSVoK7oTP1SbMWUwGb30NLg==", + "dependencies": { + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.0.1" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-commonjs": { "version": "25.0.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.4.tgz", @@ -708,13 +733,13 @@ "version": "15.7.7", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.7.tgz", "integrity": "sha512-FbtmBWCcSa2J4zL781Zf1p5YUBXQomPEcep9QZCfRfQgTxz3pJWiDFLebohZ9fFntX5ibzOkSsrJ0TEew8cAog==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { - "version": "18.2.23", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.23.tgz", - "integrity": "sha512-qHLW6n1q2+7KyBEYnrZpcsAmU/iiCh9WGCKgXvMxx89+TYdJWRjZohVIo9XTcoLhfX3+/hP0Pbulu3bCZQ9PSA==", - "dev": true, + "version": "18.2.48", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", + "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", + "devOptional": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -740,7 +765,7 @@ "version": "0.16.4", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz", "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==", - "dev": true + "devOptional": true }, "node_modules/@types/sizzle": { "version": "2.3.3", @@ -748,6 +773,11 @@ "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", "dev": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, "node_modules/@types/webextension-polyfill": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.10.2.tgz", @@ -1370,7 +1400,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "devOptional": true }, "node_modules/debug": { "version": "4.3.4", @@ -2601,6 +2631,15 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", + "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -4070,6 +4109,32 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/react-redux": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.0.tgz", + "integrity": "sha512-6qoDzIO+gbrza8h3hjMA9aq4nwVFCKFtY2iLxCtVT38Swyy2C/dJCGBXHeHLtx6qlg/8qzc2MrhOeduf5K32wQ==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25", + "react": "^18.0", + "react-native": ">=0.69", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/read-pkg": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", @@ -4159,6 +4224,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -4205,6 +4283,11 @@ "lodash": "^4.17.21" } }, + "node_modules/reselect": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.0.tgz", + "integrity": "sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==" + }, "node_modules/resolve": { "version": "1.22.3", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz", @@ -4985,6 +5068,14 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5466,6 +5557,17 @@ "fastq": "^1.6.0" } }, + "@reduxjs/toolkit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.1.0.tgz", + "integrity": "sha512-nfJ/b4ZhzUevQ1ZPKjlDL6CMYxO4o7ZL7OSsvSOxzT/EN11LsBDgTqP7aedHtBrFSVoK7oTP1SbMWUwGb30NLg==", + "requires": { + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.0.1" + } + }, "@rollup/plugin-commonjs": { "version": "25.0.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.4.tgz", @@ -5641,13 +5743,13 @@ "version": "15.7.7", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.7.tgz", "integrity": "sha512-FbtmBWCcSa2J4zL781Zf1p5YUBXQomPEcep9QZCfRfQgTxz3pJWiDFLebohZ9fFntX5ibzOkSsrJ0TEew8cAog==", - "dev": true + "devOptional": true }, "@types/react": { - "version": "18.2.23", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.23.tgz", - "integrity": "sha512-qHLW6n1q2+7KyBEYnrZpcsAmU/iiCh9WGCKgXvMxx89+TYdJWRjZohVIo9XTcoLhfX3+/hP0Pbulu3bCZQ9PSA==", - "dev": true, + "version": "18.2.48", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", + "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", + "devOptional": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -5673,7 +5775,7 @@ "version": "0.16.4", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz", "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==", - "dev": true + "devOptional": true }, "@types/sizzle": { "version": "2.3.3", @@ -5681,6 +5783,11 @@ "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==", "dev": true }, + "@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, "@types/webextension-polyfill": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@types/webextension-polyfill/-/webextension-polyfill-0.10.2.tgz", @@ -6113,7 +6220,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "devOptional": true }, "debug": { "version": "4.3.4", @@ -7038,6 +7145,11 @@ "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", "dev": true }, + "immer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", + "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==" + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -8094,6 +8206,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "react-redux": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.0.tgz", + "integrity": "sha512-6qoDzIO+gbrza8h3hjMA9aq4nwVFCKFtY2iLxCtVT38Swyy2C/dJCGBXHeHLtx6qlg/8qzc2MrhOeduf5K32wQ==", + "requires": { + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.0.0" + } + }, "read-pkg": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", @@ -8154,6 +8275,17 @@ "strip-indent": "^4.0.0" } }, + "redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "requires": {} + }, "reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -8188,6 +8320,11 @@ "lodash": "^4.17.21" } }, + "reselect": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.0.tgz", + "integrity": "sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==" + }, "resolve": { "version": "1.22.3", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.3.tgz", @@ -8760,6 +8897,12 @@ "punycode": "^2.1.0" } }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 4f898a705..1793f8e33 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "license": "Apache-2.0", "dependencies": { + "@reduxjs/toolkit": "^2.1.0", "codemirror": "^5.65.15", "dompurify": "^3.0.5", "iter-ops": "^3.1.1", @@ -27,6 +28,7 @@ "pako": "^0.2.6", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-redux": "^9.1.0", "snuownd": "github:gamefreak/snuownd#533e8dcb67fe8e4ddc83fb8317ed0d10c25b6fcb", "timeago": "^1.6.7", "tinycolor2": "^1.6.0",