From d4209c86b7183d90d9d282070efd30915eca3df9 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 7 Sep 2022 08:21:14 -0400 Subject: [PATCH 01/72] Extend input with new prepend/append options --- app/soapbox/components/ui/input/input.tsx | 39 ++++++++++++++++------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 5ca748ebc..2bd77dbfa 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -20,7 +20,7 @@ interface IInput extends Pick, 'maxL className?: string, /** Extra class names for the outer
element. */ outerClassName?: string, - /** URL to the svg icon. Cannot be used with addon. */ + /** URL to the svg icon. Cannot be used with prepend. */ icon?: string, /** Internal input name. */ name?: string, @@ -30,12 +30,13 @@ interface IInput extends Pick, 'maxL value?: string | number, /** Change event handler for the input. */ onChange?: (event: React.ChangeEvent) => void, - /** HTML input type. */ - type?: 'text' | 'number' | 'email' | 'tel' | 'password', /** Whether to display the input in red. */ hasError?: boolean, /** An element to display as prefix to input. Cannot be used with icon. */ - addon?: React.ReactElement, + prepend?: React.ReactElement, + /** An element to display as suffix to input. Cannot be used with password type. */ + append?: React.ReactElement, + isSearch?: boolean, } /** Form input element. */ @@ -43,7 +44,7 @@ const Input = React.forwardRef( (props, ref) => { const intl = useIntl(); - const { type = 'text', icon, className, outerClassName, hasError, addon, ...filteredProps } = props; + const { type = 'text', icon, className, outerClassName, hasError, append, prepend, isSearch, ...filteredProps } = props; const [revealed, setRevealed] = React.useState(false); @@ -54,16 +55,23 @@ const Input = React.forwardRef( }, []); return ( -
+
{icon ? (
) : null} - {addon ? ( + {prepend ? (
- {addon} + {prepend}
) : null} @@ -72,15 +80,24 @@ const Input = React.forwardRef( type={revealed ? 'text' : type} ref={ref} className={classNames({ - 'bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm border-gray-400 dark:border-gray-800 dark:ring-1 dark:ring-gray-800 rounded-md focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': + 'text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500': true, - 'pr-7': isPassword, + 'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': !isSearch, + 'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': isSearch, + 'pr-7': isPassword || append, 'text-red-600 border-red-600': hasError, 'pl-8': typeof icon !== 'undefined', - 'pl-16': typeof addon !== 'undefined', + 'pl-16': typeof prepend !== 'undefined', }, className)} /> + {/* eslint-disable-next-line no-nested-ternary */} + {append ? ( +
+ {append} +
+ ) : null} + {isPassword ? ( Date: Wed, 7 Sep 2022 08:24:44 -0400 Subject: [PATCH 02/72] Update usage of input --- app/soapbox/components/ui/phone-input/phone-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/phone-input/phone-input.tsx b/app/soapbox/components/ui/phone-input/phone-input.tsx index 81d2723c4..7de0437ce 100644 --- a/app/soapbox/components/ui/phone-input/phone-input.tsx +++ b/app/soapbox/components/ui/phone-input/phone-input.tsx @@ -67,7 +67,7 @@ const PhoneInput: React.FC = (props) => { Date: Thu, 8 Sep 2022 08:28:19 -0400 Subject: [PATCH 03/72] Add JSDOC --- app/soapbox/components/ui/input/input.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 2bd77dbfa..c77ae8169 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -36,6 +36,7 @@ interface IInput extends Pick, 'maxL prepend?: React.ReactElement, /** An element to display as suffix to input. Cannot be used with password type. */ append?: React.ReactElement, + /** Adds specific styling to denote a searchabe input. */ isSearch?: boolean, } From 3bef7c69ff0b21157f70ec60ca3bfbd0d76755d3 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 9 Sep 2022 10:43:33 -0400 Subject: [PATCH 04/72] Map paginated results into normalized Tag --- app/soapbox/reducers/search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/reducers/search.ts b/app/soapbox/reducers/search.ts index eb2c1ed8d..911008505 100644 --- a/app/soapbox/reducers/search.ts +++ b/app/soapbox/reducers/search.ts @@ -82,7 +82,7 @@ const paginateResults = (state: State, searchType: SearchFilter, results: APIEnt const data = results[searchType]; // Hashtags are a list of maps. Others are IDs. if (searchType === 'hashtags') { - return (items as ImmutableOrderedSet).concat(fromJS(data)); + return (items as ImmutableOrderedSet).concat((fromJS(data) as Record).map(normalizeTag)); } else { return (items as ImmutableOrderedSet).concat(toIds(data)); } From ac52071e2fa7a8029c0dc86c10c0aa6c51388365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 9 Sep 2022 23:20:26 +0200 Subject: [PATCH 05/72] Do not reset tab/searched account when clearing search input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/search.ts | 19 +++++++++++++++---- .../features/compose/components/search.tsx | 3 ++- .../compose/components/search_results.tsx | 6 +++--- app/soapbox/reducers/search.ts | 16 +++++++++++++++- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts index e8718d479..a2f165ac0 100644 --- a/app/soapbox/actions/search.ts +++ b/app/soapbox/actions/search.ts @@ -8,9 +8,10 @@ import type { SearchFilter } from 'soapbox/reducers/search'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity } from 'soapbox/types/entities'; -const SEARCH_CHANGE = 'SEARCH_CHANGE'; -const SEARCH_CLEAR = 'SEARCH_CLEAR'; -const SEARCH_SHOW = 'SEARCH_SHOW'; +const SEARCH_CHANGE = 'SEARCH_CHANGE'; +const SEARCH_CLEAR = 'SEARCH_CLEAR'; +const SEARCH_SHOW = 'SEARCH_SHOW'; +const SEARCH_RESULTS_CLEAR = 'SEARCH_RESULTS_CLEAR'; const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; @@ -28,7 +29,11 @@ const changeSearch = (value: string) => (dispatch: AppDispatch) => { // If backspaced all the way, clear the search if (value.length === 0) { - return dispatch(clearSearch()); + dispatch(clearSearchResults()); + return dispatch({ + type: SEARCH_CHANGE, + value, + }); } else { return dispatch({ type: SEARCH_CHANGE, @@ -41,6 +46,10 @@ const clearSearch = () => ({ type: SEARCH_CLEAR, }); +const clearSearchResults = () => ({ + type: SEARCH_RESULTS_CLEAR, +}); + const submitSearch = (filter?: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => { const value = getState().search.value; @@ -167,6 +176,7 @@ export { SEARCH_CHANGE, SEARCH_CLEAR, SEARCH_SHOW, + SEARCH_RESULTS_CLEAR, SEARCH_FETCH_REQUEST, SEARCH_FETCH_SUCCESS, SEARCH_FETCH_FAIL, @@ -177,6 +187,7 @@ export { SEARCH_ACCOUNT_SET, changeSearch, clearSearch, + clearSearchResults, submitSearch, fetchSearchRequest, fetchSearchSuccess, diff --git a/app/soapbox/features/compose/components/search.tsx b/app/soapbox/features/compose/components/search.tsx index ec7c89468..9402b8785 100644 --- a/app/soapbox/features/compose/components/search.tsx +++ b/app/soapbox/features/compose/components/search.tsx @@ -9,6 +9,7 @@ import { useHistory } from 'react-router-dom'; import { changeSearch, clearSearch, + clearSearchResults, setSearchAccount, showSearch, submitSearch, @@ -72,7 +73,7 @@ const Search = (props: ISearch) => { event.preventDefault(); if (value.length > 0 || submitted) { - dispatch(clearSearch()); + dispatch(clearSearchResults()); } }; diff --git a/app/soapbox/features/compose/components/search_results.tsx b/app/soapbox/features/compose/components/search_results.tsx index b1cb833ba..8f8fa5938 100644 --- a/app/soapbox/features/compose/components/search_results.tsx +++ b/app/soapbox/features/compose/components/search_results.tsx @@ -2,7 +2,7 @@ import classNames from 'clsx'; import React, { useEffect, useRef } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; -import { clearSearch, expandSearch, setFilter } from 'soapbox/actions/search'; +import { expandSearch, setFilter, setSearchAccount } from 'soapbox/actions/search'; import { fetchTrendingStatuses } from 'soapbox/actions/trending_statuses'; import Hashtag from 'soapbox/components/hashtag'; import IconButton from 'soapbox/components/icon_button'; @@ -43,7 +43,7 @@ const SearchResults = () => { const handleLoadMore = () => dispatch(expandSearch(selectedFilter)); - const handleClearSearch = () => dispatch(clearSearch()); + const handleUnsetAccount = () => dispatch(setSearchAccount(null)); const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter)); @@ -196,7 +196,7 @@ const SearchResults = () => { <> {filterByAccount ? ( - + Date: Fri, 9 Sep 2022 23:06:18 +0200 Subject: [PATCH 06/72] Allow searching your own posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/account/components/header.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index 3f4d239bf..c9620485b 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -74,6 +74,7 @@ const messages = defineMessages({ suggestUser: { id: 'admin.users.actions.suggest_user', defaultMessage: 'Suggest @{name}' }, unsuggestUser: { id: 'admin.users.actions.unsuggest_user', defaultMessage: 'Unsuggest @{name}' }, search: { id: 'account.search', defaultMessage: 'Search from @{name}' }, + searchSelf: { id: 'account.search_self', defaultMessage: 'Search your posts' }, unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, @@ -378,6 +379,13 @@ const Header: React.FC = ({ account }) => { to: '/settings', icon: require('@tabler/icons/settings.svg'), }); + if (features.searchFromAccount) { + menu.push({ + text: intl.formatMessage(messages.searchSelf, { name: account.username }), + action: onSearch, + icon: require('@tabler/icons/search.svg'), + }); + } menu.push(null); menu.push({ text: intl.formatMessage(messages.mutes), From fe8966fc3e45b6a0e0ec74f09e254da23b41d02d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 9 Sep 2022 20:44:52 -0500 Subject: [PATCH 07/72] Scaffold out timeline insertion modules --- .../features/timeline-insertion/abovefold.ts | 41 +++++++++++++++++++ .../features/timeline-insertion/index.ts | 0 .../features/timeline-insertion/linear.ts | 19 +++++++++ .../features/timeline-insertion/types.ts | 15 +++++++ package.json | 2 + yarn.lock | 10 +++++ 6 files changed, 87 insertions(+) create mode 100644 app/soapbox/features/timeline-insertion/abovefold.ts create mode 100644 app/soapbox/features/timeline-insertion/index.ts create mode 100644 app/soapbox/features/timeline-insertion/linear.ts create mode 100644 app/soapbox/features/timeline-insertion/types.ts diff --git a/app/soapbox/features/timeline-insertion/abovefold.ts b/app/soapbox/features/timeline-insertion/abovefold.ts new file mode 100644 index 000000000..78a8b6e6f --- /dev/null +++ b/app/soapbox/features/timeline-insertion/abovefold.ts @@ -0,0 +1,41 @@ +import seedrandom from 'seedrandom'; + +import type { PickAlgorithm } from './types'; + +type Opts = { + /** Randomization seed. */ + seed: string, + /** + * Start/end index of the slot by which one item will be randomly picked per page. + * + * Eg. `[3, 7]` will cause one item to be picked between the third and seventh indexes per page. + * + * `end` must be larger than `start`. + */ + range: [start: number, end: number], + /** Number of items in the page. */ + pageSize: number, +}; + +/** + * Algorithm to display items per-page. + * One item is randomly inserted into each page within the index range. + */ +const abovefoldAlgorithm: PickAlgorithm = (items, index, opts: Opts) => { + /** Current page of the index. */ + const page = Math.floor(((index + 1) / opts.pageSize) - 1); + /** Current index within the page. */ + const pageIndex = ((index + 1) % opts.pageSize) - 1; + /** RNG for the page. */ + const rng = seedrandom(`${opts.seed}-page-${page}`); + /** Index to insert the item. */ + const insertIndex = Math.floor(rng() * opts.range[1] - opts.range[0]) + opts.range[0]; + + if (pageIndex === insertIndex) { + return items[page]; + } +}; + +export { + abovefoldAlgorithm, +}; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/index.ts b/app/soapbox/features/timeline-insertion/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/app/soapbox/features/timeline-insertion/linear.ts b/app/soapbox/features/timeline-insertion/linear.ts new file mode 100644 index 000000000..923ef1207 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/linear.ts @@ -0,0 +1,19 @@ +import type { PickAlgorithm } from './types'; + +type Opts = { + /** Number of iterations until the next item is picked. */ + interval: number, +}; + +/** Picks the next item every `interval` iterations. */ +const linearAlgorithm: PickAlgorithm = (items, index, opts: Opts) => { + const itemIndex = items ? Math.floor((index + 1) / opts.interval) % items.length : 0; + const item = items ? items[itemIndex] : undefined; + const showItem = (index + 1) % opts.interval === 0; + + return showItem ? item : undefined; +}; + +export { + linearAlgorithm, +}; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/types.ts b/app/soapbox/features/timeline-insertion/types.ts new file mode 100644 index 000000000..0b6fd6c0c --- /dev/null +++ b/app/soapbox/features/timeline-insertion/types.ts @@ -0,0 +1,15 @@ +/** + * Returns an item to insert at the index, or `undefined` if an item shouldn't be inserted. + */ +type PickAlgorithm = ( + /** Elligible candidates to pick. */ + items: D[], + /** Current iteration by which an item may be chosen. */ + index: number, + /** Implementation-specific opts. */ + opts: any +) => D | undefined; + +export { + PickAlgorithm, +}; \ No newline at end of file diff --git a/package.json b/package.json index eef743978..1ba60028b 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@types/react-swipeable-views": "^0.13.1", "@types/react-toggle": "^4.0.3", "@types/redux-mock-store": "^1.0.3", + "@types/seedrandom": "^3.0.2", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", "array-includes": "^3.1.5", @@ -184,6 +185,7 @@ "resize-observer-polyfill": "^1.5.1", "sass": "^1.20.3", "sass-loader": "^13.0.0", + "seedrandom": "^3.0.5", "semver": "^7.3.2", "stringz": "^2.0.0", "substring-trie": "^1.0.2", diff --git a/yarn.lock b/yarn.lock index 1e2044863..a3cbcff2e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,6 +2841,11 @@ dependencies: schema-utils "*" +"@types/seedrandom@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-3.0.2.tgz#7f30db28221067a90b02e73ffd46b6685b18df1a" + integrity sha512-YPLqEOo0/X8JU3rdiq+RgUKtQhQtrppE766y7vMTu8dGML7TVtZNiiiaC/hhU9Zqw9UYopXxhuWWENclMVBwKQ== + "@types/semver@^7.3.9": version "7.3.9" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.9.tgz#152c6c20a7688c30b967ec1841d31ace569863fc" @@ -10461,6 +10466,11 @@ scroll-behavior@^0.9.1: dom-helpers "^3.4.0" invariant "^2.2.4" +seedrandom@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7" + integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" From 5749821b365d1696082222eea810512cf1386880 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 9 Sep 2022 20:47:51 -0500 Subject: [PATCH 08/72] Algorithms: index --> iteration --- app/soapbox/features/timeline-insertion/abovefold.ts | 6 +++--- app/soapbox/features/timeline-insertion/linear.ts | 8 ++++---- app/soapbox/features/timeline-insertion/types.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/soapbox/features/timeline-insertion/abovefold.ts b/app/soapbox/features/timeline-insertion/abovefold.ts index 78a8b6e6f..dc6e553d1 100644 --- a/app/soapbox/features/timeline-insertion/abovefold.ts +++ b/app/soapbox/features/timeline-insertion/abovefold.ts @@ -21,11 +21,11 @@ type Opts = { * Algorithm to display items per-page. * One item is randomly inserted into each page within the index range. */ -const abovefoldAlgorithm: PickAlgorithm = (items, index, opts: Opts) => { +const abovefoldAlgorithm: PickAlgorithm = (items, iteration, opts: Opts) => { /** Current page of the index. */ - const page = Math.floor(((index + 1) / opts.pageSize) - 1); + const page = Math.floor(((iteration + 1) / opts.pageSize) - 1); /** Current index within the page. */ - const pageIndex = ((index + 1) % opts.pageSize) - 1; + const pageIndex = ((iteration + 1) % opts.pageSize) - 1; /** RNG for the page. */ const rng = seedrandom(`${opts.seed}-page-${page}`); /** Index to insert the item. */ diff --git a/app/soapbox/features/timeline-insertion/linear.ts b/app/soapbox/features/timeline-insertion/linear.ts index 923ef1207..3037c3837 100644 --- a/app/soapbox/features/timeline-insertion/linear.ts +++ b/app/soapbox/features/timeline-insertion/linear.ts @@ -5,11 +5,11 @@ type Opts = { interval: number, }; -/** Picks the next item every `interval` iterations. */ -const linearAlgorithm: PickAlgorithm = (items, index, opts: Opts) => { - const itemIndex = items ? Math.floor((index + 1) / opts.interval) % items.length : 0; +/** Picks the next item every iteration. */ +const linearAlgorithm: PickAlgorithm = (items, iteration, opts: Opts) => { + const itemIndex = items ? Math.floor((iteration + 1) / opts.interval) % items.length : 0; const item = items ? items[itemIndex] : undefined; - const showItem = (index + 1) % opts.interval === 0; + const showItem = (iteration + 1) % opts.interval === 0; return showItem ? item : undefined; }; diff --git a/app/soapbox/features/timeline-insertion/types.ts b/app/soapbox/features/timeline-insertion/types.ts index 0b6fd6c0c..c1cc1ed1d 100644 --- a/app/soapbox/features/timeline-insertion/types.ts +++ b/app/soapbox/features/timeline-insertion/types.ts @@ -5,7 +5,7 @@ type PickAlgorithm = ( /** Elligible candidates to pick. */ items: D[], /** Current iteration by which an item may be chosen. */ - index: number, + iteration: number, /** Implementation-specific opts. */ opts: any ) => D | undefined; From ec225ea1c5b4de5737970f42d46d62b580260922 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 9 Sep 2022 20:49:17 -0500 Subject: [PATCH 09/72] abovefoldAlgorithm: wrap item selection --- app/soapbox/features/timeline-insertion/abovefold.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/timeline-insertion/abovefold.ts b/app/soapbox/features/timeline-insertion/abovefold.ts index dc6e553d1..71ab51f81 100644 --- a/app/soapbox/features/timeline-insertion/abovefold.ts +++ b/app/soapbox/features/timeline-insertion/abovefold.ts @@ -32,7 +32,7 @@ const abovefoldAlgorithm: PickAlgorithm = (items, iteration, opts: Opts) => { const insertIndex = Math.floor(rng() * opts.range[1] - opts.range[0]) + opts.range[0]; if (pageIndex === insertIndex) { - return items[page]; + return items[page % items.length]; } }; From 2681b32f7d21eb3d7a6dc0d66e5cd287f18c8b1a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 9 Sep 2022 22:26:36 -0500 Subject: [PATCH 10/72] StatusList: incorporate feed injection algorithms --- app/soapbox/components/status_list.tsx | 26 ++++++++++++------- .../features/timeline-insertion/abovefold.ts | 23 ++++++++++++---- .../features/timeline-insertion/index.ts | 11 ++++++++ .../features/timeline-insertion/linear.ts | 11 +++++++- .../features/timeline-insertion/types.ts | 2 +- .../normalizers/soapbox/soapbox_config.ts | 14 ++++++++++ 6 files changed, 71 insertions(+), 16 deletions(-) diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 295538da2..3bee7a03a 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -1,7 +1,9 @@ import classNames from 'clsx'; +import { Map as ImmutableMap } from 'immutable'; import debounce from 'lodash/debounce'; import React, { useRef, useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; +import { v4 as uuidv4 } from 'uuid'; import LoadGap from 'soapbox/components/load_gap'; import ScrollableList from 'soapbox/components/scrollable_list'; @@ -9,6 +11,7 @@ import StatusContainer from 'soapbox/containers/status_container'; import Ad from 'soapbox/features/ads/components/ad'; import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; +import { ALGORITHMS } from 'soapbox/features/timeline-insertion'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; import { useSoapboxConfig } from 'soapbox/hooks'; import useAds from 'soapbox/queries/ads'; @@ -60,8 +63,12 @@ const StatusList: React.FC = ({ }) => { const { data: ads } = useAds(); const soapboxConfig = useSoapboxConfig(); - const adsInterval = Number(soapboxConfig.extensions.getIn(['ads', 'interval'], 40)) || 0; + + const adsAlgorithm = String(soapboxConfig.extensions.getIn(['ads', 'algorithm', 0])); + const adsOpts = (soapboxConfig.extensions.getIn(['ads', 'algorithm', 1], ImmutableMap()) as ImmutableMap).toJS(); + const node = useRef(null); + const seed = useRef(uuidv4()); const getFeaturedStatusCount = () => { return featuredStatusIds?.size || 0; @@ -132,9 +139,10 @@ const StatusList: React.FC = ({ ); }; - const renderAd = (ad: AdEntity) => { + const renderAd = (ad: AdEntity, index: number) => { return ( = ({ const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { return statusIds.toList().reduce((acc, statusId, index) => { - const adIndex = ads ? Math.floor((index + 1) / adsInterval) % ads.length : 0; - const ad = ads ? ads[adIndex] : undefined; - const showAd = (index + 1) % adsInterval === 0; + if (showAds && ads) { + const ad = ALGORITHMS[adsAlgorithm]?.(ads, index, { ...adsOpts, seed: seed.current }); + + if (ad) { + acc.push(renderAd(ad, index)); + } + } if (statusId === null) { acc.push(renderLoadGap(index)); @@ -189,10 +201,6 @@ const StatusList: React.FC = ({ acc.push(renderStatus(statusId)); } - if (showAds && ad && showAd) { - acc.push(renderAd(ad)); - } - return acc; }, [] as React.ReactNode[]); } else { diff --git a/app/soapbox/features/timeline-insertion/abovefold.ts b/app/soapbox/features/timeline-insertion/abovefold.ts index 71ab51f81..5ab3b4306 100644 --- a/app/soapbox/features/timeline-insertion/abovefold.ts +++ b/app/soapbox/features/timeline-insertion/abovefold.ts @@ -8,7 +8,7 @@ type Opts = { /** * Start/end index of the slot by which one item will be randomly picked per page. * - * Eg. `[3, 7]` will cause one item to be picked between the third and seventh indexes per page. + * Eg. `[2, 6]` will cause one item to be picked among the third through seventh indexes. * * `end` must be larger than `start`. */ @@ -21,21 +21,34 @@ type Opts = { * Algorithm to display items per-page. * One item is randomly inserted into each page within the index range. */ -const abovefoldAlgorithm: PickAlgorithm = (items, iteration, opts: Opts) => { +const abovefoldAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => { + const opts = normalizeOpts(rawOpts); /** Current page of the index. */ - const page = Math.floor(((iteration + 1) / opts.pageSize) - 1); + const page = Math.floor(iteration / opts.pageSize); /** Current index within the page. */ - const pageIndex = ((iteration + 1) % opts.pageSize) - 1; + const pageIndex = (iteration % opts.pageSize); /** RNG for the page. */ const rng = seedrandom(`${opts.seed}-page-${page}`); /** Index to insert the item. */ - const insertIndex = Math.floor(rng() * opts.range[1] - opts.range[0]) + opts.range[0]; + const insertIndex = Math.floor(rng() * (opts.range[1] - opts.range[0])) + opts.range[0]; + + console.log({ page, iteration, pageIndex, insertIndex }); if (pageIndex === insertIndex) { return items[page % items.length]; } }; +const normalizeOpts = (opts: unknown): Opts => { + const { seed, range, pageSize } = (opts && typeof opts === 'object' ? opts : {}) as Record; + + return { + seed: typeof seed === 'string' ? seed : '', + range: Array.isArray(range) ? [Number(range[0]), Number(range[1])] : [2, 6], + pageSize: typeof pageSize === 'number' ? pageSize : 20, + }; +}; + export { abovefoldAlgorithm, }; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/index.ts b/app/soapbox/features/timeline-insertion/index.ts index e69de29bb..f4e00ed29 100644 --- a/app/soapbox/features/timeline-insertion/index.ts +++ b/app/soapbox/features/timeline-insertion/index.ts @@ -0,0 +1,11 @@ +import { abovefoldAlgorithm } from './abovefold'; +import { linearAlgorithm } from './linear'; + +import type { PickAlgorithm } from './types'; + +const ALGORITHMS: Record = { + 'linear': linearAlgorithm, + 'abovefold': abovefoldAlgorithm, +}; + +export { ALGORITHMS }; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/linear.ts b/app/soapbox/features/timeline-insertion/linear.ts index 3037c3837..a3cbce685 100644 --- a/app/soapbox/features/timeline-insertion/linear.ts +++ b/app/soapbox/features/timeline-insertion/linear.ts @@ -6,7 +6,8 @@ type Opts = { }; /** Picks the next item every iteration. */ -const linearAlgorithm: PickAlgorithm = (items, iteration, opts: Opts) => { +const linearAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => { + const opts = normalizeOpts(rawOpts); const itemIndex = items ? Math.floor((iteration + 1) / opts.interval) % items.length : 0; const item = items ? items[itemIndex] : undefined; const showItem = (iteration + 1) % opts.interval === 0; @@ -14,6 +15,14 @@ const linearAlgorithm: PickAlgorithm = (items, iteration, opts: Opts) => { return showItem ? item : undefined; }; +const normalizeOpts = (opts: unknown): Opts => { + const { interval } = (opts && typeof opts === 'object' ? opts : {}) as Record; + + return { + interval: typeof interval === 'number' ? interval : 20, + }; +}; + export { linearAlgorithm, }; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/types.ts b/app/soapbox/features/timeline-insertion/types.ts index c1cc1ed1d..69b6280c4 100644 --- a/app/soapbox/features/timeline-insertion/types.ts +++ b/app/soapbox/features/timeline-insertion/types.ts @@ -7,7 +7,7 @@ type PickAlgorithm = ( /** Current iteration by which an item may be chosen. */ iteration: number, /** Implementation-specific opts. */ - opts: any + opts: Record ) => D | undefined; export { diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index a471401c5..0e6b5c280 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -175,6 +175,19 @@ const normalizeFooterLinks = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap return soapboxConfig.setIn(path, items); }; +/** Migrate legacy ads config. */ +const normalizeAdsAlgorithm = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => { + const interval = soapboxConfig.getIn(['extensions', 'ads', 'interval']); + const algorithm = soapboxConfig.getIn(['extensions', 'ads', 'algorithm']); + + if (typeof interval === 'number' && !algorithm) { + const result = fromJS(['linear', { interval }]); + return soapboxConfig.setIn(['extensions', 'ads', 'algorithm'], result); + } else { + return soapboxConfig; + } +}; + export const normalizeSoapboxConfig = (soapboxConfig: Record) => { return SoapboxConfigRecord( ImmutableMap(fromJS(soapboxConfig)).withMutations(soapboxConfig => { @@ -186,6 +199,7 @@ export const normalizeSoapboxConfig = (soapboxConfig: Record) => { maybeAddMissingColors(soapboxConfig); normalizeCryptoAddresses(soapboxConfig); normalizeAds(soapboxConfig); + normalizeAdsAlgorithm(soapboxConfig); }), ); }; From aca2df0775b0a82959db3f73bdb00d4145ee9f99 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 10 Sep 2022 10:46:12 -0500 Subject: [PATCH 11/72] Favourites: quick & dirty conversion to TSX --- .../features/favourited_statuses/index.js | 158 ------------------ .../features/favourited_statuses/index.tsx | 151 +++++++++++++++++ 2 files changed, 151 insertions(+), 158 deletions(-) delete mode 100644 app/soapbox/features/favourited_statuses/index.js create mode 100644 app/soapbox/features/favourited_statuses/index.tsx diff --git a/app/soapbox/features/favourited_statuses/index.js b/app/soapbox/features/favourited_statuses/index.js deleted file mode 100644 index b4ffb6a8a..000000000 --- a/app/soapbox/features/favourited_statuses/index.js +++ /dev/null @@ -1,158 +0,0 @@ -import debounce from 'lodash/debounce'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts'; -import { fetchFavouritedStatuses, expandFavouritedStatuses, fetchAccountFavouritedStatuses, expandAccountFavouritedStatuses } from 'soapbox/actions/favourites'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import StatusList from 'soapbox/components/status_list'; -import { Spinner } from 'soapbox/components/ui'; -import { findAccountByUsername } from 'soapbox/selectors'; -import { getFeatures } from 'soapbox/utils/features'; - -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.favourited_statuses', defaultMessage: 'Liked posts' }, -}); - -const mapStateToProps = (state, { params }) => { - const username = params.username || ''; - const me = state.get('me'); - const meUsername = state.getIn(['accounts', me, 'username'], ''); - - const isMyAccount = (username.toLowerCase() === meUsername.toLowerCase()); - - const features = getFeatures(state.get('instance')); - - if (isMyAccount) { - return { - isMyAccount, - statusIds: state.status_lists.get('favourites').items, - isLoading: state.status_lists.get('favourites').isLoading, - hasMore: !!state.status_lists.get('favourites').next, - }; - } - - const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase()); - - let accountId = -1; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - } - - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - isMyAccount, - accountId, - unavailable, - username, - isAccount: !!state.getIn(['accounts', accountId]), - statusIds: state.status_lists.get(`favourites:${accountId}`)?.items || [], - isLoading: state.status_lists.get(`favourites:${accountId}`)?.isLoading, - hasMore: !!state.status_lists.get(`favourites:${accountId}`)?.next, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class Favourites extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.orderedSet.isRequired, - intl: PropTypes.object.isRequired, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - isMyAccount: PropTypes.bool.isRequired, - }; - - componentDidMount() { - const { accountId, isMyAccount, username } = this.props; - - if (isMyAccount) - this.props.dispatch(fetchFavouritedStatuses()); - else { - if (accountId && accountId !== -1) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchAccountFavouritedStatuses(accountId)); - } else { - this.props.dispatch(fetchAccountByUsername(username)); - } - } - } - - componentDidUpdate(prevProps) { - const { accountId, isMyAccount } = this.props; - - if (!isMyAccount && accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchAccountFavouritedStatuses(accountId)); - } - } - - handleLoadMore = debounce(() => { - const { accountId, isMyAccount } = this.props; - - if (isMyAccount) { - this.props.dispatch(expandFavouritedStatuses()); - } else { - this.props.dispatch(expandAccountFavouritedStatuses(accountId)); - } - }, 300, { leading: true }) - - render() { - const { intl, statusIds, isLoading, hasMore, isMyAccount, isAccount, accountId, unavailable } = this.props; - - if (!isMyAccount && !isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1) { - return ( - - - - ); - } - - if (unavailable) { - return ( - -
- -
-
- ); - } - - const emptyMessage = isMyAccount - ? - : ; - - return ( - - - - ); - } - -} diff --git a/app/soapbox/features/favourited_statuses/index.tsx b/app/soapbox/features/favourited_statuses/index.tsx new file mode 100644 index 000000000..ecd9a8045 --- /dev/null +++ b/app/soapbox/features/favourited_statuses/index.tsx @@ -0,0 +1,151 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts'; +import { fetchFavouritedStatuses, expandFavouritedStatuses, fetchAccountFavouritedStatuses, expandAccountFavouritedStatuses } from 'soapbox/actions/favourites'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import StatusList from 'soapbox/components/status_list'; +import { Spinner } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { findAccountByUsername } from 'soapbox/selectors'; +import { getFeatures } from 'soapbox/utils/features'; + +import Column from '../ui/components/column'; + +import type { RootState } from 'soapbox/store'; + +const messages = defineMessages({ + heading: { id: 'column.favourited_statuses', defaultMessage: 'Liked posts' }, +}); + +const mapStateToProps = (state: RootState, { params }: IFavourites) => { + const username = params?.username || ''; + const me = state.get('me'); + const meUsername = state.accounts.get(me)?.username || ''; + + const isMyAccount = (username.toLowerCase() === meUsername?.toLowerCase()); + + const features = getFeatures(state.get('instance')); + + if (isMyAccount) { + return { + isMyAccount, + statusIds: state.status_lists.get('favourites')?.items || ImmutableOrderedSet(), + isLoading: state.status_lists.get('favourites')?.isLoading === true, + hasMore: !!state.status_lists.get('favourites')?.next, + unavailable: false, + isAccount: true, + }; + } + + const accountFetchError = ((state.accounts.get(-1)?.username || '').toLowerCase() === username.toLowerCase()); + + let accountId: number | string | null = -1; + if (accountFetchError) { + accountId = null; + } else { + const account = findAccountByUsername(state, username); + accountId = account?.id || -1; + } + + const isBlocked = state.relationships.getIn([accountId, 'blocked_by'], false) === true; + const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); + + return { + isMyAccount, + accountId, + unavailable, + username, + isAccount: !!state.getIn(['accounts', accountId]), + statusIds: state.status_lists.get(`favourites:${accountId}`)?.items || ImmutableOrderedSet(), + isLoading: state.status_lists.get(`favourites:${accountId}`)?.isLoading === true, + hasMore: !!state.status_lists.get(`favourites:${accountId}`)?.next, + }; +}; + +interface IFavourites { + params?: { + username?: string, + } +} + +const Favourites: React.FC = (props) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const username = props.params?.username || ''; + const { statusIds, isLoading, hasMore, isMyAccount, isAccount, accountId, unavailable } = useAppSelector(state => mapStateToProps(state, props)); + + useEffect(() => { + if (isMyAccount) + dispatch(fetchFavouritedStatuses()); + else { + if (typeof accountId === 'string') { + dispatch(fetchAccount(accountId)); + dispatch(fetchAccountFavouritedStatuses(accountId)); + } else { + dispatch(fetchAccountByUsername(username)); + } + } + }, []); + + useEffect(() => { + if (!isMyAccount && typeof accountId === 'string') { + dispatch(fetchAccount(accountId)); + dispatch(fetchAccountFavouritedStatuses(accountId)); + } + }, [accountId]); + + const handleLoadMore = useCallback(debounce(() => { + if (isMyAccount) { + dispatch(expandFavouritedStatuses()); + } else if (typeof accountId === 'string') { + dispatch(expandAccountFavouritedStatuses(accountId)); + } + }, 300, { leading: true }), [accountId]); + + if (!isMyAccount && !isAccount && accountId !== -1) { + return ( + + ); + } + + if (accountId === -1) { + return ( + + + + ); + } + + if (unavailable) { + return ( + +
+ +
+
+ ); + } + + const emptyMessage = isMyAccount + ? + : ; + + return ( + + + + ); +}; + +export default Favourites; \ No newline at end of file From 0355d2a9275a92a8256989923d6d6906f37325e4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 10 Sep 2022 11:07:35 -0500 Subject: [PATCH 12/72] Favourites: refactor, clean up, make it sane --- .../features/favourited_statuses/index.tsx | 115 ++++++------------ 1 file changed, 36 insertions(+), 79 deletions(-) diff --git a/app/soapbox/features/favourited_statuses/index.tsx b/app/soapbox/features/favourited_statuses/index.tsx index ecd9a8045..2b720ab4c 100644 --- a/app/soapbox/features/favourited_statuses/index.tsx +++ b/app/soapbox/features/favourited_statuses/index.tsx @@ -7,84 +7,49 @@ import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts'; import { fetchFavouritedStatuses, expandFavouritedStatuses, fetchAccountFavouritedStatuses, expandAccountFavouritedStatuses } from 'soapbox/actions/favourites'; import MissingIndicator from 'soapbox/components/missing_indicator'; import StatusList from 'soapbox/components/status_list'; -import { Spinner } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; import { findAccountByUsername } from 'soapbox/selectors'; -import { getFeatures } from 'soapbox/utils/features'; import Column from '../ui/components/column'; -import type { RootState } from 'soapbox/store'; - const messages = defineMessages({ heading: { id: 'column.favourited_statuses', defaultMessage: 'Liked posts' }, }); -const mapStateToProps = (state: RootState, { params }: IFavourites) => { - const username = params?.username || ''; - const me = state.get('me'); - const meUsername = state.accounts.get(me)?.username || ''; - - const isMyAccount = (username.toLowerCase() === meUsername?.toLowerCase()); - - const features = getFeatures(state.get('instance')); - - if (isMyAccount) { - return { - isMyAccount, - statusIds: state.status_lists.get('favourites')?.items || ImmutableOrderedSet(), - isLoading: state.status_lists.get('favourites')?.isLoading === true, - hasMore: !!state.status_lists.get('favourites')?.next, - unavailable: false, - isAccount: true, - }; - } - - const accountFetchError = ((state.accounts.get(-1)?.username || '').toLowerCase() === username.toLowerCase()); - - let accountId: number | string | null = -1; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account?.id || -1; - } - - const isBlocked = state.relationships.getIn([accountId, 'blocked_by'], false) === true; - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - isMyAccount, - accountId, - unavailable, - username, - isAccount: !!state.getIn(['accounts', accountId]), - statusIds: state.status_lists.get(`favourites:${accountId}`)?.items || ImmutableOrderedSet(), - isLoading: state.status_lists.get(`favourites:${accountId}`)?.isLoading === true, - hasMore: !!state.status_lists.get(`favourites:${accountId}`)?.next, - }; -}; - interface IFavourites { params?: { username?: string, } } +/** Timeline displaying a user's favourited statuses. */ const Favourites: React.FC = (props) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const features = useFeatures(); + const ownAccount = useOwnAccount(); const username = props.params?.username || ''; - const { statusIds, isLoading, hasMore, isMyAccount, isAccount, accountId, unavailable } = useAppSelector(state => mapStateToProps(state, props)); + const account = useAppSelector(state => findAccountByUsername(state, username)); + const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase(); + + const timelineKey = isOwnAccount ? 'favourites' : `favourites:${account?.id}`; + const statusIds = useAppSelector(state => state.status_lists.get(timelineKey)?.items || ImmutableOrderedSet()); + const isLoading = useAppSelector(state => state.status_lists.get(timelineKey)?.isLoading === true); + const hasMore = useAppSelector(state => !!state.status_lists.get(timelineKey)?.next); + + const unavailable = useAppSelector(state => { + const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true; + return isOwnAccount ? false : (blockedBy && !features.blockersVisible); + }); useEffect(() => { - if (isMyAccount) + if (isOwnAccount) dispatch(fetchFavouritedStatuses()); else { - if (typeof accountId === 'string') { - dispatch(fetchAccount(accountId)); - dispatch(fetchAccountFavouritedStatuses(accountId)); + if (account) { + dispatch(fetchAccount(account.id)); + dispatch(fetchAccountFavouritedStatuses(account.id)); } else { dispatch(fetchAccountByUsername(username)); } @@ -92,33 +57,19 @@ const Favourites: React.FC = (props) => { }, []); useEffect(() => { - if (!isMyAccount && typeof accountId === 'string') { - dispatch(fetchAccount(accountId)); - dispatch(fetchAccountFavouritedStatuses(accountId)); + if (account && !isOwnAccount) { + dispatch(fetchAccount(account.id)); + dispatch(fetchAccountFavouritedStatuses(account.id)); } - }, [accountId]); + }, [account?.id]); const handleLoadMore = useCallback(debounce(() => { - if (isMyAccount) { + if (isOwnAccount) { dispatch(expandFavouritedStatuses()); - } else if (typeof accountId === 'string') { - dispatch(expandAccountFavouritedStatuses(accountId)); + } else if (account) { + dispatch(expandAccountFavouritedStatuses(account.id)); } - }, 300, { leading: true }), [accountId]); - - if (!isMyAccount && !isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1) { - return ( - - - - ); - } + }, 300, { leading: true }), [account?.id]); if (unavailable) { return ( @@ -130,7 +81,13 @@ const Favourites: React.FC = (props) => { ); } - const emptyMessage = isMyAccount + if (!account) { + return ( + + ); + } + + const emptyMessage = isOwnAccount ? : ; @@ -140,7 +97,7 @@ const Favourites: React.FC = (props) => { statusIds={statusIds} scrollKey='favourited_statuses' hasMore={hasMore} - isLoading={typeof isLoading === 'boolean' ? isLoading : true} + isLoading={isLoading} onLoadMore={handleLoadMore} emptyMessage={emptyMessage} /> From 3f04d0a0499497d27019df567bebff5919eec2e1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 10 Sep 2022 11:56:02 -0500 Subject: [PATCH 13/72] Followers/Following: convert to TSX --- app/soapbox/features/followers/index.js | 138 ----------------------- app/soapbox/features/followers/index.tsx | 115 +++++++++++++++++++ app/soapbox/features/following/index.js | 138 ----------------------- app/soapbox/features/following/index.tsx | 115 +++++++++++++++++++ app/soapbox/reducers/user_lists.ts | 1 - app/soapbox/utils/accounts.ts | 8 -- 6 files changed, 230 insertions(+), 285 deletions(-) delete mode 100644 app/soapbox/features/followers/index.js create mode 100644 app/soapbox/features/followers/index.tsx delete mode 100644 app/soapbox/features/following/index.js create mode 100644 app/soapbox/features/following/index.tsx diff --git a/app/soapbox/features/followers/index.js b/app/soapbox/features/followers/index.js deleted file mode 100644 index a25fb0d7d..000000000 --- a/app/soapbox/features/followers/index.js +++ /dev/null @@ -1,138 +0,0 @@ -import debounce from 'lodash/debounce'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { - fetchAccount, - fetchFollowers, - expandFollowers, - fetchAccountByUsername, -} from 'soapbox/actions/accounts'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import { Spinner } from 'soapbox/components/ui'; -import AccountContainer from 'soapbox/containers/account_container'; -import { findAccountByUsername } from 'soapbox/selectors'; -import { getFollowDifference } from 'soapbox/utils/accounts'; -import { getFeatures } from 'soapbox/utils/features'; - -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.followers', defaultMessage: 'Followers' }, -}); - -const mapStateToProps = (state, { params, withReplies = false }) => { - const username = params.username || ''; - const me = state.get('me'); - const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase()); - const features = getFeatures(state.get('instance')); - - let accountId = -1; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - } - - const diffCount = getFollowDifference(state, accountId, 'followers'); - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - accountId, - unavailable, - isAccount: !!state.getIn(['accounts', accountId]), - accountIds: state.user_lists.followers.get(accountId)?.items, - hasMore: !!state.user_lists.followers.get(accountId)?.next, - diffCount, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class Followers extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.orderedSet, - hasMore: PropTypes.bool, - diffCount: PropTypes.number, - isAccount: PropTypes.bool, - unavailable: PropTypes.bool, - }; - - componentDidMount() { - const { params: { username }, accountId } = this.props; - - if (accountId && accountId !== -1) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchFollowers(accountId)); - } else { - this.props.dispatch(fetchAccountByUsername(username)); - } - } - - componentDidUpdate(prevProps) { - const { accountId, dispatch } = this.props; - if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) { - dispatch(fetchAccount(accountId)); - dispatch(fetchFollowers(accountId)); - } - } - - handleLoadMore = debounce(() => { - if (this.props.accountId && this.props.accountId !== -1) { - this.props.dispatch(expandFollowers(this.props.accountId)); - } - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore, diffCount, isAccount, accountId, unavailable } = this.props; - - if (!isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1 || (!accountIds)) { - return ( - - ); - } - - if (unavailable) { - return ( -
- -
- ); - } - - return ( - - } - itemClassName='pb-4' - > - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/followers/index.tsx b/app/soapbox/features/followers/index.tsx new file mode 100644 index 000000000..580d65026 --- /dev/null +++ b/app/soapbox/features/followers/index.tsx @@ -0,0 +1,115 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { + fetchAccount, + fetchFollowers, + expandFollowers, + fetchAccountByUsername, +} from 'soapbox/actions/accounts'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { findAccountByUsername } from 'soapbox/selectors'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.followers', defaultMessage: 'Followers' }, +}); + +interface IFollowers { + params?: { + username?: string, + } +} + +/** Displays a list of accounts who follow the given account. */ +const Followers: React.FC = (props) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + const ownAccount = useOwnAccount(); + + const [loading, setLoading] = useState(true); + + const username = props.params?.username || ''; + const account = useAppSelector(state => findAccountByUsername(state, username)); + const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase(); + + const accountIds = useAppSelector(state => state.user_lists.followers.get(account!?.id)?.items || ImmutableOrderedSet()); + const hasMore = useAppSelector(state => !!state.user_lists.followers.get(account!?.id)?.next); + + const unavailable = useAppSelector(state => { + const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true; + return isOwnAccount ? false : (blockedBy && !features.blockersVisible); + }); + + useEffect(() => { + let promises = []; + + if (account) { + promises = [ + dispatch(fetchAccount(account.id)), + dispatch(fetchFollowers(account.id)), + ]; + } else { + promises = [ + dispatch(fetchAccountByUsername(username)), + ]; + } + + Promise.all(promises) + .then(() => setLoading(false)) + .catch(() => setLoading(false)); + + }, [account?.id, username]); + + const handleLoadMore = useCallback(debounce(() => { + if (account) { + dispatch(expandFollowers(account.id)); + } + }, 300, { leading: true }), [account?.id]); + + if (loading && accountIds.isEmpty()) { + return ( + + ); + } + + if (!account) { + return ( + + ); + } + + if (unavailable) { + return ( +
+ +
+ ); + } + + return ( + + } + itemClassName='pb-4' + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default Followers; \ No newline at end of file diff --git a/app/soapbox/features/following/index.js b/app/soapbox/features/following/index.js deleted file mode 100644 index 682a26411..000000000 --- a/app/soapbox/features/following/index.js +++ /dev/null @@ -1,138 +0,0 @@ -import debounce from 'lodash/debounce'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { - fetchAccount, - fetchFollowing, - expandFollowing, - fetchAccountByUsername, -} from 'soapbox/actions/accounts'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import { Spinner } from 'soapbox/components/ui'; -import AccountContainer from 'soapbox/containers/account_container'; -import { findAccountByUsername } from 'soapbox/selectors'; -import { getFollowDifference } from 'soapbox/utils/accounts'; -import { getFeatures } from 'soapbox/utils/features'; - -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.following', defaultMessage: 'Following' }, -}); - -const mapStateToProps = (state, { params, withReplies = false }) => { - const username = params.username || ''; - const me = state.get('me'); - const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase()); - const features = getFeatures(state.get('instance')); - - let accountId = -1; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - } - - const diffCount = getFollowDifference(state, accountId, 'following'); - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - accountId, - unavailable, - isAccount: !!state.getIn(['accounts', accountId]), - accountIds: state.user_lists.following.get(accountId)?.items, - hasMore: !!state.user_lists.following.get(accountId)?.next, - diffCount, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class Following extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.orderedSet, - hasMore: PropTypes.bool, - isAccount: PropTypes.bool, - unavailable: PropTypes.bool, - diffCount: PropTypes.number, - }; - - componentDidMount() { - const { params: { username }, accountId } = this.props; - - if (accountId && accountId !== -1) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchFollowing(accountId)); - } else { - this.props.dispatch(fetchAccountByUsername(username)); - } - } - - componentDidUpdate(prevProps) { - const { accountId, dispatch } = this.props; - if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) { - dispatch(fetchAccount(accountId)); - dispatch(fetchFollowing(accountId)); - } - } - - handleLoadMore = debounce(() => { - if (this.props.accountId && this.props.accountId !== -1) { - this.props.dispatch(expandFollowing(this.props.accountId)); - } - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore, isAccount, diffCount, accountId, unavailable } = this.props; - - if (!isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1 || (!accountIds)) { - return ( - - ); - } - - if (unavailable) { - return ( -
- -
- ); - } - - return ( - - } - itemClassName='pb-4' - > - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/following/index.tsx b/app/soapbox/features/following/index.tsx new file mode 100644 index 000000000..6ea7001b8 --- /dev/null +++ b/app/soapbox/features/following/index.tsx @@ -0,0 +1,115 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { + fetchAccount, + fetchFollowing, + expandFollowing, + fetchAccountByUsername, +} from 'soapbox/actions/accounts'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { findAccountByUsername } from 'soapbox/selectors'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.following', defaultMessage: 'Following' }, +}); + +interface IFollowing { + params?: { + username?: string, + } +} + +/** Displays a list of accounts the given user is following. */ +const Following: React.FC = (props) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + const ownAccount = useOwnAccount(); + + const [loading, setLoading] = useState(true); + + const username = props.params?.username || ''; + const account = useAppSelector(state => findAccountByUsername(state, username)); + const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase(); + + const accountIds = useAppSelector(state => state.user_lists.following.get(account!?.id)?.items || ImmutableOrderedSet()); + const hasMore = useAppSelector(state => !!state.user_lists.following.get(account!?.id)?.next); + + const unavailable = useAppSelector(state => { + const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true; + return isOwnAccount ? false : (blockedBy && !features.blockersVisible); + }); + + useEffect(() => { + let promises = []; + + if (account) { + promises = [ + dispatch(fetchAccount(account.id)), + dispatch(fetchFollowing(account.id)), + ]; + } else { + promises = [ + dispatch(fetchAccountByUsername(username)), + ]; + } + + Promise.all(promises) + .then(() => setLoading(false)) + .catch(() => setLoading(false)); + + }, [account?.id, username]); + + const handleLoadMore = useCallback(debounce(() => { + if (account) { + dispatch(expandFollowing(account.id)); + } + }, 300, { leading: true }), [account?.id]); + + if (loading && accountIds.isEmpty()) { + return ( + + ); + } + + if (!account) { + return ( + + ); + } + + if (unavailable) { + return ( +
+ +
+ ); + } + + return ( + + } + itemClassName='pb-4' + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default Following; \ No newline at end of file diff --git a/app/soapbox/reducers/user_lists.ts b/app/soapbox/reducers/user_lists.ts index fc86cceb2..38017f0bb 100644 --- a/app/soapbox/reducers/user_lists.ts +++ b/app/soapbox/reducers/user_lists.ts @@ -98,7 +98,6 @@ type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_ type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory']; const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => { - return state.setIn(path, ListRecord({ next, items: ImmutableOrderedSet(accounts.map(item => item.id)), diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index b2ed45d39..3a1cadbe9 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -1,5 +1,3 @@ -import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; - import type { Account } from 'soapbox/types/entities'; const getDomainFromURL = (account: Account): string => { @@ -28,12 +26,6 @@ export const getAcct = (account: Account, displayFqn: boolean): string => ( displayFqn === true ? account.fqn : account.acct ); -export const getFollowDifference = (state: ImmutableMap, accountId: string, type: string): number => { - const items: any = state.getIn(['user_lists', type, accountId, 'items'], ImmutableOrderedSet()); - const counter: number = Number(state.getIn(['accounts_counters', accountId, `${type}_count`], 0)); - return Math.max(counter - items.size, 0); -}; - export const isLocal = (account: Account): boolean => { const domain: string = account.acct.split('@')[1]; return domain === undefined ? true : false; From 82edcc4bd8f68a770a5c23ccf83d199c8e9515be Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 10 Sep 2022 12:04:08 -0500 Subject: [PATCH 14/72] Delete unused "introduction" feature --- app/soapbox/features/introduction/index.js | 172 --------------------- 1 file changed, 172 deletions(-) delete mode 100644 app/soapbox/features/introduction/index.js diff --git a/app/soapbox/features/introduction/index.js b/app/soapbox/features/introduction/index.js deleted file mode 100644 index a35ba341a..000000000 --- a/app/soapbox/features/introduction/index.js +++ /dev/null @@ -1,172 +0,0 @@ -import classNames from 'clsx'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import ReactSwipeableViews from 'react-swipeable-views'; - -import { closeOnboarding } from '../../actions/onboarding'; - -const FrameWelcome = ({ domain, onNext }) => ( -
-
-

-

{domain} }} />

-
- -
- -
-
-); - -FrameWelcome.propTypes = { - domain: PropTypes.string.isRequired, - onNext: PropTypes.func.isRequired, -}; - -const FrameFederation = ({ onNext }) => ( -
-
-
-

-

-
-
- -
- -
-
-); - -FrameFederation.propTypes = { - onNext: PropTypes.func.isRequired, -}; - -const FrameInteractions = ({ onNext }) => ( -
-
-
-

-

-
- -
-

-

-
- -
-

-

-
-
- -
- -
-
-); - -FrameInteractions.propTypes = { - onNext: PropTypes.func.isRequired, -}; - -export default @connect(state => ({ domain: state.getIn(['meta', 'domain']) })) -class Introduction extends React.PureComponent { - - static propTypes = { - domain: PropTypes.string.isRequired, - dispatch: PropTypes.func.isRequired, - }; - - state = { - currentIndex: 0, - }; - - constructor(props) { - super(props); - this.pages = [ - , - , - , - ]; - } - - componentDidMount() { - window.addEventListener('keyup', this.handleKeyUp); - } - - componentWillUnmount() { - window.addEventListener('keyup', this.handleKeyUp); - } - - handleDot = (e) => { - const i = Number(e.currentTarget.getAttribute('data-index')); - e.preventDefault(); - this.setState({ currentIndex: i }); - } - - handlePrev = () => { - this.setState(({ currentIndex }) => ({ - currentIndex: Math.max(0, currentIndex - 1), - })); - } - - handleNext = () => { - const { pages } = this; - - this.setState(({ currentIndex }) => ({ - currentIndex: Math.min(currentIndex + 1, pages.length - 1), - })); - } - - handleSwipe = (index) => { - this.setState({ currentIndex: index }); - } - - handleFinish = () => { - this.props.dispatch(closeOnboarding()); - } - - handleKeyUp = ({ key }) => { - switch (key) { - case 'ArrowLeft': - this.handlePrev(); - break; - case 'ArrowRight': - this.handleNext(); - break; - } - } - - render() { - const { currentIndex } = this.state; - const { pages } = this; - - return ( -
- - {pages.map((page, i) => ( -
{page}
- ))} -
- -
- {pages.map((_, i) => ( -
- ))} -
-
- ); - } - -} From bcfbc394ab39adb1c6929942aee0d8d11be6ea4b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 10 Sep 2022 12:05:38 -0500 Subject: [PATCH 15/72] Delete Groups components --- app/soapbox/features/groups/create/index.js | 116 -------------- app/soapbox/features/groups/edit/index.js | 148 ------------------ app/soapbox/features/groups/index/card.js | 57 ------- app/soapbox/features/groups/index/index.js | 93 ----------- app/soapbox/features/groups/members/index.js | 76 --------- .../features/groups/removed_accounts/index.js | 94 ----------- .../features/groups/sidebar_panel/index.js | 53 ------- .../features/groups/sidebar_panel/item.js | 48 ------ .../groups/timeline/components/header.js | 92 ----------- .../groups/timeline/components/panel.js | 38 ----- .../timeline/containers/header_container.js | 21 --- app/soapbox/features/groups/timeline/index.js | 107 ------------- .../features/ui/util/async-components.ts | 24 --- app/soapbox/pages/group_page.js | 75 --------- app/soapbox/pages/groups_page.js | 60 ------- 15 files changed, 1102 deletions(-) delete mode 100644 app/soapbox/features/groups/create/index.js delete mode 100644 app/soapbox/features/groups/edit/index.js delete mode 100644 app/soapbox/features/groups/index/card.js delete mode 100644 app/soapbox/features/groups/index/index.js delete mode 100644 app/soapbox/features/groups/members/index.js delete mode 100644 app/soapbox/features/groups/removed_accounts/index.js delete mode 100644 app/soapbox/features/groups/sidebar_panel/index.js delete mode 100644 app/soapbox/features/groups/sidebar_panel/item.js delete mode 100644 app/soapbox/features/groups/timeline/components/header.js delete mode 100644 app/soapbox/features/groups/timeline/components/panel.js delete mode 100644 app/soapbox/features/groups/timeline/containers/header_container.js delete mode 100644 app/soapbox/features/groups/timeline/index.js delete mode 100644 app/soapbox/pages/group_page.js delete mode 100644 app/soapbox/pages/groups_page.js diff --git a/app/soapbox/features/groups/create/index.js b/app/soapbox/features/groups/create/index.js deleted file mode 100644 index d7189baa4..000000000 --- a/app/soapbox/features/groups/create/index.js +++ /dev/null @@ -1,116 +0,0 @@ -import classNames from 'clsx'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { changeValue, submit, reset } from '../../../actions/group_editor'; - -const messages = defineMessages({ - title: { id: 'groups.form.title', defaultMessage: 'Enter a new group title' }, - description: { id: 'groups.form.description', defaultMessage: 'Enter the group description' }, - coverImage: { id: 'groups.form.coverImage', defaultMessage: 'Upload a banner image' }, - coverImageChange: { id: 'groups.form.coverImageChange', defaultMessage: 'Banner image selected' }, - create: { id: 'groups.form.create', defaultMessage: 'Create group' }, -}); - -const mapStateToProps = state => ({ - title: state.getIn(['group_editor', 'title']), - description: state.getIn(['group_editor', 'description']), - coverImage: state.getIn(['group_editor', 'coverImage']), - disabled: state.getIn(['group_editor', 'isSubmitting']), -}); - -const mapDispatchToProps = dispatch => ({ - onTitleChange: value => dispatch(changeValue('title', value)), - onDescriptionChange: value => dispatch(changeValue('description', value)), - onCoverImageChange: value => dispatch(changeValue('coverImage', value)), - onSubmit: routerHistory => dispatch(submit(routerHistory)), - reset: () => dispatch(reset()), -}); - -export default @connect(mapStateToProps, mapDispatchToProps) -@injectIntl -@withRouter -class Create extends React.PureComponent { - - static propTypes = { - title: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - coverImage: PropTypes.object, - disabled: PropTypes.bool, - intl: PropTypes.object.isRequired, - onTitleChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - reset: PropTypes.func.isRequired, - onDescriptionChange: PropTypes.func.isRequired, - onCoverImageChange: PropTypes.func.isRequired, - history: PropTypes.object, - }; - - constructor(props) { - super(props); - props.reset(); - } - - handleTitleChange = e => { - this.props.onTitleChange(e.target.value); - } - - handleDescriptionChange = e => { - this.props.onDescriptionChange(e.target.value); - } - - handleCoverImageChange = e => { - this.props.onCoverImageChange(e.target.files[0]); - } - - handleSubmit = e => { - e.preventDefault(); - this.props.onSubmit(this.props.history); - } - - render() { - const { title, description, coverImage, disabled, intl } = this.props; - - return ( -
-
- -
-
-