From 93b09d8206a2ea3ff4243a9a068249c0ed0505b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 19 Jan 2023 15:06:17 +0100 Subject: [PATCH 01/36] Add ability to follow hashtags in web UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/tags.ts | 120 ++++++++++++++++++ app/soapbox/components/ui/card/card.tsx | 18 ++- app/soapbox/components/ui/column/column.tsx | 23 +++- .../features/hashtag-timeline/index.tsx | 28 +++- app/soapbox/normalizers/tag.ts | 1 + app/soapbox/reducers/index.ts | 2 + app/soapbox/reducers/tags.ts | 30 +++++ app/soapbox/utils/features.ts | 7 + 8 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 app/soapbox/actions/tags.ts create mode 100644 app/soapbox/reducers/tags.ts diff --git a/app/soapbox/actions/tags.ts b/app/soapbox/actions/tags.ts new file mode 100644 index 0000000000..2c394ba46e --- /dev/null +++ b/app/soapbox/actions/tags.ts @@ -0,0 +1,120 @@ +import api from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST'; +const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS'; +const HASHTAG_FETCH_FAIL = 'HASHTAG_FETCH_FAIL'; + +const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST'; +const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS'; +const HASHTAG_FOLLOW_FAIL = 'HASHTAG_FOLLOW_FAIL'; + +const HASHTAG_UNFOLLOW_REQUEST = 'HASHTAG_UNFOLLOW_REQUEST'; +const HASHTAG_UNFOLLOW_SUCCESS = 'HASHTAG_UNFOLLOW_SUCCESS'; +const HASHTAG_UNFOLLOW_FAIL = 'HASHTAG_UNFOLLOW_FAIL'; + +const fetchHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(fetchHashtagRequest()); + + api(getState).get(`/api/v1/tags/${name}`).then(({ data }) => { + dispatch(fetchHashtagSuccess(name, data)); + }).catch(err => { + dispatch(fetchHashtagFail(err)); + }); +}; + +const fetchHashtagRequest = () => ({ + type: HASHTAG_FETCH_REQUEST, +}); + +const fetchHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FETCH_SUCCESS, + name, + tag, +}); + +const fetchHashtagFail = (error: AxiosError) => ({ + type: HASHTAG_FETCH_FAIL, + error, +}); + +const followHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(followHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/follow`).then(({ data }) => { + dispatch(followHashtagSuccess(name, data)); + }).catch(err => { + dispatch(followHashtagFail(name, err)); + }); +}; + +const followHashtagRequest = (name: string) => ({ + type: HASHTAG_FOLLOW_REQUEST, + name, +}); + +const followHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_FOLLOW_SUCCESS, + name, + tag, +}); + +const followHashtagFail = (name: string, error: AxiosError) => ({ + type: HASHTAG_FOLLOW_FAIL, + name, + error, +}); + +const unfollowHashtag = (name: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(unfollowHashtagRequest(name)); + + api(getState).post(`/api/v1/tags/${name}/unfollow`).then(({ data }) => { + dispatch(unfollowHashtagSuccess(name, data)); + }).catch(err => { + dispatch(unfollowHashtagFail(name, err)); + }); +}; + +const unfollowHashtagRequest = (name: string) => ({ + type: HASHTAG_UNFOLLOW_REQUEST, + name, +}); + +const unfollowHashtagSuccess = (name: string, tag: APIEntity) => ({ + type: HASHTAG_UNFOLLOW_SUCCESS, + name, + tag, +}); + +const unfollowHashtagFail = (name: string, error: AxiosError) => ({ + type: HASHTAG_UNFOLLOW_FAIL, + name, + error, +}); + +export { + HASHTAG_FETCH_REQUEST, + HASHTAG_FETCH_SUCCESS, + HASHTAG_FETCH_FAIL, + HASHTAG_FOLLOW_REQUEST, + HASHTAG_FOLLOW_SUCCESS, + HASHTAG_FOLLOW_FAIL, + HASHTAG_UNFOLLOW_REQUEST, + HASHTAG_UNFOLLOW_SUCCESS, + HASHTAG_UNFOLLOW_FAIL, + fetchHashtag, + fetchHashtagRequest, + fetchHashtagSuccess, + fetchHashtagFail, + followHashtag, + followHashtagRequest, + followHashtagSuccess, + followHashtagFail, + unfollowHashtag, + unfollowHashtagRequest, + unfollowHashtagSuccess, + unfollowHashtagFail, +}; diff --git a/app/soapbox/components/ui/card/card.tsx b/app/soapbox/components/ui/card/card.tsx index 6fc85a39af..2cc23b6b44 100644 --- a/app/soapbox/components/ui/card/card.tsx +++ b/app/soapbox/components/ui/card/card.tsx @@ -45,6 +45,12 @@ interface ICardHeader { backHref?: string, onBackClick?: (event: React.MouseEvent) => void className?: string + /** Callback when the card action is clicked. */ + onActionClick?: () => void, + /** URL to the svg icon for the card action. */ + actionIcon?: string, + /** Text for the action. */ + actionTitle?: string, children?: React.ReactNode } @@ -52,7 +58,7 @@ interface ICardHeader { * Card header container with back button. * Typically holds a CardTitle. */ -const CardHeader: React.FC = ({ className, children, backHref, onBackClick }): JSX.Element => { +const CardHeader: React.FC = ({ className, children, backHref, onBackClick, onActionClick, actionIcon, actionTitle }): JSX.Element => { const intl = useIntl(); const renderBackButton = () => { @@ -64,7 +70,7 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa const backAttributes = backHref ? { to: backHref } : { onClick: onBackClick }; return ( - + {intl.formatMessage(messages.back)} @@ -76,6 +82,12 @@ const CardHeader: React.FC = ({ className, children, backHref, onBa {renderBackButton()} {children} + + {onActionClick && actionIcon && ( + + )} ); }; @@ -86,7 +98,7 @@ interface ICardTitle { /** A card's title. */ const CardTitle: React.FC = ({ title }): JSX.Element => ( - {title} + {title} ); interface ICardBody { diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 5ccf3ac424..7099b792d3 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -7,10 +7,10 @@ import { useSoapboxConfig } from 'soapbox/hooks'; import { Card, CardBody, CardHeader, CardTitle } from '../card/card'; -type IColumnHeader = Pick; +type IColumnHeader = Pick; /** Contains the column title with optional back button. */ -const ColumnHeader: React.FC = ({ label, backHref, className }) => { +const ColumnHeader: React.FC = ({ label, backHref, className, onActionClick, actionIcon, actionTitle }) => { const history = useHistory(); const handleBackClick = () => { @@ -27,7 +27,13 @@ const ColumnHeader: React.FC = ({ label, backHref, className }) = }; return ( - + ); @@ -46,13 +52,19 @@ export interface IColumn { className?: string, /** Ref forwarded to column. */ ref?: React.Ref + /** Callback when the column action is clicked. */ + onActionClick?: () => void, + /** URL to the svg icon for the column action. */ + actionIcon?: string, + /** Text for the action. */ + actionTitle?: string, /** Children to display in the column. */ children?: React.ReactNode } /** A backdrop for the main section of the UI. */ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedRef): JSX.Element => { - const { backHref, children, label, transparent = false, withHeader = true, className } = props; + const { backHref, children, label, transparent = false, withHeader = true, onActionClick, actionIcon, actionTitle, className } = props; const soapboxConfig = useSoapboxConfig(); return ( @@ -75,6 +87,9 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR label={label} backHref={backHref} className={classNames({ 'px-4 pt-4 sm:p-0': transparent })} + onActionClick={onActionClick} + actionIcon={actionIcon} + actionTitle={actionTitle} /> )} diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index 133a96a5fb..2587ff3747 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -2,10 +2,11 @@ import React, { useEffect, useRef } from 'react'; import { useIntl, defineMessages } from 'react-intl'; import { connectHashtagStream } from 'soapbox/actions/streaming'; +import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags'; import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; import { Column } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import type { Tag as TagEntity } from 'soapbox/types/entities'; @@ -18,6 +19,8 @@ const messages = defineMessages({ any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' }, all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' }, none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {additional}' }, + followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, + unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' }, }); @@ -32,9 +35,11 @@ export const HashtagTimeline: React.FC = ({ params }) => { const intl = useIntl(); const id = params?.id || ''; const tags = params?.tags || { any: [], all: [], none: [] }; - + + const features = useFeatures(); const dispatch = useAppDispatch(); const disconnects = useRef<(() => void)[]>([]); + const tag = useAppSelector((state) => state.tags.get(id)); // Mastodon supports displaying results from multiple hashtags. // https://github.com/mastodon/mastodon/issues/6359 @@ -88,9 +93,18 @@ export const HashtagTimeline: React.FC = ({ params }) => { dispatch(expandHashtagTimeline(id, { maxId, tags })); }; + const handleFollow = () => { + if (tag?.following) { + dispatch(unfollowHashtag(id)); + } else { + dispatch(followHashtag(id)); + } + }; + useEffect(() => { subscribe(); dispatch(expandHashtagTimeline(id, { tags })); + dispatch(fetchHashtag(id)); return () => { unsubscribe(); @@ -105,7 +119,13 @@ export const HashtagTimeline: React.FC = ({ params }) => { }, [id]); return ( - + = ({ params }) => { ); }; -export default HashtagTimeline; \ No newline at end of file +export default HashtagTimeline; diff --git a/app/soapbox/normalizers/tag.ts b/app/soapbox/normalizers/tag.ts index 6d0ebae146..fde58241f5 100644 --- a/app/soapbox/normalizers/tag.ts +++ b/app/soapbox/normalizers/tag.ts @@ -19,6 +19,7 @@ export const TagRecord = ImmutableRecord({ name: '', url: '', history: null as ImmutableList | null, + following: false, }); const normalizeHistoryList = (tag: ImmutableMap) => { diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index 5b5cca999a..1d1d71802b 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -56,6 +56,7 @@ import status_hover_card from './status-hover-card'; import status_lists from './status-lists'; import statuses from './statuses'; import suggestions from './suggestions'; +import tags from './tags'; import timelines from './timelines'; import trending_statuses from './trending-statuses'; import trends from './trends'; @@ -120,6 +121,7 @@ const reducers = { announcements, compose_event, admin_user_index, + tags, }; // Build a default state from all reducers: it has the key and `undefined` diff --git a/app/soapbox/reducers/tags.ts b/app/soapbox/reducers/tags.ts new file mode 100644 index 0000000000..81488bb1e4 --- /dev/null +++ b/app/soapbox/reducers/tags.ts @@ -0,0 +1,30 @@ +import { Map as ImmutableMap } from 'immutable'; + +import { + HASHTAG_FETCH_SUCCESS, + HASHTAG_FOLLOW_REQUEST, + HASHTAG_FOLLOW_FAIL, + HASHTAG_UNFOLLOW_REQUEST, + HASHTAG_UNFOLLOW_FAIL, +} from 'soapbox/actions/tags'; +import { normalizeTag } from 'soapbox/normalizers'; + +import type { AnyAction } from 'redux'; +import type { Tag } from 'soapbox/types/entities'; + +const initialState = ImmutableMap(); + +export default function tags(state = initialState, action: AnyAction) { + switch (action.type) { + case HASHTAG_FETCH_SUCCESS: + return state.set(action.name, normalizeTag(action.tag)); + case HASHTAG_FOLLOW_REQUEST: + case HASHTAG_UNFOLLOW_FAIL: + return state.setIn([action.name, 'following'], true); + case HASHTAG_FOLLOW_FAIL: + case HASHTAG_UNFOLLOW_REQUEST: + return state.setIn([action.name, 'following'], false); + default: + return state; + } +} diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 39950a534a..2f24c07f43 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -447,6 +447,13 @@ const getInstanceFeatures = (instance: Instance) => { */ focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'), + /** + * Ability to follow hashtags. + * @see POST /api/v1/tags/:name/follow + * @see POST /api/v1/tags/:name/unfollow + */ + followHashtags: v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + /** * Ability to lock accounts and manually approve followers. * @see PATCH /api/v1/accounts/update_credentials From d891024cb54ab02f6410e4f2766f2e36ca3f887c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 20 Jan 2023 14:20:50 +0100 Subject: [PATCH 02/36] Update en.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/locales/en.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 05613e17f8..899020207f 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -695,6 +695,8 @@ "hashtag.column_header.tag_mode.all": "and {additional}", "hashtag.column_header.tag_mode.any": "or {additional}", "hashtag.column_header.tag_mode.none": "without {additional}", + "hashtag.follow": "Follow hashtag", + "hashtag.unfollow": "Unfollow hashtag", "header.home.label": "Home", "header.login.forgot_password": "Forgot password?", "header.login.label": "Log in", From 0ec5ec712977ef298e0169a4c82652f8a47be812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 20 Jan 2023 22:32:51 +0100 Subject: [PATCH 03/36] Update CHANGELOG.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdc244133..8eac504779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Admin: redirect the homepage to any URL. - Compatibility: added compatibility with Friendica. +- Hashtags: let users follow hashtags (Mastodon). ### Changed From d4bcdf428f65c5f4fcacc20c9d76e9ec6e1f174d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 4 Feb 2023 11:44:12 +0100 Subject: [PATCH 04/36] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/utils/features.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 2f24c07f43..0068f17ddc 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -463,6 +463,12 @@ const getInstanceFeatures = (instance: Instance) => { v.software === PLEROMA, ]), + /** + * Ability to list followed hashtags. + * @see GET /api/v1/followed_tags + */ + followedHashtagsList: v.software === MASTODON && gte(v.compatVersion, '4.1.0'), + /** * Whether client settings can be retrieved from the API. * @see GET /api/pleroma/frontend_configurations From c61368821a4b4d77916142256d6c8381a5479013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Tue, 2 May 2023 23:33:53 +0200 Subject: [PATCH 05/36] Use ListItem for 'Follow hashtag' setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/ui/column/column.tsx | 6 ++++-- .../features/hashtag-timeline/index.tsx | 21 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/soapbox/components/ui/column/column.tsx b/app/soapbox/components/ui/column/column.tsx index 8813b107b1..8d7c2da397 100644 --- a/app/soapbox/components/ui/column/column.tsx +++ b/app/soapbox/components/ui/column/column.tsx @@ -51,6 +51,8 @@ export interface IColumn { withHeader?: boolean /** Extra class name for top
element. */ className?: string + /** Extra class name for the element. */ + bodyClassName?: string /** Ref forwarded to column. */ ref?: React.Ref /** Children to display in the column. */ @@ -63,7 +65,7 @@ export interface IColumn { /** A backdrop for the main section of the UI. */ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedRef): JSX.Element => { - const { backHref, children, label, transparent = false, withHeader = true, className, action, size } = props; + const { backHref, children, label, transparent = false, withHeader = true, className, bodyClassName, action, size } = props; const soapboxConfig = useSoapboxConfig(); const [isScrolled, setIsScrolled] = useState(false); @@ -109,7 +111,7 @@ const Column: React.FC = React.forwardRef((props, ref: React.ForwardedR /> )} - + {children} diff --git a/app/soapbox/features/hashtag-timeline/index.tsx b/app/soapbox/features/hashtag-timeline/index.tsx index 960b699df4..e448bef8a5 100644 --- a/app/soapbox/features/hashtag-timeline/index.tsx +++ b/app/soapbox/features/hashtag-timeline/index.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useRef } from 'react'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { connectHashtagStream } from 'soapbox/actions/streaming'; import { fetchHashtag, followHashtag, unfollowHashtag } from 'soapbox/actions/tags'; import { expandHashtagTimeline, clearTimeline } from 'soapbox/actions/timelines'; -import { Column } from 'soapbox/components/ui'; +import List, { ListItem } from 'soapbox/components/list'; +import { Column, Toggle } from 'soapbox/components/ui'; import Timeline from 'soapbox/features/ui/components/timeline'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; @@ -19,8 +20,6 @@ const messages = defineMessages({ any: { id: 'hashtag.column_header.tag_mode.any', defaultMessage: 'or {additional}' }, all: { id: 'hashtag.column_header.tag_mode.all', defaultMessage: 'and {additional}' }, none: { id: 'hashtag.column_header.tag_mode.none', defaultMessage: 'without {additional}' }, - followHashtag: { id: 'hashtag.follow', defaultMessage: 'Follow hashtag' }, - unfollowHashtag: { id: 'hashtag.unfollow', defaultMessage: 'Unfollow hashtag' }, empty: { id: 'empty_column.hashtag', defaultMessage: 'There is nothing in this hashtag yet.' }, }); @@ -119,7 +118,19 @@ export const HashtagTimeline: React.FC = ({ params }) => { }, [id]); return ( - + + {features.followHashtags && ( + + } + > + + + + )} Date: Tue, 2 May 2023 23:34:46 +0200 Subject: [PATCH 06/36] Follow hashtags: Support Akkoma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/utils/features.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 04e5cc6e8e..9fd16017db 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -491,7 +491,10 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/v1/tags/:name/follow * @see POST /api/v1/tags/:name/unfollow */ - followHashtags: v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + followHashtags: any([ + v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + v.software === PLEROMA && v.build === AKKOMA, + ]), /** * Ability to lock accounts and manually approve followers. From 610864d5a9b5fbbb858ce7f307c74ab594204adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 3 May 2023 00:21:53 +0200 Subject: [PATCH 07/36] Add followed tags list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/features/followed_tags/index.tsx | 52 ++++++++++++++++++++ app/soapbox/reducers/followed_tags.ts | 47 ++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 app/soapbox/features/followed_tags/index.tsx create mode 100644 app/soapbox/reducers/followed_tags.ts diff --git a/app/soapbox/features/followed_tags/index.tsx b/app/soapbox/features/followed_tags/index.tsx new file mode 100644 index 0000000000..6745f5fc0a --- /dev/null +++ b/app/soapbox/features/followed_tags/index.tsx @@ -0,0 +1,52 @@ +import debounce from 'lodash/debounce'; +import React, { useEffect } from 'react'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { fetchFollowedHashtags, expandFollowedHashtags } from 'soapbox/actions/tags'; +import Hashtag from 'soapbox/components/hashtag'; +import ScrollableList from 'soapbox/components/scrollable-list'; +import { Column } from 'soapbox/components/ui'; +import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.followed_tags', defaultMessage: 'Followed hashtags' }, +}); + +const handleLoadMore = debounce((dispatch) => { + dispatch(expandFollowedHashtags()); +}, 300, { leading: true }); + +const FollowedTags = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(fetchFollowedHashtags()); + }, []); + + const tags = useAppSelector((state => state.followed_tags.items)); + const isLoading = useAppSelector((state => state.followed_tags.isLoading)); + const hasMore = useAppSelector((state => !!state.followed_tags.next)); + + const emptyMessage = ; + + return ( + + handleLoadMore(dispatch)} + placeholderComponent={PlaceholderHashtag} + placeholderCount={5} + itemClassName='pb-3' + > + {tags.map(tag => )} + + + ); +}; + +export default FollowedTags; diff --git a/app/soapbox/reducers/followed_tags.ts b/app/soapbox/reducers/followed_tags.ts new file mode 100644 index 0000000000..4f30a3f3a7 --- /dev/null +++ b/app/soapbox/reducers/followed_tags.ts @@ -0,0 +1,47 @@ +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; + +import { + FOLLOWED_HASHTAGS_FETCH_REQUEST, + FOLLOWED_HASHTAGS_FETCH_SUCCESS, + FOLLOWED_HASHTAGS_FETCH_FAIL, + FOLLOWED_HASHTAGS_EXPAND_REQUEST, + FOLLOWED_HASHTAGS_EXPAND_SUCCESS, + FOLLOWED_HASHTAGS_EXPAND_FAIL, +} from 'soapbox/actions/tags'; +import { normalizeTag } from 'soapbox/normalizers'; + +import type { AnyAction } from 'redux'; +import type { APIEntity, Tag } from 'soapbox/types/entities'; + +const ReducerRecord = ImmutableRecord({ + items: ImmutableList(), + isLoading: false, + next: null, +}); + +export default function followed_tags(state = ReducerRecord(), action: AnyAction) { + switch (action.type) { + case FOLLOWED_HASHTAGS_FETCH_REQUEST: + return state.set('isLoading', true); + case FOLLOWED_HASHTAGS_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('items', ImmutableList(action.followed_tags.map((item: APIEntity) => normalizeTag(item)))); + map.set('isLoading', false); + map.set('next', action.next); + }); + case FOLLOWED_HASHTAGS_FETCH_FAIL: + return state.set('isLoading', false); + case FOLLOWED_HASHTAGS_EXPAND_REQUEST: + return state.set('isLoading', true); + case FOLLOWED_HASHTAGS_EXPAND_SUCCESS: + return state.withMutations(map => { + map.update('items', list => list.concat(action.followed_tags.map((item: APIEntity) => normalizeTag(item)))); + map.set('isLoading', false); + map.set('next', action.next); + }); + case FOLLOWED_HASHTAGS_EXPAND_FAIL: + return state.set('isLoading', false); + default: + return state; + } +} From 586f536329e31e9a3bd8429f952bfa64184d4668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Wed, 3 May 2023 00:26:29 +0200 Subject: [PATCH 08/36] Update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- CHANGELOG.md | 4 +--- app/soapbox/locales/en.json | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e5b2de23..179bee42b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Admin: redirect the homepage to any URL. -- Compatibility: added compatibility with Friendica. -- Hashtags: let users follow hashtags (Mastodon). +- Hashtags: let users follow hashtags (Mastodon, Akkoma). - Posts: Support posts filtering on recent Mastodon versions - Reactions: Support custom emoji reactions - Compatbility: Support Mastodon v2 timeline filters. diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 7f2cfadd5f..0576f85760 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -673,6 +673,7 @@ "empty_column.filters": "You haven't created any muted words yet.", "empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.", "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", + "empty_column.followed_tags": "You haven't followed any hashtag yet.", "empty_column.group": "There are no posts in this group yet.", "empty_column.group_blocks": "The group hasn't banned any users yet.", "empty_column.group_membership_requests": "There are no pending membership requests for this group.", From 9e33dc80aee010e93642e833561484d862380677 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Thu, 4 May 2023 09:24:37 -0400 Subject: [PATCH 09/36] Remove search term after navigating away from Search page --- app/soapbox/features/compose/components/search.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/soapbox/features/compose/components/search.tsx b/app/soapbox/features/compose/components/search.tsx index 0e50fc0bed..3a3bdcd6ba 100644 --- a/app/soapbox/features/compose/components/search.tsx +++ b/app/soapbox/features/compose/components/search.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx'; import debounce from 'lodash/debounce'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; @@ -135,6 +135,18 @@ const Search = (props: ISearch) => { componentProps.autoSelect = false; } + useEffect(() => { + return () => { + const newPath = history.location.pathname; + const shouldPersistSearch = !!newPath.match(/@.+\/posts\/\d+/g) + || !!newPath.match(/\/tags\/.+/g); + + if (!shouldPersistSearch) { + dispatch(changeSearch('')); + } + }; + }, []); + return (
From 55ebc8c6eecf66a32d4b29b07693d54dc80a7a47 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 4 May 2023 10:24:34 -0500 Subject: [PATCH 10/36] Add notificationSchema --- app/soapbox/schemas/chat-message.ts | 10 +++ app/soapbox/schemas/emoji-reaction.ts | 3 +- app/soapbox/schemas/notification.ts | 104 ++++++++++++++++++++++++++ app/soapbox/schemas/utils.ts | 5 +- 4 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 app/soapbox/schemas/chat-message.ts create mode 100644 app/soapbox/schemas/notification.ts diff --git a/app/soapbox/schemas/chat-message.ts b/app/soapbox/schemas/chat-message.ts new file mode 100644 index 0000000000..a64ffec0b2 --- /dev/null +++ b/app/soapbox/schemas/chat-message.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +import { normalizeChatMessage } from 'soapbox/normalizers'; +import { toSchema } from 'soapbox/utils/normalizers'; + +const chatMessageSchema = toSchema(normalizeChatMessage); + +type ChatMessage = z.infer; + +export { chatMessageSchema, type ChatMessage }; \ No newline at end of file diff --git a/app/soapbox/schemas/emoji-reaction.ts b/app/soapbox/schemas/emoji-reaction.ts index 55c1762a00..28271fe293 100644 --- a/app/soapbox/schemas/emoji-reaction.ts +++ b/app/soapbox/schemas/emoji-reaction.ts @@ -1,7 +1,6 @@ import { z } from 'zod'; -/** Validates the string as an emoji. */ -const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v)); +import { emojiSchema } from './utils'; /** Pleroma emoji reaction. */ const emojiReactionSchema = z.object({ diff --git a/app/soapbox/schemas/notification.ts b/app/soapbox/schemas/notification.ts new file mode 100644 index 0000000000..3c77de6bf9 --- /dev/null +++ b/app/soapbox/schemas/notification.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; + +import { accountSchema } from './account'; +import { chatMessageSchema } from './chat-message'; +import { statusSchema } from './status'; +import { emojiSchema } from './utils'; + +const baseNotificationSchema = z.object({ + account: accountSchema, + created_at: z.string().datetime().catch(new Date().toUTCString()), + id: z.string(), + type: z.string(), + total_count: z.number().optional().catch(undefined), // TruthSocial +}); + +const mentionNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('mention'), + status: statusSchema, +}); + +const statusNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('status'), + status: statusSchema, +}); + +const reblogNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('reblog'), + status: statusSchema, +}); + +const followNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('follow'), +}); + +const followRequestNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('follow_request'), +}); + +const favouriteNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('favourite'), + status: statusSchema, +}); + +const pollNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('poll'), + status: statusSchema, +}); + +const updateNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('update'), + status: statusSchema, +}); + +const moveNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('move'), + target: accountSchema, +}); + +const chatMessageNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('chat_message'), + chat_message: chatMessageSchema, +}); + +const emojiReactionNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('pleroma:emoji_reaction'), + emoji: emojiSchema, + emoji_url: z.string().url().optional().catch(undefined), +}); + +const eventReminderNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('pleroma:event_reminder'), + status: statusSchema, +}); + +const participationRequestNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('pleroma:participation_request'), + status: statusSchema, +}); + +const participationAcceptedNotificationSchema = baseNotificationSchema.extend({ + type: z.literal('pleroma:participation_accepted'), + status: statusSchema, +}); + +const notificationSchema = z.discriminatedUnion('type', [ + mentionNotificationSchema, + statusNotificationSchema, + reblogNotificationSchema, + followNotificationSchema, + followRequestNotificationSchema, + favouriteNotificationSchema, + pollNotificationSchema, + updateNotificationSchema, + moveNotificationSchema, + chatMessageNotificationSchema, + emojiReactionNotificationSchema, + eventReminderNotificationSchema, + participationRequestNotificationSchema, + participationAcceptedNotificationSchema, +]); + +type Notification = z.infer; + +export { notificationSchema, type Notification }; \ No newline at end of file diff --git a/app/soapbox/schemas/utils.ts b/app/soapbox/schemas/utils.ts index 5a62fa0c65..1e53e11aae 100644 --- a/app/soapbox/schemas/utils.ts +++ b/app/soapbox/schemas/utils.ts @@ -13,6 +13,9 @@ function filteredArray(schema: T) { )); } +/** Validates the string as an emoji. */ +const emojiSchema = z.string().refine((v) => /\p{Extended_Pictographic}/u.test(v)); + /** Map a list of CustomEmoji to their shortcodes. */ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) { return customEmojis.reduce>((result, emoji) => { @@ -21,4 +24,4 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) { }, {}); } -export { filteredArray, makeCustomEmojiMap }; \ No newline at end of file +export { filteredArray, makeCustomEmojiMap, emojiSchema }; \ No newline at end of file From 074c3c5b3986c8d46e4b224286b3345e39ffb305 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 4 May 2023 11:26:31 -0500 Subject: [PATCH 11/36] Add attachmentSchema --- app/soapbox/schemas/attachment.ts | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 app/soapbox/schemas/attachment.ts diff --git a/app/soapbox/schemas/attachment.ts b/app/soapbox/schemas/attachment.ts new file mode 100644 index 0000000000..44b9cb126d --- /dev/null +++ b/app/soapbox/schemas/attachment.ts @@ -0,0 +1,89 @@ +import { isBlurhashValid } from 'blurhash'; +import { z } from 'zod'; + +const blurhashSchema = z.string().superRefine((value, ctx) => { + const r = isBlurhashValid(value); + + if (!r.result) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: r.errorReason, + }); + } +}); + +const baseAttachmentSchema = z.object({ + blurhash: blurhashSchema.nullable().catch(null), + description: z.string().catch(''), + external_video_id: z.string().optional().catch(undefined), // TruthSocial + id: z.string(), + pleroma: z.object({ + mime_type: z.string().regex(/^\w+\/[-+.\w]+$/), + }).optional().catch(undefined), + preview_url: z.string().url().catch(''), + remote_url: z.string().url().nullable().catch(null), + type: z.string(), + url: z.string().url(), +}); + +const imageMetaSchema = z.object({ + width: z.number(), + height: z.number(), + aspect: z.number().optional().catch(undefined), +}).transform((meta) => ({ + ...meta, + aspect: typeof meta.aspect === 'number' ? meta.aspect : meta.width / meta.height, +})); + +const imageAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('image'), + meta: z.object({ + original: imageMetaSchema.optional().catch(undefined), + }).catch({}), +}); + +const videoAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('video'), + meta: z.object({ + duration: z.number().optional().catch(undefined), + original: imageMetaSchema.optional().catch(undefined), + }).catch({}), +}); + +const gifvAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('gifv'), + meta: z.object({ + duration: z.number().optional().catch(undefined), + original: imageMetaSchema.optional().catch(undefined), + }).catch({}), +}); + +const audioAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('audio'), + meta: z.object({ + duration: z.number().optional().catch(undefined), + }).catch({}), +}); + +const unknownAttachmentSchema = baseAttachmentSchema.extend({ + type: z.literal('unknown'), +}); + +/** https://docs.joinmastodon.org/entities/attachment */ +const attachmentSchema = z.discriminatedUnion('type', [ + imageAttachmentSchema, + videoAttachmentSchema, + gifvAttachmentSchema, + audioAttachmentSchema, + unknownAttachmentSchema, +]).transform((attachment) => { + if (!attachment.preview_url) { + attachment.preview_url = attachment.url; + } + + return attachment; +}); + +type Attachment = z.infer; + +export { attachmentSchema, type Attachment }; \ No newline at end of file From a7e1350a6505a700b3b23c5d58fd0b6c20dedd59 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 4 May 2023 11:31:58 -0500 Subject: [PATCH 12/36] Add real chatMessageSchema --- app/soapbox/schemas/chat-message.ts | 22 +++++++++++++++++++--- app/soapbox/schemas/tag.ts | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/soapbox/schemas/chat-message.ts b/app/soapbox/schemas/chat-message.ts index a64ffec0b2..121ef9d5f5 100644 --- a/app/soapbox/schemas/chat-message.ts +++ b/app/soapbox/schemas/chat-message.ts @@ -1,9 +1,25 @@ import { z } from 'zod'; -import { normalizeChatMessage } from 'soapbox/normalizers'; -import { toSchema } from 'soapbox/utils/normalizers'; +import { attachmentSchema } from './attachment'; +import { cardSchema } from './card'; +import { customEmojiSchema } from './custom-emoji'; +import { emojiSchema, filteredArray } from './utils'; -const chatMessageSchema = toSchema(normalizeChatMessage); +const chatMessageSchema = z.object({ + account_id: z.string(), + media_attachments: filteredArray(attachmentSchema), + card: cardSchema.nullable().catch(null), + chat_id: z.string(), + content: z.string().catch(''), + created_at: z.string().datetime().catch(new Date().toUTCString()), + emojis: filteredArray(customEmojiSchema), + expiration: z.number().optional().catch(undefined), + emoji_reactions: z.array(emojiSchema).min(1).nullable().catch(null), + id: z.string(), + unread: z.coerce.boolean(), + deleting: z.coerce.boolean(), + pending: z.coerce.boolean(), +}); type ChatMessage = z.infer; diff --git a/app/soapbox/schemas/tag.ts b/app/soapbox/schemas/tag.ts index 5f74a31c79..22e903d605 100644 --- a/app/soapbox/schemas/tag.ts +++ b/app/soapbox/schemas/tag.ts @@ -5,7 +5,7 @@ const historySchema = z.object({ uses: z.coerce.number(), }); -/** // https://docs.joinmastodon.org/entities/tag */ +/** https://docs.joinmastodon.org/entities/tag */ const tagSchema = z.object({ name: z.string().min(1), url: z.string().url().catch(''), From 1dec42cd9fcb2e56c3dbb383a5d4c3bd453a09c2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 4 May 2023 11:42:20 -0500 Subject: [PATCH 13/36] Add contentSchema helper --- app/soapbox/schemas/account.ts | 4 ++-- app/soapbox/schemas/chat-message.ts | 4 ++-- app/soapbox/schemas/utils.ts | 5 ++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index 9381edbbd8..51202d2e6b 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -5,7 +5,7 @@ import emojify from 'soapbox/features/emoji'; import { customEmojiSchema } from './custom-emoji'; import { relationshipSchema } from './relationship'; -import { filteredArray, makeCustomEmojiMap } from './utils'; +import { contentSchema, filteredArray, makeCustomEmojiMap } from './utils'; const avatarMissing = require('assets/images/avatar-missing.png'); const headerMissing = require('assets/images/header-missing.png'); @@ -39,7 +39,7 @@ const accountSchema = z.object({ z.string(), z.null(), ]).catch(null), - note: z.string().catch(''), + note: contentSchema, pleroma: z.any(), // TODO source: z.any(), // TODO statuses_count: z.number().catch(0), diff --git a/app/soapbox/schemas/chat-message.ts b/app/soapbox/schemas/chat-message.ts index 121ef9d5f5..fe0ff0f6b8 100644 --- a/app/soapbox/schemas/chat-message.ts +++ b/app/soapbox/schemas/chat-message.ts @@ -3,14 +3,14 @@ import { z } from 'zod'; import { attachmentSchema } from './attachment'; import { cardSchema } from './card'; import { customEmojiSchema } from './custom-emoji'; -import { emojiSchema, filteredArray } from './utils'; +import { contentSchema, emojiSchema, filteredArray } from './utils'; const chatMessageSchema = z.object({ account_id: z.string(), media_attachments: filteredArray(attachmentSchema), card: cardSchema.nullable().catch(null), chat_id: z.string(), - content: z.string().catch(''), + content: contentSchema, created_at: z.string().datetime().catch(new Date().toUTCString()), emojis: filteredArray(customEmojiSchema), expiration: z.number().optional().catch(undefined), diff --git a/app/soapbox/schemas/utils.ts b/app/soapbox/schemas/utils.ts index 1e53e11aae..b641858000 100644 --- a/app/soapbox/schemas/utils.ts +++ b/app/soapbox/schemas/utils.ts @@ -2,6 +2,9 @@ import z from 'zod'; import type { CustomEmoji } from './custom-emoji'; +/** Ensure HTML content is a string, and drop empty `

` tags. */ +const contentSchema = z.string().catch('').transform((value) => value === '

' ? '' : value); + /** Validates individual items in an array, dropping any that aren't valid. */ function filteredArray(schema: T) { return z.any().array().catch([]) @@ -24,4 +27,4 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) { }, {}); } -export { filteredArray, makeCustomEmojiMap, emojiSchema }; \ No newline at end of file +export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema }; \ No newline at end of file From 9a6437568187f5338714cd6b1c987d0aae644e79 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 4 May 2023 11:51:50 -0500 Subject: [PATCH 14/36] useGroupMedia: don't use statusSchema directly yet so we can change it --- app/soapbox/api/hooks/groups/useGroupMedia.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/soapbox/api/hooks/groups/useGroupMedia.ts b/app/soapbox/api/hooks/groups/useGroupMedia.ts index 23375bdc7b..4db7fd179b 100644 --- a/app/soapbox/api/hooks/groups/useGroupMedia.ts +++ b/app/soapbox/api/hooks/groups/useGroupMedia.ts @@ -1,7 +1,10 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useEntities } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks/useApi'; -import { statusSchema } from 'soapbox/schemas/status'; +import { normalizeStatus } from 'soapbox/normalizers'; +import { toSchema } from 'soapbox/utils/normalizers'; + +const statusSchema = toSchema(normalizeStatus); function useGroupMedia(groupId: string) { const api = useApi(); From e024e9212565731a92f21071bc3551284aee3a4c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 4 May 2023 12:13:39 -0500 Subject: [PATCH 15/36] Add a real statusSchema --- app/soapbox/schemas/mention.ts | 18 +++++++++++ app/soapbox/schemas/status.ts | 57 ++++++++++++++++++++++++++++++++-- app/soapbox/schemas/utils.ts | 5 ++- 3 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 app/soapbox/schemas/mention.ts diff --git a/app/soapbox/schemas/mention.ts b/app/soapbox/schemas/mention.ts new file mode 100644 index 0000000000..9bbdbff5b1 --- /dev/null +++ b/app/soapbox/schemas/mention.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +const mentionSchema = z.object({ + acct: z.string(), + id: z.string(), + url: z.string().url().catch(''), + username: z.string().catch(''), +}).transform((mention) => { + if (!mention.username) { + mention.username = mention.acct.split('@')[0]; + } + + return mention; +}); + +type Mention = z.infer; + +export { mentionSchema, type Mention }; \ No newline at end of file diff --git a/app/soapbox/schemas/status.ts b/app/soapbox/schemas/status.ts index 66d6f05ebe..00fa6d4839 100644 --- a/app/soapbox/schemas/status.ts +++ b/app/soapbox/schemas/status.ts @@ -1,9 +1,60 @@ import { z } from 'zod'; -import { normalizeStatus } from 'soapbox/normalizers'; -import { toSchema } from 'soapbox/utils/normalizers'; +import { accountSchema } from './account'; +import { attachmentSchema } from './attachment'; +import { cardSchema } from './card'; +import { customEmojiSchema } from './custom-emoji'; +import { groupSchema } from './group'; +import { mentionSchema } from './mention'; +import { pollSchema } from './poll'; +import { tagSchema } from './tag'; +import { contentSchema, dateSchema, filteredArray } from './utils'; -const statusSchema = toSchema(normalizeStatus); +const baseStatusSchema = z.object({ + account: accountSchema, + application: z.object({ + name: z.string(), + website: z.string().url().nullable().catch(null), + }).nullable().catch(null), + bookmarked: z.coerce.boolean(), + card: cardSchema.nullable().catch(null), + content: contentSchema, + created_at: dateSchema, + disliked: z.coerce.boolean(), + dislikes_count: z.number().catch(0), + edited_at: z.string().datetime().nullable().catch(null), + emojis: filteredArray(customEmojiSchema), + favourited: z.coerce.boolean(), + favourites_count: z.number().catch(0), + group: groupSchema.nullable().catch(null), + in_reply_to_account_id: z.string().nullable().catch(null), + in_reply_to_id: z.string().nullable().catch(null), + id: z.string(), + language: z.string().nullable().catch(null), + media_attachments: filteredArray(attachmentSchema), + mentions: filteredArray(mentionSchema), + muted: z.coerce.boolean(), + pinned: z.coerce.boolean(), + pleroma: z.object({}).optional().catch(undefined), + poll: pollSchema.nullable().catch(null), + quote: z.literal(null).catch(null), + quotes_count: z.number().catch(0), + reblog: z.literal(null).catch(null), + reblogged: z.coerce.boolean(), + reblogs_count: z.number().catch(0), + replies_count: z.number().catch(0), + sensitive: z.coerce.boolean(), + spoiler_text: contentSchema, + tags: filteredArray(tagSchema), + uri: z.string().url().catch(''), + url: z.string().url().catch(''), + visibility: z.string().catch('public'), +}); + +const statusSchema = baseStatusSchema.extend({ + quote: baseStatusSchema.nullable().catch(null), + reblog: baseStatusSchema.nullable().catch(null), +}); type Status = z.infer; diff --git a/app/soapbox/schemas/utils.ts b/app/soapbox/schemas/utils.ts index b641858000..c85b2b2b16 100644 --- a/app/soapbox/schemas/utils.ts +++ b/app/soapbox/schemas/utils.ts @@ -5,6 +5,9 @@ import type { CustomEmoji } from './custom-emoji'; /** Ensure HTML content is a string, and drop empty `

` tags. */ const contentSchema = z.string().catch('').transform((value) => value === '

' ? '' : value); +/** Validate to Mastodon's date format, or use the current date. */ +const dateSchema = z.string().datetime().catch(new Date().toUTCString()); + /** Validates individual items in an array, dropping any that aren't valid. */ function filteredArray(schema: T) { return z.any().array().catch([]) @@ -27,4 +30,4 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) { }, {}); } -export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema }; \ No newline at end of file +export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema }; \ No newline at end of file From 0e7ccd57ae7f8c725ad359129ed7906d823a99ba Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 4 May 2023 12:20:39 -0500 Subject: [PATCH 16/36] Export new schemas --- app/soapbox/schemas/account.ts | 2 +- app/soapbox/schemas/custom-emoji.ts | 2 +- app/soapbox/schemas/emoji-reaction.ts | 2 +- app/soapbox/schemas/group-member.ts | 2 +- app/soapbox/schemas/group-relationship.ts | 2 +- app/soapbox/schemas/index.ts | 5 +++++ 6 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/soapbox/schemas/account.ts b/app/soapbox/schemas/account.ts index 51202d2e6b..919013329b 100644 --- a/app/soapbox/schemas/account.ts +++ b/app/soapbox/schemas/account.ts @@ -121,4 +121,4 @@ const accountSchema = z.object({ type Account = z.infer; -export { accountSchema, Account }; \ No newline at end of file +export { accountSchema, type Account }; \ No newline at end of file diff --git a/app/soapbox/schemas/custom-emoji.ts b/app/soapbox/schemas/custom-emoji.ts index 68c49c5876..1addf026af 100644 --- a/app/soapbox/schemas/custom-emoji.ts +++ b/app/soapbox/schemas/custom-emoji.ts @@ -14,4 +14,4 @@ const customEmojiSchema = z.object({ type CustomEmoji = z.infer; -export { customEmojiSchema, CustomEmoji }; +export { customEmojiSchema, type CustomEmoji }; diff --git a/app/soapbox/schemas/emoji-reaction.ts b/app/soapbox/schemas/emoji-reaction.ts index 28271fe293..1559148e1c 100644 --- a/app/soapbox/schemas/emoji-reaction.ts +++ b/app/soapbox/schemas/emoji-reaction.ts @@ -11,4 +11,4 @@ const emojiReactionSchema = z.object({ type EmojiReaction = z.infer; -export { emojiReactionSchema, EmojiReaction }; \ No newline at end of file +export { emojiReactionSchema, type EmojiReaction }; \ No newline at end of file diff --git a/app/soapbox/schemas/group-member.ts b/app/soapbox/schemas/group-member.ts index 4521450cb4..8135fecb6c 100644 --- a/app/soapbox/schemas/group-member.ts +++ b/app/soapbox/schemas/group-member.ts @@ -16,4 +16,4 @@ const groupMemberSchema = z.object({ type GroupMember = z.infer; -export { groupMemberSchema, GroupMember, GroupRoles }; \ No newline at end of file +export { groupMemberSchema, type GroupMember, GroupRoles }; \ No newline at end of file diff --git a/app/soapbox/schemas/group-relationship.ts b/app/soapbox/schemas/group-relationship.ts index 5bf4cae31c..baeb55a12a 100644 --- a/app/soapbox/schemas/group-relationship.ts +++ b/app/soapbox/schemas/group-relationship.ts @@ -14,4 +14,4 @@ const groupRelationshipSchema = z.object({ type GroupRelationship = z.infer; -export { groupRelationshipSchema, GroupRelationship }; \ No newline at end of file +export { groupRelationshipSchema, type GroupRelationship }; \ No newline at end of file diff --git a/app/soapbox/schemas/index.ts b/app/soapbox/schemas/index.ts index 25f5f3d457..8e20c0d050 100644 --- a/app/soapbox/schemas/index.ts +++ b/app/soapbox/schemas/index.ts @@ -1,13 +1,18 @@ export { accountSchema, type Account } from './account'; +export { attachmentSchema, type Attachment } from './attachment'; export { cardSchema, type Card } from './card'; +export { chatMessageSchema, type ChatMessage } from './chat-message'; export { customEmojiSchema, type CustomEmoji } from './custom-emoji'; export { emojiReactionSchema, type EmojiReaction } from './emoji-reaction'; export { groupSchema, type Group } from './group'; export { groupMemberSchema, type GroupMember } from './group-member'; export { groupRelationshipSchema, type GroupRelationship } from './group-relationship'; export { groupTagSchema, type GroupTag } from './group-tag'; +export { mentionSchema, type Mention } from './mention'; +export { notificationSchema, type Notification } from './notification'; export { pollSchema, type Poll, type PollOption } from './poll'; export { relationshipSchema, type Relationship } from './relationship'; +export { statusSchema, type Status } from './status'; export { tagSchema, type Tag } from './tag'; // Soapbox From a491c6acb80c9640d9a7457d8d5b69d3c7b48f59 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Fri, 5 May 2023 15:15:59 -0400 Subject: [PATCH 17/36] Prevent focus on 'Trouble logging in?' link --- app/soapbox/features/auth-login/components/login-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/auth-login/components/login-form.tsx b/app/soapbox/features/auth-login/components/login-form.tsx index 9a4768903d..d7871fc759 100644 --- a/app/soapbox/features/auth-login/components/login-form.tsx +++ b/app/soapbox/features/auth-login/components/login-form.tsx @@ -57,7 +57,7 @@ const LoginForm: React.FC = ({ isLoading, handleSubmit }) => { + Date: Mon, 8 May 2023 10:35:12 -0500 Subject: [PATCH 18/36] Auth: fix otherAccounts throwing a fullscreen error in local dev --- app/soapbox/actions/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/soapbox/actions/auth.ts b/app/soapbox/actions/auth.ts index 06fe848e29..e4a10edd08 100644 --- a/app/soapbox/actions/auth.ts +++ b/app/soapbox/actions/auth.ts @@ -242,7 +242,8 @@ export const fetchOwnAccounts = () => return state.auth.users.forEach((user) => { const account = state.accounts.get(user.id); if (!account) { - dispatch(verifyCredentials(user.access_token, user.url)); + dispatch(verifyCredentials(user.access_token, user.url)) + .catch(() => console.warn(`Failed to load account: ${user.url}`)); } }); }; From f290ca85e358ebb44f4e68e6127dab0f08314f0e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 May 2023 10:39:25 -0500 Subject: [PATCH 19/36] Thread: scroll up a little more on focus so the thread connector is visible --- app/soapbox/features/status/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 64eb9404f7..6628db23b4 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -404,7 +404,7 @@ const Thread: React.FC = (props) => { useEffect(() => { scroller.current?.scrollToIndex({ index: ancestorsIds.size, - offset: -140, + offset: -146, }); setImmediate(() => statusRef.current?.querySelector('.detailed-actualStatus')?.focus()); From f47b5f0a20c8885bccb56af27f38903132070d44 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 8 May 2023 10:44:07 -0500 Subject: [PATCH 20/36] Thread: fix display of initial loading indicator --- app/soapbox/features/status/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 6628db23b4..6b41148c4a 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -443,7 +443,9 @@ const Thread: React.FC = (props) => { ); } else if (!status) { return ( - + + + ); } From afec0edc1ce4cf146105834b2bb2a55437230e14 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Mon, 8 May 2023 13:29:11 -0400 Subject: [PATCH 21/36] Add tests for Group API hooks --- .../hooks/groups/__tests__/useGroup.test.ts | 41 +++++++++++++++++ .../groups/__tests__/useGroupLookup.test.ts | 41 +++++++++++++++++ .../groups/__tests__/useGroupMedia.test.ts | 44 ++++++++++++++++++ .../groups/__tests__/useGroupMembers.test.ts | 45 +++++++++++++++++++ .../hooks/groups/__tests__/useGroups.test.ts | 43 ++++++++++++++++++ app/soapbox/jest/factory.ts | 8 ++++ 6 files changed, 222 insertions(+) create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts create mode 100644 app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts new file mode 100644 index 0000000000..8afd06f1a3 --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroup.test.ts @@ -0,0 +1,41 @@ +import { __stub } from 'soapbox/api'; +import { buildGroup } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroup } from '../useGroup'; + +const group = buildGroup({ id: '1', display_name: 'soapbox' }); + +describe('useGroup hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${group.id}`).reply(200, group); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroup(group.id)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.group?.id).toBe(group.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${group.id}`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroup(group.id)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.group).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts new file mode 100644 index 0000000000..2397b16ceb --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupLookup.test.ts @@ -0,0 +1,41 @@ +import { __stub } from 'soapbox/api'; +import { buildGroup } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroupLookup } from '../useGroupLookup'; + +const group = buildGroup({ id: '1', slug: 'soapbox' }); + +describe('useGroupLookup hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).reply(200, group); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroupLookup(group.slug)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entity?.id).toBe(group.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/lookup?name=${group.slug}`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroupLookup(group.slug)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entity).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts new file mode 100644 index 0000000000..a68b79eb1d --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupMedia.test.ts @@ -0,0 +1,44 @@ +import { __stub } from 'soapbox/api'; +import { buildStatus } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroupMedia } from '../useGroupMedia'; + +const status = buildStatus(); +const groupId = '1'; + +describe('useGroupMedia hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).reply(200, [status]); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroupMedia(groupId)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entities.length).toBe(1); + expect(result.current.entities[0].id).toBe(status.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/timelines/group/${groupId}?only_media=true`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroupMedia(groupId)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.entities.length).toBe(0); + expect(result.current.isError).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts new file mode 100644 index 0000000000..6f2fb6eac5 --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroupMembers.test.ts @@ -0,0 +1,45 @@ +import { __stub } from 'soapbox/api'; +import { buildGroupMember } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; +import { GroupRoles } from 'soapbox/schemas/group-member'; + +import { useGroupMembers } from '../useGroupMembers'; + +const groupMember = buildGroupMember(); +const groupId = '1'; + +describe('useGroupMembers hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).reply(200, [groupMember]); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groupMembers.length).toBe(1); + expect(result.current.groupMembers[0].id).toBe(groupMember.id); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet(`/api/v1/groups/${groupId}/memberships?role=${GroupRoles.ADMIN}`).networkError(); + }); + }); + + it('is has error state', async() => { + const { result } = renderHook(() => useGroupMembers(groupId, GroupRoles.ADMIN)); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groupMembers.length).toBe(0); + expect(result.current.isError).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts new file mode 100644 index 0000000000..adff805f3e --- /dev/null +++ b/app/soapbox/api/hooks/groups/__tests__/useGroups.test.ts @@ -0,0 +1,43 @@ +import { __stub } from 'soapbox/api'; +import { buildGroup } from 'soapbox/jest/factory'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import { useGroups } from '../useGroups'; + +const group = buildGroup({ id: '1', display_name: 'soapbox' }); + +describe('useGroups hook', () => { + describe('with a successful request', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/groups?q=').reply(200, [group]); + }); + }); + + it('is successful', async () => { + const { result } = renderHook(() => useGroups()); + + console.log(result.current); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.groups.length).toHaveLength(1); + }); + }); + + // describe('with an unsuccessful query', () => { + // beforeEach(() => { + // __stub((mock) => { + // mock.onGet('/api/v1/groups').networkError(); + // }); + // }); + + // it('is has error state', async() => { + // const { result } = renderHook(() => useGroups()); + + // await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // expect(result.current.groups).toHaveLength(0); + // }); + // }); +}); \ No newline at end of file diff --git a/app/soapbox/jest/factory.ts b/app/soapbox/jest/factory.ts index 35ea063e07..4d8ff336ad 100644 --- a/app/soapbox/jest/factory.ts +++ b/app/soapbox/jest/factory.ts @@ -19,6 +19,7 @@ import { type Relationship, } from 'soapbox/schemas'; import { GroupRoles } from 'soapbox/schemas/group-member'; +import { statusSchema, type Status } from 'soapbox/schemas/status'; // TODO: there's probably a better way to create these factory functions. // This looks promising but didn't work on my first attempt: https://github.com/anatine/zod-plugins/tree/main/packages/zod-mock @@ -77,6 +78,12 @@ function buildRelationship(props: Partial = {}): Relationship { }, props)); } +function buildStatus(props: Partial = {}): Status { + return statusSchema.parse(Object.assign({ + id: uuidv4(), + }, props)); +} + export { buildAd, buildCard, @@ -85,4 +92,5 @@ export { buildGroupRelationship, buildGroupTag, buildRelationship, + buildStatus, }; \ No newline at end of file From 4a2b7faa594d24dcc97ac56fea78513dd37ec710 Mon Sep 17 00:00:00 2001 From: Chewbacca Date: Tue, 9 May 2023 15:16:13 -0400 Subject: [PATCH 22/36] Support soft-deleted statuses via tombstones --- app/soapbox/components/status.tsx | 12 ++++++++++++ app/soapbox/components/tombstone.tsx | 15 +++++++++++---- .../status/components/thread-status.tsx | 5 ++--- app/soapbox/normalizers/status.ts | 17 ++++++++++++++++- app/soapbox/schemas/index.ts | 1 + app/soapbox/schemas/status.ts | 5 +++++ app/soapbox/schemas/tombstone.ts | 9 +++++++++ app/styles/components/detailed-status.scss | 10 ---------- 8 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 app/soapbox/schemas/tombstone.ts diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 8456a46a41..0c1a7be59c 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -21,6 +21,7 @@ import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; import SensitiveContentOverlay from './statuses/sensitive-content-overlay'; import StatusInfo from './statuses/status-info'; +import Tombstone from './tombstone'; import { Card, Icon, Stack, Text } from './ui'; import type { @@ -388,6 +389,17 @@ const Status: React.FC = (props) => { const isUnderReview = actualStatus.visibility === 'self'; const isSensitive = actualStatus.hidden; + const isSoftDeleted = status.tombstone?.reason === 'deleted'; + + if (isSoftDeleted) { + return ( + onMoveUp ? onMoveUp(id) : null} + onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null} + /> + ); + } return ( diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index 6c6a2a6f90..b92fb7e70f 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -19,10 +19,17 @@ const Tombstone: React.FC = ({ id, onMoveUp, onMoveDown }) => { return ( -
- - - +
+
+ + + +
); diff --git a/app/soapbox/features/status/components/thread-status.tsx b/app/soapbox/features/status/components/thread-status.tsx index 8f3e5b2496..442bae0e34 100644 --- a/app/soapbox/features/status/components/thread-status.tsx +++ b/app/soapbox/features/status/components/thread-status.tsx @@ -31,9 +31,8 @@ const ThreadStatus: React.FC = (props): JSX.Element => { return (