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/actions/__tests__/carousels.test.ts b/app/soapbox/actions/__tests__/carousels.test.ts new file mode 100644 index 0000000000..0953b82768 --- /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', acct: '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/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/components/fork_awesome_icon.tsx b/app/soapbox/components/fork_awesome_icon.tsx index 616a3959d6..45b146e084 100644 --- a/app/soapbox/components/fork_awesome_icon.tsx +++ b/app/soapbox/components/fork_awesome_icon.tsx @@ -29,6 +29,6 @@ const ForkAwesomeIcon: React.FC = ({ id, className, fixedWidth {...rest} /> ); -};`` +}; export default ForkAwesomeIcon; diff --git a/app/soapbox/components/polls/poll.tsx b/app/soapbox/components/polls/poll.tsx index cdd412fa65..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'; @@ -67,13 +66,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) => ( = ({ } 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/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/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
*/ 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..096682fa04 --- /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', acct: 'a', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '3', acct: 'c', account_avatar: 'https://example.com/some.jpg' }, + { account_id: '4', acct: '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 new file mode 100644 index 0000000000..6a50f39df2 --- /dev/null +++ b/app/soapbox/features/feed-filtering/feed-carousel.tsx @@ -0,0 +1,153 @@ +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'; +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.get('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.acct} +
+ + {avatar.acct} +
+
+ ); +}; + +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 hasError = useAppSelector((state) => state.carousels.error); + 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; + } + + if (hasError) { + return ( + + + + + + ); + } + + 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..3e9fa53464 100644 --- a/app/soapbox/features/home_timeline/index.tsx +++ b/app/soapbox/features/home_timeline/index.tsx @@ -3,9 +3,9 @@ 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'; +import { useAppSelector, useAppDispatch, useFeatures } from 'soapbox/hooks'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, @@ -14,13 +14,16 @@ 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); + const currentAccountId = useAppSelector(state => state.timelines.get('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 +46,7 @@ const HomeTimeline: React.FC = () => { }; const handleRefresh = () => { - return dispatch(expandHomeTimeline()); + return dispatch(expandHomeTimeline({ maxId: null, accountId: currentAccountId })); }; useEffect(() => { @@ -62,7 +65,40 @@ const HomeTimeline: React.FC = () => { onRefresh={handleRefresh} timelineId='home' divideType='space' - emptyMessage={ }} />} + emptyMessage={ + + + + + + + + + + {features.federating && ( + + + + + ), + }} + /> + + )} + + } /> ); 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 && ( +
+ )} + ); }; 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 }) =>
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/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)); }; diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index be3afefd98..8cd5218d6a 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'; @@ -258,7 +257,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => { - + {features.suggestions && } {features.profileDirectory && } @@ -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/hooks/__tests__/useDimensions.test.ts b/app/soapbox/hooks/__tests__/useDimensions.test.ts new file mode 100644 index 0000000000..c437f1394d --- /dev/null +++ b/app/soapbox/hooks/__tests__/useDimensions.test.ts @@ -0,0 +1,80 @@ +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/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..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", @@ -453,7 +454,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.", 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'); 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/__tests__/carousels.test.ts b/app/soapbox/reducers/__tests__/carousels.test.ts new file mode 100644 index 0000000000..45e87c8ff7 --- /dev/null +++ b/app/soapbox/reducers/__tests__/carousels.test.ts @@ -0,0 +1,50 @@ +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: [], + error: false, + 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: [], error: false }; + const action = { type: CAROUSEL_AVATAR_SUCCESS, payload: [45] }; + const result = reducer(initialState, action); + + expect(result.isLoading).toEqual(false); + expect(result.avatars).toEqual([45]); + expect(result.error).toEqual(false); + }); + }); + + describe('CAROUSEL_AVATAR_FAIL', () => { + it('sets "isLoading" to "true"', () => { + const initialState = { isLoading: true, avatars: [] }; + const action = { type: CAROUSEL_AVATAR_FAIL }; + 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 new file mode 100644 index 0000000000..091c472383 --- /dev/null +++ b/app/soapbox/reducers/carousels.ts @@ -0,0 +1,38 @@ +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 + error: boolean +} + +const initialState: CarouselsState = { + avatars: [], + isLoading: false, + error: 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, error: true }; + 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` 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; } 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/} diff --git a/package.json b/package.json index 9c438abc72..47098b26be 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", @@ -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..5db73d9ab2 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" @@ -11340,18 +11355,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: