From f9d01dab0a006e36f7b40f467163a6f253cd0cd7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 13:38:08 -0500 Subject: [PATCH 001/334] Make the ThemeToggle look decent, unhide it --- app/soapbox/features/ui/components/navbar.tsx | 4 +--- app/soapbox/features/ui/components/theme_toggle.tsx | 4 ++-- app/styles/components/theme-toggle.scss | 9 ++++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/soapbox/features/ui/components/navbar.tsx b/app/soapbox/features/ui/components/navbar.tsx index d01b2fd27..6ba320195 100644 --- a/app/soapbox/features/ui/components/navbar.tsx +++ b/app/soapbox/features/ui/components/navbar.tsx @@ -68,9 +68,7 @@ const Navbar = () => {
- {settings.get('isDeveloper') && ( - - )} + {account ? (
diff --git a/app/soapbox/features/ui/components/theme_toggle.tsx b/app/soapbox/features/ui/components/theme_toggle.tsx index 5f04be848..e8c63c60f 100644 --- a/app/soapbox/features/ui/components/theme_toggle.tsx +++ b/app/soapbox/features/ui/components/theme_toggle.tsx @@ -37,8 +37,8 @@ function ThemeToggle({ showLabel }: IThemeToggle) { id={id} checked={themeMode === 'light'} icons={{ - checked: , - unchecked: , + checked: , + unchecked: , }} onChange={onToggle} /> diff --git a/app/styles/components/theme-toggle.scss b/app/styles/components/theme-toggle.scss index 5b59d80b1..112c14972 100644 --- a/app/styles/components/theme-toggle.scss +++ b/app/styles/components/theme-toggle.scss @@ -27,8 +27,11 @@ } } - .svg-icon { - width: 18px; - height: 18px; + .react-toggle-track { + @apply dark:bg-slate-600; + } + + .react-toggle-thumb { + @apply dark:bg-slate-900 dark:border-slate-800; } } From af92c6fd875c10607d06262af1bc8a70d175d5cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sun, 10 Apr 2022 22:20:43 +0200 Subject: [PATCH 002/334] Fix ThumbNavigationLink icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/thumb_navigation-link.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/thumb_navigation-link.tsx b/app/soapbox/components/thumb_navigation-link.tsx index 756abd25c..4e96260a0 100644 --- a/app/soapbox/components/thumb_navigation-link.tsx +++ b/app/soapbox/components/thumb_navigation-link.tsx @@ -31,7 +31,7 @@ const ThumbNavigationLink: React.FC = ({ count, src, text, {count !== undefined ? ( Date: Sun, 10 Apr 2022 18:21:52 -0500 Subject: [PATCH 003/334] Add preliminary Pixelfed support --- .../__fixtures__/pixelfed-instance.json | 66 +++++++++++++++++++ .../normalizers/__tests__/instance-test.js | 6 ++ app/soapbox/utils/__tests__/features-test.js | 9 +++ app/soapbox/utils/features.ts | 3 + 4 files changed, 84 insertions(+) create mode 100644 app/soapbox/__fixtures__/pixelfed-instance.json diff --git a/app/soapbox/__fixtures__/pixelfed-instance.json b/app/soapbox/__fixtures__/pixelfed-instance.json new file mode 100644 index 000000000..41830e0e4 --- /dev/null +++ b/app/soapbox/__fixtures__/pixelfed-instance.json @@ -0,0 +1,66 @@ +{ + "uri": "pixelfed.social", + "title": "pixelfed", + "short_description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms", + "description": "Pixelfed is an image sharing platform, an ethical alternative to centralized platforms", + "email": "hello@pixelfed.org", + "version": "2.7.2 (compatible; Pixelfed 0.11.2)", + "urls": { + "streaming_api": "wss://pixelfed.social" + }, + "stats": { + "user_count": 45061, + "status_count": 301357, + "domain_count": 5028 + }, + "thumbnail": "https://pixelfed.social/img/pixelfed-icon-color.png", + "languages": [ + "en" + ], + "registrations": true, + "approval_required": false, + "contact_account": { + "id": "1", + "username": "admin", + "acct": "admin", + "display_name": "Admin", + "discoverable": true, + "locked": false, + "followers_count": 419, + "following_count": 2, + "statuses_count": 6, + "note": "pixelfed.social Admin. Managed by @dansup", + "url": "https://pixelfed.social/admin", + "avatar": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4", + "created_at": "2018-06-01T03:54:08.000000Z", + "avatar_static": "https://pixelfed.social/storage/avatars/000/000/000/001/LSHNCgwbby7wu3iCYV6H_avatar.png?v=4", + "bot": false, + "emojis": [], + "fields": [], + "header": "https://pixelfed.social/storage/headers/missing.png", + "header_static": "https://pixelfed.social/storage/headers/missing.png", + "last_status_at": null + }, + "rules": [ + { + "id": "1", + "text": "Sexually explicit or violent media must be marked as sensitive when posting" + }, + { + "id": "2", + "text": "No racism, sexism, homophobia, transphobia, xenophobia, or casteism" + }, + { + "id": "3", + "text": "No incitement of violence or promotion of violent ideologies" + }, + { + "id": "4", + "text": "No harassment, dogpiling or doxxing of other users" + }, + { + "id": "5", + "text": "No content illegal in United States" + } + ] +} diff --git a/app/soapbox/normalizers/__tests__/instance-test.js b/app/soapbox/normalizers/__tests__/instance-test.js index 918c8072a..638cefbe7 100644 --- a/app/soapbox/normalizers/__tests__/instance-test.js +++ b/app/soapbox/normalizers/__tests__/instance-test.js @@ -185,4 +185,10 @@ describe('normalizeInstance()', () => { expect(result.version).toEqual('3.5.0-rc1'); }); + + it('normalizes Pixelfed instance', () => { + const instance = require('soapbox/__fixtures__/pixelfed-instance.json'); + const result = normalizeInstance(instance); + expect(result.title).toBe('pixelfed'); + }); }); diff --git a/app/soapbox/utils/__tests__/features-test.js b/app/soapbox/utils/__tests__/features-test.js index 6caa38a7b..4027dd69e 100644 --- a/app/soapbox/utils/__tests__/features-test.js +++ b/app/soapbox/utils/__tests__/features-test.js @@ -23,6 +23,15 @@ describe('parseVersion', () => { compatVersion: '3.0.0', }); }); + + it('with a Pixelfed version string', () => { + const version = '2.7.2 (compatible; Pixelfed 0.11.2)'; + expect(parseVersion(version)).toEqual({ + software: 'Pixelfed', + version: '0.11.2', + compatVersion: '2.7.2', + }); + }); }); describe('getFeatures', () => { diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 93529f21e..e1c5d12e0 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -19,6 +19,7 @@ export const MASTODON = 'Mastodon'; export const PLEROMA = 'Pleroma'; export const MITRA = 'Mitra'; export const TRUTHSOCIAL = 'TruthSocial'; +export const PIXELFED = 'Pixelfed'; const getInstanceFeatures = (instance: Instance) => { const v = parseVersion(instance.version); @@ -41,6 +42,7 @@ const getInstanceFeatures = (instance: Instance) => { bookmarks: any([ v.software === MASTODON && gte(v.compatVersion, '3.1.0'), v.software === PLEROMA && gte(v.version, '0.9.9'), + v.software === PIXELFED, ]), lists: any([ v.software === MASTODON && gte(v.compatVersion, '2.1.0'), @@ -73,6 +75,7 @@ const getInstanceFeatures = (instance: Instance) => { conversations: any([ v.software === MASTODON && gte(v.compatVersion, '2.6.0'), v.software === PLEROMA && gte(v.version, '0.9.9'), + v.software === PIXELFED, ]), emojiReacts: v.software === PLEROMA && gte(v.version, '2.0.0'), emojiReactsRGI: v.software === PLEROMA && gte(v.version, '2.2.49'), From f316dac83e91e934f85b78bdab7ca05617d855f9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 19:59:53 -0500 Subject: [PATCH 004/334] eslint: scream if I try putting a JS comment in a JSX text node --- .eslintrc.js | 1 + app/soapbox/components/ui/icon/svg-icon.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.js b/.eslintrc.js index 9a92e50a8..d885cbeea 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -141,6 +141,7 @@ module.exports = { 'react/jsx-first-prop-new-line': ['error', 'multiline-multiprop'], 'react/jsx-indent': ['error', 2], // 'react/jsx-no-bind': ['error'], + 'react/jsx-no-comment-textnodes': 'error', 'react/jsx-no-duplicate-props': 'error', 'react/jsx-no-undef': 'error', 'react/jsx-tag-spacing': 'error', diff --git a/app/soapbox/components/ui/icon/svg-icon.tsx b/app/soapbox/components/ui/icon/svg-icon.tsx index 5cb2dd192..84604150d 100644 --- a/app/soapbox/components/ui/icon/svg-icon.tsx +++ b/app/soapbox/components/ui/icon/svg-icon.tsx @@ -30,7 +30,7 @@ const SvgIcon: React.FC = ({ src, alt, size = 24, className }): JSX.El loader={loader} data-testid='svg-icon' > - /* If the fetch fails, fall back to displaying the loader */ + {/* If the fetch fails, fall back to displaying the loader */} {loader} ); From 0912700d153ae7efebd4cf313d79053571872791 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 20:31:24 -0500 Subject: [PATCH 005/334] Create preliminary EmojiButtonWrapper component --- app/soapbox/actions/{modals.js => modals.ts} | 6 +- .../components/emoji-button-wrapper.tsx | 98 +++++++++++++++++++ app/soapbox/components/hoverable.tsx | 63 ------------ app/soapbox/components/status_action_bar.tsx | 15 +-- .../ui/emoji-selector/emoji-selector.tsx | 4 +- .../features/status/components/action-bar.tsx | 45 ++++++--- 6 files changed, 139 insertions(+), 92 deletions(-) rename app/soapbox/actions/{modals.js => modals.ts} (59%) create mode 100644 app/soapbox/components/emoji-button-wrapper.tsx delete mode 100644 app/soapbox/components/hoverable.tsx diff --git a/app/soapbox/actions/modals.js b/app/soapbox/actions/modals.ts similarity index 59% rename from app/soapbox/actions/modals.js rename to app/soapbox/actions/modals.ts index 72604ecc6..9d6e85139 100644 --- a/app/soapbox/actions/modals.js +++ b/app/soapbox/actions/modals.ts @@ -1,7 +1,8 @@ export const MODAL_OPEN = 'MODAL_OPEN'; export const MODAL_CLOSE = 'MODAL_CLOSE'; -export function openModal(type, props) { +/** Open a modal of the given type */ +export function openModal(type: string, props?: any) { return { type: MODAL_OPEN, modalType: type, @@ -9,7 +10,8 @@ export function openModal(type, props) { }; } -export function closeModal(type) { +/** Close the modal */ +export function closeModal(type: string) { return { type: MODAL_CLOSE, modalType: type, diff --git a/app/soapbox/components/emoji-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx new file mode 100644 index 000000000..eb2ec2f3b --- /dev/null +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -0,0 +1,98 @@ +import classNames from 'classnames'; +import React, { useState, useRef } from 'react'; +import { usePopper } from 'react-popper'; +import { useDispatch } from 'react-redux'; + +import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; +import { openModal } from 'soapbox/actions/modals'; +import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector'; +import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks'; + +interface IEmojiButtonWrapper { + statusId: string, + children: JSX.Element, +} + +/** Provides emoji reaction functionality to the underlying button component */ +const EmojiButtonWrapper: React.FC = ({ statusId, children }): JSX.Element | null => { + const dispatch = useDispatch(); + const ownAccount = useOwnAccount(); + const status = useAppSelector(state => state.statuses.get(statusId)); + const soapboxConfig = useSoapboxConfig(); + + const [visible, setVisible] = useState(false); + // const [focused, setFocused] = useState(false); + + const ref = useRef(null); + const popperRef = useRef(null); + + const { styles, attributes } = usePopper(ref.current, popperRef.current, { + placement: 'top-start', + strategy: 'fixed', + modifiers: [ + { + name: 'offset', + options: { + offset: [-10, 0], + }, + }, + ], + }); + + if (!status) return null; + + const handleMouseEnter = () => { + setVisible(true); + }; + + const handleMouseLeave = () => { + setVisible(false); + }; + + const handleReact = (emoji: string): void => { + if (ownAccount) { + dispatch(simpleEmojiReact(status, emoji)); + } else { + dispatch(openModal('UNAUTHORIZED', { + action: 'FAVOURITE', + ap_id: status.url, + })); + } + + setVisible(false); + }; + + // const handleUnfocus: React.EventHandler = () => { + // setFocused(false); + // }; + + const selector = ( +
+ +
+ ); + + return ( +
+ {React.cloneElement(children, { + ref, + })} + + {selector} +
+ ); +}; + +export default EmojiButtonWrapper; diff --git a/app/soapbox/components/hoverable.tsx b/app/soapbox/components/hoverable.tsx deleted file mode 100644 index 751c413c1..000000000 --- a/app/soapbox/components/hoverable.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import classNames from 'classnames'; -import React, { useState, useRef } from 'react'; -import { usePopper } from 'react-popper'; - -interface IHoverable { - component: JSX.Element, -} - -/** Wrapper to render a given component when hovered */ -const Hoverable: React.FC = ({ - component, - children, -}): JSX.Element => { - - const [portalActive, setPortalActive] = useState(false); - - const ref = useRef(null); - const popperRef = useRef(null); - - const handleMouseEnter = () => { - setPortalActive(true); - }; - - const handleMouseLeave = () => { - setPortalActive(false); - }; - - const { styles, attributes } = usePopper(ref.current, popperRef.current, { - placement: 'top-start', - strategy: 'fixed', - modifiers: [ - { - name: 'offset', - options: { - offset: [-10, 0], - }, - }, - ], - }); - - return ( -
- {children} - -
- {component} -
-
- ); -}; - -export default Hoverable; diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 9011bec71..dbafd64fd 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -6,8 +6,7 @@ import { connect } from 'react-redux'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; -import EmojiSelector from 'soapbox/components/emoji_selector'; -import Hoverable from 'soapbox/components/hoverable'; +import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; import StatusActionButton from 'soapbox/components/status-action-button'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import { isUserTouching } from 'soapbox/is_mobile'; @@ -641,15 +640,7 @@ class StatusActionBar extends ImmutablePureComponent - )} - > + - + ): ( = ({ emoji, className, onClick, tabInd }; interface IEmojiSelector { - emojis: string[], + emojis: Iterable, onReact: (emoji: string) => void, visible?: boolean, focused?: boolean, @@ -40,7 +40,7 @@ const EmojiSelector: React.FC = ({ emojis, onReact, visible = fa space={2} className={classNames('bg-white dark:bg-slate-900 p-3 rounded-full shadow-md z-[999] w-max')} > - {emojis.map((emoji, i) => ( + {Array.from(emojis).map((emoji, i) => ( { {reblogButton} - + {features.emojiReacts ? ( + + + + ) : ( + + )} {canShare && ( Date: Sun, 10 Apr 2022 20:41:00 -0500 Subject: [PATCH 006/334] EmojiButtonWrapper: handle click --- .../components/emoji-button-wrapper.tsx | 19 +++++++++++++++++++ app/soapbox/components/status_action_bar.tsx | 1 - .../features/status/components/action-bar.tsx | 1 - 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/emoji-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx index eb2ec2f3b..32159b329 100644 --- a/app/soapbox/components/emoji-button-wrapper.tsx +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -7,6 +7,8 @@ import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; import { openModal } from 'soapbox/actions/modals'; import EmojiSelector from 'soapbox/components/ui/emoji-selector/emoji-selector'; import { useAppSelector, useOwnAccount, useSoapboxConfig } from 'soapbox/hooks'; +import { isUserTouching } from 'soapbox/is_mobile'; +import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; interface IEmojiButtonWrapper { statusId: string, @@ -62,6 +64,22 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children setVisible(false); }; + const handleClick: React.EventHandler = e => { + const meEmojiReact = getReactForStatus(status, soapboxConfig.allowedEmoji) || '👍'; + + if (isUserTouching()) { + if (visible) { + handleReact(meEmojiReact); + } else { + setVisible(true); + } + } else { + handleReact(meEmojiReact); + } + + e.stopPropagation(); + }; + // const handleUnfocus: React.EventHandler = () => { // setFocused(false); // }; @@ -87,6 +105,7 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children return (
{React.cloneElement(children, { + onClick: handleClick, ref, })} diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index dbafd64fd..fa99ffa4e 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -645,7 +645,6 @@ class StatusActionBar extends ImmutablePureComponent diff --git a/app/soapbox/features/status/components/action-bar.tsx b/app/soapbox/features/status/components/action-bar.tsx index 7efb40aff..9ac9b007f 100644 --- a/app/soapbox/features/status/components/action-bar.tsx +++ b/app/soapbox/features/status/components/action-bar.tsx @@ -588,7 +588,6 @@ class ActionBar extends React.PureComponent { 'fill-accent-300': Boolean(meEmojiReact), })} text={meEmojiTitle} - onClick={this.handleLikeButtonClick} /> ) : ( From c5c1f83f36a51168414fdf9dd113c143b5dd19c5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 10 Apr 2022 20:49:36 -0500 Subject: [PATCH 007/334] Fix lint --- app/soapbox/components/status_action_bar.tsx | 2 +- app/soapbox/features/ui/index.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index fa99ffa4e..41c873c7c 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -553,7 +553,7 @@ class StatusActionBar extends ImmutablePureComponent - // NOTE: we cannot nest routes in a fragment - // https://stackoverflow.com/a/68637108 + {/* + NOTE: we cannot nest routes in a fragment + https://stackoverflow.com/a/68637108 + */} {features.federating && } {features.federating && } {features.federating && } From 8cef636093874e279bcb9e2e4aa4761641abc0f0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 11 Apr 2022 14:27:32 -0500 Subject: [PATCH 008/334] Upgrade to Node.js 16.x --- .gitlab-ci.yml | 2 +- .tool-versions | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 546e3810e..0d140029f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: node:14 +image: node:16 variables: NODE_ENV: test diff --git a/.tool-versions b/.tool-versions index 2d8169e51..009455657 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 14.17.6 +nodejs 16.14.2 From 2943b9103409136281b3062903d51a6ab70009e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Mon, 11 Apr 2022 21:58:48 +0200 Subject: [PATCH 009/334] Typescript, convert some components to functional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/importer/index.js | 2 +- app/soapbox/actions/notifications.js | 2 +- app/soapbox/api.ts | 2 +- app/soapbox/{compare_id.js => compare_id.ts} | 2 +- app/soapbox/components/domain.js | 43 --------- app/soapbox/components/domain.tsx | 51 +++++++++++ app/soapbox/components/dropdown_menu.tsx | 2 +- app/soapbox/components/status_content.js | 2 +- app/soapbox/containers/domain_container.js | 35 -------- .../account_timeline/components/moved_note.js | 38 -------- .../components/moved_note.tsx | 34 +++++++ app/soapbox/features/audio/index.js | 2 +- app/soapbox/features/birthdays/account.js | 88 ------------------- app/soapbox/features/birthdays/account.tsx | 64 ++++++++++++++ .../{date_picker.js => date_picker.ts} | 0 app/soapbox/features/blocks/index.js | 74 ---------------- app/soapbox/features/blocks/index.tsx | 58 ++++++++++++ app/soapbox/features/bookmarks/index.js | 83 ----------------- app/soapbox/features/bookmarks/index.tsx | 56 ++++++++++++ .../features/chats/components/chat_list.js | 10 +-- app/soapbox/features/domain_blocks/index.js | 75 ---------------- app/soapbox/features/domain_blocks/index.tsx | 60 +++++++++++++ .../features/emoji/emoji_compressed.js | 2 +- app/soapbox/features/mutes/index.js | 74 ---------------- app/soapbox/features/mutes/index.tsx | 58 ++++++++++++ .../status/components/quoted_status.tsx | 3 +- .../ui/components/profile_info_panel.js | 2 +- app/soapbox/features/video/index.js | 6 +- app/soapbox/reducers/auth.js | 2 +- app/soapbox/reducers/conversations.js | 2 +- app/soapbox/stream.js | 2 +- app/soapbox/utils/auth.js | 64 -------------- app/soapbox/utils/auth.ts | 66 ++++++++++++++ app/soapbox/utils/{base64.js => base64.ts} | 2 +- app/soapbox/utils/greentext.js | 2 +- app/soapbox/utils/instance.js | 11 --- ..._aspect_ratio.js => media_aspect_ratio.ts} | 6 +- app/soapbox/utils/{phone.js => phone.ts} | 0 app/soapbox/utils/{status.js => status.ts} | 8 +- app/soapbox/utils/timelines.js | 13 --- app/soapbox/utils/timelines.ts | 15 ++++ package.json | 3 +- yarn.lock | 14 ++- 43 files changed, 508 insertions(+), 630 deletions(-) rename app/soapbox/{compare_id.js => compare_id.ts} (74%) delete mode 100644 app/soapbox/components/domain.js create mode 100644 app/soapbox/components/domain.tsx delete mode 100644 app/soapbox/containers/domain_container.js delete mode 100644 app/soapbox/features/account_timeline/components/moved_note.js create mode 100644 app/soapbox/features/account_timeline/components/moved_note.tsx delete mode 100644 app/soapbox/features/birthdays/account.js create mode 100644 app/soapbox/features/birthdays/account.tsx rename app/soapbox/features/birthdays/{date_picker.js => date_picker.ts} (100%) delete mode 100644 app/soapbox/features/blocks/index.js create mode 100644 app/soapbox/features/blocks/index.tsx delete mode 100644 app/soapbox/features/bookmarks/index.js create mode 100644 app/soapbox/features/bookmarks/index.tsx delete mode 100644 app/soapbox/features/domain_blocks/index.js create mode 100644 app/soapbox/features/domain_blocks/index.tsx delete mode 100644 app/soapbox/features/mutes/index.js create mode 100644 app/soapbox/features/mutes/index.tsx delete mode 100644 app/soapbox/utils/auth.js create mode 100644 app/soapbox/utils/auth.ts rename app/soapbox/utils/{base64.js => base64.ts} (82%) delete mode 100644 app/soapbox/utils/instance.js rename app/soapbox/utils/{media_aspect_ratio.js => media_aspect_ratio.ts} (70%) rename app/soapbox/utils/{phone.js => phone.ts} (100%) rename app/soapbox/utils/{status.js => status.ts} (67%) delete mode 100644 app/soapbox/utils/timelines.js create mode 100644 app/soapbox/utils/timelines.ts diff --git a/app/soapbox/actions/importer/index.js b/app/soapbox/actions/importer/index.js index ee3af3ac3..9ad58e114 100644 --- a/app/soapbox/actions/importer/index.js +++ b/app/soapbox/actions/importer/index.js @@ -93,7 +93,7 @@ const isBroken = status => { // https://gitlab.com/soapbox-pub/soapbox/-/issues/28 if (status.reblog && !status.reblog.account.id) return true; return false; - } catch(e) { + } catch (e) { return true; } }; diff --git a/app/soapbox/actions/notifications.js b/app/soapbox/actions/notifications.js index a4331bf8c..ce7e5eca0 100644 --- a/app/soapbox/actions/notifications.js +++ b/app/soapbox/actions/notifications.js @@ -121,7 +121,7 @@ export function updateNotificationsQueue(notification, intlMessages, intlLocale, }).catch(console.error); }).catch(console.error); } - } catch(e) { + } catch (e) { console.warn(e); } diff --git a/app/soapbox/api.ts b/app/soapbox/api.ts index bfa07b71a..6c81042bb 100644 --- a/app/soapbox/api.ts +++ b/app/soapbox/api.ts @@ -31,7 +31,7 @@ const getToken = (state: RootState, authType: string) => { const maybeParseJSON = (data: string) => { try { return JSON.parse(data); - } catch(Exception) { + } catch (Exception) { return data; } }; diff --git a/app/soapbox/compare_id.js b/app/soapbox/compare_id.ts similarity index 74% rename from app/soapbox/compare_id.js rename to app/soapbox/compare_id.ts index f8c15e327..e92d13ef5 100644 --- a/app/soapbox/compare_id.js +++ b/app/soapbox/compare_id.ts @@ -1,6 +1,6 @@ 'use strict'; -export default function compareId(id1, id2) { +export default function compareId(id1: string, id2: string) { if (id1 === id2) { return 0; } diff --git a/app/soapbox/components/domain.js b/app/soapbox/components/domain.js deleted file mode 100644 index 026497a14..000000000 --- a/app/soapbox/components/domain.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; - -import IconButton from './icon_button'; - -const messages = defineMessages({ - unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, -}); - -export default @injectIntl -class Account extends ImmutablePureComponent { - - static propTypes = { - domain: PropTypes.string, - onUnblockDomain: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - handleDomainUnblock = () => { - this.props.onUnblockDomain(this.props.domain); - } - - render() { - const { domain, intl } = this.props; - - return ( -
-
- - {domain} - - -
- -
-
-
- ); - } - -} diff --git a/app/soapbox/components/domain.tsx b/app/soapbox/components/domain.tsx new file mode 100644 index 000000000..005848c89 --- /dev/null +++ b/app/soapbox/components/domain.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { unblockDomain } from 'soapbox/actions/domain_blocks'; + +import IconButton from './icon_button'; + +const messages = defineMessages({ + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, +}); + +interface IDomain { + domain: string, +} + +const Domain: React.FC = ({ domain }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + // const onBlockDomain = () => { + // dispatch(openModal('CONFIRM', { + // icon: require('@tabler/icons/icons/ban.svg'), + // heading: , + // message: {domain} }} />, + // confirm: intl.formatMessage(messages.blockDomainConfirm), + // onConfirm: () => dispatch(blockDomain(domain)), + // })); + // } + + const handleDomainUnblock = () => { + dispatch(unblockDomain(domain)); + }; + + return ( +
+
+ + {domain} + + +
+ +
+
+
+ ); +}; + +export default Domain; diff --git a/app/soapbox/components/dropdown_menu.tsx b/app/soapbox/components/dropdown_menu.tsx index 8114ca7dc..01a60948d 100644 --- a/app/soapbox/components/dropdown_menu.tsx +++ b/app/soapbox/components/dropdown_menu.tsx @@ -278,7 +278,7 @@ class Dropdown extends React.PureComponent { onShiftClick(e); } else if (this.state.id === openDropdownId) { this.handleClose(); - } else if(onOpen) { + } else if (onOpen) { const { top } = e.currentTarget.getBoundingClientRect(); const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top'; diff --git a/app/soapbox/components/status_content.js b/app/soapbox/components/status_content.js index 2bd5c0712..663480a75 100644 --- a/app/soapbox/components/status_content.js +++ b/app/soapbox/components/status_content.js @@ -87,7 +87,7 @@ class StatusContent extends React.PureComponent { && this.state.collapsed === null && this.props.status.get('spoiler_text').length === 0 ) { - if (node.clientHeight > MAX_HEIGHT){ + if (node.clientHeight > MAX_HEIGHT) { this.setState({ collapsed: true }); } } diff --git a/app/soapbox/containers/domain_container.js b/app/soapbox/containers/domain_container.js deleted file mode 100644 index a1d705eaf..000000000 --- a/app/soapbox/containers/domain_container.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { blockDomain, unblockDomain } from '../actions/domain_blocks'; -import { openModal } from '../actions/modals'; -import Domain from '../components/domain'; - -const messages = defineMessages({ - blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, -}); - -const makeMapStateToProps = () => { - const mapStateToProps = () => ({}); - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { intl }) => ({ - onBlockDomain(domain) { - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/icons/ban.svg'), - heading: , - message: {domain} }} />, - confirm: intl.formatMessage(messages.blockDomainConfirm), - onConfirm: () => dispatch(blockDomain(domain)), - })); - }, - - onUnblockDomain(domain) { - dispatch(unblockDomain(domain)); - }, -}); - -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Domain)); diff --git a/app/soapbox/features/account_timeline/components/moved_note.js b/app/soapbox/features/account_timeline/components/moved_note.js deleted file mode 100644 index fb2f000e8..000000000 --- a/app/soapbox/features/account_timeline/components/moved_note.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import { NavLink } from 'react-router-dom'; - -import Icon from 'soapbox/components/icon'; - -import AvatarOverlay from '../../../components/avatar_overlay'; -import DisplayName from '../../../components/display_name'; - -export default class MovedNote extends ImmutablePureComponent { - - static propTypes = { - from: ImmutablePropTypes.map.isRequired, - to: ImmutablePropTypes.map.isRequired, - }; - - render() { - const { from, to } = this.props; - const displayNameHtml = { __html: from.get('display_name_html') }; - - return ( -
-
-
- }} /> -
- - -
- -
-
- ); - } - -} diff --git a/app/soapbox/features/account_timeline/components/moved_note.tsx b/app/soapbox/features/account_timeline/components/moved_note.tsx new file mode 100644 index 000000000..6e901252d --- /dev/null +++ b/app/soapbox/features/account_timeline/components/moved_note.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { NavLink } from 'react-router-dom'; + +import AvatarOverlay from 'soapbox/components/avatar_overlay'; +import DisplayName from 'soapbox/components/display_name'; +import Icon from 'soapbox/components/icon'; + +import type { Account as AccountEntity } from 'soapbox/types/entities'; + +interface IMovedNote { + from: AccountEntity, + to: AccountEntity, +} + +const MovedNote: React.FC = ({ from, to }) => { + const displayNameHtml = { __html: from.display_name_html }; + + return ( +
+
+
+ }} /> +
+ + +
+ +
+
+ ); +}; + +export default MovedNote; diff --git a/app/soapbox/features/audio/index.js b/app/soapbox/features/audio/index.js index 7553f2388..b8ef5892c 100644 --- a/app/soapbox/features/audio/index.js +++ b/app/soapbox/features/audio/index.js @@ -255,7 +255,7 @@ class Audio extends React.PureComponent { handleMouseVolSlide = throttle(e => { const { x } = getPointerPosition(this.volume, e); - if(!isNaN(x)) { + if (!isNaN(x)) { this.setState({ volume: x }, () => { this.audio.volume = x; }); diff --git a/app/soapbox/features/birthdays/account.js b/app/soapbox/features/birthdays/account.js deleted file mode 100644 index b29d79589..000000000 --- a/app/soapbox/features/birthdays/account.js +++ /dev/null @@ -1,88 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; - -import Avatar from 'soapbox/components/avatar'; -import DisplayName from 'soapbox/components/display_name'; -import Icon from 'soapbox/components/icon'; -import Permalink from 'soapbox/components/permalink'; -import { makeGetAccount } from 'soapbox/selectors'; - -const messages = defineMessages({ - birthday: { id: 'account.birthday', defaultMessage: 'Born {date}' }, -}); - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, { accountId }) => { - const account = getAccount(state, accountId); - - return { - account, - }; - }; - - return mapStateToProps; -}; - -export default @connect(makeMapStateToProps) -@injectIntl -class Account extends ImmutablePureComponent { - - static propTypes = { - accountId: PropTypes.string.isRequired, - intl: PropTypes.object.isRequired, - account: ImmutablePropTypes.record, - }; - - static defaultProps = { - added: false, - }; - - componentDidMount() { - const { account, accountId } = this.props; - - if (accountId && !account) { - this.props.fetchAccount(accountId); - } - } - - render() { - const { account, intl } = this.props; - - if (!account) return null; - - const birthday = account.get('birthday'); - if (!birthday) return null; - - const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' }); - - return ( -
-
- -
-
- - -
-
-
- - {formattedBirthday} -
-
-
- ); - } - -} diff --git a/app/soapbox/features/birthdays/account.tsx b/app/soapbox/features/birthdays/account.tsx new file mode 100644 index 000000000..e72499b3d --- /dev/null +++ b/app/soapbox/features/birthdays/account.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useEffect } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import Icon from 'soapbox/components/icon'; +import Permalink from 'soapbox/components/permalink'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; + +const messages = defineMessages({ + birthday: { id: 'account.birthday', defaultMessage: 'Born {date}' }, +}); + +const getAccount = makeGetAccount(); + +interface IAccount { + accountId: string, + fetchAccount: (id: string) => void, +} + +const Account: React.FC = ({ accountId, fetchAccount }) => { + const intl = useIntl(); + const account = useAppSelector((state) => getAccount(state, accountId)); + + useEffect(() => { + if (accountId && !account) { + fetchAccount(accountId); + } + }, [accountId]); + + if (!account) return null; + + const birthday = account.get('birthday'); + if (!birthday) return null; + + const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' }); + + return ( +
+
+ +
+
+ + +
+
+
+ + {formattedBirthday} +
+
+
+ ); +}; + +export default Account; diff --git a/app/soapbox/features/birthdays/date_picker.js b/app/soapbox/features/birthdays/date_picker.ts similarity index 100% rename from app/soapbox/features/birthdays/date_picker.js rename to app/soapbox/features/birthdays/date_picker.ts diff --git a/app/soapbox/features/blocks/index.js b/app/soapbox/features/blocks/index.js deleted file mode 100644 index 7bf3ca43e..000000000 --- a/app/soapbox/features/blocks/index.js +++ /dev/null @@ -1,74 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { Column, Spinner } from 'soapbox/components/ui'; - -import { fetchBlocks, expandBlocks } from '../../actions/blocks'; -import ScrollableList from '../../components/scrollable_list'; -import AccountContainer from '../../containers/account_container'; - -const messages = defineMessages({ - heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, -}); - -const mapStateToProps = state => ({ - accountIds: state.getIn(['user_lists', 'blocks', 'items']), - hasMore: !!state.getIn(['user_lists', 'blocks', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Blocks extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.orderedSet, - hasMore: PropTypes.bool, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchBlocks()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandBlocks()); - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore } = this.props; - - if (!accountIds) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/blocks/index.tsx b/app/soapbox/features/blocks/index.tsx new file mode 100644 index 000000000..cdc3b0a98 --- /dev/null +++ b/app/soapbox/features/blocks/index.tsx @@ -0,0 +1,58 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchBlocks, expandBlocks } from 'soapbox/actions/blocks'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Column, Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandBlocks()); +}, 300, { leading: true }); + +const Blocks: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const accountIds = useAppSelector((state) => state.user_lists.getIn(['blocks', 'items'])); + const hasMore = useAppSelector((state) => !!state.user_lists.getIn(['blocks', 'next'])); + + React.useEffect(() => { + dispatch(fetchBlocks()); + }, []); + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + hasMore={hasMore} + emptyMessage={emptyMessage} + className='space-y-4' + > + {accountIds.map((id: string) => + , + )} + + + ); +}; + +export default Blocks; \ No newline at end of file diff --git a/app/soapbox/features/bookmarks/index.js b/app/soapbox/features/bookmarks/index.js deleted file mode 100644 index d2b7ea613..000000000 --- a/app/soapbox/features/bookmarks/index.js +++ /dev/null @@ -1,83 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import SubNavigation from 'soapbox/components/sub_navigation'; -import { Column } from 'soapbox/components/ui'; - -import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks'; -import StatusList from '../../components/status_list'; - -const messages = defineMessages({ - heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, -}); - -const mapStateToProps = state => ({ - statusIds: state.getIn(['status_lists', 'bookmarks', 'items']), - isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), - hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Bookmarks extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - shouldUpdateScroll: PropTypes.func, - statusIds: ImmutablePropTypes.orderedSet.isRequired, - intl: PropTypes.object.isRequired, - columnId: PropTypes.string, - multiColumn: PropTypes.bool, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - }; - - fetchData = () => { - const { dispatch } = this.props; - return dispatch(fetchBookmarkedStatuses()); - } - - componentDidMount() { - this.fetchData(); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandBookmarkedStatuses()); - }, 300, { leading: true }) - - handleRefresh = () => { - return this.fetchData(); - } - - render() { - const { intl, shouldUpdateScroll, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; - const pinned = !!columnId; - - const emptyMessage = ; - - return ( - - - - - ); - } - -} diff --git a/app/soapbox/features/bookmarks/index.tsx b/app/soapbox/features/bookmarks/index.tsx new file mode 100644 index 000000000..90aae0c1f --- /dev/null +++ b/app/soapbox/features/bookmarks/index.tsx @@ -0,0 +1,56 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import SubNavigation from 'soapbox/components/sub_navigation'; +import { Column } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions/bookmarks'; +import StatusList from '../../components/status_list'; + +const messages = defineMessages({ + heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandBookmarkedStatuses()); +}, 300, { leading: true }); + +const Bookmarks: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const statusIds = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'items'])); + const isLoading = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'isLoading'], true)); + const hasMore = useAppSelector((state) => !!state.status_lists.getIn(['bookmarks', 'next'])); + + React.useEffect(() => { + dispatch(fetchBookmarkedStatuses()); + }, []); + + const handleRefresh = () => { + return dispatch(fetchBookmarkedStatuses()); + }; + + const emptyMessage = ; + + return ( + + + handleLoadMore(dispatch)} + onRefresh={handleRefresh} + emptyMessage={emptyMessage} + divideType='space' + /> + + ); +}; + +export default Bookmarks; diff --git a/app/soapbox/features/chats/components/chat_list.js b/app/soapbox/features/chats/components/chat_list.js index b31219913..ffb0c1720 100644 --- a/app/soapbox/features/chats/components/chat_list.js +++ b/app/soapbox/features/chats/components/chat_list.js @@ -35,12 +35,12 @@ const chatDateComparator = (chatA, chatB) => { return 0; }; -const makeMapStateToProps = () => { - const sortedChatIdsSelector = createSelector( - [getSortedChatIds], - chats => chats, - ); +const sortedChatIdsSelector = createSelector( + [getSortedChatIds], + chats => chats, +); +const makeMapStateToProps = () => { const mapStateToProps = state => ({ chatIds: sortedChatIdsSelector(state.getIn(['chats', 'items'])), hasMore: !!state.getIn(['chats', 'next']), diff --git a/app/soapbox/features/domain_blocks/index.js b/app/soapbox/features/domain_blocks/index.js deleted file mode 100644 index 3ab084564..000000000 --- a/app/soapbox/features/domain_blocks/index.js +++ /dev/null @@ -1,75 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { Spinner } from 'soapbox/components/ui'; - -import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks'; -import ScrollableList from '../../components/scrollable_list'; -import DomainContainer from '../../containers/domain_container'; -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' }, - unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, -}); - -const mapStateToProps = state => ({ - domains: state.getIn(['domain_lists', 'blocks', 'items']), - hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Blocks extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - hasMore: PropTypes.bool, - domains: ImmutablePropTypes.orderedSet, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchDomainBlocks()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandDomainBlocks()); - }, 300, { leading: true }); - - render() { - const { intl, domains, hasMore } = this.props; - - if (!domains) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - {domains.map(domain => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/domain_blocks/index.tsx b/app/soapbox/features/domain_blocks/index.tsx new file mode 100644 index 000000000..712a6671f --- /dev/null +++ b/app/soapbox/features/domain_blocks/index.tsx @@ -0,0 +1,60 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchDomainBlocks, expandDomainBlocks } from 'soapbox/actions/domain_blocks'; +import Domain from 'soapbox/components/domain'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.domain_blocks', defaultMessage: 'Hidden domains' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandDomainBlocks()); +}, 300, { leading: true }); + +const DomainBlocks: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const domains = useAppSelector((state) => state.domain_lists.getIn(['blocks', 'items'])) as string[]; + const hasMore = useAppSelector((state) => !!state.domain_lists.getIn(['blocks', 'next'])); + + React.useEffect(() => { + dispatch(fetchDomainBlocks()); + }, []); + + if (!domains) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + hasMore={hasMore} + emptyMessage={emptyMessage} + > + {domains.map((domain) => + , + )} + + + ); +}; + +export default DomainBlocks; diff --git a/app/soapbox/features/emoji/emoji_compressed.js b/app/soapbox/features/emoji/emoji_compressed.js index c95bccf08..542bb7feb 100644 --- a/app/soapbox/features/emoji/emoji_compressed.js +++ b/app/soapbox/features/emoji/emoji_compressed.js @@ -14,7 +14,7 @@ const { unicodeToFilename } = require('./unicode_to_filename'); const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); -if(data.compressed) { +if (data.compressed) { data = emojiMartUncompress(data); } diff --git a/app/soapbox/features/mutes/index.js b/app/soapbox/features/mutes/index.js deleted file mode 100644 index 69664c72f..000000000 --- a/app/soapbox/features/mutes/index.js +++ /dev/null @@ -1,74 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { Column, Spinner } from 'soapbox/components/ui'; - -import { fetchMutes, expandMutes } from '../../actions/mutes'; -import ScrollableList from '../../components/scrollable_list'; -import AccountContainer from '../../containers/account_container'; - -const messages = defineMessages({ - heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, -}); - -const mapStateToProps = state => ({ - accountIds: state.getIn(['user_lists', 'mutes', 'items']), - hasMore: !!state.getIn(['user_lists', 'mutes', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Mutes extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - hasMore: PropTypes.bool, - accountIds: ImmutablePropTypes.orderedSet, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchMutes()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandMutes()); - }, 300, { leading: true }); - - render() { - const { intl, hasMore, accountIds } = this.props; - - if (!accountIds) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/mutes/index.tsx b/app/soapbox/features/mutes/index.tsx new file mode 100644 index 000000000..8e27c06d2 --- /dev/null +++ b/app/soapbox/features/mutes/index.tsx @@ -0,0 +1,58 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchMutes, expandMutes } from 'soapbox/actions/mutes'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Column, Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.mutes', defaultMessage: 'Muted users' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandMutes()); +}, 300, { leading: true }); + +const Mutes: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const accountIds = useAppSelector((state) => state.user_lists.getIn(['mutes', 'items'])); + const hasMore = useAppSelector((state) => !!state.user_lists.getIn(['mutes', 'next'])); + + React.useEffect(() => { + dispatch(fetchMutes()); + }, []); + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + hasMore={hasMore} + emptyMessage={emptyMessage} + className='space-y-4' + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default Mutes; diff --git a/app/soapbox/features/status/components/quoted_status.tsx b/app/soapbox/features/status/components/quoted_status.tsx index 25c8e850e..0b715a86f 100644 --- a/app/soapbox/features/status/components/quoted_status.tsx +++ b/app/soapbox/features/status/components/quoted_status.tsx @@ -8,7 +8,8 @@ import { withRouter } from 'react-router-dom'; import AttachmentThumbs from 'soapbox/components/attachment_thumbs'; import { Stack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; -import { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; + +import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; const messages = defineMessages({ cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, diff --git a/app/soapbox/features/ui/components/profile_info_panel.js b/app/soapbox/features/ui/components/profile_info_panel.js index 57dfcf48e..e4547e270 100644 --- a/app/soapbox/features/ui/components/profile_info_panel.js +++ b/app/soapbox/features/ui/components/profile_info_panel.js @@ -22,7 +22,7 @@ const isSafeUrl = text => { try { const url = new URL(text); return ['http:', 'https:'].includes(url.protocol); - } catch(e) { + } catch (e) { return false; } }; diff --git a/app/soapbox/features/video/index.js b/app/soapbox/features/video/index.js index 708d87149..869fcdfce 100644 --- a/app/soapbox/features/video/index.js +++ b/app/soapbox/features/video/index.js @@ -216,12 +216,12 @@ class Video extends React.PureComponent { handleMouseVolSlide = throttle(e => { const { x } = getPointerPosition(this.volume, e); - if(!isNaN(x)) { + if (!isNaN(x)) { let slideamt = x; - if(x > 1) { + if (x > 1) { slideamt = 1; - } else if(x < 0) { + } else if (x < 0) { slideamt = 0; } diff --git a/app/soapbox/reducers/auth.js b/app/soapbox/reducers/auth.js index 9bd3f6f33..26d3c8794 100644 --- a/app/soapbox/reducers/auth.js +++ b/app/soapbox/reducers/auth.js @@ -44,7 +44,7 @@ const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY))); const validUser = user => { try { return validId(user.get('id')) && validId(user.get('access_token')); - } catch(e) { + } catch (e) { return false; } }; diff --git a/app/soapbox/reducers/conversations.js b/app/soapbox/reducers/conversations.js index 29ea929f3..f15408e0c 100644 --- a/app/soapbox/reducers/conversations.js +++ b/app/soapbox/reducers/conversations.js @@ -58,7 +58,7 @@ const expandNormalizedConversations = (state, conversations, next, isLoadingRece list = list.concat(items); return list.sortBy(x => x.get('last_status'), (a, b) => { - if(a === null || b === null) { + if (a === null || b === null) { return -1; } diff --git a/app/soapbox/stream.js b/app/soapbox/stream.js index fb0474367..5d201e314 100644 --- a/app/soapbox/stream.js +++ b/app/soapbox/stream.js @@ -93,7 +93,7 @@ export default function getStream(streamingAPIBaseURL, accessToken, stream, { co if (!e.data) return; try { received(JSON.parse(e.data)); - } catch(error) { + } catch (error) { console.error(e); console.error(`Could not parse the above streaming event.\n${error}`); } diff --git a/app/soapbox/utils/auth.js b/app/soapbox/utils/auth.js deleted file mode 100644 index 0d0c6c321..000000000 --- a/app/soapbox/utils/auth.js +++ /dev/null @@ -1,64 +0,0 @@ -import { List as ImmutableList } from 'immutable'; - -export const validId = id => typeof id === 'string' && id !== 'null' && id !== 'undefined'; - -export const isURL = url => { - try { - new URL(url); - return true; - } catch { - return false; - } -}; - -export const parseBaseURL = url => { - try { - return new URL(url).origin; - } catch { - return ''; - } -}; - -export const getLoggedInAccount = state => { - const me = state.get('me'); - return state.getIn(['accounts', me]); -}; - -export const isLoggedIn = getState => { - return validId(getState().get('me')); -}; - -export const getAppToken = state => state.getIn(['auth', 'app', 'access_token']); - -export const getUserToken = (state, accountId) => { - const accountUrl = state.getIn(['accounts', accountId, 'url']); - return state.getIn(['auth', 'users', accountUrl, 'access_token']); -}; - -export const getAccessToken = state => { - const me = state.get('me'); - return getUserToken(state, me); -}; - -export const getAuthUserId = state => { - const me = state.getIn(['auth', 'me']); - - return ImmutableList([ - state.getIn(['auth', 'users', me, 'id']), - me, - ]).find(validId); -}; - -export const getAuthUserUrl = state => { - const me = state.getIn(['auth', 'me']); - - return ImmutableList([ - state.getIn(['auth', 'users', me, 'url']), - me, - ]).find(isURL); -}; - -/** Get the VAPID public key. */ -export const getVapidKey = state => { - return state.getIn(['auth', 'app', 'vapid_key']) || state.getIn(['instance', 'pleroma', 'vapid_public_key']); -}; diff --git a/app/soapbox/utils/auth.ts b/app/soapbox/utils/auth.ts new file mode 100644 index 000000000..1a5b1b38e --- /dev/null +++ b/app/soapbox/utils/auth.ts @@ -0,0 +1,66 @@ +import { List as ImmutableList } from 'immutable'; + +import type { RootState } from 'soapbox/store'; + +export const validId = (id: any) => typeof id === 'string' && id !== 'null' && id !== 'undefined'; + +export const isURL = (url: string) => { + try { + new URL(url); + return true; + } catch { + return false; + } +}; + +export const parseBaseURL = (url: any) => { + try { + return new URL(url).origin; + } catch { + return ''; + } +}; + +export const getLoggedInAccount = (state: RootState) => { + const me = state.me; + return state.accounts.get(me); +}; + +export const isLoggedIn = (getState: () => RootState) => { + return validId(getState().me); +}; + +export const getAppToken = (state: RootState) => state.auth.getIn(['app', 'access_token']); + +export const getUserToken = (state: RootState, accountId?: string | false | null) => { + const accountUrl = state.accounts.getIn([accountId, 'url']); + return state.auth.getIn(['users', accountUrl, 'access_token']); +}; + +export const getAccessToken = (state: RootState) => { + const me = state.me; + return getUserToken(state, me); +}; + +export const getAuthUserId = (state: RootState) => { + const me = state.auth.get('me'); + + return ImmutableList([ + state.auth.getIn(['users', me, 'id']), + me, + ]).find(validId); +}; + +export const getAuthUserUrl = (state: RootState) => { + const me = state.auth.get('me'); + + return ImmutableList([ + state.auth.getIn(['users', me, 'url']), + me, + ]).find(isURL); +}; + +/** Get the VAPID public key. */ +export const getVapidKey = (state: RootState) => { + return state.auth.getIn(['app', 'vapid_key']) || state.instance.getIn(['pleroma', 'vapid_public_key']); +}; diff --git a/app/soapbox/utils/base64.js b/app/soapbox/utils/base64.ts similarity index 82% rename from app/soapbox/utils/base64.js rename to app/soapbox/utils/base64.ts index 8226e2c54..c512a6594 100644 --- a/app/soapbox/utils/base64.js +++ b/app/soapbox/utils/base64.ts @@ -1,4 +1,4 @@ -export const decode = base64 => { +export const decode = (base64: string) => { const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); diff --git a/app/soapbox/utils/greentext.js b/app/soapbox/utils/greentext.js index 543bc2f23..f60ee7c3c 100644 --- a/app/soapbox/utils/greentext.js +++ b/app/soapbox/utils/greentext.js @@ -15,7 +15,7 @@ export const addGreentext = html => { } else { return string; } - } catch(e) { + } catch (e) { return string; } }); diff --git a/app/soapbox/utils/instance.js b/app/soapbox/utils/instance.js deleted file mode 100644 index af9d69665..000000000 --- a/app/soapbox/utils/instance.js +++ /dev/null @@ -1,11 +0,0 @@ -export const getHost = instance => { - try { - return new URL(instance.get('uri')).host; - } catch { - try { - return new URL(`https://${instance.get('uri')}`).host; - } catch { - return null; - } - } -}; diff --git a/app/soapbox/utils/media_aspect_ratio.js b/app/soapbox/utils/media_aspect_ratio.ts similarity index 70% rename from app/soapbox/utils/media_aspect_ratio.js rename to app/soapbox/utils/media_aspect_ratio.ts index 18e6fdc57..8821d9deb 100644 --- a/app/soapbox/utils/media_aspect_ratio.js +++ b/app/soapbox/utils/media_aspect_ratio.ts @@ -1,17 +1,17 @@ export const minimumAspectRatio = 9 / 16; // Portrait phone export const maximumAspectRatio = 10; // Generous min-height -export const isPanoramic = ar => { +export const isPanoramic = (ar: number) => { if (isNaN(ar)) return false; return ar >= maximumAspectRatio; }; -export const isPortrait = ar => { +export const isPortrait = (ar: number) => { if (isNaN(ar)) return false; return ar <= minimumAspectRatio; }; -export const isNonConformingRatio = ar => { +export const isNonConformingRatio = (ar: number) => { if (isNaN(ar)) return false; return !isPanoramic(ar) && !isPortrait(ar); }; diff --git a/app/soapbox/utils/phone.js b/app/soapbox/utils/phone.ts similarity index 100% rename from app/soapbox/utils/phone.js rename to app/soapbox/utils/phone.ts diff --git a/app/soapbox/utils/status.js b/app/soapbox/utils/status.ts similarity index 67% rename from app/soapbox/utils/status.js rename to app/soapbox/utils/status.ts index acd69dc5e..7f2bfe42f 100644 --- a/app/soapbox/utils/status.js +++ b/app/soapbox/utils/status.ts @@ -1,6 +1,8 @@ import { isIntegerId } from 'soapbox/utils/numbers'; -export const getFirstExternalLink = status => { +import type { Status as StatusEntity } from 'soapbox/types/entities'; + +export const getFirstExternalLink = (status: StatusEntity) => { try { // Pulled from Pleroma's media parser const selector = 'a:not(.mention,.hashtag,.attachment,[rel~="tag"])'; @@ -12,11 +14,11 @@ export const getFirstExternalLink = status => { } }; -export const shouldHaveCard = status => { +export const shouldHaveCard = (status: StatusEntity) => { return Boolean(getFirstExternalLink(status)); }; // https://gitlab.com/soapbox-pub/soapbox-fe/-/merge_requests/1087 -export const hasIntegerMediaIds = status => { +export const hasIntegerMediaIds = (status: StatusEntity) => { return status.media_attachments.some(({ id }) => isIntegerId(id)); }; diff --git a/app/soapbox/utils/timelines.js b/app/soapbox/utils/timelines.js deleted file mode 100644 index d15a4fa88..000000000 --- a/app/soapbox/utils/timelines.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -export const shouldFilter = (status, columnSettings) => { - const shows = ImmutableMap({ - reblog: status.get('reblog') !== null, - reply: status.get('in_reply_to_id') !== null, - direct: status.get('visibility') === 'direct', - }); - - return shows.some((value, key) => { - return columnSettings.getIn(['shows', key]) === false && value; - }); -}; diff --git a/app/soapbox/utils/timelines.ts b/app/soapbox/utils/timelines.ts new file mode 100644 index 000000000..03ba96044 --- /dev/null +++ b/app/soapbox/utils/timelines.ts @@ -0,0 +1,15 @@ +import { Map as ImmutableMap } from 'immutable'; + +import type { Status as StatusEntity } from 'soapbox/types/entities'; + +export const shouldFilter = (status: StatusEntity, columnSettings: any) => { + const shows = ImmutableMap({ + reblog: status.reblog !== null, + reply: status.in_reply_to_id !== null, + direct: status.visibility === 'direct', + }); + + return shows.some((value, key) => { + return columnSettings.getIn(['shows', key]) === false && value; + }); +}; diff --git a/package.json b/package.json index c14cd88ae..4caec0c8d 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@types/jest": "^27.4.1", "@types/lodash": "^4.14.180", "@types/qrcode.react": "^1.0.2", + "@types/react-datepicker": "^4.4.0", "@types/react-helmet": "^6.1.5", "@types/react-motion": "^0.0.32", "@types/react-router-dom": "^5.3.3", @@ -145,7 +146,7 @@ "qrcode.react": "^1.0.0", "react": "^16.13.1", "react-color": "^2.18.1", - "react-datepicker": "^4.6.0", + "react-datepicker": "^4.7.0", "react-dom": "^16.13.1", "react-helmet": "^6.0.0", "react-hotkeys": "^1.1.4", diff --git a/yarn.lock b/yarn.lock index d86daa869..9ed45677b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2171,6 +2171,16 @@ dependencies: "@types/react" "*" +"@types/react-datepicker@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@types/react-datepicker/-/react-datepicker-4.4.0.tgz#0072e18536ad305fd57786f9b6f9e499eed2b475" + integrity sha512-wzmevaO51rLFwSZd5HSqBU0aAvZlRRkj6QhHqj0jfRDSKnN3y5IKXyhgxPS8R0LOWOtjdpirI1DBryjnIp/7gA== + dependencies: + "@popperjs/core" "^2.9.2" + "@types/react" "*" + date-fns "^2.0.1" + react-popper "^2.2.5" + "@types/react-dom@*": version "17.0.14" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.14.tgz#c8f917156b652ddf807711f5becbd2ab018dea9f" @@ -4006,7 +4016,7 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" -date-fns@^2.24.0: +date-fns@^2.0.1, date-fns@^2.24.0: version "2.28.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== @@ -8662,7 +8672,7 @@ react-color@^2.18.1: reactcss "^1.2.0" tinycolor2 "^1.4.1" -react-datepicker@^4.6.0: +react-datepicker@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.7.0.tgz#75e03b0a6718b97b84287933307faf2ed5f03cf4" integrity sha512-FS8KgbwqpxmJBv/bUdA42MYqYZa+fEYcpc746DZiHvVE2nhjrW/dg7c5B5fIUuI8gZET6FOzuDgezNcj568Czw== From ae396544a713dd27d684c3dd8eb93e1d91ef2057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Mon, 11 Apr 2022 23:02:37 +0200 Subject: [PATCH 010/334] Typescript, FC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../components/account_authorize.js | 50 ------------- .../components/account_authorize.tsx | 61 +++++++++++++++ .../containers/account_authorize_container.js | 27 ------- app/soapbox/features/follow_requests/index.js | 75 ------------------- .../features/follow_requests/index.tsx | 60 +++++++++++++++ app/soapbox/features/mutes/index.tsx | 2 +- app/soapbox/utils/timelines.ts | 6 +- 7 files changed, 125 insertions(+), 156 deletions(-) delete mode 100644 app/soapbox/features/follow_requests/components/account_authorize.js create mode 100644 app/soapbox/features/follow_requests/components/account_authorize.tsx delete mode 100644 app/soapbox/features/follow_requests/containers/account_authorize_container.js delete mode 100644 app/soapbox/features/follow_requests/index.js create mode 100644 app/soapbox/features/follow_requests/index.tsx diff --git a/app/soapbox/features/follow_requests/components/account_authorize.js b/app/soapbox/features/follow_requests/components/account_authorize.js deleted file mode 100644 index 56aac382a..000000000 --- a/app/soapbox/features/follow_requests/components/account_authorize.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; - -import Avatar from '../../../components/avatar'; -import DisplayName from '../../../components/display_name'; -import IconButton from '../../../components/icon_button'; -import Permalink from '../../../components/permalink'; - -const messages = defineMessages({ - authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, - reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, -}); - -export default @injectIntl -class AccountAuthorize extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.record.isRequired, - onAuthorize: PropTypes.func.isRequired, - onReject: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - render() { - const { intl, account, onAuthorize, onReject } = this.props; - const content = { __html: account.get('note_emojified') }; - - return ( -
-
- -
- -
- -
-
- -
-
-
-
-
- ); - } - -} diff --git a/app/soapbox/features/follow_requests/components/account_authorize.tsx b/app/soapbox/features/follow_requests/components/account_authorize.tsx new file mode 100644 index 000000000..cd46f7817 --- /dev/null +++ b/app/soapbox/features/follow_requests/components/account_authorize.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { authorizeFollowRequest, rejectFollowRequest } from 'soapbox/actions/accounts'; +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import IconButton from 'soapbox/components/icon_button'; +import Permalink from 'soapbox/components/permalink'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; + +const messages = defineMessages({ + authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, + reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, +}); + +const getAccount = makeGetAccount(); + +interface IAccountAuthorize { + id: string, +} + +const AccountAuthorize: React.FC = ({ id }) => { + const intl = useIntl(); + const dispatch = useDispatch(); + + const account = useAppSelector((state) => getAccount(state, id)); + + const onAuthorize = () => { + dispatch(authorizeFollowRequest(id)); + }; + + const onReject = () => { + dispatch(rejectFollowRequest(id)); + }; + + if (!account) return null; + + const content = { __html: account.note_emojified }; + + return ( +
+
+ +
+ +
+ +
+
+ +
+
+
+
+
+ ); +}; + +export default AccountAuthorize; diff --git a/app/soapbox/features/follow_requests/containers/account_authorize_container.js b/app/soapbox/features/follow_requests/containers/account_authorize_container.js deleted file mode 100644 index cf38b3c69..000000000 --- a/app/soapbox/features/follow_requests/containers/account_authorize_container.js +++ /dev/null @@ -1,27 +0,0 @@ -import { connect } from 'react-redux'; - -import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts'; -import { makeGetAccount } from '../../../selectors'; -import AccountAuthorize from '../components/account_authorize'; - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, props) => ({ - account: getAccount(state, props.id), - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { id }) => ({ - onAuthorize() { - dispatch(authorizeFollowRequest(id)); - }, - - onReject() { - dispatch(rejectFollowRequest(id)); - }, -}); - -export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize); diff --git a/app/soapbox/features/follow_requests/index.js b/app/soapbox/features/follow_requests/index.js deleted file mode 100644 index 3c3f8f75e..000000000 --- a/app/soapbox/features/follow_requests/index.js +++ /dev/null @@ -1,75 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { Spinner } from 'soapbox/components/ui'; - -import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts'; -import ScrollableList from '../../components/scrollable_list'; -import Column from '../ui/components/column'; - -import AccountAuthorizeContainer from './containers/account_authorize_container'; - -const messages = defineMessages({ - heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }, -}); - -const mapStateToProps = state => ({ - accountIds: state.getIn(['user_lists', 'follow_requests', 'items']), - hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']), -}); - -export default @connect(mapStateToProps) -@injectIntl -class FollowRequests extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - hasMore: PropTypes.bool, - accountIds: ImmutablePropTypes.orderedSet, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchFollowRequests()); - } - - handleLoadMore = debounce(() => { - this.props.dispatch(expandFollowRequests()); - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore } = this.props; - - if (!accountIds) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - - - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/follow_requests/index.tsx b/app/soapbox/features/follow_requests/index.tsx new file mode 100644 index 000000000..ef82d1aef --- /dev/null +++ b/app/soapbox/features/follow_requests/index.tsx @@ -0,0 +1,60 @@ +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchFollowRequests, expandFollowRequests } from 'soapbox/actions/accounts'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import Column from '../ui/components/column'; + +import AccountAuthorize from './components/account_authorize'; + +const messages = defineMessages({ + heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandFollowRequests()); +}, 300, { leading: true }); + +const FollowRequests: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const accountIds = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'])); + const hasMore = useAppSelector((state) => !!state.user_lists.getIn(['follow_requests', 'next'])); + + React.useEffect(() => { + dispatch(fetchFollowRequests()); + }, []); + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + hasMore={hasMore} + emptyMessage={emptyMessage} + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default FollowRequests; diff --git a/app/soapbox/features/mutes/index.tsx b/app/soapbox/features/mutes/index.tsx index 8e27c06d2..c1f306c24 100644 --- a/app/soapbox/features/mutes/index.tsx +++ b/app/soapbox/features/mutes/index.tsx @@ -47,7 +47,7 @@ const Mutes: React.FC = () => { emptyMessage={emptyMessage} className='space-y-4' > - {accountIds.map(id => + {accountIds.map((id: string) => , )} diff --git a/app/soapbox/utils/timelines.ts b/app/soapbox/utils/timelines.ts index 03ba96044..9f901b125 100644 --- a/app/soapbox/utils/timelines.ts +++ b/app/soapbox/utils/timelines.ts @@ -4,9 +4,9 @@ import type { Status as StatusEntity } from 'soapbox/types/entities'; export const shouldFilter = (status: StatusEntity, columnSettings: any) => { const shows = ImmutableMap({ - reblog: status.reblog !== null, - reply: status.in_reply_to_id !== null, - direct: status.visibility === 'direct', + reblog: status.get('reblog') !== null, + reply: status.get('in_reply_to_id') !== null, + direct: status.get('visibility') === 'direct', }); return shows.some((value, key) => { From b5ae9adf63f46908c4e6bee47455c8f1925f7c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 12 Apr 2022 18:52:56 +0200 Subject: [PATCH 011/334] Chats: typescript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../features/chats/components/audio_toggle.js | 61 ------------ .../chats/components/audio_toggle.tsx | 46 +++++++++ app/soapbox/features/chats/components/chat.js | 87 ---------------- .../features/chats/components/chat.tsx | 69 +++++++++++++ .../features/chats/components/chat_list.js | 99 ------------------- .../features/chats/components/chat_list.tsx | 84 ++++++++++++++++ app/soapbox/features/chats/index.js | 66 ------------- app/soapbox/features/chats/index.tsx | 55 +++++++++++ .../compose/containers/upload_container.js | 2 +- app/soapbox/normalizers/chat.ts | 18 ++++ app/soapbox/normalizers/chat_message.ts | 29 ++++++ app/soapbox/normalizers/index.ts | 2 + ...message_lists.js => chat_message_lists.ts} | 30 +++--- .../{chat_messages.js => chat_messages.ts} | 32 +++--- app/soapbox/reducers/chats.js | 58 ----------- app/soapbox/reducers/chats.ts | 76 ++++++++++++++ app/soapbox/selectors/index.ts | 11 ++- app/soapbox/types/entities.ts | 6 ++ 18 files changed, 431 insertions(+), 400 deletions(-) delete mode 100644 app/soapbox/features/chats/components/audio_toggle.js create mode 100644 app/soapbox/features/chats/components/audio_toggle.tsx delete mode 100644 app/soapbox/features/chats/components/chat.js create mode 100644 app/soapbox/features/chats/components/chat.tsx delete mode 100644 app/soapbox/features/chats/components/chat_list.js create mode 100644 app/soapbox/features/chats/components/chat_list.tsx delete mode 100644 app/soapbox/features/chats/index.js create mode 100644 app/soapbox/features/chats/index.tsx create mode 100644 app/soapbox/normalizers/chat.ts create mode 100644 app/soapbox/normalizers/chat_message.ts rename app/soapbox/reducers/{chat_message_lists.js => chat_message_lists.ts} (63%) rename app/soapbox/reducers/{chat_messages.js => chat_messages.ts} (60%) delete mode 100644 app/soapbox/reducers/chats.js create mode 100644 app/soapbox/reducers/chats.ts diff --git a/app/soapbox/features/chats/components/audio_toggle.js b/app/soapbox/features/chats/components/audio_toggle.js deleted file mode 100644 index f0068d1f3..000000000 --- a/app/soapbox/features/chats/components/audio_toggle.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { injectIntl, defineMessages } from 'react-intl'; -import { connect } from 'react-redux'; -import Toggle from 'react-toggle'; - -import { changeSetting, getSettings } from 'soapbox/actions/settings'; - -const messages = defineMessages({ - switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' }, - switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' }, -}); - -const mapStateToProps = state => { - return { - checked: getSettings(state).getIn(['chats', 'sound'], false), - }; -}; - -const mapDispatchToProps = (dispatch) => ({ - toggleAudio(setting) { - dispatch(changeSetting(['chats', 'sound'], setting)); - }, -}); - -export default @connect(mapStateToProps, mapDispatchToProps) -@injectIntl -class AudioToggle extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - checked: PropTypes.bool.isRequired, - toggleAudio: PropTypes.func, - showLabel: PropTypes.bool, - }; - - handleToggleAudio = () => { - this.props.toggleAudio(!this.props.checked); - } - - render() { - const { intl, checked, showLabel } = this.props; - const id = 'chats-audio-toggle'; - const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn); - - return ( -
-
- - {showLabel && ()} -
-
- ); - } - -} diff --git a/app/soapbox/features/chats/components/audio_toggle.tsx b/app/soapbox/features/chats/components/audio_toggle.tsx new file mode 100644 index 000000000..96ddf6211 --- /dev/null +++ b/app/soapbox/features/chats/components/audio_toggle.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import Toggle from 'react-toggle'; + +import { changeSetting, getSettings } from 'soapbox/actions/settings'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + switchOn: { id: 'chats.audio_toggle_on', defaultMessage: 'Audio notification on' }, + switchOff: { id: 'chats.audio_toggle_off', defaultMessage: 'Audio notification off' }, +}); + +interface IAudioToggle { + showLabel?: boolean +} + +const AudioToggle: React.FC = ({ showLabel }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const checked = useAppSelector(state => !!getSettings(state).getIn(['chats', 'sound'])); + + const handleToggleAudio = () => { + dispatch(changeSetting(['chats', 'sound'], !checked)); + }; + + const id = 'chats-audio-toggle'; + const label = intl.formatMessage(checked ? messages.switchOff : messages.switchOn); + + return ( +
+
+ + {showLabel && ()} +
+
+ ); +}; + +export default AudioToggle; diff --git a/app/soapbox/features/chats/components/chat.js b/app/soapbox/features/chats/components/chat.js deleted file mode 100644 index f19190bed..000000000 --- a/app/soapbox/features/chats/components/chat.js +++ /dev/null @@ -1,87 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import Icon from 'soapbox/components/icon'; -import emojify from 'soapbox/features/emoji/emoji'; -import { makeGetChat } from 'soapbox/selectors'; -import { shortNumberFormat } from 'soapbox/utils/numbers'; - -import Avatar from '../../../components/avatar'; -import DisplayName from '../../../components/display_name'; - -const makeMapStateToProps = () => { - const getChat = makeGetChat(); - - const mapStateToProps = (state, { chatId }) => { - const chat = state.getIn(['chats', 'items', chatId]); - - return { - chat: chat ? getChat(state, chat.toJS()) : undefined, - }; - }; - - return mapStateToProps; -}; - -export default @connect(makeMapStateToProps) -class Chat extends ImmutablePureComponent { - - static propTypes = { - chatId: PropTypes.string.isRequired, - chat: ImmutablePropTypes.map, - onClick: PropTypes.func, - }; - - handleClick = () => { - this.props.onClick(this.props.chat); - } - - render() { - const { chat } = this.props; - if (!chat) return null; - const account = chat.get('account'); - const unreadCount = chat.get('unread'); - const content = chat.getIn(['last_message', 'content']); - const attachment = chat.getIn(['last_message', 'attachment']); - const image = attachment && attachment.getIn(['pleroma', 'mime_type'], '').startsWith('image/'); - const parsedContent = content ? emojify(content) : ''; - - return ( -
-
- ); - } - -} diff --git a/app/soapbox/features/chats/components/chat.tsx b/app/soapbox/features/chats/components/chat.tsx new file mode 100644 index 000000000..afb1d9df8 --- /dev/null +++ b/app/soapbox/features/chats/components/chat.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; + +import Avatar from 'soapbox/components/avatar'; +import DisplayName from 'soapbox/components/display_name'; +import Icon from 'soapbox/components/icon'; +import emojify from 'soapbox/features/emoji/emoji'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetChat } from 'soapbox/selectors'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +import type { Account as AccountEntity, Chat as ChatEntity } from 'soapbox/types/entities'; + +const getChat = makeGetChat(); + +interface IChat { + chatId: string, + onClick: (chat: any) => void, +} + +const Chat: React.FC = ({ chatId, onClick }) => { + const chat = useAppSelector((state) => { + const chat = state.chats.getIn(['items', chatId]); + return chat ? getChat(state, (chat as any).toJS()) : undefined; + }) as ChatEntity; + + const account = chat.account as AccountEntity; + if (!chat || !account) return null; + const unreadCount = chat.unread; + const content = chat.getIn(['last_message', 'content']); + const attachment = chat.getIn(['last_message', 'attachment']); + const image = attachment && (attachment as any).getIn(['pleroma', 'mime_type'], '').startsWith('image/'); + const parsedContent = content ? emojify(content) : ''; + + return ( +
+
+ ); +}; + +export default Chat; diff --git a/app/soapbox/features/chats/components/chat_list.js b/app/soapbox/features/chats/components/chat_list.js deleted file mode 100644 index ffb0c1720..000000000 --- a/app/soapbox/features/chats/components/chat_list.js +++ /dev/null @@ -1,99 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; - -import { expandChats } from 'soapbox/actions/chats'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat'; - -import Chat from './chat'; - -const messages = defineMessages({ - emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' }, -}); - -const getSortedChatIds = chats => ( - chats - .toList() - .sort(chatDateComparator) - .map(chat => chat.get('id')) -); - -const chatDateComparator = (chatA, chatB) => { - // Sort most recently updated chats at the top - const a = new Date(chatA.get('updated_at')); - const b = new Date(chatB.get('updated_at')); - - if (a === b) return 0; - if (a > b) return -1; - if (a < b) return 1; - return 0; -}; - -const sortedChatIdsSelector = createSelector( - [getSortedChatIds], - chats => chats, -); - -const makeMapStateToProps = () => { - const mapStateToProps = state => ({ - chatIds: sortedChatIdsSelector(state.getIn(['chats', 'items'])), - hasMore: !!state.getIn(['chats', 'next']), - isLoading: state.getIn(['chats', 'loading']), - }); - - return mapStateToProps; -}; - -export default @connect(makeMapStateToProps) -@injectIntl -class ChatList extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - chatIds: ImmutablePropTypes.list, - onClickChat: PropTypes.func, - onRefresh: PropTypes.func, - hasMore: PropTypes.func, - isLoading: PropTypes.bool, - }; - - handleLoadMore = debounce(() => { - this.props.dispatch(expandChats()); - }, 300, { leading: true }); - - render() { - const { intl, chatIds, hasMore, isLoading } = this.props; - - return ( - - {chatIds.map(chatId => ( -
- -
- ))} -
- ); - } - -} diff --git a/app/soapbox/features/chats/components/chat_list.tsx b/app/soapbox/features/chats/components/chat_list.tsx new file mode 100644 index 000000000..874c71041 --- /dev/null +++ b/app/soapbox/features/chats/components/chat_list.tsx @@ -0,0 +1,84 @@ +import { Map as ImmutableMap } from 'immutable'; +import { debounce } from 'lodash'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { expandChats } from 'soapbox/actions/chats'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import PlaceholderChat from 'soapbox/features/placeholder/components/placeholder_chat'; +import { useAppSelector } from 'soapbox/hooks'; + +import Chat from './chat'; + +const messages = defineMessages({ + emptyMessage: { id: 'chat_panels.main_window.empty', defaultMessage: 'No chats found. To start a chat, visit a user\'s profile' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandChats()); +}, 300, { leading: true }); + +const getSortedChatIds = (chats: ImmutableMap) => ( + chats + .toList() + .sort(chatDateComparator) + .map(chat => chat.id) +); + +const chatDateComparator = (chatA: { updated_at: string }, chatB: { updated_at: string }) => { + // Sort most recently updated chats at the top + const a = new Date(chatA.updated_at); + const b = new Date(chatB.updated_at); + + if (a === b) return 0; + if (a > b) return -1; + if (a < b) return 1; + return 0; +}; + +const sortedChatIdsSelector = createSelector( + [getSortedChatIds], + chats => chats, +); + +interface IChatList { + onClickChat: (chat: any) => void, + onRefresh: () => void, +} + +const ChatList: React.FC = ({ onClickChat, onRefresh }) => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const chatIds = useAppSelector(state => sortedChatIdsSelector(state.chats.get('items'))); + const hasMore = useAppSelector(state => !!state.chats.get('next')); + const isLoading = useAppSelector(state => state.chats.get('isLoading')); + + return ( + handleLoadMore(dispatch)} + onRefresh={onRefresh} + placeholderComponent={PlaceholderChat} + placeholderCount={20} + > + {chatIds.map((chatId: string) => ( +
+ +
+ ))} +
+ ); +}; + +export default ChatList; diff --git a/app/soapbox/features/chats/index.js b/app/soapbox/features/chats/index.js deleted file mode 100644 index ed8c35e60..000000000 --- a/app/soapbox/features/chats/index.js +++ /dev/null @@ -1,66 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { fetchChats, launchChat } from 'soapbox/actions/chats'; -import AccountSearch from 'soapbox/components/account_search'; -import AudioToggle from 'soapbox/features/chats/components/audio_toggle'; - -import { Column } from '../../components/ui'; - -import ChatList from './components/chat_list'; - -const messages = defineMessages({ - title: { id: 'column.chats', defaultMessage: 'Chats' }, - searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' }, -}); - -export default @connect() -@injectIntl -@withRouter -class ChatIndex extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - history: PropTypes.object, - }; - - handleSuggestion = accountId => { - this.props.dispatch(launchChat(accountId, this.props.history, true)); - } - - handleClickChat = (chat) => { - this.props.history.push(`/chats/${chat.get('id')}`); - } - - handleRefresh = () => { - const { dispatch } = this.props; - return dispatch(fetchChats()); - } - - render() { - const { intl } = this.props; - - return ( - -
- -
- - - - -
- ); - } - -} diff --git a/app/soapbox/features/chats/index.tsx b/app/soapbox/features/chats/index.tsx new file mode 100644 index 000000000..c13335ff3 --- /dev/null +++ b/app/soapbox/features/chats/index.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; + +import { fetchChats, launchChat } from 'soapbox/actions/chats'; +import AccountSearch from 'soapbox/components/account_search'; +import AudioToggle from 'soapbox/features/chats/components/audio_toggle'; + +import { Column } from '../../components/ui'; + +import ChatList from './components/chat_list'; + +const messages = defineMessages({ + title: { id: 'column.chats', defaultMessage: 'Chats' }, + searchPlaceholder: { id: 'chats.search_placeholder', defaultMessage: 'Start a chat with…' }, +}); + +const ChatIndex: React.FC = () => { + const intl = useIntl(); + const dispatch = useDispatch(); + const history = useHistory(); + + const handleSuggestion = (accountId: string) => { + dispatch(launchChat(accountId, history, true)); + }; + + const handleClickChat = (chat: { id: string }) => { + history.push(`/chats/${chat.id}`); + }; + + const handleRefresh = () => { + return dispatch(fetchChats()); + }; + + return ( + +
+ +
+ + + + +
+ ); +}; + +export default ChatIndex; diff --git a/app/soapbox/features/compose/containers/upload_container.js b/app/soapbox/features/compose/containers/upload_container.js index 9c77c6df7..332f206ee 100644 --- a/app/soapbox/features/compose/containers/upload_container.js +++ b/app/soapbox/features/compose/containers/upload_container.js @@ -26,7 +26,7 @@ const mapDispatchToProps = dispatch => ({ }, onOpenModal: media => { - dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0, onClose: console.log })); + dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 })); }, onSubmit(router) { diff --git a/app/soapbox/normalizers/chat.ts b/app/soapbox/normalizers/chat.ts new file mode 100644 index 000000000..04149c682 --- /dev/null +++ b/app/soapbox/normalizers/chat.ts @@ -0,0 +1,18 @@ +import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; + +import type { ReducerAccount } from 'soapbox/reducers/accounts'; +import type { Account, EmbeddedEntity } from 'soapbox/types/entities'; + +export const ChatRecord = ImmutableRecord({ + account: null as EmbeddedEntity, + id: '', + unread: 0, + last_message: '' as string || null, + updated_at: new Date(), +}); + +export const normalizeChat = (chat: Record) => { + return ChatRecord( + ImmutableMap(fromJS(chat)), + ); +}; diff --git a/app/soapbox/normalizers/chat_message.ts b/app/soapbox/normalizers/chat_message.ts new file mode 100644 index 000000000..71536acb5 --- /dev/null +++ b/app/soapbox/normalizers/chat_message.ts @@ -0,0 +1,29 @@ +import { + List as ImmutableList, + Map as ImmutableMap, + Record as ImmutableRecord, + fromJS, +} from 'immutable'; + +import type { Attachment, Card, Emoji } from 'soapbox/types/entities'; + +export const ChatMessageRecord = ImmutableRecord({ + account_id: '', + attachment: null as Attachment | null, + card: null as Card | null, + chat_id: '', + content: '', + created_at: new Date(), + emojis: ImmutableList(), + id: '', + unread: false, + + deleting: false, + pending: false, +}); + +export const normalizeChatMessage = (chatMessage: Record) => { + return ChatMessageRecord( + ImmutableMap(fromJS(chatMessage)), + ); +}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 613de5331..3251669de 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -1,6 +1,8 @@ export { AccountRecord, FieldRecord, normalizeAccount } from './account'; export { AttachmentRecord, normalizeAttachment } from './attachment'; export { CardRecord, normalizeCard } from './card'; +export { ChatRecord, normalizeChat } from './chat'; +export { ChatMessageRecord, normalizeChatMessage } from './chat_message'; export { EmojiRecord, normalizeEmoji } from './emoji'; export { InstanceRecord, normalizeInstance } from './instance'; export { MentionRecord, normalizeMention } from './mention'; diff --git a/app/soapbox/reducers/chat_message_lists.js b/app/soapbox/reducers/chat_message_lists.ts similarity index 63% rename from app/soapbox/reducers/chat_message_lists.js rename to app/soapbox/reducers/chat_message_lists.ts index 9939885e6..79a595801 100644 --- a/app/soapbox/reducers/chat_message_lists.js +++ b/app/soapbox/reducers/chat_message_lists.ts @@ -1,4 +1,5 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { AnyAction } from 'redux'; import { CHATS_FETCH_SUCCESS, @@ -10,41 +11,46 @@ import { } from 'soapbox/actions/chats'; import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; -const initialState = ImmutableMap(); +type APIEntity = Record; +type APIEntities = Array; -const idComparator = (a, b) => { +type State = ImmutableMap>; + +const initialState: State = ImmutableMap(); + +const idComparator = (a: string, b: string) => { if (a < b) return -1; if (a > b) return 1; return 0; }; -const updateList = (state, chatId, messageIds) => { +const updateList = (state: State, chatId: string, messageIds: string[]) => { const ids = state.get(chatId, ImmutableOrderedSet()); - const newIds = ids.union(messageIds).sort(idComparator); + const newIds = (ids.union(messageIds) as ImmutableOrderedSet).sort(idComparator); return state.set(chatId, newIds); }; -const importMessage = (state, chatMessage) => { +const importMessage = (state: State, chatMessage: APIEntity) => { return updateList(state, chatMessage.chat_id, [chatMessage.id]); }; -const importMessages = (state, chatMessages) => ( +const importMessages = (state: State, chatMessages: APIEntities) => ( state.withMutations(map => chatMessages.forEach(chatMessage => importMessage(map, chatMessage))) ); -const importLastMessages = (state, chats) => +const importLastMessages = (state: State, chats: APIEntities) => state.withMutations(mutable => chats.forEach(chat => { if (chat.last_message) importMessage(mutable, chat.last_message); })); -const replaceMessage = (state, chatId, oldId, newId) => { - return state.update(chatId, chat => chat.delete(oldId).add(newId).sort(idComparator)); +const replaceMessage = (state: State, chatId: string, oldId: string, newId: string) => { + return state.update(chatId, chat => chat!.delete(oldId).add(newId).sort(idComparator)); }; -export default function chatMessageLists(state = initialState, action) { +export default function chatMessageLists(state = initialState, action: AnyAction) { switch(action.type) { case CHAT_MESSAGE_SEND_REQUEST: return updateList(state, action.chatId, [action.uuid]); @@ -58,11 +64,11 @@ export default function chatMessageLists(state = initialState, action) { else return state; case CHAT_MESSAGES_FETCH_SUCCESS: - return updateList(state, action.chatId, action.chatMessages.map(chat => chat.id)); + return updateList(state, action.chatId, action.chatMessages.map((chat: APIEntity) => chat.id)); case CHAT_MESSAGE_SEND_SUCCESS: return replaceMessage(state, action.chatId, action.uuid, action.chatMessage.id); case CHAT_MESSAGE_DELETE_SUCCESS: - return state.update(action.chatId, chat => chat.delete(action.messageId)); + return state.update(action.chatId, chat => chat!.delete(action.messageId)); default: return state; } diff --git a/app/soapbox/reducers/chat_messages.js b/app/soapbox/reducers/chat_messages.ts similarity index 60% rename from app/soapbox/reducers/chat_messages.js rename to app/soapbox/reducers/chat_messages.ts index a0787d077..d6b4f4fb9 100644 --- a/app/soapbox/reducers/chat_messages.js +++ b/app/soapbox/reducers/chat_messages.ts @@ -1,4 +1,5 @@ import { Map as ImmutableMap, fromJS } from 'immutable'; +import { AnyAction } from 'redux'; import { CHATS_FETCH_SUCCESS, @@ -10,25 +11,32 @@ import { CHAT_MESSAGE_DELETE_SUCCESS, } from 'soapbox/actions/chats'; import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; +import { normalizeChatMessage } from 'soapbox/normalizers'; -const initialState = ImmutableMap(); +type ChatMessageRecord = ReturnType; +type APIEntity = Record; +type APIEntities = Array; -const importMessage = (state, message) => { - return state.set(message.get('id'), message); +type State = ImmutableMap; + +const importMessage = (state: State, message: APIEntity) => { + return state.set(message.id, normalizeChatMessage(message)); }; -const importMessages = (state, messages) => +const importMessages = (state: State, messages: APIEntities) => state.withMutations(mutable => messages.forEach(message => importMessage(mutable, message))); -const importLastMessages = (state, chats) => +const importLastMessages = (state: State, chats: APIEntities) => state.withMutations(mutable => chats.forEach(chat => { - if (chat.get('last_message')) - importMessage(mutable, chat.get('last_message')); + if (chat.last_message) + importMessage(mutable, chat.last_message); })); -export default function chatMessages(state = initialState, action) { +const initialState: State = ImmutableMap(); + +export default function chatMessages(state = initialState, action: AnyAction) { switch(action.type) { case CHAT_MESSAGE_SEND_REQUEST: return importMessage(state, fromJS({ @@ -41,16 +49,16 @@ export default function chatMessages(state = initialState, action) { })); case CHATS_FETCH_SUCCESS: case CHATS_EXPAND_SUCCESS: - return importLastMessages(state, fromJS(action.chats)); + return importLastMessages(state, action.chats); case CHAT_MESSAGES_FETCH_SUCCESS: - return importMessages(state, fromJS(action.chatMessages)); + return importMessages(state, action.chatMessages); case CHAT_MESSAGE_SEND_SUCCESS: return importMessage(state, fromJS(action.chatMessage)).delete(action.uuid); case STREAMING_CHAT_UPDATE: - return importLastMessages(state, fromJS([action.chat])); + return importLastMessages(state, [action.chat]); case CHAT_MESSAGE_DELETE_REQUEST: return state.update(action.messageId, chatMessage => - chatMessage.set('pending', true).set('deleting', true)); + chatMessage!.set('pending', true).set('deleting', true)); case CHAT_MESSAGE_DELETE_SUCCESS: return state.delete(action.messageId); default: diff --git a/app/soapbox/reducers/chats.js b/app/soapbox/reducers/chats.js deleted file mode 100644 index 430251210..000000000 --- a/app/soapbox/reducers/chats.js +++ /dev/null @@ -1,58 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { - CHATS_FETCH_SUCCESS, - CHATS_FETCH_REQUEST, - CHATS_EXPAND_SUCCESS, - CHATS_EXPAND_REQUEST, - CHAT_FETCH_SUCCESS, - CHAT_READ_SUCCESS, - CHAT_READ_REQUEST, -} from 'soapbox/actions/chats'; -import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; - -const normalizeChat = (chat, normalOldChat) => { - const normalChat = { ...chat }; - const { account, last_message: lastMessage } = chat; - - if (account) normalChat.account = account.id; - if (lastMessage) normalChat.last_message = lastMessage.id; - - return normalChat; -}; - -const importChat = (state, chat) => state.setIn(['items', chat.id], fromJS(normalizeChat(chat))); - -const importChats = (state, chats, next) => - state.withMutations(mutable => { - if (next !== undefined) mutable.set('next', next); - chats.forEach(chat => importChat(mutable, chat)); - mutable.set('loading', false); - }); - -const initialState = ImmutableMap({ - next: null, - isLoading: false, - items: ImmutableMap({}), -}); - -export default function chats(state = initialState, action) { - switch(action.type) { - case CHATS_FETCH_REQUEST: - case CHATS_EXPAND_REQUEST: - return state.set('loading', true); - case CHATS_FETCH_SUCCESS: - case CHATS_EXPAND_SUCCESS: - return importChats(state, action.chats, action.next); - case STREAMING_CHAT_UPDATE: - return importChats(state, [action.chat]); - case CHAT_FETCH_SUCCESS: - return importChats(state, [action.chat]); - case CHAT_READ_REQUEST: - return state.setIn([action.chatId, 'unread'], 0); - case CHAT_READ_SUCCESS: - return importChats(state, [action.chat]); - default: - return state; - } -} diff --git a/app/soapbox/reducers/chats.ts b/app/soapbox/reducers/chats.ts new file mode 100644 index 000000000..956da3c39 --- /dev/null +++ b/app/soapbox/reducers/chats.ts @@ -0,0 +1,76 @@ +import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable'; + +import { + CHATS_FETCH_SUCCESS, + CHATS_FETCH_REQUEST, + CHATS_EXPAND_SUCCESS, + CHATS_EXPAND_REQUEST, + CHAT_FETCH_SUCCESS, + CHAT_READ_SUCCESS, + CHAT_READ_REQUEST, +} from 'soapbox/actions/chats'; +import { STREAMING_CHAT_UPDATE } from 'soapbox/actions/streaming'; +import { normalizeChat } from 'soapbox/normalizers'; +import { normalizeId } from 'soapbox/utils/normalizers'; + +import type { AnyAction } from 'redux'; + +type ChatRecord = ReturnType; +type APIEntity = Record; +type APIEntities = Array; + +export interface ReducerChat extends ChatRecord { + account: string | null, + last_message: string | null, +} + +const ReducerRecord = ImmutableRecord({ + next: null as string | null, + isLoading: false, + items: ImmutableMap({}), +}); + +type State = ReturnType; + +const minifyChat = (chat: ChatRecord): ReducerChat => { + return chat.mergeWith((o, n) => n || o, { + account: normalizeId(chat.getIn(['account', 'id'])), + last_message: normalizeId(chat.getIn(['last_message', 'id'])), + }) as ReducerChat; +}; + +const fixChat = (chat: APIEntity): ReducerChat => { + return normalizeChat(chat).withMutations(chat => { + minifyChat(chat); + }) as ReducerChat; +}; + +const importChat = (state: State, chat: APIEntity) => state.setIn(['items', chat.id], fixChat(chat)); + +const importChats = (state: State, chats: APIEntities, next?: string) => + state.withMutations(mutable => { + if (next !== undefined) mutable.set('next', next); + chats.forEach(chat => importChat(mutable, chat)); + mutable.set('isLoading', false); + }); + +export default function chats(state: State = ReducerRecord(), action: AnyAction): State { + switch(action.type) { + case CHATS_FETCH_REQUEST: + case CHATS_EXPAND_REQUEST: + return state.set('isLoading', true); + case CHATS_FETCH_SUCCESS: + case CHATS_EXPAND_SUCCESS: + return importChats(state, action.chats, action.next); + case STREAMING_CHAT_UPDATE: + return importChats(state, [action.chat]); + case CHAT_FETCH_SUCCESS: + return importChats(state, [action.chat]); + case CHAT_READ_REQUEST: + return state.setIn([action.chatId, 'unread'], 0); + case CHAT_READ_SUCCESS: + return importChats(state, [action.chat]); + default: + return state; + } +} diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts index ddac93b24..40f8105e3 100644 --- a/app/soapbox/selectors/index.ts +++ b/app/soapbox/selectors/index.ts @@ -12,6 +12,7 @@ import { validId } from 'soapbox/utils/auth'; import ConfigDB from 'soapbox/utils/config_db'; import { shouldFilter } from 'soapbox/utils/timelines'; +import type { ReducerChat } from 'soapbox/reducers/chats'; import type { RootState } from 'soapbox/store'; import type { Notification } from 'soapbox/types/entities'; @@ -241,16 +242,18 @@ type APIChat = { id: string, last_message: string }; export const makeGetChat = () => { return createSelector( [ - (state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]), + (state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]) as ReducerChat, (state: RootState, { id }: APIChat) => state.accounts.get(state.chats.getIn(['items', id, 'account'])), (state: RootState, { last_message }: APIChat) => state.chat_messages.get(last_message), ], - (chat, account, lastMessage: string) => { - if (!chat) return null; + (chat, account, lastMessage) => { + if (!chat || !account) return null; - return chat.withMutations((map: ImmutableMap) => { + return chat.withMutations((map) => { + // @ts-ignore map.set('account', account); + // @ts-ignore map.set('last_message', lastMessage); }); }, diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index 942e5f4f8..80caf1ea1 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -2,6 +2,8 @@ import { AccountRecord, AttachmentRecord, CardRecord, + ChatRecord, + ChatMessageRecord, EmojiRecord, FieldRecord, InstanceRecord, @@ -16,6 +18,8 @@ import type { Record as ImmutableRecord } from 'immutable'; type Attachment = ReturnType; type Card = ReturnType; +type Chat = ReturnType; +type ChatMessage = ReturnType; type Emoji = ReturnType; type Field = ReturnType; type Instance = ReturnType; @@ -44,6 +48,8 @@ export { Account, Attachment, Card, + Chat, + ChatMessage, Emoji, Field, Instance, From 9b7f8b38165db863363b9b9212900a02072ce3aa Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 Apr 2022 12:13:51 -0500 Subject: [PATCH 012/334] Fix API mock in verification test --- app/soapbox/__mocks__/api.ts | 2 +- app/soapbox/features/verification/__tests__/index.test.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/soapbox/__mocks__/api.ts b/app/soapbox/__mocks__/api.ts index 2f3a0d7b8..99797009e 100644 --- a/app/soapbox/__mocks__/api.ts +++ b/app/soapbox/__mocks__/api.ts @@ -9,7 +9,7 @@ export const __stub = (func: Function) => mocks.push(func); export const __clear = (): Function[] => mocks = []; const setupMock = (axios: AxiosInstance) => { - const mock = new MockAdapter(axios); + const mock = new MockAdapter(axios, { onNoMatch: 'throwException' }); mocks.map(func => func(mock)); }; diff --git a/app/soapbox/features/verification/__tests__/index.test.tsx b/app/soapbox/features/verification/__tests__/index.test.tsx index b4c28509e..27a88957e 100644 --- a/app/soapbox/features/verification/__tests__/index.test.tsx +++ b/app/soapbox/features/verification/__tests__/index.test.tsx @@ -2,7 +2,8 @@ import { Map as ImmutableMap } from 'immutable'; import React from 'react'; import { Route, Switch } from 'react-router-dom'; -import { __stub } from '../../../__mocks__/api'; +import { __stub } from 'soapbox/api'; + import { render, screen } from '../../../jest/test-helpers'; import Verification from '../index'; From 7394452ad913df03a36699342f1e9f19c4f3d516 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 Apr 2022 12:38:19 -0500 Subject: [PATCH 013/334] ForkTsCheckerWebpackPlugin: increase typescript memory limit to 8GB, fixes #865 --- webpack/shared.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webpack/shared.js b/webpack/shared.js index 2daba677a..1fcdef898 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -75,7 +75,7 @@ module.exports = { new webpack.ProvidePlugin({ process: 'process/browser', }), - new ForkTsCheckerWebpackPlugin(), + new ForkTsCheckerWebpackPlugin({ typescript: { memoryLimit: 8192 } }), new MiniCssExtractPlugin({ filename: 'packs/css/[name]-[contenthash:8].css', chunkFilename: 'packs/css/[name]-[contenthash:8].chunk.css', From 93a6945b7fcb1e861f3b21853cd580b2456d51f4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 Apr 2022 15:23:18 -0500 Subject: [PATCH 014/334] Let a custom auth app be embedded in the build --- app/soapbox/actions/auth.js | 22 ++++++++++++++++++---- app/soapbox/{custom.js => custom.ts} | 7 ++++--- docs/development/build-config.md | 28 ++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) rename app/soapbox/{custom.js => custom.ts} (57%) diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 1a700b9ec..5c0fc467a 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -14,6 +14,7 @@ import { createApp } from 'soapbox/actions/apps'; import { fetchMeSuccess, fetchMeFail } from 'soapbox/actions/me'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import snackbar from 'soapbox/actions/snackbar'; +import { custom } from 'soapbox/custom'; import KVStore from 'soapbox/storage/kv_store'; import { getLoggedInAccount, parseBaseURL } from 'soapbox/utils/auth'; import sourceCode from 'soapbox/utils/code'; @@ -39,12 +40,14 @@ export const AUTH_ACCOUNT_REMEMBER_REQUEST = 'AUTH_ACCOUNT_REMEMBER_REQUEST'; export const AUTH_ACCOUNT_REMEMBER_SUCCESS = 'AUTH_ACCOUNT_REMEMBER_SUCCESS'; export const AUTH_ACCOUNT_REMEMBER_FAIL = 'AUTH_ACCOUNT_REMEMBER_FAIL'; +const customApp = custom('app'); + export const messages = defineMessages({ loggedOut: { id: 'auth.logged_out', defaultMessage: 'Logged out.' }, invalidCredentials: { id: 'auth.invalid_credentials', defaultMessage: 'Wrong username or password' }, }); -const noOp = () => () => new Promise(f => f()); +const noOp = () => new Promise(f => f()); const getScopes = state => { const instance = state.get('instance'); @@ -54,12 +57,23 @@ const getScopes = state => { function createAppAndToken() { return (dispatch, getState) => { - return dispatch(createAuthApp()).then(() => { + return dispatch(getAuthApp()).then(() => { return dispatch(createAppToken()); }); }; } +/** Create an auth app, or use it from build config */ +function getAuthApp() { + return (dispatch, getState) => { + if (customApp?.client_secret) { + return noOp().then(() => dispatch({ type: AUTH_APP_CREATED, app: customApp })); + } else { + return dispatch(createAuthApp()); + } + }; +} + function createAuthApp() { return (dispatch, getState) => { const params = { @@ -117,7 +131,7 @@ export function refreshUserToken() { const refreshToken = getState().getIn(['auth', 'user', 'refresh_token']); const app = getState().getIn(['auth', 'app']); - if (!refreshToken) return dispatch(noOp()); + if (!refreshToken) return dispatch(noOp); const params = { client_id: app.get('client_id'), @@ -200,7 +214,7 @@ export function loadCredentials(token, accountUrl) { export function logIn(intl, username, password) { return (dispatch, getState) => { - return dispatch(createAuthApp()).then(() => { + return dispatch(getAuthApp()).then(() => { return dispatch(createUserToken(username, password)); }).catch(error => { if (error.response.data.error === 'mfa_required') { diff --git a/app/soapbox/custom.js b/app/soapbox/custom.ts similarity index 57% rename from app/soapbox/custom.js rename to app/soapbox/custom.ts index 623cb22b3..4bccb386d 100644 --- a/app/soapbox/custom.js +++ b/app/soapbox/custom.ts @@ -1,12 +1,13 @@ /** * Functions for dealing with custom build configuration. */ -import { NODE_ENV } from 'soapbox/build_config'; +import * as BuildConfig from 'soapbox/build_config'; /** Require a custom JSON file if it exists */ -export const custom = (filename, fallback = {}) => { - if (NODE_ENV === 'test') return fallback; +export const custom = (filename: string, fallback: any = {}): any => { + if (BuildConfig.NODE_ENV === 'test') return fallback; + // @ts-ignore: yes it does const context = require.context('custom', false, /\.json$/); const path = `./${filename}.json`; diff --git a/docs/development/build-config.md b/docs/development/build-config.md index 0d7f44b99..4467dbfe0 100644 --- a/docs/development/build-config.md +++ b/docs/development/build-config.md @@ -38,6 +38,34 @@ For example: See `app/soapbox/utils/features.js` for the full list of features. +### Embedded app (`custom/app.json`) + +By default, Soapbox will create a new OAuth app every time a user tries to register or log in. +This is usually the desired behavior, as it works "out of the box" without any additional configuration, and it is resistant to tampering and subtle client bugs. +However, some larger servers may wish to skip this step for performance reasons. + +If an app is supplied in `custom/app.json`, it will be used for authorization. +The full app entity must be provided, for example: + +```json +{ + "client_id": "cf5yI6ffXH1UcDkEApEIrtHpwCi5Tv9xmju8IKdMAkE", + "client_secret": "vHmSDpm6BJGUvR4_qWzmqWjfHcSYlZumxpFfohRwNNQ", + "id": "7132", + "name": "Soapbox FE", + "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "website": "https://soapbox.pub/", + "vapid_key": "BLElLQVJVmY_e4F5JoYxI5jXiVOYNsJ9p-amkykc9NcI-jwa9T1Y2GIbDqbY-HqC6ayPkfW4K4o9vgBFKYmkuS4" +} +``` + +It is crucial that the app has the expected scopes. +You can obtain one with the following curl command (replace `MY_DOMAIN`): + +```sh +curl -X POST -H "Content-Type: application/json" -d '{"client_name": "Soapbox FE", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", "scopes": "read write follow push admin", "website": "https://soapbox.pub/"}' "https://MY_DOMAIN.com/api/v1/apps" +``` + ### Custom files (`custom/instance/*`) You can place arbitrary files of any type in the `custom/instance/` directory. From 7a3683758594c20a15450f140fde6e6af3018f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 12 Apr 2022 22:40:09 +0200 Subject: [PATCH 015/334] typescript, FC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../features/aliases/components/search.js | 84 ------------------- .../features/aliases/components/search.tsx | 65 ++++++++++++++ .../components/account.js | 60 ------------- .../components/account.tsx | 46 ++++++++++ .../follow_recommendations_container.js | 39 --------- .../follow_recommendations_container.tsx | 30 +++++++ .../components/follow_recommendations_list.js | 62 -------------- .../follow_recommendations_list.tsx | 45 ++++++++++ .../features/follow_recommendations/index.js | 28 ------- .../features/follow_recommendations/index.tsx | 22 +++++ .../intentional_error/{index.js => index.tsx} | 10 +-- 11 files changed, 212 insertions(+), 279 deletions(-) delete mode 100644 app/soapbox/features/aliases/components/search.js create mode 100644 app/soapbox/features/aliases/components/search.tsx delete mode 100644 app/soapbox/features/follow_recommendations/components/account.js create mode 100644 app/soapbox/features/follow_recommendations/components/account.tsx delete mode 100644 app/soapbox/features/follow_recommendations/components/follow_recommendations_container.js create mode 100644 app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx delete mode 100644 app/soapbox/features/follow_recommendations/components/follow_recommendations_list.js create mode 100644 app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx delete mode 100644 app/soapbox/features/follow_recommendations/index.js create mode 100644 app/soapbox/features/follow_recommendations/index.tsx rename app/soapbox/features/intentional_error/{index.js => index.tsx} (50%) diff --git a/app/soapbox/features/aliases/components/search.js b/app/soapbox/features/aliases/components/search.js deleted file mode 100644 index 4311685e7..000000000 --- a/app/soapbox/features/aliases/components/search.js +++ /dev/null @@ -1,84 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; - -import Icon from 'soapbox/components/icon'; -import { Button } from 'soapbox/components/ui'; - -import { fetchAliasesSuggestions, clearAliasesSuggestions, changeAliasesSuggestions } from '../../../actions/aliases'; - -const messages = defineMessages({ - search: { id: 'aliases.search', defaultMessage: 'Search your old account' }, - searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Search' }, -}); - -const mapStateToProps = state => ({ - value: state.getIn(['aliases', 'suggestions', 'value']), -}); - -const mapDispatchToProps = dispatch => ({ - onSubmit: value => dispatch(fetchAliasesSuggestions(value)), - onClear: () => dispatch(clearAliasesSuggestions()), - onChange: value => dispatch(changeAliasesSuggestions(value)), -}); - -export default @connect(mapStateToProps, mapDispatchToProps) -@injectIntl -class Search extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - onClear: PropTypes.func.isRequired, - }; - - handleChange = e => { - this.props.onChange(e.target.value); - } - - handleKeyUp = e => { - if (e.keyCode === 13) { - this.props.onSubmit(this.props.value); - } - } - - handleSubmit = () => { - this.props.onSubmit(this.props.value); - } - - handleClear = () => { - this.props.onClear(); - } - - render() { - const { value, intl } = this.props; - const hasValue = value.length > 0; - - return ( -
- - -
- -
- -
- ); - } - -} diff --git a/app/soapbox/features/aliases/components/search.tsx b/app/soapbox/features/aliases/components/search.tsx new file mode 100644 index 000000000..516a38884 --- /dev/null +++ b/app/soapbox/features/aliases/components/search.tsx @@ -0,0 +1,65 @@ +import classNames from 'classnames'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { fetchAliasesSuggestions, clearAliasesSuggestions, changeAliasesSuggestions } from 'soapbox/actions/aliases'; +import Icon from 'soapbox/components/icon'; +import { Button } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + search: { id: 'aliases.search', defaultMessage: 'Search your old account' }, + searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Search' }, +}); + +const Search: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const value = useAppSelector(state => state.aliases.getIn(['suggestions', 'value'])) as string; + + const handleChange = (e: React.ChangeEvent) => { + dispatch(changeAliasesSuggestions(e.target.value)); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.keyCode === 13) { + dispatch(fetchAliasesSuggestions(value)); + } + }; + + const handleSubmit = () => { + dispatch(fetchAliasesSuggestions(value)); + }; + + const handleClear = () => { + dispatch(clearAliasesSuggestions()); + }; + + const hasValue = value.length > 0; + + return ( +
+ + +
+ +
+ +
+ ); +}; + +export default Search; diff --git a/app/soapbox/features/follow_recommendations/components/account.js b/app/soapbox/features/follow_recommendations/components/account.js deleted file mode 100644 index c15fc6bcc..000000000 --- a/app/soapbox/features/follow_recommendations/components/account.js +++ /dev/null @@ -1,60 +0,0 @@ -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 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 { makeGetAccount } from 'soapbox/selectors'; - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, props) => ({ - account: getAccount(state, props.id), - }); - - return mapStateToProps; -}; - -const getFirstSentence = str => { - const arr = str.split(/(([.?!]+\s)|[.。?!\n•])/); - - return arr[0]; -}; - -export default @connect(makeMapStateToProps) -class Account extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.record.isRequired, - intl: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - }; - - render() { - const { account } = this.props; - - return ( -
-
- -
- - - -
{getFirstSentence(account.get('note_plain'))}
-
- -
- -
-
-
- ); - } - -} diff --git a/app/soapbox/features/follow_recommendations/components/account.tsx b/app/soapbox/features/follow_recommendations/components/account.tsx new file mode 100644 index 000000000..8ba56b497 --- /dev/null +++ b/app/soapbox/features/follow_recommendations/components/account.tsx @@ -0,0 +1,46 @@ +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.js b/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.js deleted file mode 100644 index 59c4f382b..000000000 --- a/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { FormattedMessage } from 'react-intl'; - -import { Button } from 'soapbox/components/ui'; - -import FollowRecommendationsList from './follow_recommendations_list'; - -export default class FollowRecommendationsContainer extends React.Component { - - static propTypes = { - onDone: PropTypes.func.isRequired, - } - - handleDone = () => { - this.props.onDone(); - } - - render() { - return ( -
-
-

-

-

-
- - - -
- -
-
- ); - } - -} diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx b/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx new file mode 100644 index 000000000..c3f198f94 --- /dev/null +++ b/app/soapbox/features/follow_recommendations/components/follow_recommendations_container.tsx @@ -0,0 +1,30 @@ +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.js b/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.js deleted file mode 100644 index 7fc06319b..000000000 --- a/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.js +++ /dev/null @@ -1,62 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { fetchSuggestions } from 'soapbox/actions/suggestions'; -import { Spinner } from 'soapbox/components/ui'; - -import Account from './account'; - -const mapStateToProps = state => ({ - suggestions: state.getIn(['suggestions', 'items']), - isLoading: state.getIn(['suggestions', 'isLoading']), -}); - -export default @connect(mapStateToProps) -class FollowRecommendationsList extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - suggestions: ImmutablePropTypes.list, - isLoading: PropTypes.bool, - }; - - componentDidMount() { - const { dispatch, suggestions } = this.props; - - // Don't re-fetch if we're e.g. navigating backwards to this page, - // since we don't want followed accounts to disappear from the list - if (suggestions.size === 0) { - dispatch(fetchSuggestions(true)); - } - } - - render() { - const { suggestions, isLoading } = this.props; - - if (isLoading) { - return ( -
- -
- ); - } - - return ( -
- {suggestions.size > 0 ? suggestions.map(suggestion => ( - - )) : ( -
- -
- )} -
- ); - - } - -} diff --git a/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx b/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx new file mode 100644 index 000000000..4ca838072 --- /dev/null +++ b/app/soapbox/features/follow_recommendations/components/follow_recommendations_list.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { 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.get('items')); + const isLoading = useAppSelector((state) => state.suggestions.get('isLoading')); + + useEffect(() => { + if (suggestions.size === 0) { + dispatch(fetchSuggestions()); + } + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {suggestions.size > 0 ? suggestions.map((suggestion: { account: string }) => ( + + )) : ( +
+ +
+ )} +
+ ); +}; + +export default FollowRecommendationsList; diff --git a/app/soapbox/features/follow_recommendations/index.js b/app/soapbox/features/follow_recommendations/index.js deleted file mode 100644 index 2e9e3bcc3..000000000 --- a/app/soapbox/features/follow_recommendations/index.js +++ /dev/null @@ -1,28 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { withRouter } from 'react-router-dom'; - -import Column from 'soapbox/features/ui/components/column'; - -import FollowRecommendationsContainer from './components/follow_recommendations_container'; - -export default @withRouter -class FollowRecommendations extends React.Component { - - static propTypes = { - history: PropTypes.object.isRequired, - }; - - onDone = () => { - this.props.history.push('/'); - } - - render() { - return ( - - - - ); - } - -} diff --git a/app/soapbox/features/follow_recommendations/index.tsx b/app/soapbox/features/follow_recommendations/index.tsx new file mode 100644 index 000000000..444504532 --- /dev/null +++ b/app/soapbox/features/follow_recommendations/index.tsx @@ -0,0 +1,22 @@ +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/intentional_error/index.js b/app/soapbox/features/intentional_error/index.tsx similarity index 50% rename from app/soapbox/features/intentional_error/index.js rename to app/soapbox/features/intentional_error/index.tsx index b3d7fa0f7..01ae3a5c4 100644 --- a/app/soapbox/features/intentional_error/index.js +++ b/app/soapbox/features/intentional_error/index.tsx @@ -4,10 +4,8 @@ import React from 'react'; * IntentionalError: * For testing logging/monitoring & previewing ErrorBoundary design. */ -export default class IntentionalError extends React.Component { +const IntentionalError: React.FC = () => { + throw 'This error is intentional.'; +}; - render() { - throw 'This error is intentional.'; - } - -} +export default IntentionalError; From cf218e41ef06bcc3051cf32e99b58335ca67c043 Mon Sep 17 00:00:00 2001 From: jon r Date: Tue, 12 Apr 2022 22:13:37 +0000 Subject: [PATCH 016/334] README Upstream changed the location of the document about the differences between the Pleroma and Mastodon APIs. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6068126d3..54aaae634 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Soapbox FE is a [single-page application (SPA)](https://en.wikipedia.org/wiki/Si It has a single HTML file, `index.html`, responsible only for loading the required JavaScript and CSS. It interacts with the backend through [XMLHttpRequest (XHR)](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest). -It incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/) used by Pleroma and Mastodon, but requires many [Pleroma-specific features](https://docs-develop.pleroma.social/backend/API/differences_in_mastoapi_responses/) in order to function. +It incorporates much of the [Mastodon API](https://docs.joinmastodon.org/methods/) used by Pleroma and Mastodon, but requires many [Pleroma-specific features](https://docs.pleroma.social/backend/development/API/differences_in_mastoapi_responses/) in order to function. # Running locally From 8377e3c86b9e840f554383e74d9e75ce1b7f5315 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 Apr 2022 19:08:31 -0500 Subject: [PATCH 017/334] Make darkMode a feature (for now) --- app/soapbox/features/ui/components/navbar.tsx | 8 ++++++-- app/soapbox/utils/features.ts | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/ui/components/navbar.tsx b/app/soapbox/features/ui/components/navbar.tsx index 6ba320195..8b7f9286b 100644 --- a/app/soapbox/features/ui/components/navbar.tsx +++ b/app/soapbox/features/ui/components/navbar.tsx @@ -7,7 +7,7 @@ import { Link } from 'react-router-dom'; import { Avatar, Button, Icon } from 'soapbox/components/ui'; import Search from 'soapbox/features/compose/components/search'; import ThemeToggle from 'soapbox/features/ui/components/theme_toggle'; -import { useOwnAccount, useSoapboxConfig, useSettings } from 'soapbox/hooks'; +import { useOwnAccount, useSoapboxConfig, useSettings, useFeatures } from 'soapbox/hooks'; import { openSidebar } from '../../../actions/sidebar'; @@ -19,6 +19,7 @@ const Navbar = () => { const account = useOwnAccount(); const settings = useSettings(); + const features = useFeatures(); const soapboxConfig = useSoapboxConfig(); const singleUserMode = soapboxConfig.get('singleUserMode'); @@ -68,7 +69,10 @@ const Navbar = () => {
- + {/* TODO: make this available for everyone when it's ready (possibly in a different place) */} + {(features.darkMode || settings.get('isDeveloper')) && ( + + )} {account ? (
diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index e1c5d12e0..468015696 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -134,6 +134,10 @@ const getInstanceFeatures = (instance: Instance) => { trendingTruths: v.software === TRUTHSOCIAL, trendingStatuses: v.software === MASTODON && gte(v.compatVersion, '3.5.0'), pepe: v.software === TRUTHSOCIAL, + + // FIXME: long-term this shouldn't be a feature, + // but for now we want it to be overrideable in the build + darkMode: true, }; }; From 9c79ae386a32c01c257013ff9c10f05a1a34bb6c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 12 Apr 2022 19:52:20 -0500 Subject: [PATCH 018/334] SidebarMenu: convert to tsx --- .../{sidebar_menu.js => sidebar_menu.tsx} | 78 +++++++++---------- .../reducers/{sidebar.js => sidebar.ts} | 12 ++- 2 files changed, 50 insertions(+), 40 deletions(-) rename app/soapbox/components/{sidebar_menu.js => sidebar_menu.tsx} (82%) rename app/soapbox/reducers/{sidebar.js => sidebar.ts} (50%) diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.tsx similarity index 82% rename from app/soapbox/components/sidebar_menu.js rename to app/soapbox/components/sidebar_menu.tsx index bba7d06ce..61a38a0ba 100644 --- a/app/soapbox/components/sidebar_menu.js +++ b/app/soapbox/components/sidebar_menu.tsx @@ -1,23 +1,24 @@ import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { Link, NavLink } from 'react-router-dom'; import { logOut, switchAccount } from 'soapbox/actions/auth'; import { fetchOwnAccounts } from 'soapbox/actions/auth'; -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import Account from 'soapbox/components/account'; import { Stack } from 'soapbox/components/ui'; import ProfileStats from 'soapbox/features/ui/components/profile_stats'; -import { getFeatures } from 'soapbox/utils/features'; +import { useAppSelector, useSoapboxConfig, useFeatures } from 'soapbox/hooks'; import { closeSidebar } from '../actions/sidebar'; import { makeGetAccount, makeGetOtherAccounts } from '../selectors'; import { HStack, Icon, IconButton, Text } from './ui'; +import type { List as ImmutableList } from 'immutable'; +import type { Account as AccountEntity } from 'soapbox/types/entities'; + const messages = defineMessages({ followers: { id: 'account.followers', defaultMessage: 'Followers' }, follows: { id: 'account.follows', defaultMessage: 'Follows' }, @@ -33,7 +34,14 @@ const messages = defineMessages({ logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, }); -const SidebarLink = ({ to, icon, text, onClick }) => ( +interface ISidebarLink { + to: string, + icon: string, + text: string, + onClick: React.EventHandler, +} + +const SidebarLink: React.FC = ({ to, icon, text, onClick }) => (
@@ -45,25 +53,19 @@ const SidebarLink = ({ to, icon, text, onClick }) => ( ); -SidebarLink.propTypes = { - to: PropTypes.string.isRequired, - icon: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, -}; +const getOtherAccounts = makeGetOtherAccounts(); -const SidebarMenu = () => { +const SidebarMenu: React.FC = (): JSX.Element | null => { const intl = useIntl(); const dispatch = useDispatch(); - const logo = useSelector((state) => getSoapboxConfig(state).get('logo')); - const features = useSelector((state) => getFeatures(state.get('instance'))); + const { logo } = useSoapboxConfig(); + const features = useFeatures(); const getAccount = makeGetAccount(); - const getOtherAccounts = makeGetOtherAccounts(); - const me = useSelector((state) => state.get('me')); - const account = useSelector((state) => getAccount(state, me)); - const otherAccounts = useSelector((state) => getOtherAccounts(state)); - const sidebarOpen = useSelector((state) => state.get('sidebar').sidebarOpen); + const me = useAppSelector((state) => state.me); + const account = useAppSelector((state) => me ? getAccount(state, me) : null); + const otherAccounts: ImmutableList = useAppSelector((state) => getOtherAccounts(state)); + const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const closeButtonRef = React.useRef(null); @@ -76,25 +78,27 @@ const SidebarMenu = () => { onClose(); }; - const handleSwitchAccount = (event, account) => { - event.preventDefault(); - switchAccount(account); - dispatch(switchAccount(account.get('id'))); + const handleSwitchAccount = (account: AccountEntity): React.EventHandler => { + return (e) => { + e.preventDefault(); + switchAccount(account); + dispatch(switchAccount(account.id)); + }; }; - const onClickLogOut = (event) => { - event.preventDefault(); + const onClickLogOut: React.EventHandler = (e) => { + e.preventDefault(); dispatch(logOut(intl)); }; - const handleSwitcherClick = (e) => { + const handleSwitcherClick: React.EventHandler = (e) => { e.preventDefault(); setSwitcher((prevState) => (!prevState)); }; - const renderAccount = (account) => ( - handleSwitchAccount(event, account)} key={account.get('id')}> + const renderAccount = (account: AccountEntity) => ( + ); @@ -103,17 +107,13 @@ const SidebarMenu = () => { dispatch(fetchOwnAccounts()); }, []); - if (!account) { - return null; - } - - const acct = account.get('acct'); - const classes = classNames('sidebar-menu__root', { - 'sidebar-menu__root--visible': sidebarOpen, - }); + if (!account) return null; return ( -
+
{ - + @@ -184,7 +184,7 @@ const SidebarMenu = () => {
Date: Tue, 12 Apr 2022 20:10:47 -0500 Subject: [PATCH 019/334] Add timeline links to navigation --- app/soapbox/components/sidebar-navigation.tsx | 40 +++++++++---------- app/soapbox/components/sidebar_menu.tsx | 30 ++++++++++++-- app/soapbox/utils/features.ts | 8 ++++ 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index 0898279f9..b15b2d72a 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -27,7 +27,7 @@ const SidebarNavigation = () => { } + text={} /> {account && ( @@ -42,7 +42,7 @@ const SidebarNavigation = () => { to='/notifications' icon={require('icons/alert.svg')} count={notificationCount} - text={} + text={} /> { /> )} - {/* {features.federating ? ( - - - {instance.title} - - ) : ( - - - - + {(features.localTimeline || features.publicTimeline) && ( +
)} - {features.federating && ( - - - - - )} */} + {features.localTimeline && ( + } + /> + )} + + {(features.publicTimeline && features.federating) && ( + } + /> + )}
{account && ( diff --git a/app/soapbox/components/sidebar_menu.tsx b/app/soapbox/components/sidebar_menu.tsx index 61a38a0ba..c31ec29db 100644 --- a/app/soapbox/components/sidebar_menu.tsx +++ b/app/soapbox/components/sidebar_menu.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import React from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { useDispatch } from 'react-redux'; import { Link, NavLink } from 'react-router-dom'; @@ -37,7 +37,7 @@ const messages = defineMessages({ interface ISidebarLink { to: string, icon: string, - text: string, + text: string | JSX.Element, onClick: React.EventHandler, } @@ -62,6 +62,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const { logo } = useSoapboxConfig(); const features = useFeatures(); 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 otherAccounts: ImmutableList = useAppSelector((state) => getOtherAccounts(state)); @@ -130,7 +131,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { {logo ? ( - Logo + Logo ): ( { + {/* TODO: make this available to everyone */} {account.staff && ( -
- ); - - return ( - - {/* -
- - - - -
- - - - - -
-
*/} - - -
- ); - } - -} diff --git a/app/soapbox/features/list_timeline/index.tsx b/app/soapbox/features/list_timeline/index.tsx new file mode 100644 index 000000000..2697c201c --- /dev/null +++ b/app/soapbox/features/list_timeline/index.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { useEffect } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { useParams } from 'react-router-dom'; + +import { fetchList } from 'soapbox/actions/lists'; +import { openModal } from 'soapbox/actions/modals'; +import { connectListStream } from 'soapbox/actions/streaming'; +import { expandListTimeline } from 'soapbox/actions/timelines'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import { Button, Spinner } from 'soapbox/components/ui'; +import Column from 'soapbox/features/ui/components/column'; +import { useAppSelector } from 'soapbox/hooks'; + +import StatusListContainer from '../ui/containers/status_list_container'; + +// const messages = defineMessages({ +// deleteHeading: { id: 'confirmations.delete_list.heading', defaultMessage: 'Delete list' }, +// deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, +// deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, +// }); + +const ListTimeline: React.FC = () => { + const dispatch = useDispatch(); + const { id } = useParams<{ id: string }>(); + // const intl = useIntl(); + // const history = useHistory(); + + const list = useAppSelector((state) => state.lists.get(id)); + // const hasUnread = useAppSelector((state) => state.timelines.getIn([`list:${props.params.id}`, 'unread']) > 0); + + useEffect(() => { + const disconnect = handleConnect(id); + + return () => { + disconnect(); + }; + }, [id]); + + const handleConnect = (id: string) => { + dispatch(fetchList(id)); + dispatch(expandListTimeline(id)); + + return dispatch(connectListStream(id)); + }; + + const handleLoadMore = (maxId: string) => { + dispatch(expandListTimeline(id, { maxId })); + }; + + const handleEditClick = () => { + dispatch(openModal('LIST_EDITOR', { listId: id })); + }; + + // const handleDeleteClick = () => { + // dispatch(openModal('CONFIRM', { + // icon: require('@tabler/icons/icons/trash.svg'), + // heading: intl.formatMessage(messages.deleteHeading), + // message: intl.formatMessage(messages.deleteMessage), + // confirm: intl.formatMessage(messages.deleteConfirm), + // onConfirm: () => { + // dispatch(deleteList(id)); + // history.push('/lists'); + // }, + // })); + // }; + + const title = list ? list.get('title') : id; + + if (typeof list === 'undefined') { + return ( + +
+ +
+
+ ); + } else if (list === false) { + return ( + + ); + } + + const emptyMessage = ( +
+ +

+ +
+ ); + + return ( + + {/* +
+ + + + +
+ + + + + +
+
*/} + + +
+ ); +}; + +export default ListTimeline; diff --git a/app/soapbox/features/lists/components/new_list_form.js b/app/soapbox/features/lists/components/new_list_form.js deleted file mode 100644 index a065f0b70..000000000 --- a/app/soapbox/features/lists/components/new_list_form.js +++ /dev/null @@ -1,82 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { defineMessages, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; - -import { changeListEditorTitle, submitListEditor } from '../../../actions/lists'; -import { Button } from '../../../components/ui'; - -const messages = defineMessages({ - label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' }, - title: { id: 'lists.new.create', defaultMessage: 'Add list' }, - create: { id: 'lists.new.create_title', defaultMessage: 'Create' }, -}); - -const mapStateToProps = state => ({ - value: state.getIn(['listEditor', 'title']), - disabled: state.getIn(['listEditor', 'isSubmitting']), -}); - -const mapDispatchToProps = dispatch => ({ - onChange: value => dispatch(changeListEditorTitle(value)), - onSubmit: () => dispatch(submitListEditor(true)), -}); - -export default @connect(mapStateToProps, mapDispatchToProps) -@injectIntl -class NewListForm extends React.PureComponent { - - static propTypes = { - value: PropTypes.string.isRequired, - disabled: PropTypes.bool, - intl: PropTypes.object.isRequired, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - }; - - handleChange = e => { - this.props.onChange(e.target.value); - } - - handleSubmit = e => { - e.preventDefault(); - this.props.onSubmit(); - } - - handleClick = e => { - e.preventDefault(); - this.props.onSubmit(); - } - - render() { - const { value, disabled, intl } = this.props; - - const label = intl.formatMessage(messages.label); - const create = intl.formatMessage(messages.create); - - return ( -
- - - -
- ); - } - -} diff --git a/app/soapbox/features/lists/components/new_list_form.tsx b/app/soapbox/features/lists/components/new_list_form.tsx new file mode 100644 index 000000000..b6385ecf3 --- /dev/null +++ b/app/soapbox/features/lists/components/new_list_form.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useDispatch } from 'react-redux'; + +import { changeListEditorTitle, submitListEditor } from 'soapbox/actions/lists'; +import { Button } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' }, + title: { id: 'lists.new.create', defaultMessage: 'Add list' }, + create: { id: 'lists.new.create_title', defaultMessage: 'Create' }, +}); + +const NewListForm: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const value = useAppSelector((state) => state.listEditor.get('title')); + const disabled = useAppSelector((state) => !!state.listEditor.get('isSubmitting')); + + const handleChange = (e: React.ChangeEvent) => { + dispatch(changeListEditorTitle(e.target.value)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(submitListEditor(true)); + }; + + const label = intl.formatMessage(messages.label); + const create = intl.formatMessage(messages.create); + + return ( +
+ + + +
+ ); +}; + +export default NewListForm; diff --git a/app/soapbox/features/lists/index.js b/app/soapbox/features/lists/index.js deleted file mode 100644 index 03521286d..000000000 --- a/app/soapbox/features/lists/index.js +++ /dev/null @@ -1,89 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; - -import { fetchLists } from 'soapbox/actions/lists'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import { Spinner } from 'soapbox/components/ui'; -import { CardHeader, CardTitle } from 'soapbox/components/ui'; - - -import Column from '../ui/components/column'; -import ColumnLink from '../ui/components/column_link'; - -import NewListForm from './components/new_list_form'; - -const messages = defineMessages({ - heading: { id: 'column.lists', defaultMessage: 'Lists' }, - subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' }, - add: { id: 'lists.new.create', defaultMessage: 'Add list' }, -}); - -const getOrderedLists = createSelector([state => state.get('lists')], lists => { - if (!lists) { - return lists; - } - - return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); -}); - -const mapStateToProps = state => ({ - lists: getOrderedLists(state), -}); - -export default @connect(mapStateToProps) -@injectIntl -class Lists extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - lists: ImmutablePropTypes.list, - intl: PropTypes.object.isRequired, - }; - - componentDidMount() { - this.props.dispatch(fetchLists()); - } - - render() { - const { intl, lists } = this.props; - - if (!lists) { - return ( - - - - ); - } - - const emptyMessage = ; - - return ( - -
- - - - -
- - - - - {lists.map(list => - , - )} - -
- ); - } - -} diff --git a/app/soapbox/features/lists/index.tsx b/app/soapbox/features/lists/index.tsx new file mode 100644 index 000000000..5b9a0e936 --- /dev/null +++ b/app/soapbox/features/lists/index.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { useEffect } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { useDispatch } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { fetchLists } from 'soapbox/actions/lists'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import { CardHeader, CardTitle } from 'soapbox/components/ui'; +import { useAppSelector } from 'soapbox/hooks'; + +import Column from '../ui/components/column'; +import ColumnLink from '../ui/components/column_link'; + +import NewListForm from './components/new_list_form'; + +import type { RootState } from 'soapbox/store'; + +const messages = defineMessages({ + heading: { id: 'column.lists', defaultMessage: 'Lists' }, + subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' }, + add: { id: 'lists.new.create', defaultMessage: 'Add list' }, +}); + +const getOrderedLists = createSelector([(state: RootState) => state.lists], lists => { + if (!lists) { + return lists; + } + + return lists.toList().filter((item) => !!item).sort((a: any, b: any) => a.get('title').localeCompare(b.get('title'))); +}); + +const Lists: React.FC = () => { + const dispatch = useDispatch(); + const intl = useIntl(); + + const lists = useAppSelector((state) => getOrderedLists(state)); + + useEffect(() => { + dispatch(fetchLists()); + }, []); + + if (!lists) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + +
+ + + + +
+ + + + + {lists.map((list: any) => + , + )} + +
+ ); +}; + +export default Lists; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index 3251669de..b6d3ba8de 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -5,6 +5,7 @@ export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat_message'; export { EmojiRecord, normalizeEmoji } from './emoji'; export { InstanceRecord, normalizeInstance } from './instance'; +export { ListRecord, normalizeList } from './list'; export { MentionRecord, normalizeMention } from './mention'; export { NotificationRecord, normalizeNotification } from './notification'; export { PollRecord, PollOptionRecord, normalizePoll } from './poll'; diff --git a/app/soapbox/normalizers/list.ts b/app/soapbox/normalizers/list.ts new file mode 100644 index 000000000..ed3ed1c94 --- /dev/null +++ b/app/soapbox/normalizers/list.ts @@ -0,0 +1,19 @@ +/** + * List normalizer: + * Converts API lists into our internal format. + * @see {@link https://docs.joinmastodon.org/entities/list/} + */ +import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable'; + +// https://docs.joinmastodon.org/entities/list/ +export const ListRecord = ImmutableRecord({ + id: '', + title: '', + replies_policy: null as 'followed' | 'list' | 'none' | null, +}); + +export const normalizeList = (list: Record) => { + return ListRecord( + ImmutableMap(fromJS(list)), + ); +}; diff --git a/app/soapbox/reducers/list_adder.js b/app/soapbox/reducers/list_adder.ts similarity index 51% rename from app/soapbox/reducers/list_adder.js rename to app/soapbox/reducers/list_adder.ts index 0f61273aa..c045a4d53 100644 --- a/app/soapbox/reducers/list_adder.js +++ b/app/soapbox/reducers/list_adder.ts @@ -1,4 +1,5 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; +import { AnyAction } from 'redux'; import { LIST_ADDER_RESET, @@ -10,20 +11,24 @@ import { LIST_EDITOR_REMOVE_SUCCESS, } from '../actions/lists'; -const initialState = ImmutableMap({ - accountId: null, +const ListsRecord = ImmutableRecord({ + items: ImmutableList(), + loaded: false, + isLoading: false, +}); + +const ReducerRecord = ImmutableRecord({ + accountId: null as string | null, - lists: ImmutableMap({ - items: ImmutableList(), - loaded: false, - isLoading: false, - }), + lists: ListsRecord(), }); -export default function listAdderReducer(state = initialState, action) { +type State = ReturnType; + +export default function listAdderReducer(state: State = ReducerRecord(), action: AnyAction) { switch(action.type) { case LIST_ADDER_RESET: - return initialState; + return ReducerRecord(); case LIST_ADDER_SETUP: return state.withMutations(map => { map.set('accountId', action.account.get('id')); @@ -36,12 +41,12 @@ export default function listAdderReducer(state = initialState, action) { return state.update('lists', lists => lists.withMutations(map => { map.set('isLoading', false); map.set('loaded', true); - map.set('items', ImmutableList(action.lists.map(item => item.id))); + map.set('items', ImmutableList(action.lists.map((item: { id: string }) => item.id))); })); case LIST_EDITOR_ADD_SUCCESS: - return state.updateIn(['lists', 'items'], list => list.unshift(action.listId)); + return state.updateIn(['lists', 'items'], list => (list as ImmutableList).unshift(action.listId)); case LIST_EDITOR_REMOVE_SUCCESS: - return state.updateIn(['lists', 'items'], list => list.filterNot(item => item === action.listId)); + return state.updateIn(['lists', 'items'], list => (list as ImmutableList).filterNot(item => item === action.listId)); default: return state; } diff --git a/app/soapbox/reducers/list_editor.js b/app/soapbox/reducers/list_editor.ts similarity index 68% rename from app/soapbox/reducers/list_editor.js rename to app/soapbox/reducers/list_editor.ts index ceceb27c7..3c1611a92 100644 --- a/app/soapbox/reducers/list_editor.js +++ b/app/soapbox/reducers/list_editor.ts @@ -1,4 +1,5 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; +import { AnyAction } from 'redux'; import { LIST_CREATE_REQUEST, @@ -20,28 +21,34 @@ import { LIST_EDITOR_REMOVE_SUCCESS, } from '../actions/lists'; -const initialState = ImmutableMap({ - listId: null, +const AccountsRecord = ImmutableRecord({ + items: ImmutableList(), + loaded: false, + isLoading: false, +}); + +const SuggestionsRecord = ImmutableRecord({ + value: '', + items: ImmutableList(), +}); + +const ReducerRecord = ImmutableRecord({ + listId: null as string | null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ - items: ImmutableList(), - loaded: false, - isLoading: false, - }), + accounts: AccountsRecord(), - suggestions: ImmutableMap({ - value: '', - items: ImmutableList(), - }), + suggestions: SuggestionsRecord(), }); -export default function listEditorReducer(state = initialState, action) { +type State = ReturnType; + +export default function listEditorReducer(state: State = ReducerRecord(), action: AnyAction) { switch(action.type) { case LIST_EDITOR_RESET: - return initialState; + return ReducerRecord(); case LIST_EDITOR_SETUP: return state.withMutations(map => { map.set('listId', action.list.get('id')); @@ -76,21 +83,21 @@ export default function listEditorReducer(state = initialState, action) { return state.update('accounts', accounts => accounts.withMutations(map => { map.set('isLoading', false); map.set('loaded', true); - map.set('items', ImmutableList(action.accounts.map(item => item.id))); + map.set('items', ImmutableList(action.accounts.map((item: { id: string }) => item.id))); })); case LIST_EDITOR_SUGGESTIONS_CHANGE: return state.setIn(['suggestions', 'value'], action.value); case LIST_EDITOR_SUGGESTIONS_READY: - return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map((item: { id: string }) => item.id))); case LIST_EDITOR_SUGGESTIONS_CLEAR: return state.update('suggestions', suggestions => suggestions.withMutations(map => { map.set('items', ImmutableList()); map.set('value', ''); })); case LIST_EDITOR_ADD_SUCCESS: - return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId)); + return state.updateIn(['accounts', 'items'], list => (list as ImmutableList).unshift(action.accountId)); case LIST_EDITOR_REMOVE_SUCCESS: - return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId)); + return state.updateIn(['accounts', 'items'], list => (list as ImmutableList).filterNot((item) => item === action.accountId)); default: return state; } diff --git a/app/soapbox/reducers/lists.js b/app/soapbox/reducers/lists.js deleted file mode 100644 index 2a797772b..000000000 --- a/app/soapbox/reducers/lists.js +++ /dev/null @@ -1,38 +0,0 @@ -import { Map as ImmutableMap, fromJS } from 'immutable'; - -import { - LIST_FETCH_SUCCESS, - LIST_FETCH_FAIL, - LISTS_FETCH_SUCCESS, - LIST_CREATE_SUCCESS, - LIST_UPDATE_SUCCESS, - LIST_DELETE_SUCCESS, -} from '../actions/lists'; - -const initialState = ImmutableMap(); - -const normalizeList = (state, list) => state.set(list.id, fromJS(list)); - -const normalizeLists = (state, lists) => { - lists.forEach(list => { - state = normalizeList(state, list); - }); - - return state; -}; - -export default function lists(state = initialState, action) { - switch(action.type) { - case LIST_FETCH_SUCCESS: - case LIST_CREATE_SUCCESS: - case LIST_UPDATE_SUCCESS: - return normalizeList(state, action.list); - case LISTS_FETCH_SUCCESS: - return normalizeLists(state, action.lists); - case LIST_DELETE_SUCCESS: - case LIST_FETCH_FAIL: - return state.set(action.id, false); - default: - return state; - } -} diff --git a/app/soapbox/reducers/lists.ts b/app/soapbox/reducers/lists.ts new file mode 100644 index 000000000..e0ab4db97 --- /dev/null +++ b/app/soapbox/reducers/lists.ts @@ -0,0 +1,46 @@ +import { Map as ImmutableMap } from 'immutable'; +import { AnyAction } from 'redux'; + +import { + LIST_FETCH_SUCCESS, + LIST_FETCH_FAIL, + LISTS_FETCH_SUCCESS, + LIST_CREATE_SUCCESS, + LIST_UPDATE_SUCCESS, + LIST_DELETE_SUCCESS, +} from 'soapbox/actions/lists'; +import { normalizeList } from 'soapbox/normalizers'; + +type ListRecord = ReturnType; +type APIEntity = Record; +type APIEntities = Array; + +type State = ImmutableMap; + +const initialState: State = ImmutableMap(); + +const importList = (state: State, list: APIEntity) => state.set(list.id, normalizeList(list)); + +const importLists = (state: State, lists: APIEntities) => { + lists.forEach(list => { + state = importList(state, list); + }); + + return state; +}; + +export default function lists(state: State = initialState, action: AnyAction) { + switch(action.type) { + case LIST_FETCH_SUCCESS: + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: + return importList(state, action.list); + case LISTS_FETCH_SUCCESS: + return importLists(state, action.lists); + case LIST_DELETE_SUCCESS: + case LIST_FETCH_FAIL: + return state.set(action.id, false); + default: + return state; + } +} From 883ffb0a743d2e732440febd034df4e0852a0d06 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 13 Apr 2022 15:46:26 -0500 Subject: [PATCH 023/334] TimelineQueueButtonHeader: add whitespace-nowrap --- app/soapbox/components/timeline_queue_button_header.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/timeline_queue_button_header.js b/app/soapbox/components/timeline_queue_button_header.js index 99f764e22..29a8e787a 100644 --- a/app/soapbox/components/timeline_queue_button_header.js +++ b/app/soapbox/components/timeline_queue_button_header.js @@ -103,7 +103,7 @@ class TimelineQueueButtonHeader extends React.PureComponent { return (
- + {(count > 0) && ( From eaf42370b95608fc385db146e42e2a620f67b409 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 13 Apr 2022 15:49:36 -0500 Subject: [PATCH 024/334] ComposeForm: make all buttons acessible on mobile (hotfix) --- app/soapbox/features/compose/components/compose_form.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/compose/components/compose_form.js b/app/soapbox/features/compose/components/compose_form.js index 675abe4bf..2f054836f 100644 --- a/app/soapbox/features/compose/components/compose_form.js +++ b/app/soapbox/features/compose/components/compose_form.js @@ -364,7 +364,7 @@ class ComposeForm extends ImmutablePureComponent {
@@ -378,7 +378,7 @@ class ComposeForm extends ImmutablePureComponent {
-
+
{maxTootChars && (
From 2faaa75d9c7672252a625dce557d16575d5f33bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 14 Apr 2022 00:04:41 +0200 Subject: [PATCH 025/334] tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../reducers/__tests__/list_adder-test.js | 58 ++++++++--------- .../reducers/__tests__/list_editor-test.js | 62 +++++++++---------- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/app/soapbox/reducers/__tests__/list_adder-test.js b/app/soapbox/reducers/__tests__/list_adder-test.js index bda177fb4..4806d3d13 100644 --- a/app/soapbox/reducers/__tests__/list_adder-test.js +++ b/app/soapbox/reducers/__tests__/list_adder-test.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; import * as actions from 'soapbox/actions/lists'; @@ -6,87 +6,87 @@ import reducer from '../list_adder'; describe('list_adder reducer', () => { it('should return the initial state', () => { - expect(reducer(undefined, {})).toEqual(ImmutableMap({ + expect(reducer(undefined, {})).toMatchObject({ accountId: null, - lists: ImmutableMap({ + lists: { items: ImmutableList(), loaded: false, isLoading: false, - }), - })); + }, + }); }); it('should handle LIST_ADDER_RESET', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ accountId: null, - lists: ImmutableMap({ + lists: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), - }); + })(), + })(); const action = { type: actions.LIST_ADDER_RESET, }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ accountId: null, - lists: ImmutableMap({ + lists: { items: ImmutableList(), loaded: false, isLoading: false, - }), - })); + }, + }); }); it('should handle LIST_ADDER_LISTS_FETCH_REQUEST', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ accountId: null, - lists: ImmutableMap({ + lists: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), - }); + })(), + })(); const action = { type: actions.LIST_ADDER_LISTS_FETCH_REQUEST, }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ accountId: null, - lists: ImmutableMap({ + lists: { items: ImmutableList(), loaded: false, isLoading: true, - }), - })); + }, + }); }); it('should handle LIST_ADDER_LISTS_FETCH_FAIL', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ accountId: null, - lists: ImmutableMap({ + lists: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), - }); + })(), + })(); const action = { type: actions.LIST_ADDER_LISTS_FETCH_FAIL, }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ accountId: null, - lists: ImmutableMap({ + lists: { items: ImmutableList(), loaded: false, isLoading: false, - }), - })); + }, + }); }); // it('should handle LIST_ADDER_LISTS_FETCH_SUCCESS', () => { diff --git a/app/soapbox/reducers/__tests__/list_editor-test.js b/app/soapbox/reducers/__tests__/list_editor-test.js index 0fe2ec121..1b351bca1 100644 --- a/app/soapbox/reducers/__tests__/list_editor-test.js +++ b/app/soapbox/reducers/__tests__/list_editor-test.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord } from 'immutable'; import * as actions from 'soapbox/actions/lists'; @@ -6,83 +6,83 @@ import reducer from '../list_editor'; describe('list_editor reducer', () => { it('should return the initial state', () => { - expect(reducer(undefined, {})).toEqual(ImmutableMap({ + expect(reducer(undefined, {})).toMatchObject({ listId: null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ + accounts: { items: ImmutableList(), loaded: false, isLoading: false, - }), + }, - suggestions: ImmutableMap({ + suggestions: { value: '', items: ImmutableList(), - }), - })); + }, + }); }); it('should handle LIST_EDITOR_RESET', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ listId: null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ + accounts: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), + })(), - suggestions: ImmutableMap({ + suggestions: ImmutableRecord({ value: '', items: ImmutableList(), - }), - }); + })(), + })(); const action = { type: actions.LIST_EDITOR_RESET, }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ listId: null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ + accounts: { items: ImmutableList(), loaded: false, isLoading: false, - }), + }, - suggestions: ImmutableMap({ + suggestions: { value: '', items: ImmutableList(), - }), - })); + }, + }); }); it('should handle LIST_EDITOR_SETUP', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ listId: null, isSubmitting: false, isChanged: false, title: '', - accounts: ImmutableMap({ + accounts: ImmutableRecord({ items: ImmutableList(), loaded: false, isLoading: false, - }), + })(), - suggestions: ImmutableMap({ + suggestions: ImmutableRecord({ value: '', items: ImmutableList(), - }), - }); + })(), + })(); const action = { type: actions.LIST_EDITOR_SETUP, list: ImmutableMap({ @@ -90,23 +90,23 @@ describe('list_editor reducer', () => { title: 'list 1', }), }; - expect(reducer(state, action)).toEqual(ImmutableMap({ + expect(reducer(state, action)).toMatchObject({ listId: '22', isSubmitting: false, isChanged: false, title: 'list 1', - accounts: ImmutableMap({ + accounts: { items: ImmutableList(), loaded: false, isLoading: false, - }), + }, - suggestions: ImmutableMap({ + suggestions: { value: '', items: ImmutableList(), - }), - })); + }, + }); }); it('should handle LIST_EDITOR_TITLE_CHANGE', () => { From 9596ed072b90d3e29af749a65fa662989c3025e9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 13 Apr 2022 17:37:38 -0500 Subject: [PATCH 026/334] EmojiButtonWrapper: improve touch behavior --- app/soapbox/components/emoji-button-wrapper.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/soapbox/components/emoji-button-wrapper.tsx b/app/soapbox/components/emoji-button-wrapper.tsx index 32159b329..1dad15dc7 100644 --- a/app/soapbox/components/emoji-button-wrapper.tsx +++ b/app/soapbox/components/emoji-button-wrapper.tsx @@ -44,13 +44,19 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children if (!status) return null; const handleMouseEnter = () => { - setVisible(true); + if (!isUserTouching()) { + setVisible(true); + } }; const handleMouseLeave = () => { setVisible(false); }; + const handleUnfocus = () => { + setVisible(false); + }; + const handleReact = (emoji: string): void => { if (ownAccount) { dispatch(simpleEmojiReact(status, emoji)); @@ -77,6 +83,7 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children handleReact(meEmojiReact); } + e.preventDefault(); e.stopPropagation(); }; @@ -106,6 +113,7 @@ const EmojiButtonWrapper: React.FC = ({ statusId, children
{React.cloneElement(children, { onClick: handleClick, + onBlur: handleUnfocus, ref, })} From b79b74918f19e0ebd6a3076fb59109ac935d6249 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 13 Apr 2022 18:15:23 -0500 Subject: [PATCH 027/334] Restore Dashboard links to navigation --- app/soapbox/components/sidebar-navigation.tsx | 7 ++++--- app/soapbox/components/thumb_navigation.tsx | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index b15b2d72a..96411d6c3 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -17,6 +17,7 @@ const SidebarNavigation = () => { const account = useAppSelector((state) => state.accounts.get(me)); const notificationCount = useAppSelector((state) => state.notifications.get('unread')); const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: any, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0)); + const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const baseURL = getBaseURL(ImmutableMap(account)); const features = getFeatures(instance); @@ -70,14 +71,14 @@ const SidebarNavigation = () => { ) )} - {/* {(account && account.staff) && ( + {(account && account.staff) && ( } count={dashboardCount} /> - )} */} + )} {(account && instance.invites_enabled) && ( { const account = useOwnAccount(); const notificationCount = useAppSelector((state) => state.notifications.unread); const chatsCount = useAppSelector((state) => state.chats.get('items').reduce((acc: number, curr: any) => acc + Math.min(curr.get('unread', 0), 1), 0)); - // const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); + const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); const features = getFeatures(useAppSelector((state) => state.instance)); return ( @@ -57,14 +57,14 @@ const ThumbNavigation: React.FC = (): JSX.Element => { ) )} - {/* (account && account.staff && ( + {(account && account.staff) && ( } to='/admin' count={dashboardCount} /> - ) */} + )}
); }; From 4230464812e6dea6d0377af581d96f871337bccd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 13 Apr 2022 18:28:05 -0500 Subject: [PATCH 028/334] Disable PreHeader --- app/soapbox/features/ui/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 7a35dc488..bc50ca114 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -41,7 +41,7 @@ import { expandNotifications } from '../../actions/notifications'; import { fetchScheduledStatuses } from '../../actions/scheduled_statuses'; import { connectUserStream } from '../../actions/streaming'; import { expandHomeTimeline } from '../../actions/timelines'; -import PreHeader from '../../features/public_layout/components/pre_header'; +// import PreHeader from '../../features/public_layout/components/pre_header'; // import GroupSidebarPanel from '../groups/sidebar_panel'; import BackgroundShapes from './components/background_shapes'; @@ -727,7 +727,7 @@ class UI extends React.PureComponent {
- + {/* */} From 39b819241f973e6f913667f3bda59083eaf4d142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 14 Apr 2022 15:24:11 +0200 Subject: [PATCH 029/334] Dashboard styles, typescript, add useAppDispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../admin/components/latest_accounts_panel.js | 87 ------------------- .../components/latest_accounts_panel.tsx | 63 ++++++++++++++ app/soapbox/features/admin/index.js | 79 ++++++++--------- app/soapbox/features/admin/user_index.js | 1 + app/soapbox/hooks/useAppDispatch.ts | 5 ++ app/styles/components/admin.scss | 60 +------------ app/styles/components/wtf-panel.scss | 2 +- 7 files changed, 107 insertions(+), 190 deletions(-) delete mode 100644 app/soapbox/features/admin/components/latest_accounts_panel.js create mode 100644 app/soapbox/features/admin/components/latest_accounts_panel.tsx create mode 100644 app/soapbox/hooks/useAppDispatch.ts diff --git a/app/soapbox/features/admin/components/latest_accounts_panel.js b/app/soapbox/features/admin/components/latest_accounts_panel.js deleted file mode 100644 index 6377b6810..000000000 --- a/app/soapbox/features/admin/components/latest_accounts_panel.js +++ /dev/null @@ -1,87 +0,0 @@ -import { is } 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 { injectIntl, defineMessages } from 'react-intl'; -import { connect } from 'react-redux'; - -import { fetchUsers } from 'soapbox/actions/admin'; -import compareId from 'soapbox/compare_id'; -import AccountListPanel from 'soapbox/features/ui/components/account_list_panel'; - -const messages = defineMessages({ - title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' }, - expand: { id: 'admin.latest_accounts_panel.expand_message', defaultMessage: 'Click to see {count} more {count, plural, one {account} other {accounts}}' }, -}); - -const mapStateToProps = state => { - const accountIds = state.getIn(['admin', 'latestUsers']); - - // HACK: AdminAPI only recently started sorting new users at the top. - // Try a dirty check to see if the users are sorted properly, or don't show the panel. - // Probably works most of the time. - const sortedIds = accountIds.sort(compareId).reverse(); - const hasDates = accountIds.every(id => state.getIn(['accounts', id, 'created_at'])); - const isSorted = hasDates && is(accountIds, sortedIds); - - return { - isSorted, - accountIds, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class LatestAccountsPanel extends ImmutablePureComponent { - - static propTypes = { - accountIds: ImmutablePropTypes.orderedSet.isRequired, - limit: PropTypes.number, - }; - - static defaultProps = { - limit: 5, - } - - state = { - total: 0, - } - - componentDidMount() { - const { dispatch, limit } = this.props; - - dispatch(fetchUsers(['local', 'active'], 1, null, limit)) - .then(({ count }) => { - this.setState({ total: count }); - }) - .catch(() => {}); - } - - render() { - const { intl, accountIds, limit, isSorted, ...props } = this.props; - const { total } = this.state; - - if (!isSorted || !accountIds || accountIds.isEmpty()) { - return null; - } - - const expandCount = total - accountIds.size; - - return ( - - ); - } - -} diff --git a/app/soapbox/features/admin/components/latest_accounts_panel.tsx b/app/soapbox/features/admin/components/latest_accounts_panel.tsx new file mode 100644 index 000000000..ae32a40f5 --- /dev/null +++ b/app/soapbox/features/admin/components/latest_accounts_panel.tsx @@ -0,0 +1,63 @@ +import { OrderedSet as ImmutableOrderedSet, is } from 'immutable'; +import React, { useState } from 'react'; +import { useEffect } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; + +import { fetchUsers } from 'soapbox/actions/admin'; +import compareId from 'soapbox/compare_id'; +import { Text, Widget } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch'; + +const messages = defineMessages({ + title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' }, + expand: { id: 'admin.latest_accounts_panel.expand_message', defaultMessage: 'Click to see {count} more {count, plural, one {account} other {accounts}}' }, +}); + +interface ILatestAccountsPanel { + limit?: number, +} + +const LatestAccountsPanel: React.FC = ({ limit = 5 }) => { + const dispatch = useAppDispatch(); + const intl = useIntl(); + + const accountIds = useAppSelector>((state) => state.admin.get('latestUsers')); + const hasDates = useAppSelector((state) => accountIds.every(id => !!state.accounts.getIn([id, 'created_at']))); + + const [total, setTotal] = useState(0); + + useEffect(() => { + dispatch(fetchUsers(['local', 'active'], 1, null, limit)) + .then((value) => { + setTotal((value as { count: number }).count); + }) + .catch(() => {}); + }, []); + + const sortedIds = accountIds.sort(compareId).reverse(); + const isSorted = hasDates && is(accountIds, sortedIds); + + if (!isSorted || !accountIds || accountIds.isEmpty()) { + return null; + } + + const expandCount = total - accountIds.size; + + return ( + + {accountIds.take(limit).map((account) => ( + + ))} + {!!expandCount && ( + + {intl.formatMessage(messages.expand, { count: expandCount })} + + )} + + ); +}; + +export default LatestAccountsPanel; diff --git a/app/soapbox/features/admin/index.js b/app/soapbox/features/admin/index.js index 09f626f0e..93a38c53f 100644 --- a/app/soapbox/features/admin/index.js +++ b/app/soapbox/features/admin/index.js @@ -7,6 +7,7 @@ import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email_list'; +import { Text } from 'soapbox/components/ui'; import sourceCode from 'soapbox/utils/code'; import { parseVersion } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features'; @@ -86,56 +87,46 @@ class Dashboard extends ImmutablePureComponent {
{mau &&
-
-
- -
-
- -
-
+ + + + + +
} -
- -
- -
-
- -
- -
+ + + + + + + + {isNumber(retention) && (
-
-
- {retention}% -
-
- -
-
+ + {retention}% + + + +
)} + + + + + + + +
- -
- -
-
- -
- -
-
-
-
- -
-
- -
-
+ + + + + +
{account.admin && } diff --git a/app/soapbox/features/admin/user_index.js b/app/soapbox/features/admin/user_index.js index d93f99d28..dc7691f69 100644 --- a/app/soapbox/features/admin/user_index.js +++ b/app/soapbox/features/admin/user_index.js @@ -116,6 +116,7 @@ class UserIndex extends ImmutablePureComponent { showLoading={showLoading} onLoadMore={this.handleLoadMore} emptyMessage={intl.formatMessage(messages.empty)} + className='mt-4 space-y-4' > {accountIds.map(id => , diff --git a/app/soapbox/hooks/useAppDispatch.ts b/app/soapbox/hooks/useAppDispatch.ts new file mode 100644 index 000000000..11e7226d5 --- /dev/null +++ b/app/soapbox/hooks/useAppDispatch.ts @@ -0,0 +1,5 @@ +import { useDispatch } from 'react-redux'; + +import { AppDispatch } from 'soapbox/store'; + +export const useAppDispatch = () => useDispatch(); \ No newline at end of file diff --git a/app/styles/components/admin.scss b/app/styles/components/admin.scss index 88a34b05c..56e7e9022 100644 --- a/app/styles/components/admin.scss +++ b/app/styles/components/admin.scss @@ -1,65 +1,9 @@ .dashcounters { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); - margin: 0 -5px 0; - padding: 20px; + @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mb-4; } .dashcounter { - box-sizing: border-box; - flex: 0 0 33.333%; - padding: 0 5px; - margin-bottom: 10px; - - > a, - > div { - box-sizing: border-box; - text-decoration: none; - color: inherit; - display: block; - padding: 20px; - background: var(--accent-color--faint); - border-radius: 4px; - transition: 0.2s; - height: 100%; - } - - > a:hover { - background: var(--accent-color--med); - transform: translateY(-2px); - } - - &__num, - &__icon, - &__text { - text-align: center; - font-weight: 500; - font-size: 24px; - line-height: 30px; - color: var(--primary-text-color); - margin-bottom: 10px; - } - - &__icon { - display: flex; - justify-content: center; - - .svg-icon { - width: 48px; - height: 48px; - - svg { - stroke-width: 1px; - } - } - } - - &__label { - font-size: 14px; - color: hsla(var(--primary-text-color_hsl), 0.6); - text-align: center; - font-weight: 500; - } + @apply bg-gray-200 dark:bg-gray-600 p-4 rounded flex flex-col items-center space-y-2 hover:-translate-y-1 transition-transform cursor-pointer; } .dashwidgets { diff --git a/app/styles/components/wtf-panel.scss b/app/styles/components/wtf-panel.scss index 120abe338..113e33e1f 100644 --- a/app/styles/components/wtf-panel.scss +++ b/app/styles/components/wtf-panel.scss @@ -139,13 +139,13 @@ } &__expand-btn { + @apply border-gray-300 dark:border-gray-600; display: block; width: 100%; height: 100%; max-height: 46px; position: relative; border-top: 1px solid; - border-color: var(--brand-color--faint); transition: max-height 150ms ease; overflow: hidden; opacity: 1; From 580633c91549893059c4487168c4396031b43dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 14 Apr 2022 15:26:27 +0200 Subject: [PATCH 030/334] AccountContainer: use withDate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/account.tsx | 4 ++++ .../features/admin/components/latest_accounts_panel.tsx | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/soapbox/components/account.tsx b/app/soapbox/components/account.tsx index 3288127a1..132b9dfa2 100644 --- a/app/soapbox/components/account.tsx +++ b/app/soapbox/components/account.tsx @@ -35,6 +35,7 @@ interface IAccount { showProfileHoverCard?: boolean, timestamp?: string | Date, timestampUrl?: string, + withDate?: boolean, withRelationship?: boolean, } @@ -51,6 +52,7 @@ const Account = ({ showProfileHoverCard = true, timestamp, timestampUrl, + withDate = false, withRelationship = true, }: IAccount) => { const overflowRef = React.useRef(null); @@ -122,6 +124,8 @@ const Account = ({ ); } + if (withDate) timestamp = account.created_at; + const LinkEl: any = showProfileHoverCard ? Link : 'div'; return ( diff --git a/app/soapbox/features/admin/components/latest_accounts_panel.tsx b/app/soapbox/features/admin/components/latest_accounts_panel.tsx index ae32a40f5..7f53abd46 100644 --- a/app/soapbox/features/admin/components/latest_accounts_panel.tsx +++ b/app/soapbox/features/admin/components/latest_accounts_panel.tsx @@ -24,10 +24,10 @@ const LatestAccountsPanel: React.FC = ({ limit = 5 }) => { const dispatch = useAppDispatch(); const intl = useIntl(); - const accountIds = useAppSelector>((state) => state.admin.get('latestUsers')); + const accountIds = useAppSelector>((state) => state.admin.get('latestUsers').take(limit)); const hasDates = useAppSelector((state) => accountIds.every(id => !!state.accounts.getIn([id, 'created_at']))); - const [total, setTotal] = useState(0); + const [total, setTotal] = useState(accountIds.size); useEffect(() => { dispatch(fetchUsers(['local', 'active'], 1, null, limit)) @@ -49,7 +49,7 @@ const LatestAccountsPanel: React.FC = ({ limit = 5 }) => { return ( {accountIds.take(limit).map((account) => ( - + ))} {!!expandCount && ( From bd1e6d364a189e7619fb3ceed64393f69cf94f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 14 Apr 2022 15:36:07 +0200 Subject: [PATCH 031/334] AutosuggestInput dark styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/autosuggest_input.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/soapbox/components/autosuggest_input.js b/app/soapbox/components/autosuggest_input.js index 1bb2e2edf..d13873c80 100644 --- a/app/soapbox/components/autosuggest_input.js +++ b/app/soapbox/components/autosuggest_input.js @@ -206,8 +206,8 @@ export default class AutosuggestInput extends ImmutablePureComponent { key={key} data-index={i} className={classNames({ - 'px-4 py-2.5 text-sm text-gray-700 cursor-pointer hover:bg-gray-100 group': true, - 'bg-gray-100 hover:bg-gray-100': i === selectedSuggestion, + 'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-400 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 group': true, + 'bg-gray-100 dark:bg-slate-700 hover:bg-gray-100 dark:hover:bg-gray-700': i === selectedSuggestion, })} onMouseDown={this.onSuggestionClick} > @@ -238,7 +238,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { return menu.map((item, i) => (
Date: Thu, 14 Apr 2022 15:37:20 +0200 Subject: [PATCH 032/334] dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/styles/components/reply-mentions.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/styles/components/reply-mentions.scss b/app/styles/components/reply-mentions.scss index f94ba5181..5312d0859 100644 --- a/app/styles/components/reply-mentions.scss +++ b/app/styles/components/reply-mentions.scss @@ -1,5 +1,5 @@ .reply-mentions { - @apply text-gray-500 mb-1 text-sm; + @apply text-gray-500 dark:text-gray-400 mb-1 text-sm; &__account { @apply text-primary-600 no-underline; From 23943ccdee732e10a72e955cba8c79f042d68227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 14 Apr 2022 15:51:23 +0200 Subject: [PATCH 033/334] Remote interaction modal styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../ui/components/unauthorized_modal.js | 39 ++++++++----------- app/styles/components/modal.scss | 11 ++---- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/app/soapbox/features/ui/components/unauthorized_modal.js b/app/soapbox/features/ui/components/unauthorized_modal.js index 2e5a1a2f6..cf2c4d79e 100644 --- a/app/soapbox/features/ui/components/unauthorized_modal.js +++ b/app/soapbox/features/ui/components/unauthorized_modal.js @@ -3,16 +3,14 @@ import React from 'react'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; -import { Link, withRouter } from 'react-router-dom'; +import { withRouter } from 'react-router-dom'; import { remoteInteraction } from 'soapbox/actions/interactions'; import snackbar from 'soapbox/actions/snackbar'; import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import IconButton from 'soapbox/components/icon_button'; +import { Button, Modal, Stack, Text } from 'soapbox/components/ui'; import { getFeatures } from 'soapbox/utils/features'; -import { Modal, Stack, Text } from '../../../components/ui'; - const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, accountPlaceholder: { id: 'remote_interaction.account_placeholder', defaultMessage: 'Enter your username@domain you want to act from' }, @@ -133,11 +131,14 @@ class UnauthorizedModal extends ImmutablePureComponent { } return ( -
-
-

{header}

- -
+ } + secondaryAction={this.onRegister} + secondaryText={} + >
- +
- + - +
{!singleUserMode && ( - <> -

- - - - + + + )} - - -
-
+ ); } diff --git a/app/styles/components/modal.scss b/app/styles/components/modal.scss index c04089318..e1a506187 100644 --- a/app/styles/components/modal.scss +++ b/app/styles/components/modal.scss @@ -817,9 +817,7 @@ &__content { display: flex; flex-direction: column; - // align-items: center; row-gap: 10px; - padding: 10px; .unauthorized-modal-content__button { margin: 0 auto; @@ -832,11 +830,8 @@ gap: 10px; width: 100%; - .button { - width: auto; - margin: 0; - text-transform: none; - overflow: unset; + button { + align-self: flex-end; } } @@ -848,9 +843,9 @@ &::before, &::after { + @apply border-b border-gray-300 dark:border-gray-600; content: ""; flex: 1; - border-bottom: 1px solid hsla(var(--primary-text-color_hsl), 0.2); } } From 6aa69f57b8ace3252b644f67699ed35ce73388d0 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 7 Apr 2022 13:39:22 -0400 Subject: [PATCH 034/334] Convert Registration component to TSX --- app/soapbox/components/ui/input/input.tsx | 4 ++-- .../{registration.js => registration.tsx} | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) rename app/soapbox/features/verification/{registration.js => registration.tsx} (87%) diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 343a9d0dd..4b81ed440 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -11,7 +11,7 @@ const messages = defineMessages({ hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' }, }); -interface IInput extends Pick, 'onChange' | 'type'> { +interface IInput extends Pick, 'onChange' | 'required' | 'type'> { autoFocus?: boolean, defaultValue?: string, className?: string, @@ -19,7 +19,7 @@ interface IInput extends Pick, 'onCh name?: string, placeholder?: string, value?: string, - onChange?: () => void, + onChange?: (event: React.ChangeEvent) => void, type: 'text' | 'email' | 'tel' | 'password' } diff --git a/app/soapbox/features/verification/registration.js b/app/soapbox/features/verification/registration.tsx similarity index 87% rename from app/soapbox/features/verification/registration.js rename to app/soapbox/features/verification/registration.tsx index b00be7ad0..9d87ae410 100644 --- a/app/soapbox/features/verification/registration.js +++ b/app/soapbox/features/verification/registration.tsx @@ -1,6 +1,7 @@ +import { AxiosError } from 'axios'; import * as React from 'react'; import { useIntl } from 'react-intl'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { Redirect } from 'react-router-dom'; import { logIn, verifyCredentials } from 'soapbox/actions/auth'; @@ -8,6 +9,7 @@ import { fetchInstance } from 'soapbox/actions/instance'; import snackbar from 'soapbox/actions/snackbar'; import { createAccount } from 'soapbox/actions/verification'; import { removeStoredVerification } from 'soapbox/actions/verification'; +import { useAppSelector } from 'soapbox/hooks'; import { Button, Form, FormGroup, Input } from '../../components/ui'; @@ -20,11 +22,11 @@ const Registration = () => { const dispatch = useDispatch(); const intl = useIntl(); - const isLoading = useSelector((state) => state.verification.get('isLoading')); - const siteTitle = useSelector((state) => state.instance.title); + const isLoading = useAppSelector((state) => state.verification.get('isLoading') as boolean); + const siteTitle = useAppSelector((state) => state.instance.title); const [state, setState] = React.useState(initialState); - const [shouldRedirect, setShouldRedirect] = React.useState(false); + const [shouldRedirect, setShouldRedirect] = React.useState(false); const { username, password } = state; const handleSubmit = React.useCallback((event) => { @@ -33,7 +35,7 @@ const Registration = () => { // TODO: handle validation errors from Pepe dispatch(createAccount(username, password)) .then(() => dispatch(logIn(intl, username, password))) - .then(({ access_token }) => dispatch(verifyCredentials(access_token))) + .then(({ access_token }: any) => dispatch(verifyCredentials(access_token))) .then(() => dispatch(fetchInstance())) .then(() => { setShouldRedirect(true); @@ -47,7 +49,7 @@ const Registration = () => { ), ); }) - .catch((error) => { + .catch((error: AxiosError) => { if (error?.response?.status === 422) { dispatch( snackbar.error( From 5e626995dfed14391d831c2bf2e332e34add9e1f Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 7 Apr 2022 13:39:53 -0400 Subject: [PATCH 035/334] Convert Registration component test to TSX --- .../__tests__/{registration.test.js => registration.test.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/soapbox/features/verification/__tests__/{registration.test.js => registration.test.tsx} (100%) diff --git a/app/soapbox/features/verification/__tests__/registration.test.js b/app/soapbox/features/verification/__tests__/registration.test.tsx similarity index 100% rename from app/soapbox/features/verification/__tests__/registration.test.js rename to app/soapbox/features/verification/__tests__/registration.test.tsx From f625e13a251d6d63d036745d736a969a0251353d Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:49:41 -0400 Subject: [PATCH 036/334] Update Textarea type --- app/soapbox/components/ui/textarea/textarea.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/soapbox/components/ui/textarea/textarea.tsx b/app/soapbox/components/ui/textarea/textarea.tsx index d091f5165..81a8488b8 100644 --- a/app/soapbox/components/ui/textarea/textarea.tsx +++ b/app/soapbox/components/ui/textarea/textarea.tsx @@ -1,14 +1,13 @@ import classNames from 'classnames'; import React from 'react'; -interface ITextarea { +interface ITextarea extends Pick, 'maxLength' | 'onChange' | 'required'> { autoFocus?: boolean, defaultValue?: string, name?: string, isCodeEditor?: boolean, placeholder?: string, value?: string, - onChange?: (event: React.ChangeEvent) => void } const Textarea = React.forwardRef( From b7e9015dfd3fd32cc7c9d00d30399c2949b1affa Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:04 -0400 Subject: [PATCH 037/334] Add 'transform' prop to Text --- app/soapbox/components/ui/text/text.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/soapbox/components/ui/text/text.tsx b/app/soapbox/components/ui/text/text.tsx index c1b8f4c25..b2db90b2c 100644 --- a/app/soapbox/components/ui/text/text.tsx +++ b/app/soapbox/components/ui/text/text.tsx @@ -6,6 +6,7 @@ type Weights = 'normal' | 'medium' | 'semibold' | 'bold' type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' type Alignments = 'left' | 'center' | 'right' type TrackingSizes = 'normal' | 'wide' +type TransformProperties = 'uppercase' | 'normal' type Families = 'sans' | 'mono' type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' @@ -48,6 +49,11 @@ const trackingSizes = { wide: 'tracking-wide', }; +const transformProperties = { + normal: 'normal-case', + uppercase: 'uppercase', +}; + const families = { sans: 'font-sans', mono: 'font-mono', @@ -62,6 +68,7 @@ interface IText extends Pick, 'danger tag?: Tags, theme?: Themes, tracking?: TrackingSizes, + transform?: TransformProperties, truncate?: boolean, weight?: Weights } @@ -76,6 +83,7 @@ const Text: React.FC = React.forwardRef( tag = 'p', theme = 'default', tracking = 'normal', + transform = 'normal', truncate = false, weight = 'normal', ...filteredProps @@ -99,6 +107,7 @@ const Text: React.FC = React.forwardRef( [trackingSizes[tracking]]: true, [families[family]]: true, [alignmentClass]: typeof align !== 'undefined', + [transformProperties[transform]]: typeof transform !== 'undefined', }, className)} /> ); From 3e0d7de2cd8ec759d13206f025a352fe4396bb9e Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:17 -0400 Subject: [PATCH 038/334] Add more sizes to Stack --- app/soapbox/components/ui/stack/stack.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/stack/stack.tsx b/app/soapbox/components/ui/stack/stack.tsx index 1441ae17e..7e55ff5a2 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 +type SIZES = 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10 const spaces = { '0.5': 'space-y-0.5', @@ -11,6 +11,7 @@ const spaces = { 3: 'space-y-3', 4: 'space-y-4', 5: 'space-y-5', + 10: 'space-y-10', }; const justifyContentOptions = { From fd6ae83fe7ae398e276cbfde384b1bc0402e1a27 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:29 -0400 Subject: [PATCH 039/334] Add 'maxLength' prop to Input --- app/soapbox/components/ui/input/input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/input/input.tsx b/app/soapbox/components/ui/input/input.tsx index 4b81ed440..6441cd37b 100644 --- a/app/soapbox/components/ui/input/input.tsx +++ b/app/soapbox/components/ui/input/input.tsx @@ -11,7 +11,7 @@ const messages = defineMessages({ hidePassword: { id: 'input.password.hide_password', defaultMessage: 'Hide password' }, }); -interface IInput extends Pick, 'onChange' | 'required' | 'type'> { +interface IInput extends Pick, 'maxLength' | 'onChange' | 'required' | 'type'> { autoFocus?: boolean, defaultValue?: string, className?: string, From afec2ad9eae37fdd3592d9282454816124e15f47 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:39 -0400 Subject: [PATCH 040/334] Add 'strokeWidth' prop to Icon --- app/soapbox/components/ui/icon/icon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/icon/icon.tsx b/app/soapbox/components/ui/icon/icon.tsx index e8d8162a9..224d15a9b 100644 --- a/app/soapbox/components/ui/icon/icon.tsx +++ b/app/soapbox/components/ui/icon/icon.tsx @@ -2,7 +2,7 @@ import React from 'react'; import SvgIcon from './svg-icon'; -interface IIcon { +interface IIcon extends Pick, 'strokeWidth'> { className?: string, count?: number, alt?: string, From 3bd8ef13ef01e349cdb1b42e9204e05bb2d5ebd0 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:50:54 -0400 Subject: [PATCH 041/334] Add 'link' theme to Button --- app/soapbox/components/ui/button/useButtonStyles.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/button/useButtonStyles.ts b/app/soapbox/components/ui/button/useButtonStyles.ts index 15a6f87cc..f670d028e 100644 --- a/app/soapbox/components/ui/button/useButtonStyles.ts +++ b/app/soapbox/components/ui/button/useButtonStyles.ts @@ -1,6 +1,6 @@ import classNames from 'classnames'; -type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent' +type ButtonThemes = 'primary' | 'secondary' | 'ghost' | 'accent' | 'danger' | 'transparent' | 'link' type ButtonSizes = 'sm' | 'md' | 'lg' type IButtonStyles = { @@ -25,6 +25,7 @@ const useButtonStyles = ({ accent: 'border-transparent text-white bg-accent-500 hover:bg-accent-300 focus:ring-pink-500 focus:ring-2 focus:ring-offset-2', danger: 'border-transparent text-danger-700 bg-danger-100 hover:bg-danger-200 focus:ring-danger-500 focus:ring-2 focus:ring-offset-2', transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80', + link: 'border-transparent text-primary-600 hover:bg-gray-100 hover:text-primary-700', }; const sizes = { From c8c715ee4b13e790d9bd7ab09020f7acb73beedd Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:51:28 -0400 Subject: [PATCH 042/334] Add Onboarding controls to Redux --- .../actions/__tests__/onboarding.test.ts | 101 ++++++++++++++++++ app/soapbox/actions/onboarding.js | 8 -- app/soapbox/actions/onboarding.ts | 40 +++++++ app/soapbox/jest/test-helpers.tsx | 18 ++++ .../reducers/__tests__/onboarding.test.ts | 27 +++++ app/soapbox/reducers/index.ts | 2 + app/soapbox/reducers/onboarding.ts | 22 ++++ 7 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 app/soapbox/actions/__tests__/onboarding.test.ts delete mode 100644 app/soapbox/actions/onboarding.js create mode 100644 app/soapbox/actions/onboarding.ts create mode 100644 app/soapbox/reducers/__tests__/onboarding.test.ts create mode 100644 app/soapbox/reducers/onboarding.ts diff --git a/app/soapbox/actions/__tests__/onboarding.test.ts b/app/soapbox/actions/__tests__/onboarding.test.ts new file mode 100644 index 000000000..cdd268ed5 --- /dev/null +++ b/app/soapbox/actions/__tests__/onboarding.test.ts @@ -0,0 +1,101 @@ +import { mockStore, mockWindowProperty } from 'soapbox/jest/test-helpers'; +import rootReducer from 'soapbox/reducers'; + +import { checkOnboardingStatus, startOnboarding, endOnboarding } from '../onboarding'; + +describe('checkOnboarding()', () => { + let mockGetItem: any; + + mockWindowProperty('localStorage', { + getItem: (key: string) => mockGetItem(key), + }); + + beforeEach(() => { + mockGetItem = jest.fn().mockReturnValue(null); + }); + + it('does nothing if localStorage item is not set', async() => { + mockGetItem = jest.fn().mockReturnValue(null); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('does nothing if localStorage item is invalid', async() => { + mockGetItem = jest.fn().mockReturnValue('invalid'); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); + + it('dispatches the correct action', async() => { + mockGetItem = jest.fn().mockReturnValue('1'); + + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(checkOnboardingStatus()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockGetItem.mock.calls.length).toBe(1); + }); +}); + +describe('startOnboarding()', () => { + let mockSetItem: any; + + mockWindowProperty('localStorage', { + setItem: (key: string, value: string) => mockSetItem(key, value), + }); + + beforeEach(() => { + mockSetItem = jest.fn(); + }); + + it('dispatches the correct action', async() => { + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(startOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_START' }]); + expect(mockSetItem.mock.calls.length).toBe(1); + }); +}); + +describe('endOnboarding()', () => { + let mockRemoveItem: any; + + mockWindowProperty('localStorage', { + removeItem: (key: string) => mockRemoveItem(key), + }); + + beforeEach(() => { + mockRemoveItem = jest.fn(); + }); + + it('dispatches the correct action', async() => { + const state = rootReducer(undefined, { onboarding: { needsOnboarding: false } }); + const store = mockStore(state); + + await store.dispatch(endOnboarding()); + const actions = store.getActions(); + + expect(actions).toEqual([{ type: 'ONBOARDING_END' }]); + expect(mockRemoveItem.mock.calls.length).toBe(1); + }); +}); diff --git a/app/soapbox/actions/onboarding.js b/app/soapbox/actions/onboarding.js deleted file mode 100644 index a1dd3a731..000000000 --- a/app/soapbox/actions/onboarding.js +++ /dev/null @@ -1,8 +0,0 @@ -import { changeSetting, saveSettings } from './settings'; - -export const INTRODUCTION_VERSION = 20181216044202; - -export const closeOnboarding = () => dispatch => { - dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); - dispatch(saveSettings()); -}; diff --git a/app/soapbox/actions/onboarding.ts b/app/soapbox/actions/onboarding.ts new file mode 100644 index 000000000..ff12bd074 --- /dev/null +++ b/app/soapbox/actions/onboarding.ts @@ -0,0 +1,40 @@ +const ONBOARDING_START = 'ONBOARDING_START'; +const ONBOARDING_END = 'ONBOARDING_END'; + +const ONBOARDING_LOCAL_STORAGE_KEY = 'soapbox:onboarding'; + +type OnboardingStartAction = { + type: typeof ONBOARDING_START +} + +type OnboardingEndAction = { + type: typeof ONBOARDING_END +} + +export type OnboardingActions = OnboardingStartAction | OnboardingEndAction + +const checkOnboardingStatus = () => (dispatch: React.Dispatch) => { + const needsOnboarding = localStorage.getItem(ONBOARDING_LOCAL_STORAGE_KEY) === '1'; + + if (needsOnboarding) { + dispatch({ type: ONBOARDING_START }); + } +}; + +const startOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.setItem(ONBOARDING_LOCAL_STORAGE_KEY, '1'); + dispatch({ type: ONBOARDING_START }); +}; + +const endOnboarding = () => (dispatch: React.Dispatch) => { + localStorage.removeItem(ONBOARDING_LOCAL_STORAGE_KEY); + dispatch({ type: ONBOARDING_END }); +}; + +export { + ONBOARDING_END, + ONBOARDING_START, + checkOnboardingStatus, + endOnboarding, + startOnboarding, +}; diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index 7a4f8f53b..0b195e404 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -63,6 +63,23 @@ const customRender = ( ...options, }); +const mockWindowProperty = (property: any, value: any) => { + const { [property]: originalProperty } = window; + delete window[property]; + + beforeAll(() => { + Object.defineProperty(window, property, { + configurable: true, + writable: true, + value, + }); + }); + + afterAll(() => { + window[property] = originalProperty; + }); +}; + export * from '@testing-library/react'; export { customRender as render, @@ -70,4 +87,5 @@ export { applyActions, rootState, rootReducer, + mockWindowProperty, }; diff --git a/app/soapbox/reducers/__tests__/onboarding.test.ts b/app/soapbox/reducers/__tests__/onboarding.test.ts new file mode 100644 index 000000000..95ecdf755 --- /dev/null +++ b/app/soapbox/reducers/__tests__/onboarding.test.ts @@ -0,0 +1,27 @@ +import { ONBOARDING_START, ONBOARDING_END } from 'soapbox/actions/onboarding'; + +import reducer from '../onboarding'; + +describe('onboarding reducer', () => { + it('should return the initial state', () => { + expect(reducer(undefined, {})).toEqual({ + needsOnboarding: false, + }); + }); + + describe('ONBOARDING_START', () => { + it('sets "needsOnboarding" to "true"', () => { + const initialState = { needsOnboarding: false }; + const action = { type: ONBOARDING_START }; + expect(reducer(initialState, action).needsOnboarding).toEqual(true); + }); + }); + + describe('ONBOARDING_END', () => { + it('sets "needsOnboarding" to "false"', () => { + const initialState = { needsOnboarding: true }; + const action = { type: ONBOARDING_END }; + expect(reducer(initialState, action).needsOnboarding).toEqual(false); + }); + }); +}); diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index 4e54ec19f..61234e2ed 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -39,6 +39,7 @@ import meta from './meta'; import modals from './modals'; import mutes from './mutes'; import notifications from './notifications'; +import onboarding from './onboarding'; import patron from './patron'; import pending_statuses from './pending_statuses'; import polls from './polls'; @@ -118,6 +119,7 @@ const reducers = { accounts_meta, trending_statuses, verification, + onboarding, }; // Build a default state from all reducers: it has the key and `undefined` diff --git a/app/soapbox/reducers/onboarding.ts b/app/soapbox/reducers/onboarding.ts new file mode 100644 index 000000000..844d6b353 --- /dev/null +++ b/app/soapbox/reducers/onboarding.ts @@ -0,0 +1,22 @@ +import { ONBOARDING_START, ONBOARDING_END } from 'soapbox/actions/onboarding'; + +import type { OnboardingActions } from 'soapbox/actions/onboarding'; + +type OnboardingState = { + needsOnboarding: boolean, +} + +const initialState: OnboardingState = { + needsOnboarding: false, +}; + +export default function onboarding(state: OnboardingState = initialState, action: OnboardingActions): OnboardingState { + switch(action.type) { + case ONBOARDING_START: + return { ...state, needsOnboarding: true }; + case ONBOARDING_END: + return { ...state, needsOnboarding: false }; + default: + return state; + } +} From 98c77006ce80236b5e1698cda2eba88a5a930e22 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 12 Apr 2022 09:52:04 -0400 Subject: [PATCH 043/334] Add Onboarding components --- app/soapbox/containers/soapbox.js | 33 ++-- .../features/onboarding/onboarding-wizard.tsx | 111 ++++++++++++++ .../steps/avatar-selection-step.tsx | 118 +++++++++++++++ .../features/onboarding/steps/bio-step.tsx | 94 ++++++++++++ .../onboarding/steps/completed-step.tsx | 37 +++++ .../steps/cover-photo-selection-step.tsx | 142 ++++++++++++++++++ .../onboarding/steps/display-name-step.tsx | 100 ++++++++++++ .../steps/suggested-accounts-step.tsx | 76 ++++++++++ .../features/verification/registration.tsx | 2 + package.json | 1 + yarn.lock | 7 + 11 files changed, 711 insertions(+), 10 deletions(-) create mode 100644 app/soapbox/features/onboarding/onboarding-wizard.tsx create mode 100644 app/soapbox/features/onboarding/steps/avatar-selection-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/bio-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/completed-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/cover-photo-selection-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/display-name-step.tsx create mode 100644 app/soapbox/features/onboarding/steps/suggested-accounts-step.tsx diff --git a/app/soapbox/containers/soapbox.js b/app/soapbox/containers/soapbox.js index c1a7db84e..4c5a8914d 100644 --- a/app/soapbox/containers/soapbox.js +++ b/app/soapbox/containers/soapbox.js @@ -19,7 +19,9 @@ import { FE_SUBDIRECTORY } from 'soapbox/build_config'; import { NODE_ENV } from 'soapbox/build_config'; import Helmet from 'soapbox/components/helmet'; import AuthLayout from 'soapbox/features/auth_layout'; +import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard'; import PublicLayout from 'soapbox/features/public_layout'; +import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container'; import WaitlistPage from 'soapbox/features/verification/waitlist_page'; import { createGlobals } from 'soapbox/globals'; import messages from 'soapbox/locales/messages'; @@ -27,10 +29,9 @@ import { makeGetAccount } from 'soapbox/selectors'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import { generateThemeCss } from 'soapbox/utils/theme'; -import { INTRODUCTION_VERSION } from '../actions/onboarding'; +import { checkOnboardingStatus } from '../actions/onboarding'; import { preload } from '../actions/preload'; import ErrorBoundary from '../components/error_boundary'; -// import Introduction from '../features/introduction'; import UI from '../features/ui'; import { store } from '../store'; @@ -54,6 +55,7 @@ store.dispatch(fetchMe()) // Postpone for authenticated fetch store.dispatch(loadInstance()); store.dispatch(loadSoapboxConfig()); + store.dispatch(checkOnboardingStatus()); if (!account) { store.dispatch(fetchVerificationConfig()); @@ -66,7 +68,6 @@ const makeAccount = makeGetAccount(); const mapStateToProps = (state) => { const me = state.get('me'); const account = makeAccount(state, me); - const showIntroduction = account ? state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION : false; const settings = getSettings(state); const soapboxConfig = getSoapboxConfig(state); const locale = settings.get('locale'); @@ -74,7 +75,6 @@ const mapStateToProps = (state) => { const singleUserMode = soapboxConfig.get('singleUserMode') && soapboxConfig.get('singleUserModeProfile'); return { - showIntroduction, me, account, instanceLoaded: isInstanceLoaded(state), @@ -88,6 +88,7 @@ const mapStateToProps = (state) => { brandColor: soapboxConfig.get('brandColor'), themeMode: settings.get('themeMode'), singleUserMode, + needsOnboarding: state.onboarding.needsOnboarding, }; }; @@ -95,13 +96,13 @@ const mapStateToProps = (state) => { class SoapboxMount extends React.PureComponent { static propTypes = { - showIntroduction: PropTypes.bool, me: SoapboxPropTypes.me, account: ImmutablePropTypes.record, instanceLoaded: PropTypes.bool, reduceMotion: PropTypes.bool, underlineLinks: PropTypes.bool, systemFont: PropTypes.bool, + needsOnboarding: PropTypes.bool, dyslexicFont: PropTypes.bool, demetricator: PropTypes.bool, locale: PropTypes.string.isRequired, @@ -151,11 +152,23 @@ class SoapboxMount extends React.PureComponent { const waitlisted = account && !account.getIn(['source', 'approved'], true); // Disabling introduction for launch - // const { showIntroduction } = this.props; - // - // if (showIntroduction) { - // return ; - // } + const { needsOnboarding } = this.props; + + if (needsOnboarding) { + return ( + + + + + {themeCss && } + + + + + + + ); + } const bodyClass = classNames('bg-white dark:bg-slate-900 text-base', { 'no-reduce-motion': !this.props.reduceMotion, diff --git a/app/soapbox/features/onboarding/onboarding-wizard.tsx b/app/soapbox/features/onboarding/onboarding-wizard.tsx new file mode 100644 index 000000000..7ef3cb804 --- /dev/null +++ b/app/soapbox/features/onboarding/onboarding-wizard.tsx @@ -0,0 +1,111 @@ +import classNames from 'classnames'; +import * as React from 'react'; +import { useDispatch } from 'react-redux'; +import ReactSwipeableViews from 'react-swipeable-views'; + +import { endOnboarding } from 'soapbox/actions/onboarding'; +import { HStack } from 'soapbox/components/ui'; + +import AvatarSelectionStep from './steps/avatar-selection-step'; +import BioStep from './steps/bio-step'; +import CompletedStep from './steps/completed-step'; +import CoverPhotoSelectionStep from './steps/cover-photo-selection-step'; +import DisplayNameStep from './steps/display-name-step'; +import SuggestedAccountsStep from './steps/suggested-accounts-step'; + +const OnboardingWizard = () => { + const dispatch = useDispatch(); + + const [currentStep, setCurrentStep] = React.useState(0); + + const handleSwipe = (nextStep: number) => { + setCurrentStep(nextStep); + }; + + const handlePreviousStep = () => { + setCurrentStep((prevStep) => Math.max(0, prevStep - 1)); + }; + + const handleNextStep = () => { + setCurrentStep((prevStep) => Math.min(prevStep + 1, steps.length - 1)); + }; + + const handleComplete = () => { + dispatch(endOnboarding()); + }; + + const steps = [ + , + , + , + , + , + , + ]; + + const handleKeyUp = ({ key }: KeyboardEvent): void => { + switch (key) { + case 'ArrowLeft': + handlePreviousStep(); + break; + case 'ArrowRight': + handleNextStep(); + break; + } + }; + + const handleDotClick = (nextStep: number) => { + setCurrentStep(nextStep); + }; + + React.useEffect(() => { + document.addEventListener('keyup', handleKeyUp); + + return () => { + document.removeEventListener('keyup', handleKeyUp); + }; + }, []); + + return ( +
+
+ +
+
+ + {steps.map((step, i) => ( +
+
+ {step} +
+
+ ))} +
+ + + {steps.map((_, i) => ( +
+
+
+ ); +}; + +export default OnboardingWizard; diff --git a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx new file mode 100644 index 000000000..3f2fec197 --- /dev/null +++ b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx @@ -0,0 +1,118 @@ +import { AxiosError } from 'axios'; +import classNames from 'classnames'; +import * as React from 'react'; +import { useDispatch } from 'react-redux'; + +import { patchMe } from 'soapbox/actions/me'; +import snackbar from 'soapbox/actions/snackbar'; +import { Avatar, Button, Card, CardBody, Icon, Spinner, Stack, Text } from 'soapbox/components/ui'; +import { useOwnAccount } from 'soapbox/hooks'; +import resizeImage from 'soapbox/utils/resize_image'; + +const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => { + const dispatch = useDispatch(); + const account = useOwnAccount(); + + const fileInput = React.useRef(null); + const [selectedFile, setSelectedFile] = React.useState(); + const [isSubmitting, setSubmitting] = React.useState(false); + const [isDisabled, setDisabled] = React.useState(true); + + const openFilePicker = () => { + fileInput.current?.click(); + }; + + const handleFileChange = (event: React.ChangeEvent) => { + const maxPixels = 400 * 400; + const [rawFile] = event.target.files || [] as any; + + resizeImage(rawFile, maxPixels).then((file) => { + const url = file ? URL.createObjectURL(file) : account?.avatar as string; + + setSelectedFile(url); + setSubmitting(true); + + const formData = new FormData(); + formData.append('avatar', rawFile); + const credentials = dispatch(patchMe(formData)); + + Promise.all([credentials]).then(() => { + setDisabled(false); + setSubmitting(false); + onNext(); + }).catch((error: AxiosError) => { + setSubmitting(false); + setDisabled(false); + setSelectedFile(null); + + if (error.response?.status === 422) { + dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', ''))); + } else { + dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.')); + } + }); + }).catch(console.error); + }; + + return ( + + +
+
+ + + Choose a profile picture + + + + Just have fun with it. + + +
+ +
+ +
+ {account && ( + + )} + + {isSubmitting && ( +
+ +
+ )} + + + + +
+ + + + + {isDisabled && ( + + )} + +
+
+
+
+
+ ); +}; + +export default AvatarSelectionStep; diff --git a/app/soapbox/features/onboarding/steps/bio-step.tsx b/app/soapbox/features/onboarding/steps/bio-step.tsx new file mode 100644 index 000000000..384adc311 --- /dev/null +++ b/app/soapbox/features/onboarding/steps/bio-step.tsx @@ -0,0 +1,94 @@ +import { AxiosError } from 'axios'; +import * as React from 'react'; +import { useDispatch } from 'react-redux'; + +import { patchMe } from 'soapbox/actions/me'; +import snackbar from 'soapbox/actions/snackbar'; +import { Button, Card, CardBody, FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui'; + +const BioStep = ({ onNext }: { onNext: () => void }) => { + const dispatch = useDispatch(); + + const [value, setValue] = React.useState(''); + const [isSubmitting, setSubmitting] = React.useState(false); + const [errors, setErrors] = React.useState([]); + + const trimmedValue = value.trim(); + const isValid = trimmedValue.length > 0; + const isDisabled = !isValid; + + const handleSubmit = () => { + setSubmitting(true); + + const credentials = dispatch(patchMe({ note: value })); + + Promise.all([credentials]) + .then(() => { + setSubmitting(false); + onNext(); + }).catch((error: AxiosError) => { + setSubmitting(false); + + if (error.response?.status === 422) { + setErrors([error.response.data.error.replace('Validation failed: ', '')]); + } else { + dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.')); + } + }); + }; + + return ( + + +
+
+ + + Write a short bio + + + + You can always edit this later. + + +
+ + +
+ +