diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..7a73a41bf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/app/soapbox/actions/tags.ts b/app/soapbox/actions/tags.ts new file mode 100644 index 000000000..d61b2d9eb --- /dev/null +++ b/app/soapbox/actions/tags.ts @@ -0,0 +1,119 @@ +import { isLoggedIn } from 'soapbox/utils/auth'; +import { getFeatures } from 'soapbox/utils/features'; + +import api, { getNextLink } from '../api'; + +import type { AppDispatch, RootState } from 'soapbox/store'; + +const TAG_FETCH_REQUEST = 'TAG_FETCH_REQUEST'; +const TAG_FETCH_SUCCESS = 'TAG_FETCH_SUCCESS'; +const TAG_FETCH_FAIL = 'TAG_FETCH_FAIL'; + +const TAG_FOLLOW_REQUEST = 'TAG_FOLLOW_REQUEST'; +const TAG_FOLLOW_SUCCESS = 'TAG_FOLLOW_SUCCESS'; +const TAG_FOLLOW_FAIL = 'TAG_FOLLOW_FAIL'; + +const TAG_UNFOLLOW_REQUEST = 'TAG_UNFOLLOW_REQUEST'; +const TAG_UNFOLLOW_SUCCESS = 'TAG_UNFOLLOW_SUCCESS'; +const TAG_UNFOLLOW_FAIL = 'TAG_UNFOLLOW_FAIL'; + +const fetchTags = () => async(dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const state = getState(); + const instance = state.instance; + const features = getFeatures(instance); + + if (!features.followTags) return; + + dispatch({ type: TAG_FETCH_REQUEST, skipLoading: true }); + + try { + let next = null; + let tags = []; + do { + const response = await api(getState).get(next || '/api/v1/followed_tags'); + tags = [...tags, ...response.data]; + next = getNextLink(response); + } while (next); + dispatch({ + type: TAG_FETCH_SUCCESS, + tags, + skipLoading: true, + }); + } catch (err) { + dispatch({ + type: TAG_FETCH_FAIL, + err, + skipLoading: true, + skipAlert: true, + }); + } +}; + +const followTag = (tagId: string) => async(dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const state = getState(); + const features = getFeatures(state.instance); + + if (!features.followTags) return; + + dispatch({ type: TAG_FOLLOW_REQUEST }); + + try { + const { data } = await api(getState).post(`/api/v1/tags/${tagId}/follow`); + dispatch({ + type: TAG_FOLLOW_SUCCESS, + tag: data, + }); + + } catch (err) { + dispatch({ + type: TAG_FOLLOW_FAIL, + err, + }); + } +}; + +const unfollowTag = (tagId: string) => async(dispatch: AppDispatch, getState: () => RootState) => { + if (!isLoggedIn(getState)) return; + + const state = getState(); + const features = getFeatures(state.instance); + + if (!features.followTags) return; + + dispatch({ type: TAG_UNFOLLOW_REQUEST }); + + try { + const { data } = await api(getState).post(`/api/v1/tags/${tagId}/unfollow`); + dispatch({ + type: TAG_UNFOLLOW_SUCCESS, + tag: data, + }); + + } catch (err) { + dispatch({ + type: TAG_UNFOLLOW_FAIL, + err, + }); + } +}; + +export { + fetchTags, + TAG_FETCH_FAIL, + TAG_FETCH_REQUEST, + TAG_FETCH_SUCCESS, + + followTag, + TAG_FOLLOW_FAIL, + TAG_FOLLOW_REQUEST, + TAG_FOLLOW_SUCCESS, + + unfollowTag, + TAG_UNFOLLOW_FAIL, + TAG_UNFOLLOW_REQUEST, + TAG_UNFOLLOW_SUCCESS, +}; \ No newline at end of file diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index a9d23f891..cc83d1dc0 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -22,6 +22,7 @@ const messages = defineMessages({ settings: { id: 'tabs_bar.settings', defaultMessage: 'Settings' }, direct: { id: 'column.direct', defaultMessage: 'Direct messages' }, directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' }, + tags: { id: 'navigation_bar.tags', defaultMessage: 'Hashtags' }, }); /** Desktop sidebar with links to different views in the app. */ @@ -79,6 +80,14 @@ const SidebarNavigation = () => { }); } + if (features.followTags) { + menu.push({ + to: '/followed_hashtags', + text: intl.formatMessage(messages.tags), + icon: require('@tabler/icons/hash.svg'), + }); + } + if (features.profileDirectory) { menu.push({ to: '/directory', diff --git a/app/soapbox/components/sidebar_menu.tsx b/app/soapbox/components/sidebar_menu.tsx index b464d111e..f0b6f64e5 100644 --- a/app/soapbox/components/sidebar_menu.tsx +++ b/app/soapbox/components/sidebar_menu.tsx @@ -38,6 +38,7 @@ const messages = defineMessages({ direct: { id: 'column.direct', defaultMessage: 'Direct messages' }, directory: { id: 'navigation_bar.profile_directory', defaultMessage: 'Profile directory' }, dashboard: { id: 'tabs_bar.dashboard', defaultMessage: 'Dashboard' }, + tags: { id: 'navigation_bar.tags', defaultMessage: 'Hashtags' }, }); interface ISidebarLink { @@ -243,6 +244,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { /> )} + {features.followTags && ( + + )} + {features.profileDirectory && ( = ({ title }): JSX.Element => ( - {title} + {title} ); /** A card's body. */ diff --git a/app/soapbox/components/ui/icon-button/icon-button.tsx b/app/soapbox/components/ui/icon-button/icon-button.tsx index 6f0eb869e..9402c1280 100644 --- a/app/soapbox/components/ui/icon-button/icon-button.tsx +++ b/app/soapbox/components/ui/icon-button/icon-button.tsx @@ -10,7 +10,7 @@ interface IIconButton extends React.ButtonHTMLAttributes { /** URL to the svg icon. */ src: string, /** Text to display next ot the button. */ - text?: string, + text?: React.ReactNode, /** Don't render a background behind the icon. */ transparent?: boolean, /** Predefined styles to display for the button. */ diff --git a/app/soapbox/features/followed_tags/index.tsx b/app/soapbox/features/followed_tags/index.tsx new file mode 100644 index 000000000..e6ca0af2d --- /dev/null +++ b/app/soapbox/features/followed_tags/index.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { fetchTags, followTag, unfollowTag } from 'soapbox/actions/tags'; +import Icon from 'soapbox/components/icon'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Button, Column, IconButton, Spinner, Text } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +const messages = defineMessages({ + heading: { id: 'column.tags', defaultMessage: 'Followed hashtags' }, +}); + +interface IFollowButton { + id: string, + } + +const FollowButton: React.FC = ({ id }) => { + const { isFollow } = useAppSelector(state => ({ isFollow: state.tags.list.find((t) => t.name === id) })); + const dispatch = useAppDispatch(); + + const onClick = React.useCallback(() => { + const action = isFollow ? unfollowTag : followTag; + dispatch(action(id)); + }, [isFollow, id]); + + const text = React.useMemo(() => { + return isFollow ? ( + + + ) : ( + + ); + }, [isFollow]); + + + return ( + + ); +}; + +const FollowedHashtags = () => { + const intl = useIntl(); + const [tags, setTags] = React.useState(null); + const dispatch = useAppDispatch(); + + const { tags: serverTags, loading } = useAppSelector((state) => ({ tags: state.tags.list, loading: state.tags.loading })); + + // we want to keep our own list to allow user to refollow instantly if unfollow was a mistake + React.useEffect(() => { + if (loading || tags) return; + setTags(serverTags); + }, [serverTags, tags, loading]); + + React.useEffect(() => { + dispatch(fetchTags()); + }, [dispatch]); + + + return ( + + { + !tags ? ( + + ) : ( + } + > + { + tags?.map((tag) => ( +
+
+ + + { tag.name } + +
+
+ + | + +
+
+ )) + } +
+ ) + } + +
+ ); +}; + +export default FollowedHashtags; diff --git a/app/soapbox/features/hashtag_timeline/index.js b/app/soapbox/features/hashtag_timeline/index.js deleted file mode 100644 index d1611a8c8..000000000 --- a/app/soapbox/features/hashtag_timeline/index.js +++ /dev/null @@ -1,130 +0,0 @@ -import isEqual from 'lodash/isEqual'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { connectHashtagStream } from '../../actions/streaming'; -import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines'; -import ColumnHeader from '../../components/column_header'; -import { Column } from '../../components/ui'; -import Timeline from '../ui/components/timeline'; - -const mapStateToProps = (state, props) => ({ - hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, -}); - -export default @connect(mapStateToProps) -class HashtagTimeline extends React.PureComponent { - - disconnects = []; - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - hasUnread: PropTypes.bool, - }; - - title = () => { - const title = [`#${this.props.params.id}`]; - - // TODO: wtf is all this? - // It exists in Mastodon's codebase, but undocumented - if (this.additionalFor('any')) { - title.push(' ', ); - } - - if (this.additionalFor('all')) { - title.push(' ', ); - } - - if (this.additionalFor('none')) { - title.push(' ', ); - } - - return title; - } - - // TODO: wtf is this? - // It exists in Mastodon's codebase, but undocumented - additionalFor = (mode) => { - const { tags } = this.props.params; - - if (tags && (tags[mode] || []).length > 0) { - return tags[mode].map(tag => tag.value).join('/'); - } else { - return ''; - } - } - - _subscribe(dispatch, id, tags = {}) { - const any = (tags.any || []).map(tag => tag.value); - const all = (tags.all || []).map(tag => tag.value); - const none = (tags.none || []).map(tag => tag.value); - - [id, ...any].map(tag => { - this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => { - const tags = status.tags.map(tag => tag.name); - - return all.filter(tag => tags.includes(tag)).length === all.length && - none.filter(tag => tags.includes(tag)).length === 0; - }))); - }); - } - - _unsubscribe() { - this.disconnects.map(disconnect => disconnect()); - this.disconnects = []; - } - - componentDidMount() { - const { dispatch } = this.props; - const { id, tags } = this.props.params; - - this._subscribe(dispatch, id, tags); - dispatch(expandHashtagTimeline(id, { tags })); - } - - componentDidUpdate(prevProps) { - const { dispatch } = this.props; - const { id, tags } = this.props.params; - const { id: prevId, tags: prevTags } = prevProps.params; - - if (id !== prevId || !isEqual(tags, prevTags)) { - this._unsubscribe(); - this._subscribe(dispatch, id, tags); - dispatch(clearTimeline(`hashtag:${id}`)); - dispatch(expandHashtagTimeline(id, { tags })); - } - } - - componentWillUnmount() { - this._unsubscribe(); - } - - handleLoadMore = maxId => { - const { id, tags } = this.props.params; - this.props.dispatch(expandHashtagTimeline(id, { maxId, tags })); - } - - render() { - const { hasUnread } = this.props; - const { id } = this.props.params; - - return ( - -
- -
- } - divideType='space' - /> -
- ); - } - -} diff --git a/app/soapbox/features/hashtag_timeline/index.tsx b/app/soapbox/features/hashtag_timeline/index.tsx new file mode 100644 index 000000000..80adebed4 --- /dev/null +++ b/app/soapbox/features/hashtag_timeline/index.tsx @@ -0,0 +1,156 @@ +import isEqual from 'lodash/isEqual'; +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useParams } from 'react-router-dom'; + +import { unfollowTag, followTag } from 'soapbox/actions/tags'; +import Icon from 'soapbox/components/icon'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import { connectHashtagStream } from '../../actions/streaming'; +import { expandHashtagTimeline, clearTimeline } from '../../actions/timelines'; +import ColumnHeader from '../../components/column_header'; +import { Button, Column, Spinner } from '../../components/ui'; +import Timeline from '../ui/components/timeline'; + +interface IFollowButton { + id: string, +} + +const FollowButton: React.FC = ({ id }) => { + const { isFollow, loading } = useAppSelector(state => ({ loading: state.tags.loading, isFollow: state.tags.list.find((t) => t.name === id) })); + const dispatch = useAppDispatch(); + + const onClick = React.useCallback(() => { + const action = isFollow ? unfollowTag : followTag; + dispatch(action(id)); + }, [isFollow, id]); + + const text = React.useMemo(() => { + if (loading) return ; + return isFollow ? ( + + + ) : ( + + ); + }, [loading, isFollow]); + + + return ( + + ); +}; + + +const HashtagTimeline: React.FC = () => { + const dispatch = useAppDispatch(); + const disconnects = React.useRef([]); + const { id, tags } = useParams<{ id: string, tags: any }>(); + const prevParams = React.useRef({ id, tags }); + + const hasUnread = useAppSelector((state) => (state.getIn(['timelines', `hashtag:${id}`, 'unread']) as number) > 0); + + // TODO: wtf is this? + // It exists in Mastodon's codebase, but undocumented + const additionalFor = React.useCallback((mode) => { + if (tags && (tags[mode] || []).length > 0) { + return tags[mode].map(tag => tag.value).join('/'); + } else { + return ''; + } + }, [tags]); + + + const title = React.useMemo(() => { + const t: React.ReactNode[] = [`#${id}`]; + + // TODO: wtf is all this? + // It exists in Mastodon's codebase, but undocumented + if (additionalFor('any')) { + t.push(' ', ); + } + + if (additionalFor('all')) { + t.push(' ', ); + } + + if (additionalFor('none')) { + t.push(' ', ); + } + + return t; + }, [id, additionalFor]); + + + const subscribe = React.useCallback((dispatch, _id, tags = {}) => { + const any = (tags.any || []).map(tag => tag.value); + const all = (tags.all || []).map(tag => tag.value); + const none = (tags.none || []).map(tag => tag.value); + + [_id, ...any].map(tag => { + disconnects.current.push(dispatch(connectHashtagStream(_id, tag, status => { + const tags = status.tags.map(tag => tag.name); + + return all.filter(tag => tags.includes(tag)).length === all.length && + none.filter(tag => tags.includes(tag)).length === 0; + }))); + }); + }, []); + + const unsubscribe = React.useCallback(() => { + disconnects.current.map((d) => d()); + disconnects.current = []; + }, []); + + React.useEffect(() => { + const { id: prevId, tags: prevTags } = prevParams.current; + if (id !== prevId || !isEqual(tags, prevTags)) { + dispatch(clearTimeline(`hashtag:${id}`)); + } + + subscribe(dispatch, id, tags); + dispatch(expandHashtagTimeline(id, { tags })); + + return () => { + unsubscribe(); + }; + }, [dispatch, tags, id, subscribe, unsubscribe]); + + + const handleLoadMore = React.useCallback((maxId) => { + dispatch(expandHashtagTimeline(id, { maxId, tags })); + }, [dispatch, id, tags]); + + + return ( + +
+ + { title } + +
+ } + /> + + } + divideType='space' + /> +
+ ); +}; + +export default HashtagTimeline; \ No newline at end of file diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 428c6aea7..a5c199f69 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -21,6 +21,7 @@ import { register as registerPushNotifications } from 'soapbox/actions/push_noti import { fetchScheduledStatuses } from 'soapbox/actions/scheduled_statuses'; import { connectUserStream } from 'soapbox/actions/streaming'; import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions'; +import { fetchTags } from 'soapbox/actions/tags'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; import Icon from 'soapbox/components/icon'; import SidebarNavigation from 'soapbox/components/sidebar-navigation'; @@ -114,6 +115,7 @@ import { LogoutPage, AuthTokenList, ProfileFields, + FollowedHashtags, } from './util/async-components'; import { WrappedRoute } from './util/react_router_helpers'; @@ -259,6 +261,7 @@ const SwitchingColumnsArea: React.FC = ({ children }) => { {features.chats && } + {features.federating && } @@ -465,11 +468,13 @@ const UI: React.FC = ({ children }) => { setTimeout(() => dispatch(fetchFilters()), 500); + setTimeout(() => dispatch(fetchTags()), 700); + if (account.locked) { - setTimeout(() => dispatch(fetchFollowRequests()), 700); + setTimeout(() => dispatch(fetchFollowRequests()), 900); } - setTimeout(() => dispatch(fetchScheduledStatuses()), 900); + setTimeout(() => dispatch(fetchScheduledStatuses()), 1100); }; useEffect(() => { diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 990d76070..2eef38b9e 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -525,3 +525,7 @@ export function FamiliarFollowersModal() { export function AnnouncementsPanel() { return import(/* webpackChunkName: "features/announcements" */'../../../components/announcements/announcements-panel'); } + +export function FollowedHashtags() { + return import('../../../features/followed_tags'); +} diff --git a/app/soapbox/locales/fr.json b/app/soapbox/locales/fr.json index f8038ef7e..eb442ac18 100644 --- a/app/soapbox/locales/fr.json +++ b/app/soapbox/locales/fr.json @@ -224,7 +224,7 @@ "column.direct": "Messages privés", "column.directory": "Annuaire", "column.domain_blocks": "Domaines cachés", - "column.edit_profile": "Edit profile", + "column.edit_profile": "Éditer le profil", "column.export_data": "Exporter vos données", "column.familiar_followers": "People you know following {name}", "column.favourited_statuses": "Liked posts", @@ -436,7 +436,7 @@ "edit_profile.fields.stranger_notifications_label": "Bloquer les notifications d'inconnus", "edit_profile.fields.website_label": "Website", "edit_profile.fields.website_placeholder": "Display a Link", - "edit_profile.header": "Edit Profile", + "edit_profile.header": "Éditer le profil", "edit_profile.hints.accepts_email_list": "Opt-in to news and marketing updates.", "edit_profile.hints.avatar": "PNG, GIF or JPG. Sera réduit à {size}", "edit_profile.hints.bot": "Ce compte réalise essentiellement des actions automatiques", @@ -645,7 +645,7 @@ "lists.edit": "Éditer la liste", "lists.edit.submit": "Changer le titre", "lists.new.create": "Ajouter une liste", - "lists.new.create_title": "Create", + "lists.new.create_title": "Créer", "lists.new.save_title": "Save Title", "lists.new.title_placeholder": "Titre de la nouvelle liste", "lists.search": "Rechercher parmi les gens que vous suivez", diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index 87750381b..98d229f59 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -58,6 +58,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'; @@ -124,6 +125,7 @@ const reducers = { rules, history, announcements, + 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 000000000..7beac7c43 --- /dev/null +++ b/app/soapbox/reducers/tags.ts @@ -0,0 +1,57 @@ +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; + +import { TAG_FETCH_FAIL, TAG_FETCH_REQUEST, TAG_FETCH_SUCCESS, TAG_FOLLOW_FAIL, TAG_FOLLOW_REQUEST, TAG_FOLLOW_SUCCESS, TAG_UNFOLLOW_FAIL, TAG_UNFOLLOW_REQUEST, TAG_UNFOLLOW_SUCCESS } from 'soapbox/actions/tags'; +import { normalizeTag } from 'soapbox/normalizers'; + +import type { AnyAction } from 'redux'; +import type { APIEntity, Tag } from 'soapbox/types/entities'; + +const TagRecord = ImmutableRecord({ + list: ImmutableList(), + loading: true, +}); + +type State = ReturnType; + +const importTags = (state: State, tags: APIEntity[]): State => { + return state.withMutations((s) => { + s.set('list', ImmutableList(tags.map((tag) => normalizeTag(tag)))); + s.set('loading', false); + }); +}; + +const addTag = (state: State, tag: APIEntity): State => { + return state.withMutations((s) => { + s.set('list', state.list.push(normalizeTag(tag))); + s.set('loading', false); + }); +}; + +const removeTag = (state: State, _tag: APIEntity): State => { + const tag = normalizeTag(_tag); + return state.withMutations((s) => { + s.set('list', state.list.filter((t) => t.name !== tag.name)); + s.set('loading', false); + }); +}; + +export default function filters(state = TagRecord(), action: AnyAction): State { + switch (action.type) { + case TAG_FETCH_REQUEST: + case TAG_FOLLOW_REQUEST: + case TAG_UNFOLLOW_REQUEST: + return state.set('loading', true); + case TAG_FETCH_SUCCESS: + return importTags(state, action.tags); + case TAG_FOLLOW_SUCCESS: + return addTag(state, action.tag); + case TAG_UNFOLLOW_SUCCESS: + return removeTag(state, action.tag); + case TAG_FETCH_FAIL: + case TAG_FOLLOW_FAIL: + case TAG_UNFOLLOW_FAIL: + return state.set('loading', false); + default: + return state; + } +} diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 45b74dfe3..6f4064b70 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -341,6 +341,15 @@ const getInstanceFeatures = (instance: Instance) => { (v.software === PLEROMA || v.software === AKKOMA), ]), + /** + * Can follow hashtag and show tags in home timeline + * @see GET /api/v1/followed_tags + */ + followTags: any([ + v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + v.software === AKKOMA, + ]), + /** * Whether client settings can be retrieved from the API. * @see GET /api/pleroma/frontend_configurations