From 5ba4275f73a13eebc7d055feeb58b82e05b65cc8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 9 May 2022 17:39:38 -0500 Subject: [PATCH 0001/1726] Stop doing Tabler import hack, use our forked version from git --- package.json | 2 +- webpack/shared.js | 3 --- yarn.lock | 7 +++---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 5d19be83b..4e3608b97 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@sentry/browser": "^6.12.0", "@sentry/react": "^6.12.0", "@sentry/tracing": "^6.12.0", - "@tabler/icons": "^1.53.0", + "@tabler/icons": "https://gitlab.com/soapbox-pub/tabler-icons.git", "@tailwindcss/forms": "^0.4.0", "@tailwindcss/typography": "^0.5.1", "@testing-library/react": "^12.1.4", diff --git a/webpack/shared.js b/webpack/shared.js index 9dee79de1..fb8308ee4 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -145,9 +145,6 @@ module.exports = { 'node_modules', ], alias: { - // Override tabler's package.json to allow importing .svg files directly - // https://stackoverflow.com/a/35990101/8811886 - '@tabler': resolve('node_modules', '@tabler'), 'icons': resolve('app', 'icons'), 'custom': resolve('custom'), }, diff --git a/yarn.lock b/yarn.lock index 9445a64a8..cb9d3fb3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1883,10 +1883,9 @@ remark "^13.0.0" unist-util-find-all-after "^3.0.2" -"@tabler/icons@^1.53.0": - version "1.53.0" - resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.53.0.tgz#51536e01b343cfaf26b701df306b2c0369769e3c" - integrity sha512-Skk1BqXEOEhiRsXJgZBYtjFa/+4dMSFA5UyzTUW20oyyUSd3iizhEWrYt0jT87iFu771gWoqVV2/OGobBcGjgQ== +"@tabler/icons@https://gitlab.com/soapbox-pub/tabler-icons.git": + version "1.68.0" + resolved "https://gitlab.com/soapbox-pub/tabler-icons.git#20e43498bdc0f38f75f176ef1a6526ca76c9172e" "@tailwindcss/forms@^0.4.0": version "0.4.0" From 660c098166ca2253a56a28f8432b1ae28a47f42f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 9 May 2022 17:57:15 -0500 Subject: [PATCH 0002/1726] Allow custom module imports --- custom/modules/.gitkeep | 0 webpack/rules/assets.js | 5 ++++- webpack/shared.js | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 custom/modules/.gitkeep diff --git a/custom/modules/.gitkeep b/custom/modules/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/webpack/rules/assets.js b/webpack/rules/assets.js index 1b291ae0c..e0a14c250 100644 --- a/webpack/rules/assets.js +++ b/webpack/rules/assets.js @@ -66,7 +66,10 @@ module.exports = [{ }, { test: /\.svg$/, type: 'asset/resource', - include: resolve('node_modules', '@tabler'), + include: [ + resolve('node_modules', '@tabler'), + resolve('custom', 'modules', '@tabler'), + ], generator: { filename: 'packs/icons/[name]-[contenthash:8][ext]', }, diff --git a/webpack/shared.js b/webpack/shared.js index fb8308ee4..326981b8b 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -141,6 +141,7 @@ module.exports = { resolve: { extensions: settings.extensions, modules: [ + resolve('custom', 'modules'), resolve(settings.source_path), 'node_modules', ], From 9a207c970f10fdc449688699223197640f1c566c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 30 Jun 2022 16:51:36 +0200 Subject: [PATCH 0003/1726] TypeScript, React.FC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- ...t_textarea.js => autosuggest_textarea.tsx} | 79 +++++++------ app/soapbox/components/dropdown_menu.tsx | 2 +- app/soapbox/components/fork_awesome_icon.js | 39 ------- app/soapbox/components/fork_awesome_icon.tsx | 34 ++++++ app/soapbox/components/icon.js | 33 ------ app/soapbox/components/icon.tsx | 27 +++++ app/soapbox/components/icon_with_counter.tsx | 4 +- app/soapbox/components/sidebar-navigation.tsx | 28 +++-- app/soapbox/components/sub_navigation.js | 105 ------------------ app/soapbox/components/sub_navigation.tsx | 83 ++++++++++++++ app/soapbox/components/svg_icon.js | 33 ------ app/soapbox/components/svg_icon.tsx | 29 +++++ app/soapbox/features/aliases/index.tsx | 2 +- app/soapbox/features/filters/index.tsx | 2 +- .../public_layout/components/footer.js | 63 ----------- .../public_layout/components/footer.tsx | 51 +++++++++ .../features/ui/components/actions_modal.tsx | 2 +- .../ui/components/bundle_modal_error.js | 53 --------- .../ui/components/bundle_modal_error.tsx | 45 ++++++++ 19 files changed, 337 insertions(+), 377 deletions(-) rename app/soapbox/components/{autosuggest_textarea.js => autosuggest_textarea.tsx} (79%) delete mode 100644 app/soapbox/components/fork_awesome_icon.js create mode 100644 app/soapbox/components/fork_awesome_icon.tsx delete mode 100644 app/soapbox/components/icon.js create mode 100644 app/soapbox/components/icon.tsx delete mode 100644 app/soapbox/components/sub_navigation.js create mode 100644 app/soapbox/components/sub_navigation.tsx delete mode 100644 app/soapbox/components/svg_icon.js create mode 100644 app/soapbox/components/svg_icon.tsx delete mode 100644 app/soapbox/features/public_layout/components/footer.js create mode 100644 app/soapbox/features/public_layout/components/footer.tsx delete mode 100644 app/soapbox/features/ui/components/bundle_modal_error.js create mode 100644 app/soapbox/features/ui/components/bundle_modal_error.tsx diff --git a/app/soapbox/components/autosuggest_textarea.js b/app/soapbox/components/autosuggest_textarea.tsx similarity index 79% rename from app/soapbox/components/autosuggest_textarea.js rename to app/soapbox/components/autosuggest_textarea.tsx index 9a7ff45dc..69d29261f 100644 --- a/app/soapbox/components/autosuggest_textarea.js +++ b/app/soapbox/components/autosuggest_textarea.tsx @@ -1,17 +1,17 @@ import Portal from '@reach/portal'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; import AutosuggestAccount from '../features/compose/components/autosuggest_account'; import { isRtl } from '../rtl'; -import AutosuggestEmoji from './autosuggest_emoji'; +import AutosuggestEmoji, { Emoji } from './autosuggest_emoji'; -const textAtCursorMatchesToken = (str, caretPosition) => { +import type { List as ImmutableList } from 'immutable'; + +const textAtCursorMatchesToken = (str: string, caretPosition: number) => { let word; const left = str.slice(0, caretPosition).search(/\S+$/); @@ -36,25 +36,28 @@ const textAtCursorMatchesToken = (str, caretPosition) => { } }; -export default class AutosuggestTextarea extends ImmutablePureComponent { +interface IAutosuggesteTextarea { + id?: string, + value: string, + suggestions: ImmutableList, + disabled: boolean, + placeholder: string, + onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void, + onSuggestionsClearRequested: () => void, + onSuggestionsFetchRequested: (token: string | number) => void, + onChange: React.ChangeEventHandler, + onKeyUp: React.KeyboardEventHandler, + onKeyDown: React.KeyboardEventHandler, + onPaste: (files: FileList) => void, + autoFocus: boolean, + onFocus: () => void, + onBlur?: () => void, + condensed?: boolean, +} - static propTypes = { - value: PropTypes.string, - suggestions: ImmutablePropTypes.list, - disabled: PropTypes.bool, - placeholder: PropTypes.string, - onSuggestionSelected: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onKeyUp: PropTypes.func, - onKeyDown: PropTypes.func, - onPaste: PropTypes.func.isRequired, - autoFocus: PropTypes.bool, - onFocus: PropTypes.func, - onBlur: PropTypes.func, - condensed: PropTypes.bool, - }; +class AutosuggestTextarea extends ImmutablePureComponent { + + textarea: HTMLTextAreaElement | null = null; static defaultProps = { autoFocus: true, @@ -68,7 +71,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { tokenStart: 0, }; - onChange = (e) => { + onChange: React.ChangeEventHandler = (e) => { const [tokenStart, token] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); if (token !== null && this.state.lastToken !== token) { @@ -82,7 +85,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { this.props.onChange(e); } - onKeyDown = (e) => { + onKeyDown: React.KeyboardEventHandler = (e) => { const { suggestions, disabled } = this.props; const { selectedSuggestion, suggestionsHidden } = this.state; @@ -91,7 +94,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { return; } - if (e.which === 229 || e.isComposing) { + if (e.which === 229 || (e as any).isComposing) { // Ignore key events during text composition // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac) return; @@ -100,7 +103,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { switch (e.key) { case 'Escape': if (suggestions.size === 0 || suggestionsHidden) { - document.querySelector('.ui').parentElement.focus(); + document.querySelector('.ui')?.parentElement?.focus(); } else { e.preventDefault(); this.setState({ suggestionsHidden: true }); @@ -156,14 +159,14 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } } - onSuggestionClick = (e) => { - const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + onSuggestionClick: React.MouseEventHandler = (e) => { + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any); e.preventDefault(); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); - this.textarea.focus(); + this.textarea?.focus(); } - shouldComponentUpdate(nextProps, nextState) { + shouldComponentUpdate(nextProps: IAutosuggesteTextarea, nextState: any) { // Skip updating when only the lastToken changes so the // cursor doesn't jump around due to re-rendering unnecessarily const lastTokenUpdated = this.state.lastToken !== nextState.lastToken; @@ -172,29 +175,29 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { if (lastTokenUpdated && !valueUpdated) { return false; } else { - return super.shouldComponentUpdate(nextProps, nextState); + return super.shouldComponentUpdate!(nextProps, nextState, undefined); } } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: IAutosuggesteTextarea, prevState: any) { const { suggestions } = this.props; if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) { this.setState({ suggestionsHidden: false }); } } - setTextarea = (c) => { + setTextarea: React.Ref = (c) => { this.textarea = c; } - onPaste = (e) => { + onPaste: React.ClipboardEventHandler = (e) => { if (e.clipboardData && e.clipboardData.files.length === 1) { this.props.onPaste(e.clipboardData.files); e.preventDefault(); } } - renderSuggestion = (suggestion, i) => { + renderSuggestion = (suggestion: string | Emoji, i: number) => { const { selectedSuggestion } = this.state; let inner, key; @@ -212,7 +215,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { return (
@@ -297,3 +300,5 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } } + +export default AutosuggestTextarea; diff --git a/app/soapbox/components/dropdown_menu.tsx b/app/soapbox/components/dropdown_menu.tsx index 957583b60..b07412270 100644 --- a/app/soapbox/components/dropdown_menu.tsx +++ b/app/soapbox/components/dropdown_menu.tsx @@ -18,7 +18,7 @@ let id = 0; export interface MenuItem { action?: React.EventHandler, middleClick?: React.EventHandler, - text: string | JSX.Element, + text: string, href?: string, to?: string, newTab?: boolean, diff --git a/app/soapbox/components/fork_awesome_icon.js b/app/soapbox/components/fork_awesome_icon.js deleted file mode 100644 index 1d85f1288..000000000 --- a/app/soapbox/components/fork_awesome_icon.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * ForkAwesomeIcon: renders a ForkAwesome icon. - * Full list: https://forkaweso.me/Fork-Awesome/icons/ - * @module soapbox/components/fork_awesome_icon - * @see soapbox/components/icon - */ - -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; - -export default class ForkAwesomeIcon extends React.PureComponent { - - static propTypes = { - id: PropTypes.string.isRequired, - className: PropTypes.string, - fixedWidth: PropTypes.bool, - }; - - render() { - const { id, className, fixedWidth, ...other } = this.props; - - // Use the Fork Awesome retweet icon, but change its alt - // tag. There is a common adblocker rule which hides elements with - // alt='retweet' unless the domain is twitter.com. This should - // change what screenreaders call it as well. - const alt = (id === 'retweet') ? 'repost' : id; - - return ( - - ); - } - -} diff --git a/app/soapbox/components/fork_awesome_icon.tsx b/app/soapbox/components/fork_awesome_icon.tsx new file mode 100644 index 000000000..616a3959d --- /dev/null +++ b/app/soapbox/components/fork_awesome_icon.tsx @@ -0,0 +1,34 @@ +/** + * ForkAwesomeIcon: renders a ForkAwesome icon. + * Full list: https://forkaweso.me/Fork-Awesome/icons/ + * @module soapbox/components/fork_awesome_icon + * @see soapbox/components/icon + */ + +import classNames from 'classnames'; +import React from 'react'; + +export interface IForkAwesomeIcon extends React.HTMLAttributes { + id: string, + className?: string, + fixedWidth?: boolean, +} + +const ForkAwesomeIcon: React.FC = ({ id, className, fixedWidth, ...rest }) => { + // Use the Fork Awesome retweet icon, but change its alt + // tag. There is a common adblocker rule which hides elements with + // alt='retweet' unless the domain is twitter.com. This should + // change what screenreaders call it as well. + // const alt = (id === 'retweet') ? 'repost' : id; + + return ( + + ); +};`` + +export default ForkAwesomeIcon; diff --git a/app/soapbox/components/icon.js b/app/soapbox/components/icon.js deleted file mode 100644 index 3a7059061..000000000 --- a/app/soapbox/components/icon.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Icon: abstract icon class that can render icons from multiple sets. - * @module soapbox/components/icon - * @see soapbox/components/fork_awesome_icon - * @see soapbox/components/svg_icon - */ - -import PropTypes from 'prop-types'; -import React from 'react'; - -import ForkAwesomeIcon from './fork_awesome_icon'; -import SvgIcon from './svg_icon'; - -export default class Icon extends React.PureComponent { - - static propTypes = { - id: PropTypes.string, - src: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - className: PropTypes.string, - fixedWidth: PropTypes.bool, - }; - - render() { - const { id, src, fixedWidth, ...rest } = this.props; - - if (src) { - return ; - } else { - return ; - } - } - -} diff --git a/app/soapbox/components/icon.tsx b/app/soapbox/components/icon.tsx new file mode 100644 index 000000000..cba7b5805 --- /dev/null +++ b/app/soapbox/components/icon.tsx @@ -0,0 +1,27 @@ +/** + * Icon: abstract icon class that can render icons from multiple sets. + * @module soapbox/components/icon + * @see soapbox/components/fork_awesome_icon + * @see soapbox/components/svg_icon + */ + +import React from 'react'; + +import ForkAwesomeIcon, { IForkAwesomeIcon } from './fork_awesome_icon'; +import SvgIcon, { ISvgIcon } from './svg_icon'; + +export type IIcon = IForkAwesomeIcon | ISvgIcon; + +const Icon: React.FC = (props) => { + if ((props as ISvgIcon).src) { + const { src, ...rest } = (props as ISvgIcon); + + return ; + } else { + const { id, fixedWidth, ...rest } = (props as IForkAwesomeIcon); + + return ; + } +}; + +export default Icon; diff --git a/app/soapbox/components/icon_with_counter.tsx b/app/soapbox/components/icon_with_counter.tsx index d0fd093a6..2d95cb9f9 100644 --- a/app/soapbox/components/icon_with_counter.tsx +++ b/app/soapbox/components/icon_with_counter.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import Icon from 'soapbox/components/icon'; +import Icon, { IIcon } from 'soapbox/components/icon'; import { Counter } from 'soapbox/components/ui'; interface IIconWithCounter extends React.HTMLAttributes { @@ -12,7 +12,7 @@ interface IIconWithCounter extends React.HTMLAttributes { const IconWithCounter: React.FC = ({ icon, count, ...rest }) => { return (
- + {count > 0 && ( diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index f4cf64e49..427b0ea30 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { getSettings } from 'soapbox/actions/settings'; import DropdownMenu from 'soapbox/containers/dropdown_menu_container'; @@ -11,8 +11,20 @@ import SidebarNavigationLink from './sidebar-navigation-link'; import type { Menu } from 'soapbox/components/dropdown_menu'; +const messages = defineMessages({ + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, + lists: { id: 'column.lists', defaultMessage: 'Lists' }, + developers: { id: 'navigation.developers', defaultMessage: 'Developers' }, + dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' }, + all: { id: 'tabs_bar.all', defaultMessage: 'All' }, + fediverse: { id: 'tabs_bar.fediverse', defaultMessage: 'Fediverse' }, +}); + /** Desktop sidebar with links to different views in the app. */ const SidebarNavigation = () => { + const intl = useIntl(); + const instance = useAppSelector((state) => state.instance); const settings = useAppSelector((state) => getSettings(state)); const account = useOwnAccount(); @@ -30,7 +42,7 @@ const SidebarNavigation = () => { if (account.locked || followRequestsCount > 0) { menu.push({ to: '/follow_requests', - text: , + text: intl.formatMessage(messages.follow_requests), icon: require('@tabler/icons/icons/user-plus.svg'), count: followRequestsCount, }); @@ -39,7 +51,7 @@ const SidebarNavigation = () => { if (features.bookmarks) { menu.push({ to: '/bookmarks', - text: , + text: intl.formatMessage(messages.bookmarks), icon: require('@tabler/icons/icons/bookmark.svg'), }); } @@ -47,7 +59,7 @@ const SidebarNavigation = () => { if (features.lists) { menu.push({ to: '/lists', - text: , + text: intl.formatMessage(messages.lists), icon: require('@tabler/icons/icons/list.svg'), }); } @@ -56,7 +68,7 @@ const SidebarNavigation = () => { menu.push({ to: '/developers', icon: require('@tabler/icons/icons/code.svg'), - text: , + text: intl.formatMessage(messages.developers), }); } @@ -64,7 +76,7 @@ const SidebarNavigation = () => { menu.push({ to: '/soapbox/admin', icon: require('@tabler/icons/icons/dashboard.svg'), - text: , + text: intl.formatMessage(messages.dashboard), count: dashboardCount, }); } @@ -78,7 +90,7 @@ const SidebarNavigation = () => { menu.push({ to: '/timeline/local', icon: features.federating ? require('@tabler/icons/icons/users.svg') : require('@tabler/icons/icons/world.svg'), - text: features.federating ? instance.title : , + text: features.federating ? instance.title : intl.formatMessage(messages.all), }); } @@ -86,7 +98,7 @@ const SidebarNavigation = () => { menu.push({ to: '/timeline/fediverse', icon: require('icons/fediverse.svg'), - text: , + text: intl.formatMessage(messages.fediverse), }); } diff --git a/app/soapbox/components/sub_navigation.js b/app/soapbox/components/sub_navigation.js deleted file mode 100644 index f75ca802f..000000000 --- a/app/soapbox/components/sub_navigation.js +++ /dev/null @@ -1,105 +0,0 @@ -import throttle from 'lodash/throttle'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { injectIntl, defineMessages } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { openModal } from 'soapbox/actions/modals'; - -import { CardHeader, CardTitle } from './ui'; - -const messages = defineMessages({ - back: { id: 'column_back_button.label', defaultMessage: 'Back' }, - settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, -}); - -const mapDispatchToProps = (dispatch, { settings: Settings }) => { - return { - onOpenSettings() { - dispatch(openModal('COMPONENT', { component: Settings })); - }, - }; -}; - -export default @connect(undefined, mapDispatchToProps) -@injectIntl -@withRouter -class SubNavigation extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - message: PropTypes.string, - settings: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), - onOpenSettings: PropTypes.func.isRequired, - history: PropTypes.object, - } - - state = { - scrolled: false, - } - - handleBackClick = () => { - if (window.history && window.history.length === 1) { - this.props.history.push('/'); - } else { - this.props.history.goBack(); - } - } - - handleBackKeyUp = (e) => { - if (e.key === 'Enter') { - this.handleClick(); - } - } - - componentDidMount() { - this.attachScrollListener(); - } - - componentWillUnmount() { - this.detachScrollListener(); - } - - attachScrollListener() { - window.addEventListener('scroll', this.handleScroll); - } - - detachScrollListener() { - window.removeEventListener('scroll', this.handleScroll); - } - - handleScroll = throttle(() => { - if (this.node) { - const { offsetTop } = this.node; - - if (offsetTop > 0) { - this.setState({ scrolled: true }); - } else { - this.setState({ scrolled: false }); - } - } - }, 150, { trailing: true }); - - handleOpenSettings = () => { - this.props.onOpenSettings(); - } - - setRef = c => { - this.node = c; - } - - render() { - const { intl, message } = this.props; - - return ( - - - - ); - } - -} diff --git a/app/soapbox/components/sub_navigation.tsx b/app/soapbox/components/sub_navigation.tsx new file mode 100644 index 000000000..b8e2b310d --- /dev/null +++ b/app/soapbox/components/sub_navigation.tsx @@ -0,0 +1,83 @@ +// import throttle from 'lodash/throttle'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +// import { connect } from 'react-redux'; +import { useHistory } from 'react-router-dom'; + +// import { openModal } from 'soapbox/actions/modals'; +// import { useAppDispatch } from 'soapbox/hooks'; + +import { CardHeader, CardTitle } from './ui'; + +const messages = defineMessages({ + back: { id: 'column_back_button.label', defaultMessage: 'Back' }, + settings: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, +}); + +interface ISubNavigation { + message: String, + settings?: React.ComponentType, +} + +const SubNavigation: React.FC = ({ message }) => { + const intl = useIntl(); + // const dispatch = useAppDispatch(); + const history = useHistory(); + + // const ref = useRef(null); + + // const [scrolled, setScrolled] = useState(false); + + // const onOpenSettings = () => { + // dispatch(openModal('COMPONENT', { component: Settings })); + // }; + + const handleBackClick = () => { + if (window.history && window.history.length === 1) { + history.push('/'); + } else { + history.goBack(); + } + }; + + // const handleBackKeyUp = (e) => { + // if (e.key === 'Enter') { + // handleClick(); + // } + // } + + // const handleOpenSettings = () => { + // onOpenSettings(); + // } + + // useEffect(() => { + // const handleScroll = throttle(() => { + // if (this.node) { + // const { offsetTop } = this.node; + + // if (offsetTop > 0) { + // setScrolled(true); + // } else { + // setScrolled(false); + // } + // } + // }, 150, { trailing: true }); + + // window.addEventListener('scroll', handleScroll); + + // return () => { + // window.removeEventListener('scroll', handleScroll); + // }; + // }, []); + + return ( + + + + ); +}; + +export default SubNavigation; diff --git a/app/soapbox/components/svg_icon.js b/app/soapbox/components/svg_icon.js deleted file mode 100644 index 04f0cd526..000000000 --- a/app/soapbox/components/svg_icon.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * SvgIcon: abstact component to render SVG icons. - * @module soapbox/components/svg_icon - * @see soapbox/components/icon - */ - -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports - -export default class SvgIcon extends React.PureComponent { - - static propTypes = { - src: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, - alt: PropTypes.string, - className: PropTypes.string, - }; - - render() { - const { src, className, alt, ...other } = this.props; - - return ( -
- } /> -
- ); - } - -} diff --git a/app/soapbox/components/svg_icon.tsx b/app/soapbox/components/svg_icon.tsx new file mode 100644 index 000000000..a81979d0d --- /dev/null +++ b/app/soapbox/components/svg_icon.tsx @@ -0,0 +1,29 @@ +/** + * SvgIcon: abstact component to render SVG icons. + * @module soapbox/components/svg_icon + * @see soapbox/components/icon + */ + +import classNames from 'classnames'; +import React from 'react'; +import InlineSVG from 'react-inlinesvg'; // eslint-disable-line no-restricted-imports + +export interface ISvgIcon extends React.HTMLAttributes { + src: string, + id?: string, + alt?: string, + className?: string, +} + +const SvgIcon: React.FC = ({ src, alt, className, ...rest }) => { + return ( +
+ } /> +
+ ); +}; + +export default SvgIcon; diff --git a/app/soapbox/features/aliases/index.tsx b/app/soapbox/features/aliases/index.tsx index 0e765339e..9dfb52c44 100644 --- a/app/soapbox/features/aliases/index.tsx +++ b/app/soapbox/features/aliases/index.tsx @@ -84,7 +84,7 @@ const Aliases = () => { {alias}
- +
diff --git a/app/soapbox/features/filters/index.tsx b/app/soapbox/features/filters/index.tsx index fcdf262eb..e2d6f2050 100644 --- a/app/soapbox/features/filters/index.tsx +++ b/app/soapbox/features/filters/index.tsx @@ -216,7 +216,7 @@ const Filters = () => {
- +
diff --git a/app/soapbox/features/public_layout/components/footer.js b/app/soapbox/features/public_layout/components/footer.js deleted file mode 100644 index 11a56806b..000000000 --- a/app/soapbox/features/public_layout/components/footer.js +++ /dev/null @@ -1,63 +0,0 @@ -import { List as ImmutableList } from 'immutable'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; - -import { getSettings } from 'soapbox/actions/settings'; -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import { Text } from 'soapbox/components/ui'; - -const mapStateToProps = (state, props) => { - const soapboxConfig = getSoapboxConfig(state); - - return { - copyright: soapboxConfig.get('copyright'), - navlinks: soapboxConfig.getIn(['navlinks', 'homeFooter'], ImmutableList()), - locale: getSettings(state).get('locale'), - }; -}; - -export default @connect(mapStateToProps) -class Footer extends ImmutablePureComponent { - - static propTypes = { - copyright: PropTypes.string, - locale: PropTypes.string, - navlinks: ImmutablePropTypes.list, - } - - render() { - const { copyright, locale, navlinks } = this.props; - - return ( -
-
- {navlinks.map((link, idx) => { - const url = link.get('url'); - const isExternal = url.startsWith('http'); - const Comp = isExternal ? 'a' : Link; - const compProps = isExternal ? { href: url, target: '_blank' } : { to: url }; - - return ( -
- - - {link.getIn(['titleLocales', locale]) || link.get('title')} - - -
- ); - })} -
- -
- {copyright} -
-
- ); - } - -} diff --git a/app/soapbox/features/public_layout/components/footer.tsx b/app/soapbox/features/public_layout/components/footer.tsx new file mode 100644 index 000000000..69f933339 --- /dev/null +++ b/app/soapbox/features/public_layout/components/footer.tsx @@ -0,0 +1,51 @@ +import { List as ImmutableList } from 'immutable'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { getSettings } from 'soapbox/actions/settings'; +import { getSoapboxConfig } from 'soapbox/actions/soapbox'; +import { Text } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import type { FooterItem } from 'soapbox/types/soapbox'; + +const Footer = () => { + const { copyright, navlinks, locale } = useAppSelector((state) => { + const soapboxConfig = getSoapboxConfig(state); + + return { + copyright: soapboxConfig.copyright, + navlinks: (soapboxConfig.navlinks.get('homeFooter') || ImmutableList()) as ImmutableList, + locale: getSettings(state).get('locale') as string, + }; + }); + + return ( +
+
+ {navlinks.map((link, idx) => { + const url = link.get('url'); + const isExternal = url.startsWith('http'); + const Comp = (isExternal ? 'a' : Link) as 'a'; + const compProps = isExternal ? { href: url, target: '_blank' } : { to: url }; + + return ( +
+ + + {(link.getIn(['titleLocales', locale]) || link.get('title')) as string} + + +
+ ); + })} +
+ +
+ {copyright} +
+
+ ); +}; + +export default Footer; diff --git a/app/soapbox/features/ui/components/actions_modal.tsx b/app/soapbox/features/ui/components/actions_modal.tsx index e123149b6..5d7b313aa 100644 --- a/app/soapbox/features/ui/components/actions_modal.tsx +++ b/app/soapbox/features/ui/components/actions_modal.tsx @@ -40,7 +40,7 @@ const ActionsModal: React.FC = ({ status, actions, onClick, onClo className={classNames({ active, destructive })} data-method={isLogout ? 'delete' : null} > - {icon && } + {icon && }
{text}
{meta}
diff --git a/app/soapbox/features/ui/components/bundle_modal_error.js b/app/soapbox/features/ui/components/bundle_modal_error.js deleted file mode 100644 index 70d8265e3..000000000 --- a/app/soapbox/features/ui/components/bundle_modal_error.js +++ /dev/null @@ -1,53 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; - -import IconButton from '../../../components/icon_button'; - -const messages = defineMessages({ - error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' }, - retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' }, - close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, -}); - -class BundleModalError extends React.PureComponent { - - static propTypes = { - onRetry: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - } - - handleRetry = () => { - this.props.onRetry(); - } - - render() { - const { onClose, intl: { formatMessage } } = this.props; - - // Keep the markup in sync with - // (make sure they have the same dimensions) - return ( -
-
- - {formatMessage(messages.error)} -
- -
-
- -
-
-
- ); - } - -} - -export default injectIntl(BundleModalError); diff --git a/app/soapbox/features/ui/components/bundle_modal_error.tsx b/app/soapbox/features/ui/components/bundle_modal_error.tsx new file mode 100644 index 000000000..2945c442b --- /dev/null +++ b/app/soapbox/features/ui/components/bundle_modal_error.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import IconButton from 'soapbox/components/icon_button'; + +const messages = defineMessages({ + error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this page.' }, + retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' }, + close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, +}); + +interface IBundleModalError { + onRetry: () => void, + onClose: () => void, +} + +const BundleModalError: React.FC = ({ onRetry, onClose }) => { + const intl = useIntl(); + + const handleRetry = () => { + onRetry(); + }; + + return ( +
+
+ + {intl.formatMessage(messages.error)} +
+ +
+
+ +
+
+
+ ); +}; + +export default BundleModalError; From f148cda74a8adce6a127f499913c0401863ef10f Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 1 Jul 2022 16:07:01 -0400 Subject: [PATCH 0004/1726] Extend Account component --- app/soapbox/components/account.tsx | 66 +++++++++++-------- app/soapbox/components/quoted-status.tsx | 1 + app/soapbox/components/scroll-top-button.tsx | 12 ++-- app/soapbox/components/sidebar_menu.tsx | 6 +- app/soapbox/components/status.tsx | 7 +- app/soapbox/components/ui/stack/stack.tsx | 2 +- .../compose/components/reply_indicator.tsx | 1 + .../steps/suggested-accounts-step.tsx | 1 + .../features/ui/components/actions_modal.tsx | 1 + .../modals/report-modal/report-modal.tsx | 1 + .../ui/components/profile-dropdown.tsx | 2 +- 11 files changed, 60 insertions(+), 40 deletions(-) diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 2d53e6da8..72ba40e38 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -9,7 +9,7 @@ import { getAcct } from 'soapbox/utils/accounts'; import { displayFqn } from 'soapbox/utils/state'; import RelativeTimestamp from './relative_timestamp'; -import { Avatar, Emoji, HStack, Icon, IconButton, Text } from './ui'; +import { Avatar, Emoji, HStack, Icon, IconButton, Stack, Text } from './ui'; import type { Account as AccountEntity } from 'soapbox/types/entities'; @@ -57,7 +57,9 @@ interface IAccount { timestamp?: string | Date, timestampUrl?: string, futureTimestamp?: boolean, + withAccountNote?: boolean, withDate?: boolean, + withLinkToProfile?: boolean, withRelationship?: boolean, showEdit?: boolean, emoji?: string, @@ -78,7 +80,9 @@ const Account = ({ timestamp, timestampUrl, futureTimestamp = false, + withAccountNote = false, withDate = false, + withLinkToProfile = true, withRelationship = true, showEdit = false, emoji, @@ -154,12 +158,12 @@ const Account = ({ if (withDate) timestamp = account.created_at; - const LinkEl: any = showProfileHoverCard ? Link : 'div'; + const LinkEl: any = withLinkToProfile ? Link : 'div'; return (
- + {children}} @@ -202,35 +206,45 @@ const Account = ({ - - @{username} + + + @{username} - {account.favicon && ( - - )} + {account.favicon && ( + + )} - {(timestamp) ? ( - <> - · + {(timestamp) ? ( + <> + · - {timestampUrl ? ( - + {timestampUrl ? ( + + + + ) : ( - - ) : ( - - )} - - ) : null} + )} + + ) : null} - {showEdit ? ( - <> - · + {showEdit ? ( + <> + · - - - ) : null} - + + + ) : null} + + + {withAccountNote && ( + + )} +
diff --git a/app/soapbox/components/quoted-status.tsx b/app/soapbox/components/quoted-status.tsx index 5d6eb526e..82fe8860a 100644 --- a/app/soapbox/components/quoted-status.tsx +++ b/app/soapbox/components/quoted-status.tsx @@ -137,6 +137,7 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => timestamp={status.created_at} withRelationship={false} showProfileHoverCard={!compose} + withLinkToProfile={false} /> {renderReplyMentions()} diff --git a/app/soapbox/components/scroll-top-button.tsx b/app/soapbox/components/scroll-top-button.tsx index 3652296ef..8735e4296 100644 --- a/app/soapbox/components/scroll-top-button.tsx +++ b/app/soapbox/components/scroll-top-button.tsx @@ -34,6 +34,12 @@ const ScrollTopButton: React.FC = ({ const [scrolled, setScrolled] = useState(false); const autoload = settings.get('autoloadTimelines') === true; + const visible = count > 0 && scrolled; + + const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', { + 'hidden': !visible, + }); + const getScrollTop = (): number => { return (document.scrollingElement || document.documentElement).scrollTop; }; @@ -75,12 +81,6 @@ const ScrollTopButton: React.FC = ({ maybeUnload(); }, [count]); - const visible = count > 0 && scrolled; - - const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', { - 'hidden': !visible, - }); - return (
diff --git a/app/soapbox/components/sidebar_menu.tsx b/app/soapbox/components/sidebar_menu.tsx index 2940c5bfa..3164c83e3 100644 --- a/app/soapbox/components/sidebar_menu.tsx +++ b/app/soapbox/components/sidebar_menu.tsx @@ -84,7 +84,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const getAccount = makeGetAccount(); const instance = useAppSelector((state) => state.instance); const me = useAppSelector((state) => state.me); - const account = useAppSelector((state) => me ? getAccount(state, me) : null); + const account = useAppSelector((state) => me ? getAccount(state, me) : null); const otherAccounts: ImmutableList = useAppSelector((state) => getOtherAccounts(state)); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const settings = useAppSelector((state) => getSettings(state)); @@ -121,7 +121,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const renderAccount = (account: AccountEntity) => (
- +
); @@ -166,7 +166,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { - + diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 200b2de10..52ec1a93b 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -134,11 +134,11 @@ class Status extends ImmutablePureComponent { this.didShowCard = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card); } - getSnapshotBeforeUpdate(): ScrollPosition | undefined { + getSnapshotBeforeUpdate(): ScrollPosition | null { if (this.props.getScrollPosition) { - return this.props.getScrollPosition(); + return this.props.getScrollPosition() || null; } else { - return undefined; + return null; } } @@ -483,6 +483,7 @@ class Status extends ImmutablePureComponent { hideActions={!reblogElement} showEdit={!!status.edited_at} showProfileHoverCard={this.props.hoverable} + withLinkToProfile={this.props.hoverable} />
diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 3bb96d276..17b4df36e 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React from 'react'; -type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10 +type SIZES = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10 const spaces = { '0.5': 'space-y-0.5', diff --git a/app/soapbox/features/compose/components/reply_indicator.tsx b/app/soapbox/features/compose/components/reply_indicator.tsx index f47b0494b..99eb5a43f 100644 --- a/app/soapbox/features/compose/components/reply_indicator.tsx +++ b/app/soapbox/features/compose/components/reply_indicator.tsx @@ -39,6 +39,7 @@ const ReplyIndicator: React.FC = ({ status, hideActions, onCanc id={status.getIn(['account', 'id']) as string} timestamp={status.created_at} showProfileHoverCard={false} + withLinkToProfile={false} /> void }) => { // @ts-ignore: TS thinks `id` is passed to , but it isn't id={suggestion.account} showProfileHoverCard={false} + withLinkToProfile={false} />
))} diff --git a/app/soapbox/features/ui/components/actions_modal.tsx b/app/soapbox/features/ui/components/actions_modal.tsx index e123149b6..e760df031 100644 --- a/app/soapbox/features/ui/components/actions_modal.tsx +++ b/app/soapbox/features/ui/components/actions_modal.tsx @@ -60,6 +60,7 @@ const ActionsModal: React.FC = ({ status, actions, onClick, onClo key={status.account as string} id={status.account as string} showProfileHoverCard={false} + withLinkToProfile={false} timestamp={status.created_at} /> diff --git a/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx b/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx index 39575beb4..cd4f1ff08 100644 --- a/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx +++ b/app/soapbox/features/ui/components/modals/report-modal/report-modal.tsx @@ -49,6 +49,7 @@ const SelectedStatus = ({ statusId }: { statusId: string }) => { diff --git a/app/soapbox/features/ui/components/profile-dropdown.tsx b/app/soapbox/features/ui/components/profile-dropdown.tsx index 0216f3d39..65bf945bf 100644 --- a/app/soapbox/features/ui/components/profile-dropdown.tsx +++ b/app/soapbox/features/ui/components/profile-dropdown.tsx @@ -58,7 +58,7 @@ const ProfileDropdown: React.FC = ({ account, children }) => { const renderAccount = (account: AccountEntity) => { return ( - + ); }; From 1309521b9cdc8dcc20aaf81b56b362416a65d08f Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 1 Jul 2022 16:07:16 -0400 Subject: [PATCH 0005/1726] Improve Stack and HStack components --- app/soapbox/components/ui/hstack/hstack.tsx | 3 ++- app/soapbox/components/ui/stack/stack.tsx | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/hstack/hstack.tsx b/app/soapbox/components/ui/hstack/hstack.tsx index 9769ebc60..2a021d903 100644 --- a/app/soapbox/components/ui/hstack/hstack.tsx +++ b/app/soapbox/components/ui/hstack/hstack.tsx @@ -6,6 +6,7 @@ const justifyContentOptions = { center: 'justify-center', start: 'justify-start', end: 'justify-end', + around: 'justify-around', }; const alignItemsOptions = { @@ -32,7 +33,7 @@ interface IHStack { /** Extra class names on the
element. */ className?: string, /** Horizontal alignment of children. */ - justifyContent?: 'between' | 'center' | 'start' | 'end', + justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around', /** Size of the gap between elements. */ space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8, /** Whether to let the flexbox grow. */ diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 17b4df36e..9ecb4a104 100644 --- a/app/soapbox/components/ui/stack/stack.tsx +++ b/app/soapbox/components/ui/stack/stack.tsx @@ -4,6 +4,7 @@ import React from 'react'; type SIZES = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10 const spaces = { + 0: 'space-y-0', '0.5': 'space-y-0.5', 1: 'space-y-1', '1.5': 'space-y-1.5', From da98a1e1373c69b16f37245b0c6ddf6926346384 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 1 Jul 2022 16:09:07 -0400 Subject: [PATCH 0006/1726] Build Feed Suggestions --- app/soapbox/actions/suggestions.ts | 73 +++++++++++++++- app/soapbox/actions/timelines.ts | 29 ++++--- app/soapbox/components/status_list.tsx | 13 ++- .../feed-suggestions/feed-suggestions.tsx | 85 +++++++++++++++++++ .../features/follow-recommendations/index.tsx | 82 ++++++++++++++++++ .../components/account.tsx | 46 ---------- .../follow_recommendations_container.tsx | 30 ------- .../follow_recommendations_list.tsx | 44 ---------- .../features/follow_recommendations/index.tsx | 22 ----- app/soapbox/features/ui/index.tsx | 5 +- .../features/ui/util/async-components.ts | 2 +- app/soapbox/reducers/suggestions.ts | 13 +++ app/soapbox/reducers/timelines.ts | 19 ++++- app/soapbox/utils/features.ts | 5 ++ 14 files changed, 304 insertions(+), 164 deletions(-) create mode 100644 app/soapbox/features/feed-suggestions/feed-suggestions.tsx create mode 100644 app/soapbox/features/follow-recommendations/index.tsx delete mode 100644 app/soapbox/features/follow_recommendations/components/account.tsx delete mode 100644 app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx delete mode 100644 app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx delete mode 100644 app/soapbox/features/follow_recommendations/index.tsx diff --git a/app/soapbox/actions/suggestions.ts b/app/soapbox/actions/suggestions.ts index d82f81f40..86743f5aa 100644 --- a/app/soapbox/actions/suggestions.ts +++ b/app/soapbox/actions/suggestions.ts @@ -1,3 +1,5 @@ +import { AxiosResponse } from 'axios'; + import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; @@ -5,6 +7,7 @@ import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts } from './importer'; +import { insertSuggestionsIntoTimeline } from './timelines'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity } from 'soapbox/types/entities'; @@ -19,6 +22,10 @@ const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST'; const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS'; const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL'; +const SUGGESTIONS_TRUTH_FETCH_REQUEST = 'SUGGESTIONS_TRUTH_FETCH_REQUEST'; +const SUGGESTIONS_TRUTH_FETCH_SUCCESS = 'SUGGESTIONS_TRUTH_FETCH_SUCCESS'; +const SUGGESTIONS_TRUTH_FETCH_FAIL = 'SUGGESTIONS_TRUTH_FETCH_FAIL'; + const fetchSuggestionsV1 = (params: Record = {}) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: SUGGESTIONS_FETCH_REQUEST, skipLoading: true }); @@ -52,6 +59,48 @@ const fetchSuggestionsV2 = (params: Record = {}) => }); }; +export type SuggestedProfile = { + account_avatar: string + account_id: string + acct: string + display_name: string + note: string + verified: boolean +} + +const mapSuggestedProfileToAccount = (suggestedProfile: SuggestedProfile) => ({ + id: suggestedProfile.account_id, + avatar: suggestedProfile.account_avatar, + avatar_static: suggestedProfile.account_avatar, + acct: suggestedProfile.acct, + display_name: suggestedProfile.display_name, + note: suggestedProfile.note, + verified: suggestedProfile.verified, +}); + +const fetchTruthSuggestions = (params: Record = {}) => + (dispatch: AppDispatch, getState: () => RootState) => { + const next = getState().suggestions.next; + + dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true }); + + return api(getState) + .get(next ? next : '/api/v1/truth/carousels/suggestions', next ? {} : { params }) + .then((response: AxiosResponse) => { + const suggestedProfiles = response.data; + const next = getLinks(response).refs.find(link => link.rel === 'next')?.uri; + + const accounts = suggestedProfiles.map(mapSuggestedProfileToAccount); + dispatch(importFetchedAccounts(accounts)); + dispatch({ type: SUGGESTIONS_TRUTH_FETCH_SUCCESS, suggestions: suggestedProfiles, next, skipLoading: true }); + return suggestedProfiles; + }) + .catch(error => { + dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); + throw error; + }); + }; + const fetchSuggestions = (params: Record = { limit: 50 }) => (dispatch: AppDispatch, getState: () => RootState) => { const state = getState(); @@ -59,17 +108,24 @@ const fetchSuggestions = (params: Record = { limit: 50 }) => const instance = state.instance; const features = getFeatures(instance); - if (!me) return; + if (!me) return null; - if (features.suggestionsV2) { - dispatch(fetchSuggestionsV2(params)) + if (features.truthSuggestions) { + return dispatch(fetchTruthSuggestions(params)) + .then((suggestions: APIEntity[]) => { + const accountIds = suggestions.map((account) => account.account_id); + dispatch(fetchRelationships(accountIds)); + }) + .catch(() => { }); + } else if (features.suggestionsV2) { + return dispatch(fetchSuggestionsV2(params)) .then((suggestions: APIEntity[]) => { const accountIds = suggestions.map(({ account }) => account.id); dispatch(fetchRelationships(accountIds)); }) .catch(() => { }); } else if (features.suggestions) { - dispatch(fetchSuggestionsV1(params)) + return dispatch(fetchSuggestionsV1(params)) .then((accounts: APIEntity[]) => { const accountIds = accounts.map(({ id }) => id); dispatch(fetchRelationships(accountIds)); @@ -77,9 +133,14 @@ const fetchSuggestions = (params: Record = { limit: 50 }) => .catch(() => { }); } else { // Do nothing + return null; } }; +const fetchSuggestionsForTimeline = () => (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch(fetchSuggestions({ limit: 20 }))?.then(() => dispatch(insertSuggestionsIntoTimeline())); +}; + const dismissSuggestion = (accountId: string) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return; @@ -100,8 +161,12 @@ export { SUGGESTIONS_V2_FETCH_REQUEST, SUGGESTIONS_V2_FETCH_SUCCESS, SUGGESTIONS_V2_FETCH_FAIL, + SUGGESTIONS_TRUTH_FETCH_REQUEST, + SUGGESTIONS_TRUTH_FETCH_SUCCESS, + SUGGESTIONS_TRUTH_FETCH_FAIL, fetchSuggestionsV1, fetchSuggestionsV2, fetchSuggestions, + fetchSuggestionsForTimeline, dismissSuggestion, }; diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index a962b068c..4528d4bd7 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -12,21 +12,22 @@ import type { AxiosError } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity, Status } from 'soapbox/types/entities'; -const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; -const TIMELINE_DELETE = 'TIMELINE_DELETE'; -const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; +const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; +const TIMELINE_DELETE = 'TIMELINE_DELETE'; +const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE'; const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE'; const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; -const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; +const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; -const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; +const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; const TIMELINE_REPLACE = 'TIMELINE_REPLACE'; +const TIMELINE_INSERT = 'TIMELINE_INSERT'; const MAX_QUEUED_ITEMS = 40; @@ -110,9 +111,9 @@ const dequeueTimeline = (timelineId: string, expandFunc?: (lastStatusId: string) const deleteFromTimelines = (id: string) => (dispatch: AppDispatch, getState: () => RootState) => { - const accountId = getState().statuses.get(id)?.account; + const accountId = getState().statuses.get(id)?.account; const references = getState().statuses.filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); - const reblogOf = getState().statuses.getIn([id, 'reblog'], null); + const reblogOf = getState().statuses.getIn([id, 'reblog'], null); dispatch({ type: TIMELINE_DELETE, @@ -127,7 +128,7 @@ const clearTimeline = (timeline: string) => (dispatch: AppDispatch) => dispatch({ type: TIMELINE_CLEAR, timeline }); -const noOp = () => {}; +const noOp = () => { }; const noOpAsync = () => () => new Promise(f => f(undefined)); const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none') => { @@ -214,9 +215,9 @@ const expandGroupTimeline = (id: string, { maxId }: Record = {}, do const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, - any: parseTags(tags, 'any'), - all: parseTags(tags, 'all'), - none: parseTags(tags, 'none'), + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), }, done); }; @@ -259,6 +260,10 @@ const scrollTopTimeline = (timeline: string, top: boolean) => ({ top, }); +const insertSuggestionsIntoTimeline = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: TIMELINE_INSERT, timeline: 'home' }); +}; + export { TIMELINE_UPDATE, TIMELINE_DELETE, @@ -272,6 +277,7 @@ export { TIMELINE_CONNECT, TIMELINE_DISCONNECT, TIMELINE_REPLACE, + TIMELINE_INSERT, MAX_QUEUED_ITEMS, processTimelineUpdate, updateTimeline, @@ -298,4 +304,5 @@ export { connectTimeline, disconnectTimeline, scrollTopTimeline, + insertSuggestionsIntoTimeline, }; diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 7a86778eb..62f67910e 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -6,8 +6,13 @@ import { FormattedMessage } from 'react-intl'; import LoadGap from 'soapbox/components/load_gap'; import ScrollableList from 'soapbox/components/scrollable_list'; import StatusContainer from 'soapbox/containers/status_container'; +import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; +import { useAppSelector } from 'soapbox/hooks'; + +import { Button, Card, CardBody, CardTitle, HStack, Stack, Text } from './ui'; +import VerificationBadge from './verification_badge'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import type { VirtuosoHandle } from 'react-virtuoso'; @@ -77,7 +82,7 @@ const StatusList: React.FC = ({ const handleLoadOlder = useCallback(debounce(() => { const maxId = lastStatusId || statusIds.last(); if (onLoadMore && maxId) { - onLoadMore(maxId); + onLoadMore(maxId.replace('末suggestions-', '')); } }, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]); @@ -149,11 +154,17 @@ const StatusList: React.FC = ({ )); }; + const renderFeedSuggestions = (): React.ReactNode => { + return ; + }; + const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { return statusIds.toArray().map((statusId, index) => { if (statusId === null) { return renderLoadGap(index); + } else if (statusId.startsWith('末suggestions-')) { + return renderFeedSuggestions(); } else if (statusId.startsWith('末pending-')) { return renderPendingStatus(statusId); } else { diff --git a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx new file mode 100644 index 000000000..a1add25ee --- /dev/null +++ b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import VerificationBadge from 'soapbox/components/verification_badge'; +import { useAccount, useAppSelector } from 'soapbox/hooks'; + +import { Card, CardBody, CardTitle, HStack, Stack, Text } from '../../components/ui'; +import ActionButton from '../ui/components/action-button'; + +import type { Account } from 'soapbox/types/entities'; + +const SuggestionItem = ({ accountId }: { accountId: string }) => { + const account = useAccount(accountId) as Account; + + return ( + + + + {account.acct} + + + + + + {account.verified && } + + + @{account.acct} + + + + +
+ +
+
+ ); +}; + +const FeedSuggestions = () => { + const suggestedProfiles = useAppSelector((state) => state.suggestions.items); + + return ( + + + + + + View all + + + + + + {suggestedProfiles.slice(0, 4).map((suggestedProfile) => ( + + ))} + + + + ); +}; + +export default FeedSuggestions; diff --git a/app/soapbox/features/follow-recommendations/index.tsx b/app/soapbox/features/follow-recommendations/index.tsx new file mode 100644 index 000000000..18f230196 --- /dev/null +++ b/app/soapbox/features/follow-recommendations/index.tsx @@ -0,0 +1,82 @@ +import debounce from 'lodash/debounce'; +import React, { useEffect } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import { fetchSuggestions } from 'soapbox/actions/suggestions'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Stack, Text } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import Column from 'soapbox/features/ui/components/column'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; + +const FollowRecommendations: React.FC = () => { + const dispatch = useAppDispatch(); + const features = useFeatures(); + const history = useHistory(); + + const suggestions = useAppSelector((state) => state.suggestions.items); + const hasMore = useAppSelector((state) => !!state.suggestions.next); + const isLoading = useAppSelector((state) => state.suggestions.isLoading); + + const handleLoadMore = debounce(() => { + if (isLoading) { + return null; + } + + return dispatch(fetchSuggestions({ limit: 20 })); + }, 300); + + const onDone = () => { + history.push('/'); + }; + + useEffect(() => { + dispatch(fetchSuggestions({ limit: 20 })); + }, []); + + if (suggestions.size === 0 && !isLoading) { + return ( + + + + + + ); + } + + return ( + + + + {features.truthSuggestions ? ( + suggestions.map((suggestedProfile) => ( + + )) + ) : ( + suggestions.map((suggestion) => ( + + )) + )} + + + + ); +}; + +export default FollowRecommendations; diff --git a/app/soapbox/features/follow_recommendations/components/account.tsx b/app/soapbox/features/follow_recommendations/components/account.tsx deleted file mode 100644 index 67bb18f50..000000000 --- a/app/soapbox/features/follow_recommendations/components/account.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; - -import Avatar from 'soapbox/components/avatar'; -import DisplayName from 'soapbox/components/display-name'; -import Permalink from 'soapbox/components/permalink'; -import ActionButton from 'soapbox/features/ui/components/action-button'; -import { useAppSelector } from 'soapbox/hooks'; -import { makeGetAccount } from 'soapbox/selectors'; - -const getAccount = makeGetAccount(); - -const getFirstSentence = (str: string) => { - const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/); - - return arr[0]; -}; - -interface IAccount { - id: string, -} - -const Account: React.FC = ({ id }) => { - const account = useAppSelector((state) => getAccount(state, id)); - - if (!account) return null; - - return ( -
-
- -
- - - -
{getFirstSentence(account.get('note_plain'))}
-
- -
- -
-
-
- ); -}; - -export default Account; diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx b/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx deleted file mode 100644 index c3f198f94..000000000 --- a/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { FormattedMessage } from 'react-intl'; - -import { Button } from 'soapbox/components/ui'; - -import FollowRecommendationsList from './follow_recommendations_list'; - -interface IFollowRecommendationsContainer { - onDone: () => void, -} - -const FollowRecommendationsContainer: React.FC = ({ onDone }) => ( -
-
-

-

-

-
- - - -
- -
-
-); - -export default FollowRecommendationsContainer; diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx b/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx deleted file mode 100644 index e9e295d58..000000000 --- a/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { useEffect } from 'react'; -import { FormattedMessage } from 'react-intl'; -import { useDispatch } from 'react-redux'; - -import { fetchSuggestions } from 'soapbox/actions/suggestions'; -import { Spinner } from 'soapbox/components/ui'; -import { useAppSelector } from 'soapbox/hooks'; - -import Account from './account'; - -const FollowRecommendationsList: React.FC = () => { - const dispatch = useDispatch(); - - const suggestions = useAppSelector((state) => state.suggestions.items); - const isLoading = useAppSelector((state) => state.suggestions.isLoading); - - useEffect(() => { - if (suggestions.size === 0) { - dispatch(fetchSuggestions()); - } - }, []); - - if (isLoading) { - return ( -
- -
- ); - } - - return ( -
- {suggestions.size > 0 ? suggestions.map((suggestion) => ( - - )) : ( -
- -
- )} -
- ); -}; - -export default FollowRecommendationsList; diff --git a/app/soapbox/features/follow_recommendations/index.tsx b/app/soapbox/features/follow_recommendations/index.tsx deleted file mode 100644 index 444504532..000000000 --- a/app/soapbox/features/follow_recommendations/index.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { useHistory } from 'react-router-dom'; - -import Column from 'soapbox/features/ui/components/column'; - -import FollowRecommendationsContainer from './components/follow_recommendations_container'; - -const FollowRecommendations: React.FC = () => { - const history = useHistory(); - - const onDone = () => { - history.push('/'); - }; - - return ( - - - - ); -}; - -export default FollowRecommendations; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 8cd5218d6..2251c5ab0 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -19,6 +19,7 @@ import { expandNotifications } from 'soapbox/actions/notifications'; import { register as registerPushNotifications } from 'soapbox/actions/push_notifications'; import { fetchScheduledStatuses } from 'soapbox/actions/scheduled_statuses'; import { connectUserStream } from 'soapbox/actions/streaming'; +import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; import Icon from 'soapbox/components/icon'; import SidebarNavigation from 'soapbox/components/sidebar-navigation'; @@ -441,7 +442,9 @@ const UI: React.FC = ({ children }) => { const loadAccountData = () => { if (!account) return; - dispatch(expandHomeTimeline()); + dispatch(expandHomeTimeline({}, () => { + dispatch(fetchSuggestionsForTimeline()); + })); dispatch(expandNotifications()) // @ts-ignore diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index a6379b130..30420cf27 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -455,7 +455,7 @@ export function WhoToFollowPanel() { } export function FollowRecommendations() { - return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations'); + return import(/* webpackChunkName: "features/follow-recommendations" */'../../follow-recommendations'); } export function Directory() { diff --git a/app/soapbox/reducers/suggestions.ts b/app/soapbox/reducers/suggestions.ts index fc095113f..8335ff9ec 100644 --- a/app/soapbox/reducers/suggestions.ts +++ b/app/soapbox/reducers/suggestions.ts @@ -10,8 +10,11 @@ import { SUGGESTIONS_V2_FETCH_REQUEST, SUGGESTIONS_V2_FETCH_SUCCESS, SUGGESTIONS_V2_FETCH_FAIL, + SUGGESTIONS_TRUTH_FETCH_SUCCESS, } from 'soapbox/actions/suggestions'; +import { SuggestedProfile } from '../actions/suggestions'; + import type { AnyAction } from 'redux'; import type { APIEntity } from 'soapbox/types/entities'; @@ -53,6 +56,14 @@ const importSuggestions = (state: State, suggestions: APIEntities, next: string }); }; +const importTruthSuggestions = (state: State, suggestions: SuggestedProfile[], next: string | null) => { + return state.withMutations(state => { + state.update('items', items => items.concat(suggestions.map(x => ({ ...x, account: x.account_id })).map(suggestion => SuggestionRecord(suggestion)))); + state.set('isLoading', false); + state.set('next', next); + }); +}; + const dismissAccount = (state: State, accountId: string) => { return state.update('items', items => items.filterNot(item => item.account === accountId)); }; @@ -70,6 +81,8 @@ export default function suggestionsReducer(state: State = ReducerRecord(), actio return importAccounts(state, action.accounts); case SUGGESTIONS_V2_FETCH_SUCCESS: return importSuggestions(state, action.suggestions, action.next); + case SUGGESTIONS_TRUTH_FETCH_SUCCESS: + return importTruthSuggestions(state, action.suggestions, action.next); case SUGGESTIONS_FETCH_FAIL: case SUGGESTIONS_V2_FETCH_FAIL: return state.set('isLoading', false); diff --git a/app/soapbox/reducers/timelines.ts b/app/soapbox/reducers/timelines.ts index a1f33417f..05961ba71 100644 --- a/app/soapbox/reducers/timelines.ts +++ b/app/soapbox/reducers/timelines.ts @@ -5,6 +5,7 @@ import { Record as ImmutableRecord, fromJS, } from 'immutable'; +import sample from 'lodash/sample'; import { ACCOUNT_BLOCK_SUCCESS, @@ -30,6 +31,7 @@ import { MAX_QUEUED_ITEMS, TIMELINE_SCROLL_TOP, TIMELINE_REPLACE, + TIMELINE_INSERT, } from '../actions/timelines'; import type { AnyAction } from 'redux'; @@ -37,7 +39,7 @@ import type { StatusVisibility } from 'soapbox/normalizers/status'; import type { APIEntity, Status } from 'soapbox/types/entities'; const TRUNCATE_LIMIT = 40; -const TRUNCATE_SIZE = 20; +const TRUNCATE_SIZE = 20; const TimelineRecord = ImmutableRecord({ unread: 0, @@ -115,7 +117,7 @@ const expandNormalizedTimeline = (state: State, timelineId: string, statuses: Im }; const updateTimeline = (state: State, timelineId: string, statusId: string) => { - const top = state.get(timelineId)?.top; + const top = state.get(timelineId)?.top; const oldIds = state.get(timelineId)?.items || ImmutableOrderedSet(); const unread = state.get(timelineId)?.unread || 0; @@ -135,8 +137,8 @@ const updateTimeline = (state: State, timelineId: string, statusId: string) => { }; const updateTimelineQueue = (state: State, timelineId: string, statusId: string) => { - const queuedIds = state.get(timelineId)?.queuedItems || ImmutableOrderedSet(); - const listedIds = state.get(timelineId)?.items || ImmutableOrderedSet(); + const queuedIds = state.get(timelineId)?.queuedItems || ImmutableOrderedSet(); + const listedIds = state.get(timelineId)?.items || ImmutableOrderedSet(); const queuedCount = state.get(timelineId)?.totalQueuedItemsCount || 0; if (queuedIds.includes(statusId)) return state; @@ -353,6 +355,15 @@ export default function timelines(state: State = initialState, action: AnyAction timeline.set('items', ImmutableOrderedSet([])); })) .update('home', TimelineRecord(), timeline => timeline.set('feedAccountId', action.accountId)); + case TIMELINE_INSERT: + return state.update(action.timeline, TimelineRecord(), timeline => timeline.withMutations(timeline => { + timeline.update('items', oldIds => { + const oldIdsArray = oldIds.toArray(); + const positionInTimeline = sample([5, 6, 7, 8, 9]) as number; + oldIdsArray.splice(positionInTimeline, 0, `末suggestions-${oldIds.last()}`); + return ImmutableOrderedSet(oldIdsArray); + }); + })); default: return state; } diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 206eaf067..0afe0c254 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -577,6 +577,11 @@ const getInstanceFeatures = (instance: Instance) => { v.software === TRUTHSOCIAL, ]), + /** + * Supports Truth suggestions. + */ + truthSuggestions: v.software === TRUTHSOCIAL, + /** * Whether the backend allows adding users you don't follow to lists. * @see POST /api/v1/lists/:id/accounts From b9d05f546c3b19d976f0c7c7eeaf89b49cdc42a8 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 1 Jul 2022 16:16:34 -0400 Subject: [PATCH 0007/1726] Lint --- app/soapbox/components/status_list.tsx | 4 ---- app/soapbox/features/follow-recommendations/index.tsx | 4 ---- 2 files changed, 8 deletions(-) diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 62f67910e..35edc9283 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -9,10 +9,6 @@ import StatusContainer from 'soapbox/containers/status_container'; import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; -import { useAppSelector } from 'soapbox/hooks'; - -import { Button, Card, CardBody, CardTitle, HStack, Stack, Text } from './ui'; -import VerificationBadge from './verification_badge'; import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; import type { VirtuosoHandle } from 'react-virtuoso'; diff --git a/app/soapbox/features/follow-recommendations/index.tsx b/app/soapbox/features/follow-recommendations/index.tsx index 18f230196..7b68192de 100644 --- a/app/soapbox/features/follow-recommendations/index.tsx +++ b/app/soapbox/features/follow-recommendations/index.tsx @@ -27,10 +27,6 @@ const FollowRecommendations: React.FC = () => { return dispatch(fetchSuggestions({ limit: 20 })); }, 300); - const onDone = () => { - history.push('/'); - }; - useEffect(() => { dispatch(fetchSuggestions({ limit: 20 })); }, []); From 41d5769aa0070078640c64744349ce2a5dacd6ec Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 1 Jul 2022 16:24:04 -0400 Subject: [PATCH 0008/1726] Lint --- app/soapbox/features/follow-recommendations/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/soapbox/features/follow-recommendations/index.tsx b/app/soapbox/features/follow-recommendations/index.tsx index 7b68192de..7ad9c7ad3 100644 --- a/app/soapbox/features/follow-recommendations/index.tsx +++ b/app/soapbox/features/follow-recommendations/index.tsx @@ -1,7 +1,6 @@ import debounce from 'lodash/debounce'; import React, { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useHistory } from 'react-router-dom'; import { fetchSuggestions } from 'soapbox/actions/suggestions'; import ScrollableList from 'soapbox/components/scrollable_list'; @@ -13,7 +12,6 @@ import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; const FollowRecommendations: React.FC = () => { const dispatch = useAppDispatch(); const features = useFeatures(); - const history = useHistory(); const suggestions = useAppSelector((state) => state.suggestions.items); const hasMore = useAppSelector((state) => !!state.suggestions.next); From b46ccc8b3e4f92b0f6431ccd4f2918b229534d69 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 6 Jul 2022 08:10:10 -0400 Subject: [PATCH 0009/1726] Add tests for suggestions action --- .../actions/__tests__/suggestions.test.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 app/soapbox/actions/__tests__/suggestions.test.ts diff --git a/app/soapbox/actions/__tests__/suggestions.test.ts b/app/soapbox/actions/__tests__/suggestions.test.ts new file mode 100644 index 000000000..76e53d576 --- /dev/null +++ b/app/soapbox/actions/__tests__/suggestions.test.ts @@ -0,0 +1,108 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { __stub } from 'soapbox/api'; +import { mockStore } from 'soapbox/jest/test-helpers'; +import rootReducer from 'soapbox/reducers'; + +import { + fetchSuggestions, +} from '../suggestions'; + +let store; +let state; + +describe('fetchSuggestions()', () => { + describe('with Truth Social software', () => { + beforeEach(() => { + state = rootReducer(undefined, {}) + .set('instance', { + version: '3.4.1 (compatible; TruthSocial 1.0.0)', + pleroma: ImmutableMap({ + metadata: ImmutableMap({ + features: [], + }), + }), + }) + .set('me', '123'); + store = mockStore(state); + }); + + describe('with a successful API request', () => { + const response = [ + { + account_id: '1', + acct: 'jl', + account_avatar: 'https://example.com/some.jpg', + display_name: 'justin', + note: '

note

', + verified: true, + }, + ]; + + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions').reply(200, response, { + link: '; rel=\'prev\'', + }); + }); + }); + + it('dispatches the correct actions', async() => { + const expectedActions = [ + { type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true }, + { + type: 'ACCOUNTS_IMPORT', accounts: [{ + acct: response[0].acct, + avatar: response[0].account_avatar, + avatar_static: response[0].account_avatar, + id: response[0].account_id, + note: response[0].note, + verified: response[0].verified, + display_name: response[0].display_name, + }], + }, + { + type: 'SUGGESTIONS_TRUTH_FETCH_SUCCESS', + suggestions: response, + next: undefined, + skipLoading: true, + }, + { + type: 'RELATIONSHIPS_FETCH_REQUEST', + skipLoading: true, + ids: [response[0].account_id], + }, + ]; + await store.dispatch(fetchSuggestions()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/suggestions').networkError(); + }); + }); + + it('should dispatch the correct actions', async() => { + const expectedActions = [ + { type: 'SUGGESTIONS_V2_FETCH_REQUEST', skipLoading: true }, + { + type: 'SUGGESTIONS_V2_FETCH_FAIL', + error: new Error('Network Error'), + skipLoading: true, + skipAlert: true, + }, + ]; + + await store.dispatch(fetchSuggestions()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + }); +}); From 7b9a9c8e3481d0d8632d0cc79ff09f1f92d491af Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 6 Jul 2022 08:41:15 -0400 Subject: [PATCH 0010/1726] Fix overflow of text in feed filtering --- app/soapbox/features/feed-filtering/feed-carousel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index 6a50f39df..8fcf643c1 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -41,7 +41,7 @@ const CarouselItem = ({ avatar }: { avatar: any }) => { />
- {avatar.acct} + {avatar.acct} ); From 2f465fbc141584b687afabed0f92a2cd0923f0f6 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 6 Jul 2022 08:53:58 -0400 Subject: [PATCH 0011/1726] Use same value as 'showProfileHoverCard' --- app/soapbox/components/quoted-status.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/quoted-status.tsx b/app/soapbox/components/quoted-status.tsx index 82fe8860a..431263bd1 100644 --- a/app/soapbox/components/quoted-status.tsx +++ b/app/soapbox/components/quoted-status.tsx @@ -137,7 +137,7 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => timestamp={status.created_at} withRelationship={false} showProfileHoverCard={!compose} - withLinkToProfile={false} + withLinkToProfile={!compose} /> {renderReplyMentions()} From c664844e3c04d167d6fe56e57467772bfddc4a96 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 6 Jul 2022 09:04:21 -0400 Subject: [PATCH 0012/1726] Persist suggestions when filtering feed --- app/soapbox/actions/timelines.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index 4528d4bd7..74622d180 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -142,7 +142,7 @@ const replaceHomeTimeline = ( { maxId }: Record = {}, ) => (dispatch: AppDispatch, _getState: () => RootState) => { dispatch({ type: TIMELINE_REPLACE, accountId }); - dispatch(expandHomeTimeline({ accountId, maxId })); + dispatch(expandHomeTimeline({ accountId, maxId }, () => dispatch(insertSuggestionsIntoTimeline()))); }; const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => From b70627168766b7065b110fae5596671bc77635b8 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 6 Jul 2022 09:04:26 -0400 Subject: [PATCH 0013/1726] Intl --- .../features/feed-suggestions/feed-suggestions.tsx | 11 +++++++++-- app/soapbox/features/follow-recommendations/index.tsx | 11 ++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx index a1add25ee..a5740abf9 100644 --- a/app/soapbox/features/feed-suggestions/feed-suggestions.tsx +++ b/app/soapbox/features/feed-suggestions/feed-suggestions.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import VerificationBadge from 'soapbox/components/verification_badge'; @@ -9,6 +10,11 @@ import ActionButton from '../ui/components/action-button'; import type { Account } from 'soapbox/types/entities'; +const messages = defineMessages({ + heading: { id: 'feedSuggestions.heading', defaultMessage: 'Suggested profiles' }, + viewAll: { id: 'feedSuggestions.viewAll', defaultMessage: 'View all' }, +}); + const SuggestionItem = ({ accountId }: { accountId: string }) => { const account = useAccount(accountId) as Account; @@ -52,18 +58,19 @@ const SuggestionItem = ({ accountId }: { accountId: string }) => { }; const FeedSuggestions = () => { + const intl = useIntl(); const suggestedProfiles = useAppSelector((state) => state.suggestions.items); return ( - + - View all + {intl.formatMessage(messages.viewAll)} diff --git a/app/soapbox/features/follow-recommendations/index.tsx b/app/soapbox/features/follow-recommendations/index.tsx index 7ad9c7ad3..235142aa4 100644 --- a/app/soapbox/features/follow-recommendations/index.tsx +++ b/app/soapbox/features/follow-recommendations/index.tsx @@ -1,6 +1,6 @@ import debounce from 'lodash/debounce'; import React, { useEffect } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { fetchSuggestions } from 'soapbox/actions/suggestions'; import ScrollableList from 'soapbox/components/scrollable_list'; @@ -9,8 +9,13 @@ import AccountContainer from 'soapbox/containers/account_container'; import Column from 'soapbox/features/ui/components/column'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; +const messages = defineMessages({ + heading: { id: 'followRecommendations.heading', defaultMessage: 'Suggested profiles' }, +}); + const FollowRecommendations: React.FC = () => { const dispatch = useAppDispatch(); + const intl = useIntl(); const features = useFeatures(); const suggestions = useAppSelector((state) => state.suggestions.items); @@ -31,7 +36,7 @@ const FollowRecommendations: React.FC = () => { if (suggestions.size === 0 && !isLoading) { return ( - + @@ -40,7 +45,7 @@ const FollowRecommendations: React.FC = () => { } return ( - + Date: Wed, 6 Jul 2022 14:36:28 -0400 Subject: [PATCH 0014/1726] Improve alignment of actions --- app/soapbox/features/follow-recommendations/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/soapbox/features/follow-recommendations/index.tsx b/app/soapbox/features/follow-recommendations/index.tsx index 235142aa4..7fda03c7a 100644 --- a/app/soapbox/features/follow-recommendations/index.tsx +++ b/app/soapbox/features/follow-recommendations/index.tsx @@ -61,6 +61,7 @@ const FollowRecommendations: React.FC = () => { id={suggestedProfile.account} withAccountNote showProfileHoverCard={false} + actionAlignment='top' /> )) ) : ( From b3b6a7e4bce394fbc9e3d68bbc322e4cce69d1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 6 Jul 2022 23:25:19 +0200 Subject: [PATCH 0015/1726] Announcements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/announcements.ts | 187 ++++++++++++++++++ app/soapbox/actions/streaming.ts | 19 +- .../security/mfa/disable_otp_form.tsx | 2 +- .../features/security/mfa/enable_otp_form.tsx | 2 +- .../ui/components/announcements-panel.tsx | 161 +++++++++++++++ app/soapbox/features/ui/index.tsx | 3 + .../features/ui/util/async-components.ts | 4 + app/soapbox/normalizers/announcement.ts | 65 ++++++ .../normalizers/announcement_reaction.ts | 22 +++ app/soapbox/normalizers/index.ts | 2 + app/soapbox/pages/home_page.tsx | 6 + app/soapbox/reducers/announcements.ts | 112 +++++++++++ app/soapbox/reducers/index.ts | 2 + app/soapbox/types/entities.ts | 6 + app/soapbox/utils/features.ts | 5 + 15 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 app/soapbox/actions/announcements.ts create mode 100644 app/soapbox/features/ui/components/announcements-panel.tsx create mode 100644 app/soapbox/normalizers/announcement.ts create mode 100644 app/soapbox/normalizers/announcement_reaction.ts create mode 100644 app/soapbox/reducers/announcements.ts diff --git a/app/soapbox/actions/announcements.ts b/app/soapbox/actions/announcements.ts new file mode 100644 index 000000000..99e28e8fe --- /dev/null +++ b/app/soapbox/actions/announcements.ts @@ -0,0 +1,187 @@ +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; +export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; +export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; +export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; +export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; + +export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST'; +export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS'; +export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL'; + +export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; +export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; + +export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; +export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; + +export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; + +export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW'; + +const noOp = () => {}; + +export const fetchAnnouncements = (done = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchAnnouncementsRequest()); + + return api(getState).get('/api/v1/announcements').then(response => { + dispatch(fetchAnnouncementsSuccess(response.data)); + }).catch(error => { + dispatch(fetchAnnouncementsFail(error)); + }).finally(() => { + done(); + }); + }; + +export const fetchAnnouncementsRequest = () => ({ + type: ANNOUNCEMENTS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchAnnouncementsSuccess = (announcements: APIEntity) => ({ + type: ANNOUNCEMENTS_FETCH_SUCCESS, + announcements, + skipLoading: true, +}); + +export const fetchAnnouncementsFail = (error: AxiosError) => ({ + type: ANNOUNCEMENTS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const updateAnnouncements = (announcement: APIEntity) => ({ + type: ANNOUNCEMENTS_UPDATE, + announcement: announcement, +}); + +export const dismissAnnouncement = (announcementId: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(dismissAnnouncementRequest(announcementId)); + + api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { + dispatch(dismissAnnouncementSuccess(announcementId)); + }).catch(error => { + dispatch(dismissAnnouncementFail(announcementId, error)); + }); + }; + +export const dismissAnnouncementRequest = (announcementId: string) => ({ + type: ANNOUNCEMENTS_DISMISS_REQUEST, + id: announcementId, +}); + +export const dismissAnnouncementSuccess = (announcementId: string) => ({ + type: ANNOUNCEMENTS_DISMISS_SUCCESS, + id: announcementId, +}); + +export const dismissAnnouncementFail = (announcementId: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_DISMISS_FAIL, + id: announcementId, + error, +}); + +export const addReaction = (announcementId: string, name: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const announcement = getState().announcements.items.find(x => x.get('id') === announcementId); + + let alreadyAdded = false; + + if (announcement) { + const reaction = announcement.reactions.find(x => x.name === name); + if (reaction && reaction.me) { + alreadyAdded = true; + } + } + + if (!alreadyAdded) { + dispatch(addReactionRequest(announcementId, name, alreadyAdded)); + } + + api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(announcementId, name, err)); + } + }); + }; + +export const addReactionRequest = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionSuccess = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionFail = (announcementId: string, name: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const removeReaction = (announcementId: string, name: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(removeReactionRequest(announcementId, name)); + + api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(removeReactionSuccess(announcementId, name)); + }).catch(err => { + dispatch(removeReactionFail(announcementId, name, err)); + }); + }; + +export const removeReactionRequest = (announcementId: string, name: string) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionSuccess = (announcementId: string, name: string) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionFail = (announcementId: string, name: string, error: AxiosError) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const updateReaction = (reaction: APIEntity) => ({ + type: ANNOUNCEMENTS_REACTION_UPDATE, + reaction, +}); + +export const toggleShowAnnouncements = () => ({ + type: ANNOUNCEMENTS_TOGGLE_SHOW, +}); + +export const deleteAnnouncement = (id: string) => ({ + type: ANNOUNCEMENTS_DELETE, + id, +}); diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index c47667197..c77daa6ac 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -3,6 +3,12 @@ import messages from 'soapbox/locales/messages'; import { connectStream } from '../stream'; +import { + deleteAnnouncement, + fetchAnnouncements, + updateAnnouncements, + updateReaction as updateAnnouncementsReaction, +} from './announcements'; import { updateConversations } from './conversations'; import { fetchFilters } from './filters'; import { updateNotificationsQueue, expandNotifications } from './notifications'; @@ -100,13 +106,24 @@ const connectTimelineStream = ( case 'pleroma:follow_relationships_update': dispatch(updateFollowRelationships(JSON.parse(data.payload))); break; + case 'announcement': + dispatch(updateAnnouncements(JSON.parse(data.payload))); + break; + case 'announcement.reaction': + dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); + break; + case 'announcement.delete': + dispatch(deleteAnnouncement(data.payload)); + break; } }, }; }); const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => void) => - dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({} as any, done)))); + dispatch(expandHomeTimeline({}, () => + dispatch(expandNotifications({}, () => + dispatch(fetchAnnouncements(done)))))); const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); diff --git a/app/soapbox/features/security/mfa/disable_otp_form.tsx b/app/soapbox/features/security/mfa/disable_otp_form.tsx index ba0e5fbbf..63a551f31 100644 --- a/app/soapbox/features/security/mfa/disable_otp_form.tsx +++ b/app/soapbox/features/security/mfa/disable_otp_form.tsx @@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar'; import { Button, Form, FormGroup, Input, FormActions, Stack, Text } from 'soapbox/components/ui'; import { useAppDispatch } from 'soapbox/hooks'; -const messages = defineMessages({ +const messages = defineMessages({ mfa_setup_disable_button: { id: 'column.mfa_disable_button', defaultMessage: 'Disable' }, disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' }, mfaDisableSuccess: { id: 'mfa.disable.success_message', defaultMessage: 'MFA disabled' }, diff --git a/app/soapbox/features/security/mfa/enable_otp_form.tsx b/app/soapbox/features/security/mfa/enable_otp_form.tsx index a5608bf18..98dae6519 100644 --- a/app/soapbox/features/security/mfa/enable_otp_form.tsx +++ b/app/soapbox/features/security/mfa/enable_otp_form.tsx @@ -7,7 +7,7 @@ import snackbar from 'soapbox/actions/snackbar'; import { Button, FormActions, Spinner, Stack, Text } from 'soapbox/components/ui'; import { useAppDispatch } from 'soapbox/hooks'; -const messages = defineMessages({ +const messages = defineMessages({ mfaCancelButton: { id: 'column.mfa_cancel', defaultMessage: 'Cancel' }, mfaSetupButton: { id: 'column.mfa_setup', defaultMessage: 'Proceed to Setup' }, codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' }, diff --git a/app/soapbox/features/ui/components/announcements-panel.tsx b/app/soapbox/features/ui/components/announcements-panel.tsx new file mode 100644 index 000000000..4fa106a56 --- /dev/null +++ b/app/soapbox/features/ui/components/announcements-panel.tsx @@ -0,0 +1,161 @@ +import classNames from 'classnames'; +import React, { useEffect, useRef, useState } from 'react'; +import { FormattedDate, FormattedMessage } from 'react-intl'; +import { useHistory } from 'react-router-dom'; +import ReactSwipeableViews from 'react-swipeable-views'; + +import { Card, HStack, Widget } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; + +const AnnouncementContent = ({ announcement }: { announcement: AnnouncementEntity }) => { + const history = useHistory(); + + const node = useRef(null); + + useEffect(() => { + updateLinks(); + }); + + const onMentionClick = (mention: MentionEntity, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/@${mention.acct}`); + } + }; + + const onHashtagClick = (hashtag: string, e: MouseEvent) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/tags/${hashtag}`); + } + }; + + /** For regular links, just stop propogation */ + const onLinkClick = (e: MouseEvent) => { + e.stopPropagation(); + }; + + const updateLinks = () => { + if (!node.current) return; + + const links = node.current.querySelectorAll('a'); + + links.forEach(link => { + // Skip already processed + if (link.classList.contains('status-link')) return; + + // Add attributes + link.classList.add('status-link'); + link.setAttribute('rel', 'nofollow noopener'); + link.setAttribute('target', '_blank'); + + const mention = announcement.mentions.find(mention => link.href === `${mention.url}`); + + // Add event listeners on mentions and hashtags + if (mention) { + link.addEventListener('click', onMentionClick.bind(link, mention), false); + link.setAttribute('title', mention.acct); + } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { + link.addEventListener('click', onHashtagClick.bind(link, link.text), false); + } else { + link.setAttribute('title', link.href); + link.addEventListener('click', onLinkClick.bind(link), false); + } + }); + }; + + + return ( +
+ ); +}; + +const Announcement = ({ announcement }: { announcement: AnnouncementEntity }) => { + const startsAt = announcement.starts_at && new Date(announcement.starts_at); + const endsAt = announcement.ends_at && new Date(announcement.ends_at); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear(); + const skipTime = announcement.all_day; + + return ( +
+ + {hasTimeRange && · - } + + + + + {/* */} +
+ ); +}; + +const AnnouncementsPanel = () => { + // const dispatch = useDispatch(); + const [index, setIndex] = useState(0); + + const announcements = useAppSelector((state) => state.announcements.items); + + if (announcements.size === 0) return null; + + const handleChangeIndex = (index: number) => { + setIndex(index % announcements.size); + }; + + return ( + }> + + + {announcements.map((announcement) => ( + + )).reverse()} + + + + {announcements.map((_, i) => ( + + ); + + +}; + +const ReactionsBar = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const handleEmojiPick = data => { + addReaction(announcementId, data.native.replace(/:/g, '')); + }; + + const willEnter = () => reduceMotion ? 1 : 0; + + const willLeave = () => reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }); + + const visibleReactions = reactions.filter(x => x.get('count') > 0); + + const styles = visibleReactions.map(reaction => ({ + key: reaction.get('name'), + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} + + {visibleReactions.size < 8 && } +
+ )} +
+ ); +}; + +const Announcement = ({ announcement, addReaction, removeReaction, emojiMap }: { announcement: AnnouncementEntity }) => { const startsAt = announcement.starts_at && new Date(announcement.starts_at); const endsAt = announcement.ends_at && new Date(announcement.ends_at); const now = new Date(); @@ -102,20 +222,24 @@ const Announcement = ({ announcement }: { announcement: AnnouncementEntity }) => {/* */}
); }; const AnnouncementsPanel = () => { - // const dispatch = useDispatch(); + const dispatch = useAppDispatch(); + // const emojiMap = useAppSelector(state => customEmojiMap(state)); const [index, setIndex] = useState(0); const announcements = useAppSelector((state) => state.announcements.items); + const addReaction = (id: string, name: string) => dispatch(addReaction(id, name)); + const removeReaction = (id: string, name: string) => dispatch(removeReaction(id, name)); + if (announcements.size === 0) return null; const handleChangeIndex = (index: number) => { @@ -130,9 +254,9 @@ const AnnouncementsPanel = () => { From cb26a515a2cc32de58cd0c1459465c9b32cad6dd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Jul 2022 15:08:01 -0500 Subject: [PATCH 0018/1726] Fix suggestions test types --- app/soapbox/actions/__tests__/suggestions.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/soapbox/actions/__tests__/suggestions.test.ts b/app/soapbox/actions/__tests__/suggestions.test.ts index 76e53d576..3c8d0c95a 100644 --- a/app/soapbox/actions/__tests__/suggestions.test.ts +++ b/app/soapbox/actions/__tests__/suggestions.test.ts @@ -1,28 +1,28 @@ import { Map as ImmutableMap } from 'immutable'; import { __stub } from 'soapbox/api'; -import { mockStore } from 'soapbox/jest/test-helpers'; -import rootReducer from 'soapbox/reducers'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; +import { normalizeInstance } from 'soapbox/normalizers'; import { fetchSuggestions, } from '../suggestions'; -let store; +let store: ReturnType; let state; describe('fetchSuggestions()', () => { describe('with Truth Social software', () => { beforeEach(() => { - state = rootReducer(undefined, {}) - .set('instance', { + state = rootState + .set('instance', normalizeInstance({ version: '3.4.1 (compatible; TruthSocial 1.0.0)', pleroma: ImmutableMap({ metadata: ImmutableMap({ features: [], }), }), - }) + })) .set('me', '123'); store = mockStore(state); }); From d5a6e978e634d1c00219604ea784115337088df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 9 Jul 2022 23:47:58 +0200 Subject: [PATCH 0019/1726] Announcements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/animated-number.tsx | 63 ++++ .../announcements/announcement-content.tsx | 80 +++++ .../components/announcements/announcement.tsx | 70 +++++ .../announcements/announcements-panel.tsx | 69 +++++ .../components/announcements/emoji.tsx | 51 ++++ .../components/announcements/reaction.tsx | 66 ++++ .../announcements/reactions-bar.tsx | 65 ++++ .../components/emoji_picker_dropdown.js | 22 +- .../emoji_picker_dropdown_container.js | 6 +- .../ui/components/announcements-panel.tsx | 285 ------------------ .../features/ui/util/async-components.ts | 2 +- app/soapbox/normalizers/announcement.ts | 13 + app/soapbox/reducers/announcements.ts | 1 - 13 files changed, 494 insertions(+), 299 deletions(-) create mode 100644 app/soapbox/components/animated-number.tsx create mode 100644 app/soapbox/components/announcements/announcement-content.tsx create mode 100644 app/soapbox/components/announcements/announcement.tsx create mode 100644 app/soapbox/components/announcements/announcements-panel.tsx create mode 100644 app/soapbox/components/announcements/emoji.tsx create mode 100644 app/soapbox/components/announcements/reaction.tsx create mode 100644 app/soapbox/components/announcements/reactions-bar.tsx delete mode 100644 app/soapbox/features/ui/components/announcements-panel.tsx diff --git a/app/soapbox/components/animated-number.tsx b/app/soapbox/components/animated-number.tsx new file mode 100644 index 000000000..0f6908fde --- /dev/null +++ b/app/soapbox/components/animated-number.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import { FormattedNumber } from 'react-intl'; +import { TransitionMotion, spring } from 'react-motion'; + +import { useSettings } from 'soapbox/hooks'; + +const obfuscatedCount = (count: number) => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + +interface IAnimatedNumber { + value: number; + obfuscate?: boolean; +} + +const AnimatedNumber: React.FC = ({ value, obfuscate }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const [direction, setDirection] = useState(1); + const [displayedValue, setDisplayedValue] = useState(value); + + useEffect(() => { + if (displayedValue !== undefined) { + if (value > displayedValue) setDirection(1); + else if (value < displayedValue) setDirection(-1); + } + setDisplayedValue(value); + }, [value]); + + const willEnter = () => ({ y: -1 * direction }); + + const willLeave = () => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }); + + if (reduceMotion) { + return obfuscate ? <>{obfuscatedCount(displayedValue)} : ; + } + + const styles = [{ + key: `${displayedValue}`, + data: displayedValue, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + + {items => ( + + {items.map(({ key, data, style }) => ( + 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : } + ))} + + )} + + ); +}; + +export default AnimatedNumber; \ No newline at end of file diff --git a/app/soapbox/components/announcements/announcement-content.tsx b/app/soapbox/components/announcements/announcement-content.tsx new file mode 100644 index 000000000..01c21b39f --- /dev/null +++ b/app/soapbox/components/announcements/announcement-content.tsx @@ -0,0 +1,80 @@ +import React, { useEffect, useRef } from 'react'; +import { useHistory } from 'react-router-dom'; + +import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; + +interface IAnnouncementContent { + announcement: AnnouncementEntity; +} + +const AnnouncementContent: React.FC = ({ announcement }) => { + const history = useHistory(); + + const node = useRef(null); + + useEffect(() => { + updateLinks(); + }); + + const onMentionClick = (mention: MentionEntity, e: MouseEvent) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/@${mention.acct}`); + } + }; + + const onHashtagClick = (hashtag: string, e: MouseEvent) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + history.push(`/tags/${hashtag}`); + } + }; + + /** For regular links, just stop propogation */ + const onLinkClick = (e: MouseEvent) => { + e.stopPropagation(); + }; + + const updateLinks = () => { + if (!node.current) return; + + const links = node.current.querySelectorAll('a'); + + links.forEach(link => { + // Skip already processed + if (link.classList.contains('status-link')) return; + + // Add attributes + link.classList.add('status-link'); + link.setAttribute('rel', 'nofollow noopener'); + link.setAttribute('target', '_blank'); + + const mention = announcement.mentions.find(mention => link.href === `${mention.url}`); + + // Add event listeners on mentions and hashtags + if (mention) { + link.addEventListener('click', onMentionClick.bind(link, mention), false); + link.setAttribute('title', mention.acct); + } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { + link.addEventListener('click', onHashtagClick.bind(link, link.text), false); + } else { + link.setAttribute('title', link.href); + link.addEventListener('click', onLinkClick.bind(link), false); + } + }); + }; + + return ( +
+ ); +}; + +export default AnnouncementContent; diff --git a/app/soapbox/components/announcements/announcement.tsx b/app/soapbox/components/announcements/announcement.tsx new file mode 100644 index 000000000..4f8ffa3bc --- /dev/null +++ b/app/soapbox/components/announcements/announcement.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { FormattedDate } from 'react-intl'; + +import { Stack, Text } from 'soapbox/components/ui'; + +import AnnouncementContent from './announcement-content'; +import ReactionsBar from './reactions-bar'; + +import type { Map as ImmutableMap } from 'immutable'; +import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities'; + +interface IAnnouncement { + announcement: AnnouncementEntity; + addReaction: (id: string, name: string) => void; + removeReaction: (id: string, name: string) => void; + emojiMap: ImmutableMap>; +} + +const Announcement: React.FC = ({ announcement, addReaction, removeReaction, emojiMap }) => { + const startsAt = announcement.starts_at && new Date(announcement.starts_at); + const endsAt = announcement.ends_at && new Date(announcement.ends_at); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear(); + const skipTime = announcement.all_day; + + return ( +
+ + {hasTimeRange && ( + + + {' '} + - + {' '} + + + )} + + + + + +
+ ); +}; + +export default Announcement; diff --git a/app/soapbox/components/announcements/announcements-panel.tsx b/app/soapbox/components/announcements/announcements-panel.tsx new file mode 100644 index 000000000..ade25057f --- /dev/null +++ b/app/soapbox/components/announcements/announcements-panel.tsx @@ -0,0 +1,69 @@ +import classNames from 'classnames'; +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; +import { createSelector } from 'reselect'; + +import { addReaction as addReactionAction, removeReaction as removeReactionAction } from 'soapbox/actions/announcements'; +import { Card, HStack, Widget } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import Announcement from './announcement'; + +import type { RootState } from 'soapbox/store'; + +const customEmojiMap = createSelector([(state: RootState) => state.custom_emojis], items => (items as ImmutableList>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap>())); + +const AnnouncementsPanel = () => { + const dispatch = useAppDispatch(); + const emojiMap = useAppSelector(state => customEmojiMap(state)); + const [index, setIndex] = useState(0); + + const announcements = useAppSelector((state) => state.announcements.items); + + const addReaction = (id: string, name: string) => dispatch(addReactionAction(id, name)); + const removeReaction = (id: string, name: string) => dispatch(removeReactionAction(id, name)); + + if (announcements.size === 0) return null; + + const handleChangeIndex = (index: number) => { + setIndex(index % announcements.size); + }; + + return ( + }> + + + {announcements.map((announcement) => ( + + )).reverse()} + + {announcements.size > 2 && ( + + {announcements.map((_, i) => ( + + ); +}; + +export default Reaction; diff --git a/app/soapbox/components/announcements/reactions-bar.tsx b/app/soapbox/components/announcements/reactions-bar.tsx new file mode 100644 index 000000000..13e880b9f --- /dev/null +++ b/app/soapbox/components/announcements/reactions-bar.tsx @@ -0,0 +1,65 @@ +import classNames from 'classnames'; +import React from 'react'; +import { TransitionMotion, spring } from 'react-motion'; + +import { Icon } from 'soapbox/components/ui'; +import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container'; +import { useSettings } from 'soapbox/hooks'; + +import Reaction from './reaction'; + +import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import type { Emoji } from 'soapbox/components/autosuggest_emoji'; +import type { AnnouncementReaction } from 'soapbox/types/entities'; + +interface IReactionsBar { + announcementId: string; + reactions: ImmutableList; + emojiMap: ImmutableMap>; + addReaction: (id: string, name: string) => void; + removeReaction: (id: string, name: string) => void; +} + +const ReactionsBar: React.FC = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => { + const reduceMotion = useSettings().get('reduceMotion'); + + const handleEmojiPick = (data: Emoji) => { + addReaction(announcementId, data.native.replace(/:/g, '')); + }; + + const willEnter = () => ({ scale: reduceMotion ? 1 : 0 }); + + const willLeave = () => ({ scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }); + + const visibleReactions = reactions.filter(x => x.count > 0); + + const styles = visibleReactions.map(reaction => ({ + key: reaction.name, + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + + {items => ( +
+ {items.map(({ key, data, style }) => ( + + ))} + + {visibleReactions.size < 8 && } />} +
+ )} +
+ ); +}; + +export default ReactionsBar; diff --git a/app/soapbox/features/compose/components/emoji_picker_dropdown.js b/app/soapbox/features/compose/components/emoji_picker_dropdown.js index ca1fff018..e9af12031 100644 --- a/app/soapbox/features/compose/components/emoji_picker_dropdown.js +++ b/app/soapbox/features/compose/components/emoji_picker_dropdown.js @@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent { onPickEmoji: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, + button: PropTypes.node, }; state = { @@ -352,20 +353,14 @@ class EmojiPickerDropdown extends React.PureComponent { } render() { - const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; + const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props; const title = intl.formatMessage(messages.emoji); const { active, loading, placement } = this.state; return (
- + > + {button || } +
({ frequentlyUsedEmojis: getFrequentlyUsedEmojis(state), }); -const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ +const mapDispatchToProps = (dispatch, props) => ({ onSkinTone: skinTone => { dispatch(changeSetting(['skinTone'], skinTone)); }, @@ -75,8 +75,8 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ onPickEmoji: emoji => { dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks - if (onPickEmoji) { - onPickEmoji(emoji); + if (props.onPickEmoji) { + props.onPickEmoji(emoji); } }, }); diff --git a/app/soapbox/features/ui/components/announcements-panel.tsx b/app/soapbox/features/ui/components/announcements-panel.tsx deleted file mode 100644 index d249d84a9..000000000 --- a/app/soapbox/features/ui/components/announcements-panel.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import classNames from 'classnames'; -import { Map as ImmutableMap } from 'immutable'; -import React, { useEffect, useRef, useState } from 'react'; -import { FormattedDate, FormattedMessage } from 'react-intl'; -import { TransitionMotion, spring } from 'react-motion'; -import { useHistory } from 'react-router-dom'; -import ReactSwipeableViews from 'react-swipeable-views'; -import { createSelector } from 'reselect'; - -import { Card, HStack, Widget } from 'soapbox/components/ui'; -import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container'; -import unicodeMapping from 'soapbox/features/emoji/emoji_unicode_mapping_light'; -import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks'; -import { joinPublicPath } from 'soapbox/utils/static'; - -import type { RootState } from 'soapbox/store'; -import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; - -const customEmojiMap = createSelector([(state: RootState) => state.custom_emojis], items => items.reduce((map, emoji) => map.set(emoji.shortcode, emoji), ImmutableMap())); - -const AnnouncementContent = ({ announcement }: { announcement: AnnouncementEntity }) => { - const history = useHistory(); - - const node = useRef(null); - - useEffect(() => { - updateLinks(); - }); - - const onMentionClick = (mention: MentionEntity, e: MouseEvent) => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - e.stopPropagation(); - history.push(`/@${mention.acct}`); - } - }; - - const onHashtagClick = (hashtag: string, e: MouseEvent) => { - hashtag = hashtag.replace(/^#/, '').toLowerCase(); - - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - e.stopPropagation(); - history.push(`/tags/${hashtag}`); - } - }; - - /** For regular links, just stop propogation */ - const onLinkClick = (e: MouseEvent) => { - e.stopPropagation(); - }; - - const updateLinks = () => { - if (!node.current) return; - - const links = node.current.querySelectorAll('a'); - - links.forEach(link => { - // Skip already processed - if (link.classList.contains('status-link')) return; - - // Add attributes - link.classList.add('status-link'); - link.setAttribute('rel', 'nofollow noopener'); - link.setAttribute('target', '_blank'); - - const mention = announcement.mentions.find(mention => link.href === `${mention.url}`); - - // Add event listeners on mentions and hashtags - if (mention) { - link.addEventListener('click', onMentionClick.bind(link, mention), false); - link.setAttribute('title', mention.acct); - } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { - link.addEventListener('click', onHashtagClick.bind(link, link.text), false); - } else { - link.setAttribute('title', link.href); - link.addEventListener('click', onLinkClick.bind(link), false); - } - }); - }; - - - return ( -
- ); -}; - -const Emoji = ({ emoji, emojiMap, hovered }) => { - const autoPlayGif = useSettings().get('autoPlayGif'); - - if (unicodeMapping[emoji]) { - const { filename, shortCode } = unicodeMapping[emoji]; - const title = shortCode ? `:${shortCode}:` : ''; - - return ( - {emoji} - ); - } else if (emojiMap.get(emoji)) { - const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); - const shortCode = `:${emoji}:`; - - return ( - {shortCode} - ); - } else { - return null; - } -}; - - -const Reaction = ({ announcementId, reaction, addReaction, removeReaction, emojiMap, style }) => { - const [hovered, setHovered] = useState(false); - - const handleClick = () => { - if (reaction.get('me')) { - removeReaction(announcementId, reaction.get('name')); - } else { - addReaction(announcementId, reaction.get('name')); - } - }; - - const handleMouseEnter = () => setHovered(true); - - const handleMouseLeave = () => setHovered(false); - - let shortCode = reaction.get('name'); - - if (unicodeMapping[shortCode]) { - shortCode = unicodeMapping[shortCode].shortCode; - } - - return ( - - ); - - -}; - -const ReactionsBar = ({ announcementId, reactions, addReaction, removeReaction, emojiMap }) => { - const reduceMotion = useSettings().get('reduceMotion'); - - const handleEmojiPick = data => { - addReaction(announcementId, data.native.replace(/:/g, '')); - }; - - const willEnter = () => reduceMotion ? 1 : 0; - - const willLeave = () => reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }); - - const visibleReactions = reactions.filter(x => x.get('count') > 0); - - const styles = visibleReactions.map(reaction => ({ - key: reaction.get('name'), - data: reaction, - style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, - })).toArray(); - - return ( - - {items => ( -
- {items.map(({ key, data, style }) => ( - - ))} - - {visibleReactions.size < 8 && } -
- )} -
- ); -}; - -const Announcement = ({ announcement, addReaction, removeReaction, emojiMap }: { announcement: AnnouncementEntity }) => { - const startsAt = announcement.starts_at && new Date(announcement.starts_at); - const endsAt = announcement.ends_at && new Date(announcement.ends_at); - const now = new Date(); - const hasTimeRange = startsAt && endsAt; - const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear(); - const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear(); - const skipTime = announcement.all_day; - - return ( -
- - {hasTimeRange && · - } - - - - - {/* */} -
- ); -}; - -const AnnouncementsPanel = () => { - const dispatch = useAppDispatch(); - // const emojiMap = useAppSelector(state => customEmojiMap(state)); - const [index, setIndex] = useState(0); - - const announcements = useAppSelector((state) => state.announcements.items); - - const addReaction = (id: string, name: string) => dispatch(addReaction(id, name)); - const removeReaction = (id: string, name: string) => dispatch(removeReaction(id, name)); - - if (announcements.size === 0) return null; - - const handleChangeIndex = (index: number) => { - setIndex(index % announcements.size); - }; - - return ( - }> - - - {announcements.map((announcement) => ( - - )).reverse()} - - - - {announcements.map((_, i) => ( -
; + return ( +
+ {this.renderContent()} +
+ ); } } diff --git a/app/soapbox/features/federation_restrictions/components/restricted_instance.tsx b/app/soapbox/features/federation_restrictions/components/restricted_instance.tsx index 4949bf0cb..7b2547d2b 100644 --- a/app/soapbox/features/federation_restrictions/components/restricted_instance.tsx +++ b/app/soapbox/features/federation_restrictions/components/restricted_instance.tsx @@ -24,20 +24,19 @@ const RestrictedInstance: React.FC = ({ host }) => { }; return ( -
- -
- -
-
+
+ + +
{remoteInstance.get('host')}
-
+
diff --git a/app/soapbox/features/federation_restrictions/index.tsx b/app/soapbox/features/federation_restrictions/index.tsx index 396f527a0..95adc8b25 100644 --- a/app/soapbox/features/federation_restrictions/index.tsx +++ b/app/soapbox/features/federation_restrictions/index.tsx @@ -40,17 +40,15 @@ const FederationRestrictions = () => { return ( -
- - {intl.formatMessage(messages.boxMessage, { siteTitle })} - -
+ + {intl.formatMessage(messages.boxMessage, { siteTitle })} + -
+
{hosts.map((host) => )} diff --git a/app/styles/application.scss b/app/styles/application.scss index 1dcd2efd5..f27ab386e 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -67,7 +67,6 @@ @import 'components/backups'; @import 'components/crypto-donate'; @import 'components/remote-timeline'; -@import 'components/federation-restrictions'; @import 'components/aliases'; @import 'components/icon'; @import 'components/radio-button'; diff --git a/app/styles/components/federation-restrictions.scss b/app/styles/components/federation-restrictions.scss deleted file mode 100644 index 4bc42ff94..000000000 --- a/app/styles/components/federation-restrictions.scss +++ /dev/null @@ -1,69 +0,0 @@ -.federation-restrictions { - padding-top: 15px; - - .slist .item-list > article { - padding: 0 20px; - - &:last-child { - padding-bottom: 15px; - } - } -} - -.restricted-instance { - &__header { - padding: 10px 0; - display: flex; - text-decoration: none; - color: var(--primary-text-color); - } - - &__icon { - width: 16px; - - .svg-icon svg { - stroke-width: 1.3; - } - } - - &--expanded &__icon i.fa { - transform: translateX(-3px); - } - - &--reject &__host { - text-decoration: line-through; - } - - &__restrictions { - height: 0; - overflow: hidden; - } - - &--expanded &__restrictions { - height: auto; - } - - .instance-restrictions { - padding: 5px 0 5px 15px; - border-left: 3px solid hsla(var(--primary-text-color_hsl), 0.4); - color: var(--primary-text-color--faint); - margin-bottom: 15px; - - .federation-restriction { - padding: 7px 0; - font-size: 14px; - } - - &__message { - margin-bottom: 10px; - - i.fa { - padding-right: 10px; - } - - &:last-child { - margin-bottom: 0; - } - } - } -} From 96e47962c9050a8be1b20cba1c8bb6fd9df8ba85 Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 11 Jul 2022 09:10:10 -0400 Subject: [PATCH 0024/1726] Hide carousel if no avatars --- .../__tests__/feed-carousel.test.tsx | 20 +++++++++++++++++++ .../features/feed-filtering/feed-carousel.tsx | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx index eb84e7ad0..d7f439fa2 100644 --- a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx +++ b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx @@ -56,11 +56,31 @@ describe('', () => { }); it('should render the Carousel', () => { + store.carousels = { + avatars: [ + { account_id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' }, + ], + }; + render(, undefined, store); expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1); }); + describe('with 0 avatars', () => { + beforeEach(() => { + store.carousels = { + avatars: [], + }; + }); + + it('renders the error message', () => { + render(, undefined, store); + + expect(screen.queryAllByTestId('feed-carousel-error')).toHaveLength(0); + }); + }); + describe('with a failed request to the API', () => { beforeEach(() => { store.carousels = { diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index 803773885..59c6776ba 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -93,6 +93,10 @@ const FeedCarousel = () => { ); } + if (avatars.length === 0) { + return null; + } + return (
From 930a10a63e99f4ed5ad376a327fd569e1e8aaf49 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 11 Jul 2022 10:48:25 -0500 Subject: [PATCH 0025/1726] SettingsStore: convert to TSX+FC --- .../features/developers/settings-store.tsx | 88 +++++++++++++ .../features/developers/settings_store.js | 116 ------------------ .../features/ui/util/async-components.ts | 2 +- 3 files changed, 89 insertions(+), 117 deletions(-) create mode 100644 app/soapbox/features/developers/settings-store.tsx delete mode 100644 app/soapbox/features/developers/settings_store.js diff --git a/app/soapbox/features/developers/settings-store.tsx b/app/soapbox/features/developers/settings-store.tsx new file mode 100644 index 000000000..b4dbe9e95 --- /dev/null +++ b/app/soapbox/features/developers/settings-store.tsx @@ -0,0 +1,88 @@ +import React, { useState, useEffect } from 'react'; +import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; + +import { showAlertForError } from 'soapbox/actions/alerts'; +import { patchMe } from 'soapbox/actions/me'; +import { FE_NAME, SETTINGS_UPDATE } from 'soapbox/actions/settings'; +import { Button, Form, FormActions, FormGroup, Textarea } from 'soapbox/components/ui'; +import Column from 'soapbox/features/ui/components/column'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; + +const isJSONValid = (text: any): boolean => { + try { + JSON.parse(text); + return true; + } catch { + return false; + } +}; + +const messages = defineMessages({ + heading: { id: 'column.settings_store', defaultMessage: 'Settings store' }, + hint: { id: 'developers.settings_store.hint', defaultMessage: 'It is possible to directly edit your user settings here. BE CAREFUL! Editing this section can break your account, and you will only be able to recover through the API.' }, +}); + +const SettingsStore: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const settingsStore = useAppSelector(state => state.get('settings')); + + const [rawJSON, setRawJSON] = useState(JSON.stringify(settingsStore, null, 2)); + const [jsonValid, setJsonValid] = useState(true); + const [isLoading, setLoading] = useState(false); + + const handleEditJSON: React.ChangeEventHandler = ({ target }) => { + const rawJSON = target.value; + setRawJSON(rawJSON); + setJsonValid(isJSONValid(rawJSON)); + }; + + const handleSubmit: React.FormEventHandler = e => { + const settings = JSON.parse(rawJSON); + + setLoading(true); + dispatch(patchMe({ + pleroma_settings_store: { + [FE_NAME]: settings, + }, + })).then(response => { + dispatch({ type: SETTINGS_UPDATE, settings }); + setLoading(false); + }).catch(error => { + dispatch(showAlertForError(error)); + setLoading(false); + }); + }; + + useEffect(() => { + setRawJSON(JSON.stringify(settingsStore, null, 2)); + setJsonValid(true); + }, [settingsStore]); + + return ( + +
+ +