From 89e0eef60f98be2f3c789fd4e1da4d9ad8186365 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 28 Jun 2022 14:28:20 -0500 Subject: [PATCH 01/19] Remove the guestExperience setting --- .../features/ui/__tests__/index.test.tsx | 74 +++++++++---------- app/soapbox/features/ui/index.tsx | 21 +----- .../normalizers/soapbox/soapbox_config.ts | 1 - 3 files changed, 37 insertions(+), 59 deletions(-) diff --git a/app/soapbox/features/ui/__tests__/index.test.tsx b/app/soapbox/features/ui/__tests__/index.test.tsx index e4a360dffb..5a31649961 100644 --- a/app/soapbox/features/ui/__tests__/index.test.tsx +++ b/app/soapbox/features/ui/__tests__/index.test.tsx @@ -5,6 +5,7 @@ import { Route, Switch } from 'react-router-dom'; import { render, screen, waitFor } from '../../../jest/test-helpers'; import { normalizeAccount } from '../../../normalizers'; import UI from '../index'; +import { WrappedRoute } from '../util/react_router_helpers'; const TestableComponent = () => ( @@ -12,6 +13,9 @@ const TestableComponent = () => ( Sign in + + {/* WrappedRount will redirect to /login for logged out users... which will resolve to the route above! */} + ); @@ -33,53 +37,47 @@ describe('', () => { }); describe('when logged out', () => { - describe('with guest experience disabled', () => { - beforeEach(() => { - store = { ...store, soapbox: ImmutableMap({ guestExperience: false }) }; - }); + describe('when viewing a Profile Page', () => { + it('should render the Profile page', async() => { + render( + , + {}, + store, + { initialEntries: ['/@username'] }, + ); - describe('when viewing a Profile Page', () => { - it('should render the Profile page', async() => { - render( - , - {}, - store, - { initialEntries: ['/@username'] }, - ); - - await waitFor(() => { - expect(screen.getByTestId('cta-banner')).toHaveTextContent('Sign up now to discuss'); - }); + await waitFor(() => { + expect(screen.getByTestId('cta-banner')).toHaveTextContent('Sign up now to discuss'); }); }); + }); - describe('when viewing a Status Page', () => { - it('should render the Status page', async() => { - render( - , - {}, - store, - { initialEntries: ['/@username/posts/12'] }, - ); + describe('when viewing a Status Page', () => { + it('should render the Status page', async() => { + render( + , + {}, + store, + { initialEntries: ['/@username/posts/12'] }, + ); - await waitFor(() => { - expect(screen.getByTestId('cta-banner')).toHaveTextContent('Sign up now to discuss'); - }); + await waitFor(() => { + expect(screen.getByTestId('cta-banner')).toHaveTextContent('Sign up now to discuss'); }); }); + }); - describe('when viewing any other page', () => { - it('should redirect to the login page', async() => { - render( - , - {}, - store, - { initialEntries: ['/@username/media'] }, - ); + describe('when viewing Notifications', () => { + it('should redirect to the login page', async() => { + render( + , + {}, + store, + { initialEntries: ['/notifications'] }, + ); - await waitFor(() => { - expect(screen.getByTestId('sign-in')).toHaveTextContent('Sign in'); - }); + await waitFor(() => { + expect(screen.getByTestId('sign-in')).toHaveTextContent('Sign in'); }); }); }); diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index be3afefd98..8cf66b5307 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -5,7 +5,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { HotKeys } from 'react-hotkeys'; import { defineMessages, useIntl } from 'react-intl'; import { useDispatch } from 'react-redux'; -import { Switch, useHistory, useLocation, matchPath, Redirect } from 'react-router-dom'; +import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom'; import { fetchFollowRequests } from 'soapbox/actions/accounts'; import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin'; @@ -34,7 +34,6 @@ import ProfilePage from 'soapbox/pages/profile_page'; import RemoteInstancePage from 'soapbox/pages/remote_instance_page'; import StatusPage from 'soapbox/pages/status_page'; import { getAccessToken, getVapidKey } from 'soapbox/utils/auth'; -import { cacheCurrentUrl } from 'soapbox/utils/redirect'; import { isStandalone } from 'soapbox/utils/state'; // import GroupSidebarPanel from '../groups/sidebar_panel'; @@ -329,7 +328,6 @@ const UI: React.FC = ({ children }) => { const intl = useIntl(); const history = useHistory(); const dispatch = useDispatch(); - const { guestExperience } = useSoapboxConfig(); const [draggingOver, setDraggingOver] = useState(false); const [mobile, setMobile] = useState(isMobile(window.innerWidth)); @@ -608,23 +606,6 @@ const UI: React.FC = ({ children }) => { // Wait for login to succeed or fail if (me === null) return null; - const isProfileOrStatusPage = !!matchPath( - history.location.pathname, - [ - '/@:username', - '/@:username/posts/:statusId', - '/users/:username', - '/users/:username/statuses/:statusId', - ], - ); - - // Require login if Guest Experience is disabled and we're not trying - // to render a Profile or Status. - if (!me && (!guestExperience && !isProfileOrStatusPage)) { - cacheCurrentUrl(history.location); - return ; - } - type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; const handlers: HotkeyHandlers = { diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index d9860909de..efb3b00447 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -115,7 +115,6 @@ export const SoapboxConfigRecord = ImmutableRecord({ singleUserMode: false, singleUserModeProfile: '', linkFooterMessage: '', - guestExperience: true, links: ImmutableMap(), }, 'SoapboxConfig'); From 33bebf5bba7d3f47105cb79f5af6950520b83060 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Jun 2022 08:55:42 -0400 Subject: [PATCH 02/19] Add "carousel" redux logic --- .../actions/__tests__/carousels.test.ts | 58 +++++++++++++++++++ app/soapbox/actions/carousels.ts | 25 ++++++++ .../reducers/__tests__/carousels.test.ts | 45 ++++++++++++++ app/soapbox/reducers/carousels.ts | 36 ++++++++++++ app/soapbox/reducers/index.ts | 2 + 5 files changed, 166 insertions(+) create mode 100644 app/soapbox/actions/__tests__/carousels.test.ts create mode 100644 app/soapbox/actions/carousels.ts create mode 100644 app/soapbox/reducers/__tests__/carousels.test.ts create mode 100644 app/soapbox/reducers/carousels.ts diff --git a/app/soapbox/actions/__tests__/carousels.test.ts b/app/soapbox/actions/__tests__/carousels.test.ts new file mode 100644 index 0000000000..f3d0c42488 --- /dev/null +++ b/app/soapbox/actions/__tests__/carousels.test.ts @@ -0,0 +1,58 @@ +import { __stub } from 'soapbox/api'; +import { mockStore, rootState } from 'soapbox/jest/test-helpers'; + +import { fetchCarouselAvatars } from '../carousels'; + +describe('fetchCarouselAvatars()', () => { + let store; + + beforeEach(() => { + store = mockStore(rootState); + }); + + describe('with a successful API request', () => { + let avatars; + + beforeEach(() => { + avatars = [ + { account_id: '1', username: 'jl', account_avatar: 'https://example.com/some.jpg' }, + ]; + + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/avatars').reply(200, avatars); + }); + }); + + it('should fetch the users from the API', async() => { + const expectedActions = [ + { type: 'CAROUSEL_AVATAR_REQUEST' }, + { type: 'CAROUSEL_AVATAR_SUCCESS', payload: avatars }, + ]; + + await store.dispatch(fetchCarouselAvatars()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); + + describe('with an unsuccessful API request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/truth/carousels/avatars').networkError(); + }); + }); + + it('should dispatch failed action', async() => { + const expectedActions = [ + { type: 'CAROUSEL_AVATAR_REQUEST' }, + { type: 'CAROUSEL_AVATAR_FAIL' }, + ]; + + await store.dispatch(fetchCarouselAvatars()); + const actions = store.getActions(); + + expect(actions).toEqual(expectedActions); + }); + }); +}); diff --git a/app/soapbox/actions/carousels.ts b/app/soapbox/actions/carousels.ts new file mode 100644 index 0000000000..7935536c49 --- /dev/null +++ b/app/soapbox/actions/carousels.ts @@ -0,0 +1,25 @@ +import { AxiosResponse } from 'axios'; + +import { AppDispatch, RootState } from 'soapbox/store'; + +import api from '../api'; + +const CAROUSEL_AVATAR_REQUEST = 'CAROUSEL_AVATAR_REQUEST'; +const CAROUSEL_AVATAR_SUCCESS = 'CAROUSEL_AVATAR_SUCCESS'; +const CAROUSEL_AVATAR_FAIL = 'CAROUSEL_AVATAR_FAIL'; + +const fetchCarouselAvatars = () => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CAROUSEL_AVATAR_REQUEST }); + + return api(getState) + .get('/api/v1/truth/carousels/avatars') + .then((response: AxiosResponse) => dispatch({ type: CAROUSEL_AVATAR_SUCCESS, payload: response.data })) + .catch(() => dispatch({ type: CAROUSEL_AVATAR_FAIL })); +}; + +export { + CAROUSEL_AVATAR_REQUEST, + CAROUSEL_AVATAR_SUCCESS, + CAROUSEL_AVATAR_FAIL, + fetchCarouselAvatars, +}; diff --git a/app/soapbox/reducers/__tests__/carousels.test.ts b/app/soapbox/reducers/__tests__/carousels.test.ts new file mode 100644 index 0000000000..394a2c037b --- /dev/null +++ b/app/soapbox/reducers/__tests__/carousels.test.ts @@ -0,0 +1,45 @@ +import { AnyAction } from 'redux'; + +import { + CAROUSEL_AVATAR_REQUEST, + CAROUSEL_AVATAR_SUCCESS, + CAROUSEL_AVATAR_FAIL, +} from 'soapbox/actions/carousels'; + +import reducer from '../carousels'; + +describe('carousels reducer', () => { + it('should return the initial state', () => { + expect(reducer(undefined, {} as AnyAction)).toEqual({ + avatars: [], + isLoading: false, + }); + }); + + describe('CAROUSEL_AVATAR_REQUEST', () => { + it('sets "isLoading" to "true"', () => { + const initialState = { isLoading: false, avatars: [] }; + const action = { type: CAROUSEL_AVATAR_REQUEST }; + expect(reducer(initialState, action).isLoading).toEqual(true); + }); + }); + + describe('CAROUSEL_AVATAR_SUCCESS', () => { + it('sets the next state', () => { + const initialState = { isLoading: true, avatars: [] }; + const action = { type: CAROUSEL_AVATAR_SUCCESS, payload: [45] }; + const result = reducer(initialState, action); + + expect(result.isLoading).toEqual(false); + expect(result.avatars).toEqual([45]); + }); + }); + + describe('CAROUSEL_AVATAR_FAIL', () => { + it('sets "isLoading" to "true"', () => { + const initialState = { isLoading: true, avatars: [] }; + const action = { type: CAROUSEL_AVATAR_FAIL }; + expect(reducer(initialState, action).isLoading).toEqual(false); + }); + }); +}); diff --git a/app/soapbox/reducers/carousels.ts b/app/soapbox/reducers/carousels.ts new file mode 100644 index 0000000000..47a6c7576a --- /dev/null +++ b/app/soapbox/reducers/carousels.ts @@ -0,0 +1,36 @@ +import { AnyAction } from 'redux'; + +import { + CAROUSEL_AVATAR_REQUEST, + CAROUSEL_AVATAR_SUCCESS, + CAROUSEL_AVATAR_FAIL, +} from '../actions/carousels'; + +type Avatar = { + account_id: string + account_avatar: string + username: string +} + +type CarouselsState = { + avatars: Avatar[], + isLoading: boolean +} + +const initialState: CarouselsState = { + avatars: [], + isLoading: false, +}; + +export default function rules(state: CarouselsState = initialState, action: AnyAction): CarouselsState { + switch (action.type) { + case CAROUSEL_AVATAR_REQUEST: + return { ...state, isLoading: true }; + case CAROUSEL_AVATAR_SUCCESS: + return { ...state, isLoading: false, avatars: action.payload }; + case CAROUSEL_AVATAR_FAIL: + return { ...state, isLoading: false }; + default: + return state; + } +} diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index b0d3ee03f0..7f33dad1c0 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -14,6 +14,7 @@ import alerts from './alerts'; import aliases from './aliases'; import auth from './auth'; import backups from './backups'; +import carousels from './carousels'; import chat_message_lists from './chat_message_lists'; import chat_messages from './chat_messages'; import chats from './chats'; @@ -122,6 +123,7 @@ const reducers = { onboarding, rules, history, + carousels, }; // Build a default state from all reducers: it has the key and `undefined` From c41530b4177485b7f272c3f7f9165447a1a2438b Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Jun 2022 08:55:55 -0400 Subject: [PATCH 03/19] Add "feedUserFiltering" feature --- app/soapbox/utils/features.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 2e644ef15f..6f4a77ddf3 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -271,6 +271,9 @@ const getInstanceFeatures = (instance: Instance) => { /** Whether the instance federates. */ federating: federation.get('enabled', true) === true, // Assume true unless explicitly false + /** Whether or not to show the Feed Carousel for suggested Statuses */ + feedUserFiltering: v.software === TRUTHSOCIAL, + /** * Can edit and manage timeline filters (aka "muted words"). * @see {@link https://docs.joinmastodon.org/methods/accounts/filters/} From 45147cd5466a058bc3b52ef5b7d39d0abc8d5d74 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Jun 2022 08:56:35 -0400 Subject: [PATCH 04/19] Fix spacing with PlaceholderStatus component --- .../features/placeholder/components/placeholder_status.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/placeholder/components/placeholder_status.tsx b/app/soapbox/features/placeholder/components/placeholder_status.tsx index 64ee0f17ce..2616b13c66 100644 --- a/app/soapbox/features/placeholder/components/placeholder_status.tsx +++ b/app/soapbox/features/placeholder/components/placeholder_status.tsx @@ -14,7 +14,7 @@ const PlaceholderStatus: React.FC = ({ thread = false }) =>
From 5884d02e9d23f79fd612adb4d50c97e33520caf4 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Jun 2022 08:56:50 -0400 Subject: [PATCH 05/19] Add "8" to HStack space options --- app/soapbox/components/ui/hstack/hstack.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/hstack/hstack.tsx b/app/soapbox/components/ui/hstack/hstack.tsx index 803bbd7c1e..9769ebc604 100644 --- a/app/soapbox/components/ui/hstack/hstack.tsx +++ b/app/soapbox/components/ui/hstack/hstack.tsx @@ -23,6 +23,7 @@ const spaces = { 3: 'space-x-3', 4: 'space-x-4', 6: 'space-x-6', + 8: 'space-x-8', }; interface IHStack { @@ -33,7 +34,7 @@ interface IHStack { /** Horizontal alignment of children. */ justifyContent?: 'between' | 'center' | 'start' | 'end', /** Size of the gap between elements. */ - space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6, + space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8, /** Whether to let the flexbox grow. */ grow?: boolean, /** Extra CSS styles for the
*/ From 8039772d05c061a1cb4745bbdfa32c595d43b6a6 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Jun 2022 08:57:22 -0400 Subject: [PATCH 06/19] Extend PlaceholderAvatar component to support "withText" prop --- .../components/placeholder_avatar.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/app/soapbox/features/placeholder/components/placeholder_avatar.tsx b/app/soapbox/features/placeholder/components/placeholder_avatar.tsx index a9740e86a4..8f6959d157 100644 --- a/app/soapbox/features/placeholder/components/placeholder_avatar.tsx +++ b/app/soapbox/features/placeholder/components/placeholder_avatar.tsx @@ -1,11 +1,14 @@ import * as React from 'react'; +import { Stack } from 'soapbox/components/ui'; + interface IPlaceholderAvatar { - size: number, + size: number + withText?: boolean } /** Fake avatar to display while data is loading. */ -const PlaceholderAvatar: React.FC = ({ size }) => { +const PlaceholderAvatar: React.FC = ({ size, withText = false }) => { const style = React.useMemo(() => { if (!size) { return {}; @@ -18,10 +21,16 @@ const PlaceholderAvatar: React.FC = ({ size }) => { }, [size]); return ( -
+ +
+ + {withText && ( +
+ )} + ); }; From bdee28fd070c099dd0b9f4be96a8af88a5c4d73d Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Jun 2022 09:25:46 -0400 Subject: [PATCH 07/19] Add "useDimensions" hook --- .../hooks/__tests__/useDimensions.test.ts | 76 +++++++++++++++++++ app/soapbox/hooks/index.ts | 1 + app/soapbox/hooks/useDimensions.ts | 38 ++++++++++ package.json | 1 + yarn.lock | 15 ++++ 5 files changed, 131 insertions(+) create mode 100644 app/soapbox/hooks/__tests__/useDimensions.test.ts create mode 100644 app/soapbox/hooks/useDimensions.ts diff --git a/app/soapbox/hooks/__tests__/useDimensions.test.ts b/app/soapbox/hooks/__tests__/useDimensions.test.ts new file mode 100644 index 0000000000..ec86d691ae --- /dev/null +++ b/app/soapbox/hooks/__tests__/useDimensions.test.ts @@ -0,0 +1,76 @@ +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useDimensions } from '../useDimensions'; + +let listener: ((rect: any) => void) | undefined = undefined; + +(window as any).ResizeObserver = class ResizeObserver { + constructor(ls) { + listener = ls; + } + + observe() {} + disconnect() {} +}; + +describe('useDimensions()', () => { + it('defaults to 0', () => { + const { result } = renderHook(() => useDimensions()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as any)(div); + }); + + expect(result.current[1]).toMatchObject({ + width: 0, + height: 0, + }); + }); + + it('measures the dimensions of a DOM element', () => { + const { result } = renderHook(() => useDimensions()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as any)(div); + }); + + act(() => { + listener!([ + { + contentRect: { + width: 200, + height: 200, + }, + }, + ]); + }); + + expect(result.current[1]).toMatchObject({ + width: 200, + height: 200, + }); + }); + + it('disconnects on unmount', () => { + const disconnect = jest.fn(); + (window as any).ResizeObserver = class ResizeObserver { + observe() {} + disconnect() { + disconnect(); + } + }; + + const { result, unmount } = renderHook(() => useDimensions()); + + act(() => { + const div = document.createElement('div'); + (result.current[0] as any)(div); + }); + + expect(disconnect).toHaveBeenCalledTimes(0); + unmount(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/soapbox/hooks/index.ts b/app/soapbox/hooks/index.ts index c25cb082a0..2cd767c2d1 100644 --- a/app/soapbox/hooks/index.ts +++ b/app/soapbox/hooks/index.ts @@ -1,6 +1,7 @@ export { useAccount } from './useAccount'; export { useAppDispatch } from './useAppDispatch'; export { useAppSelector } from './useAppSelector'; +export { useDimensions } from './useDimensions'; export { useFeatures } from './useFeatures'; export { useOnScreen } from './useOnScreen'; export { useOwnAccount } from './useOwnAccount'; diff --git a/app/soapbox/hooks/useDimensions.ts b/app/soapbox/hooks/useDimensions.ts new file mode 100644 index 0000000000..8ba699925d --- /dev/null +++ b/app/soapbox/hooks/useDimensions.ts @@ -0,0 +1,38 @@ +import { Ref, useEffect, useMemo, useState } from 'react'; + +type UseDimensionsRect = { width: number, height: number }; +type UseDimensionsResult = [Ref, any] + +const defaultState: UseDimensionsRect = { + width: 0, + height: 0, +}; + +const useDimensions = (): UseDimensionsResult => { + const [element, ref] = useState(null); + const [rect, setRect] = useState(defaultState); + + const observer = useMemo( + () => + new (window as any).ResizeObserver((entries: any) => { + if (entries[0]) { + const { width, height } = entries[0].contentRect; + setRect({ width, height }); + } + }), + [], + ); + + useEffect((): any => { + if (!element) return null; + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [element]); + + return [ref, rect]; +}; + +export { useDimensions }; diff --git a/package.json b/package.json index 9c438abc72..31b4b956a0 100644 --- a/package.json +++ b/package.json @@ -202,6 +202,7 @@ }, "devDependencies": { "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.0.3", "@typescript-eslint/eslint-plugin": "^5.15.0", "@typescript-eslint/parser": "^5.15.0", diff --git a/yarn.lock b/yarn.lock index f13fa58a45..b88349ff0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2329,6 +2329,14 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@^12.1.4": version "12.1.4" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.4.tgz#09674b117e550af713db3f4ec4c0942aa8bbf2c0" @@ -9616,6 +9624,13 @@ react-dom@^17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-event-listener@^0.6.0: version "0.6.6" resolved "https://registry.yarnpkg.com/react-event-listener/-/react-event-listener-0.6.6.tgz#758f7b991cad9086dd39fd29fad72127e1d8962a" From 1f3785c9208efd2d95ccc3a751489e134be5ded3 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Jun 2022 11:20:10 -0400 Subject: [PATCH 08/19] Add FeedCarousel --- app/soapbox/actions/timelines.ts | 24 ++- .../features/feed-filtering/feed-carousel.tsx | 139 ++++++++++++++++++ app/soapbox/features/home_timeline/index.tsx | 19 ++- app/soapbox/pages/home_page.tsx | 3 + app/soapbox/reducers/timelines.ts | 8 + 5 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 app/soapbox/features/feed-filtering/feed-carousel.tsx diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts index d6cb24f52c..a962b068c4 100644 --- a/app/soapbox/actions/timelines.ts +++ b/app/soapbox/actions/timelines.ts @@ -26,6 +26,8 @@ const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +const TIMELINE_REPLACE = 'TIMELINE_REPLACE'; + const MAX_QUEUED_ITEMS = 40; const processTimelineUpdate = (timeline: string, status: APIEntity, accept: ((status: APIEntity) => boolean) | null) => @@ -134,6 +136,14 @@ const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none }); }; +const replaceHomeTimeline = ( + accountId: string | null, + { maxId }: Record = {}, +) => (dispatch: AppDispatch, _getState: () => RootState) => { + dispatch({ type: TIMELINE_REPLACE, accountId }); + dispatch(expandHomeTimeline({ accountId, maxId })); +}; + const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => (dispatch: AppDispatch, getState: () => RootState) => { const timeline = getState().timelines.get(timelineId) || {} as Record; @@ -163,8 +173,16 @@ const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => - expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); +const expandHomeTimeline = ({ accountId, maxId }: Record = {}, done = noOp) => { + const endpoint = accountId ? `/api/v1/accounts/${accountId}/statuses` : '/api/v1/timelines/home'; + const params: any = { max_id: maxId }; + if (accountId) { + params.exclude_replies = true; + params.with_muted = true; + } + + return expandTimeline('home', endpoint, params, done); +}; const expandPublicTimeline = ({ maxId, onlyMedia }: Record = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); @@ -253,6 +271,7 @@ export { TIMELINE_EXPAND_FAIL, TIMELINE_CONNECT, TIMELINE_DISCONNECT, + TIMELINE_REPLACE, MAX_QUEUED_ITEMS, processTimelineUpdate, updateTimeline, @@ -261,6 +280,7 @@ export { deleteFromTimelines, clearTimeline, expandTimeline, + replaceHomeTimeline, expandHomeTimeline, expandPublicTimeline, expandRemoteTimeline, diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx new file mode 100644 index 0000000000..dfd9ba2eab --- /dev/null +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -0,0 +1,139 @@ +import classNames from 'classnames'; +import React, { useEffect, useState } from 'react'; + +import { fetchCarouselAvatars } from 'soapbox/actions/carousels'; +import { replaceHomeTimeline } from 'soapbox/actions/timelines'; +import { useAppDispatch, useAppSelector, useDimensions, useFeatures } from 'soapbox/hooks'; + +import { Card, HStack, Icon, Stack, Text } from '../../components/ui'; +import PlaceholderAvatar from '../placeholder/components/placeholder_avatar'; + +const CarouselItem = ({ avatar }: { avatar: any }) => { + const dispatch = useAppDispatch(); + + const selectedAccountId = useAppSelector(state => state.timelines.getIn(['home', 'feedAccountId'])); + const isSelected = avatar.account_id === selectedAccountId; + + const handleClick = () => + isSelected + ? dispatch(replaceHomeTimeline(null, { maxId: null })) + : dispatch(replaceHomeTimeline(avatar.account_id, { maxId: null })); + + return ( +
+ +
+ {isSelected && ( +
+ +
+ )} + + {avatar.username} +
+ + {avatar.username} +
+
+ ); +}; + +const FeedCarousel = () => { + const dispatch = useAppDispatch(); + const features = useFeatures(); + + const [cardRef, { width }] = useDimensions(); + + const [pageSize, setPageSize] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + + const avatars = useAppSelector((state) => state.carousels.avatars); + const isLoading = useAppSelector((state) => state.carousels.isLoading); + const numberOfPages = Math.floor(avatars.length / pageSize); + + const hasNextPage = currentPage < numberOfPages && numberOfPages > 1; + const hasPrevPage = currentPage > 1 && numberOfPages > 1; + + const handleNextPage = () => setCurrentPage((prevPage) => prevPage + 1); + const handlePrevPage = () => setCurrentPage((prevPage) => prevPage - 1); + + useEffect(() => { + if (width) { + setPageSize(Math.round(width / (80 + 15))); + } + }, [width]); + + useEffect(() => { + if (features.feedUserFiltering) { + dispatch(fetchCarouselAvatars()); + } + }, []); + + if (!features.feedUserFiltering) { + return null; + } + + return ( + +
+ {hasPrevPage && ( +
+
+ +
+
+ )} + + + {isLoading ? ( + new Array(pageSize).fill(0).map((_, idx) => ( +
+ +
+ )) + ) : ( + avatars.map((avatar) => ( + + )) + )} +
+ + {hasNextPage && ( +
+
+ +
+
+ )} +
+
+ ); +}; + +export default FeedCarousel; diff --git a/app/soapbox/features/home_timeline/index.tsx b/app/soapbox/features/home_timeline/index.tsx index ad3ee672d9..6f19239ac4 100644 --- a/app/soapbox/features/home_timeline/index.tsx +++ b/app/soapbox/features/home_timeline/index.tsx @@ -3,7 +3,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; -import { Column } from 'soapbox/components/ui'; +import { Column, Stack, Text } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; @@ -17,10 +17,11 @@ const HomeTimeline: React.FC = () => { const polling = useRef(null); const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true); + const currentAccountId = useAppSelector(state => state.timelines.getIn(['home', 'feedAccountId'])); const siteTitle = useAppSelector(state => state.instance.title); const handleLoadMore = (maxId: string) => { - dispatch(expandHomeTimeline({ maxId })); + dispatch(expandHomeTimeline({ maxId, accountId: currentAccountId })); }; // Mastodon generates the feed in Redis, and can return a partial timeline @@ -43,7 +44,7 @@ const HomeTimeline: React.FC = () => { }; const handleRefresh = () => { - return dispatch(expandHomeTimeline()); + return dispatch(expandHomeTimeline({ maxId: null, accountId: currentAccountId })); }; useEffect(() => { @@ -62,7 +63,17 @@ const HomeTimeline: React.FC = () => { onRefresh={handleRefresh} timelineId='home' divideType='space' - emptyMessage={ }} />} + emptyMessage={ + + + You’re not following anyone yet + + + + {siteTitle} gets more interesting once you follow other users. + + + } /> ); diff --git a/app/soapbox/pages/home_page.tsx b/app/soapbox/pages/home_page.tsx index 8e9482671d..149fa90f4d 100644 --- a/app/soapbox/pages/home_page.tsx +++ b/app/soapbox/pages/home_page.tsx @@ -1,6 +1,7 @@ import React, { useRef } from 'react'; import { Link } from 'react-router-dom'; +import FeedCarousel from 'soapbox/features/feed-filtering/feed-carousel'; import LinkFooter from 'soapbox/features/ui/components/link_footer'; import { WhoToFollowPanel, @@ -56,6 +57,8 @@ const HomePage: React.FC = ({ children }) => { )} + + {children} {!me && ( diff --git a/app/soapbox/reducers/timelines.ts b/app/soapbox/reducers/timelines.ts index 33ad2733e2..a1f33417f5 100644 --- a/app/soapbox/reducers/timelines.ts +++ b/app/soapbox/reducers/timelines.ts @@ -29,6 +29,7 @@ import { TIMELINE_DEQUEUE, MAX_QUEUED_ITEMS, TIMELINE_SCROLL_TOP, + TIMELINE_REPLACE, } from '../actions/timelines'; import type { AnyAction } from 'redux'; @@ -46,6 +47,7 @@ const TimelineRecord = ImmutableRecord({ hasMore: true, items: ImmutableOrderedSet(), queuedItems: ImmutableOrderedSet(), //max= MAX_QUEUED_ITEMS + feedAccountId: null, totalQueuedItemsCount: 0, //used for queuedItems overflow for MAX_QUEUED_ITEMS+ loadingFailed: false, isPartial: false, @@ -345,6 +347,12 @@ export default function timelines(state: State = initialState, action: AnyAction return timelineDisconnect(state, action.timeline); case GROUP_REMOVE_STATUS_SUCCESS: return removeStatusFromGroup(state, action.groupId, action.id); + case TIMELINE_REPLACE: + return state + .update('home', TimelineRecord(), timeline => timeline.withMutations(timeline => { + timeline.set('items', ImmutableOrderedSet([])); + })) + .update('home', TimelineRecord(), timeline => timeline.set('feedAccountId', action.accountId)); default: return state; } From 14a9a2f4eb70dccd95ea7d2f2946fa79badf808a Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 22 Jun 2022 11:20:29 -0400 Subject: [PATCH 09/19] Update UI around Empty Message --- app/soapbox/__fixtures__/intlMessages.json | 4 +-- app/soapbox/components/scrollable_list.tsx | 6 ++-- app/soapbox/features/home_timeline/index.tsx | 31 +++++++++++++++++-- .../hooks/__tests__/useDimensions.test.ts | 4 +++ app/soapbox/locales/bg.json | 2 +- app/soapbox/locales/br.json | 2 +- app/soapbox/locales/defaultMessages.json | 2 +- app/soapbox/locales/en.json | 4 ++- app/soapbox/locales/ga.json | 2 +- app/soapbox/locales/hi.json | 2 +- app/soapbox/locales/lt.json | 2 +- app/soapbox/locales/mk.json | 2 +- app/soapbox/locales/ms.json | 2 +- 13 files changed, 48 insertions(+), 17 deletions(-) diff --git a/app/soapbox/__fixtures__/intlMessages.json b/app/soapbox/__fixtures__/intlMessages.json index 82a4899098..361eeffd9a 100644 --- a/app/soapbox/__fixtures__/intlMessages.json +++ b/app/soapbox/__fixtures__/intlMessages.json @@ -159,7 +159,7 @@ "empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.", @@ -637,7 +637,7 @@ "empty_column.follow_requests": "You don\"t have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don\"t have any lists yet. When you create one, it will show up here.", diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index 31b892e6db..92cf793487 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -7,7 +7,7 @@ import PullToRefresh from 'soapbox/components/pull-to-refresh'; import { useSettings } from 'soapbox/hooks'; import LoadMore from './load_more'; -import { Spinner, Text } from './ui'; +import { Card, Spinner, Text } from './ui'; /** Custom Viruoso component context. */ type Context = { @@ -157,13 +157,13 @@ const ScrollableList = React.forwardRef(({
{alwaysPrepend && prepend} -
+ {isLoading ? ( ) : ( {emptyMessage} )} -
+
); }; diff --git a/app/soapbox/features/home_timeline/index.tsx b/app/soapbox/features/home_timeline/index.tsx index 6f19239ac4..f1494198a7 100644 --- a/app/soapbox/features/home_timeline/index.tsx +++ b/app/soapbox/features/home_timeline/index.tsx @@ -5,7 +5,7 @@ import { Link } from 'react-router-dom'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; import { Column, Stack, Text } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; -import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; +import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, @@ -14,6 +14,8 @@ const messages = defineMessages({ const HomeTimeline: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const features = useFeatures(); + const polling = useRef(null); const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true); @@ -66,12 +68,35 @@ const HomeTimeline: React.FC = () => { emptyMessage={ - You’re not following anyone yet + - {siteTitle} gets more interesting once you follow other users. + + + {features.federating && ( + + + + + ), + }} + /> + + )} } /> diff --git a/app/soapbox/hooks/__tests__/useDimensions.test.ts b/app/soapbox/hooks/__tests__/useDimensions.test.ts index ec86d691ae..c437f1394d 100644 --- a/app/soapbox/hooks/__tests__/useDimensions.test.ts +++ b/app/soapbox/hooks/__tests__/useDimensions.test.ts @@ -5,12 +5,14 @@ import { useDimensions } from '../useDimensions'; let listener: ((rect: any) => void) | undefined = undefined; (window as any).ResizeObserver = class ResizeObserver { + constructor(ls) { listener = ls; } observe() {} disconnect() {} + }; describe('useDimensions()', () => { @@ -56,10 +58,12 @@ describe('useDimensions()', () => { it('disconnects on unmount', () => { const disconnect = jest.fn(); (window as any).ResizeObserver = class ResizeObserver { + observe() {} disconnect() { disconnect(); } + }; const { result, unmount } = renderHook(() => useDimensions()); diff --git a/app/soapbox/locales/bg.json b/app/soapbox/locales/bg.json index 7a62a52f0b..4e937e97cc 100644 --- a/app/soapbox/locales/bg.json +++ b/app/soapbox/locales/bg.json @@ -450,7 +450,7 @@ "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", diff --git a/app/soapbox/locales/br.json b/app/soapbox/locales/br.json index 1a0d58e9a7..9bec55d5b1 100644 --- a/app/soapbox/locales/br.json +++ b/app/soapbox/locales/br.json @@ -450,7 +450,7 @@ "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", diff --git a/app/soapbox/locales/defaultMessages.json b/app/soapbox/locales/defaultMessages.json index 1d554d3792..e9c48411f7 100644 --- a/app/soapbox/locales/defaultMessages.json +++ b/app/soapbox/locales/defaultMessages.json @@ -3793,7 +3793,7 @@ "id": "column.home" }, { - "defaultMessage": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "defaultMessage": "Or you can visit {public} to get started and meet other users.", "id": "empty_column.home" }, { diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 2d246e12ce..3e740d32ca 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -453,7 +453,9 @@ "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", + "empty_column.home.title": "You're not following anyone yet", + "empty_column.home.subtitle": "{siteTitle} gets more interesting once you follow other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", diff --git a/app/soapbox/locales/ga.json b/app/soapbox/locales/ga.json index eec9f2971a..eb21a107f7 100644 --- a/app/soapbox/locales/ga.json +++ b/app/soapbox/locales/ga.json @@ -450,7 +450,7 @@ "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", diff --git a/app/soapbox/locales/hi.json b/app/soapbox/locales/hi.json index 292d6a033d..cbc84692cb 100644 --- a/app/soapbox/locales/hi.json +++ b/app/soapbox/locales/hi.json @@ -450,7 +450,7 @@ "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", diff --git a/app/soapbox/locales/lt.json b/app/soapbox/locales/lt.json index 44b913afb1..ff62cbf659 100644 --- a/app/soapbox/locales/lt.json +++ b/app/soapbox/locales/lt.json @@ -450,7 +450,7 @@ "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", diff --git a/app/soapbox/locales/mk.json b/app/soapbox/locales/mk.json index c760d08e00..8d017fb272 100644 --- a/app/soapbox/locales/mk.json +++ b/app/soapbox/locales/mk.json @@ -450,7 +450,7 @@ "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", diff --git a/app/soapbox/locales/ms.json b/app/soapbox/locales/ms.json index eaafcd52f5..dc75e0a32d 100644 --- a/app/soapbox/locales/ms.json +++ b/app/soapbox/locales/ms.json @@ -450,7 +450,7 @@ "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.group": "There is nothing in this group yet. When members of this group make new posts, they will appear here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", - "empty_column.home": "Your home timeline is empty! Visit {public} to get started and meet other users.", + "empty_column.home": "Or you can visit {public} to get started and meet other users.", "empty_column.home.local_tab": "the {site_title} tab", "empty_column.list": "There is nothing in this list yet. When members of this list create new posts, they will appear here.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", From 9e6bb5264a3b9194452c17cf5790e368c5f11efb Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 23 Jun 2022 09:26:13 -0400 Subject: [PATCH 10/19] Handle error state for carousels --- .../features/feed-filtering/feed-carousel.tsx | 12 ++++++++++++ app/soapbox/locales/en.json | 1 + app/soapbox/reducers/__tests__/carousels.test.ts | 6 +++++- app/soapbox/reducers/carousels.ts | 6 ++++-- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index dfd9ba2eab..ca39049000 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; import { fetchCarouselAvatars } from 'soapbox/actions/carousels'; import { replaceHomeTimeline } from 'soapbox/actions/timelines'; @@ -57,6 +58,7 @@ const FeedCarousel = () => { const avatars = useAppSelector((state) => state.carousels.avatars); const isLoading = useAppSelector((state) => state.carousels.isLoading); + const hasError = useAppSelector((state) => state.carousels.error); const numberOfPages = Math.floor(avatars.length / pageSize); const hasNextPage = currentPage < numberOfPages && numberOfPages > 1; @@ -81,6 +83,16 @@ const FeedCarousel = () => { return null; } + if (hasError) { + return ( + + + + + + ); + } + return (
diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 3e740d32ca..9a1af5b431 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -258,6 +258,7 @@ "column_forbidden.title": "Forbidden", "column_header.show_settings": "Show settings", "common.cancel": "Cancel", + "common.error": "Something isn't right. Try reloading the page.", "community.column_settings.media_only": "Media Only", "community.column_settings.title": "Local timeline settings", "compose.character_counter.title": "Used {chars} out of {maxChars} characters", diff --git a/app/soapbox/reducers/__tests__/carousels.test.ts b/app/soapbox/reducers/__tests__/carousels.test.ts index 394a2c037b..40b8f928af 100644 --- a/app/soapbox/reducers/__tests__/carousels.test.ts +++ b/app/soapbox/reducers/__tests__/carousels.test.ts @@ -32,6 +32,7 @@ describe('carousels reducer', () => { expect(result.isLoading).toEqual(false); expect(result.avatars).toEqual([45]); + expect(result.error).toEqual(false); }); }); @@ -39,7 +40,10 @@ describe('carousels reducer', () => { it('sets "isLoading" to "true"', () => { const initialState = { isLoading: true, avatars: [] }; const action = { type: CAROUSEL_AVATAR_FAIL }; - expect(reducer(initialState, action).isLoading).toEqual(false); + const result = reducer(initialState, action); + + expect(result.isLoading).toEqual(false); + expect(result.error).toEqual(true); }); }); }); diff --git a/app/soapbox/reducers/carousels.ts b/app/soapbox/reducers/carousels.ts index 47a6c7576a..091c472383 100644 --- a/app/soapbox/reducers/carousels.ts +++ b/app/soapbox/reducers/carousels.ts @@ -13,13 +13,15 @@ type Avatar = { } type CarouselsState = { - avatars: Avatar[], + avatars: Avatar[] isLoading: boolean + error: boolean } const initialState: CarouselsState = { avatars: [], isLoading: false, + error: false, }; export default function rules(state: CarouselsState = initialState, action: AnyAction): CarouselsState { @@ -29,7 +31,7 @@ export default function rules(state: CarouselsState = initialState, action: AnyA case CAROUSEL_AVATAR_SUCCESS: return { ...state, isLoading: false, avatars: action.payload }; case CAROUSEL_AVATAR_FAIL: - return { ...state, isLoading: false }; + return { ...state, isLoading: false, error: true }; default: return state; } From 5f2e2c7fa4f55c6d04d32ec85cb5f7d6f2ab9113 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 23 Jun 2022 11:51:18 -0400 Subject: [PATCH 11/19] Add tests for FeedCarousel --- .../__tests__/feed-carousel.test.tsx | 131 ++++++++++++++++++ .../features/feed-filtering/feed-carousel.tsx | 6 +- 2 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx diff --git a/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx new file mode 100644 index 0000000000..4744b1c52e --- /dev/null +++ b/app/soapbox/features/feed-filtering/__tests__/feed-carousel.test.tsx @@ -0,0 +1,131 @@ +import userEvent from '@testing-library/user-event'; +import { Map as ImmutableMap } from 'immutable'; +import React from 'react'; + +import { __stub } from '../../../api'; +import { render, screen, waitFor } from '../../../jest/test-helpers'; +import FeedCarousel from '../feed-carousel'; + +jest.mock('../../../hooks/useDimensions', () => ({ + useDimensions: () => [null, { width: 200 }], +})); + +(window as any).ResizeObserver = class ResizeObserver { + + observe() { } + disconnect() { } + +}; + +describe('', () => { + let store; + + describe('with "feedUserFiltering" disabled', () => { + beforeEach(() => { + store = { + instance: { + version: '2.7.2 (compatible; Pleroma 2.4.52-1337-g4779199e.gleasonator+soapbox)', + pleroma: ImmutableMap({ + metadata: ImmutableMap({ + features: [], + }), + }), + }, + }; + }); + + it('should render nothing', () => { + render(, null, store); + + expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(0); + }); + }); + + describe('with "feedUserFiltering" enabled', () => { + beforeEach(() => { + store = { + instance: { + version: '3.4.1 (compatible; TruthSocial 1.0.0)', + pleroma: ImmutableMap({ + metadata: ImmutableMap({ + features: [], + }), + }), + }, + }; + }); + + it('should render the Carousel', () => { + render(, null, store); + + expect(screen.queryAllByTestId('feed-carousel')).toHaveLength(1); + }); + + describe('with a failed request to the API', () => { + beforeEach(() => { + store.carousels = { + avatars: [], + error: true, + }; + }); + + it('renders the error message', () => { + render(, null, store); + + expect(screen.getByTestId('feed-carousel-error')).toBeInTheDocument(); + }); + }); + + describe('with multiple pages of avatars', () => { + beforeEach(() => { + store.carousels = { + error: false, + avatars: [], + }; + + __stub(mock => { + mock.onGet('/api/v1/truth/carousels/avatars') + .reply(200, [ + { account_id: '1', username: 'a', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '2', username: 'b', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '3', username: 'c', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '4', username: 'd', account_avatar: 'https://example.com/some.jpg' }, + ]); + }); + + Element.prototype.getBoundingClientRect = jest.fn(() => { + return { + width: 200, + height: 120, + x: 0, + y: 0, + toJSON: () => null, + top: 0, + left: 0, + bottom: 0, + right: 0, + }; + }); + }); + + it('should render the correct prev/next buttons', async() => { + const user = userEvent.setup(); + render(, null, store); + + await waitFor(() => { + expect(screen.getByTestId('next-page')).toBeInTheDocument(); + expect(screen.queryAllByTestId('prev-page')).toHaveLength(0); + }); + + await waitFor(() => { + user.click(screen.getByTestId('next-page')); + }); + + await waitFor(() => { + expect(screen.getByTestId('prev-page')).toBeInTheDocument(); + expect(screen.queryAllByTestId('next-page')).toHaveLength(0); + }); + }); + }); + }); +}); diff --git a/app/soapbox/features/feed-filtering/feed-carousel.tsx b/app/soapbox/features/feed-filtering/feed-carousel.tsx index ca39049000..2ca9f34bc0 100644 --- a/app/soapbox/features/feed-filtering/feed-carousel.tsx +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -85,7 +85,7 @@ const FeedCarousel = () => { if (hasError) { return ( - + @@ -94,12 +94,13 @@ const FeedCarousel = () => { } return ( - +
{hasPrevPage && (
- {avatar.username} + {avatar.acct}
); diff --git a/app/soapbox/features/home_timeline/index.tsx b/app/soapbox/features/home_timeline/index.tsx index f1494198a7..3e9fa53464 100644 --- a/app/soapbox/features/home_timeline/index.tsx +++ b/app/soapbox/features/home_timeline/index.tsx @@ -19,7 +19,7 @@ const HomeTimeline: React.FC = () => { const polling = useRef(null); const isPartial = useAppSelector(state => state.timelines.get('home')?.isPartial === true); - const currentAccountId = useAppSelector(state => state.timelines.getIn(['home', 'feedAccountId'])); + const currentAccountId = useAppSelector(state => state.timelines.get('home')?.feedAccountId); const siteTitle = useAppSelector(state => state.instance.title); const handleLoadMore = (maxId: string) => { From 1e53c9f6d439bc4548abd9b46cf0c09410edef71 Mon Sep 17 00:00:00 2001 From: Justin Date: Mon, 27 Jun 2022 10:44:54 -0400 Subject: [PATCH 14/19] username -> acct --- app/soapbox/actions/__tests__/carousels.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/actions/__tests__/carousels.test.ts b/app/soapbox/actions/__tests__/carousels.test.ts index f3d0c42488..0953b82768 100644 --- a/app/soapbox/actions/__tests__/carousels.test.ts +++ b/app/soapbox/actions/__tests__/carousels.test.ts @@ -15,7 +15,7 @@ describe('fetchCarouselAvatars()', () => { beforeEach(() => { avatars = [ - { account_id: '1', username: 'jl', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '1', acct: 'jl', account_avatar: 'https://example.com/some.jpg' }, ]; __stub((mock) => { From 8c581fc415e999cb80c1e1d7760bdaaca99bb7f5 Mon Sep 17 00:00:00 2001 From: Justin Date: Wed, 29 Jun 2022 09:12:12 -0400 Subject: [PATCH 15/19] Skip dequeue when feed filtering --- app/soapbox/components/scroll-top-button.tsx | 4 ++-- app/soapbox/features/ui/components/timeline.tsx | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/scroll-top-button.tsx b/app/soapbox/components/scroll-top-button.tsx index 33aadf3282..3652296efa 100644 --- a/app/soapbox/components/scroll-top-button.tsx +++ b/app/soapbox/components/scroll-top-button.tsx @@ -52,7 +52,7 @@ const ScrollTopButton: React.FC = ({ } else { setScrolled(false); } - }, 150, { trailing: true }), [autoload, threshold, autoloadThreshold]); + }, 150, { trailing: true }), [autoload, threshold, autoloadThreshold, onClick]); const scrollUp = () => { window.scrollTo({ top: 0 }); @@ -69,7 +69,7 @@ const ScrollTopButton: React.FC = ({ return () => { window.removeEventListener('scroll', handleScroll); }; - }, []); + }, [onClick]); useEffect(() => { maybeUnload(); diff --git a/app/soapbox/features/ui/components/timeline.tsx b/app/soapbox/features/ui/components/timeline.tsx index bc7c01c47b..53d1ad7f83 100644 --- a/app/soapbox/features/ui/components/timeline.tsx +++ b/app/soapbox/features/ui/components/timeline.tsx @@ -33,8 +33,13 @@ const Timeline: React.FC = ({ const isPartial = useAppSelector(state => (state.timelines.get(timelineId)?.isPartial || false) === true); const hasMore = useAppSelector(state => state.timelines.get(timelineId)?.hasMore === true); const totalQueuedItemsCount = useAppSelector(state => state.timelines.get(timelineId)?.totalQueuedItemsCount || 0); + const isFilteringFeed = useAppSelector(state => !!state.timelines.get(timelineId)?.feedAccountId); const handleDequeueTimeline = () => { + if (isFilteringFeed) { + return; + } + dispatch(dequeueTimeline(timelineId, onLoadMore)); }; From 2e3718e5487cde888edab316e8d0d357cb82ae57 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 Jun 2022 10:14:04 -0500 Subject: [PATCH 16/19] Make /search a non-public route --- app/soapbox/features/ui/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 8cf66b5307..8cd5218d6a 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -257,7 +257,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => { - + {features.suggestions && } {features.profileDirectory && } From 60f6bff3065766fed488e37b0fc97408c71f78fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 29 Jun 2022 23:05:26 +0200 Subject: [PATCH 17/19] Polls: Hide multiple options hint if already voted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/polls/poll.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/soapbox/components/polls/poll.tsx b/app/soapbox/components/polls/poll.tsx index cdd412fa65..f06bfb0a23 100644 --- a/app/soapbox/components/polls/poll.tsx +++ b/app/soapbox/components/polls/poll.tsx @@ -67,13 +67,13 @@ const Poll: React.FC = ({ id, status }): JSX.Element | null => { return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
e.stopPropagation()}> - {poll.multiple && ( + {!showResults && poll.multiple && ( {intl.formatMessage(messages.multiple)} )} - + {poll.options.map((option, i) => ( Date: Wed, 29 Jun 2022 21:09:22 -0500 Subject: [PATCH 18/19] Upgrade Twemoji to v14.0.2 --- package.json | 2 +- yarn.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 9c438abc72..634159aae5 100644 --- a/package.json +++ b/package.json @@ -188,7 +188,7 @@ "tiny-queue": "^0.2.1", "ts-loader": "^9.3.0", "tslib": "^2.3.1", - "twemoji": "https://github.com/twitter/twemoji#v13.0.2", + "twemoji": "https://github.com/twitter/twemoji#v14.0.2", "typescript": "^4.4.4", "util": "^0.12.4", "uuid": "^8.3.2", diff --git a/yarn.lock b/yarn.lock index f13fa58a45..359ec79f6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11340,18 +11340,18 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" -twemoji-parser@13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-13.0.0.tgz#bd9d1b98474f1651dc174696b45cabefdfa405af" - integrity sha512-zMaGdskpH8yKjT2RSE/HwE340R4Fm+fbie4AaqjDa4H/l07YUmAvxkSfNl6awVWNRRQ0zdzLQ8SAJZuY5MgstQ== +twemoji-parser@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-14.0.0.tgz#13dabcb6d3a261d9efbf58a1666b182033bf2b62" + integrity sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA== -"twemoji@https://github.com/twitter/twemoji#v13.0.2": - version "13.0.2" - resolved "https://github.com/twitter/twemoji#3946e782ac81821b6a78e4b6c73d4e787b34bcf0" +"twemoji@https://github.com/twitter/twemoji#v14.0.2": + version "14.0.2" + resolved "https://github.com/twitter/twemoji#7a3dad4a4da30497093dab22eafba135f02308e1" dependencies: fs-extra "^8.0.1" jsonfile "^5.0.0" - twemoji-parser "13.0.0" + twemoji-parser "14.0.0" universalify "^0.1.2" type-check@^0.4.0, type-check@~0.4.0: From d4facbd3f5f25c003cd11d00e42f3c2ff3c33de5 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 30 Jun 2022 08:51:35 -0400 Subject: [PATCH 19/19] Lint --- app/soapbox/components/polls/poll.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/soapbox/components/polls/poll.tsx b/app/soapbox/components/polls/poll.tsx index f06bfb0a23..a30348678a 100644 --- a/app/soapbox/components/polls/poll.tsx +++ b/app/soapbox/components/polls/poll.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl';