mirror of
https://github.com/mastodon/mastodon.git
synced 2025-09-06 09:51:24 +00:00
fix: Update hashtags when (un)following a hashtag (#35101)
This commit is contained in:
parent
d28a4428b5
commit
b9b1500fc5
|
@ -1,12 +1,30 @@
|
||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
apiGetTag,
|
apiGetTag,
|
||||||
apiFollowTag,
|
apiFollowTag,
|
||||||
apiUnfollowTag,
|
apiUnfollowTag,
|
||||||
apiFeatureTag,
|
apiFeatureTag,
|
||||||
apiUnfeatureTag,
|
apiUnfeatureTag,
|
||||||
|
apiGetFollowedTags,
|
||||||
} from 'mastodon/api/tags';
|
} from 'mastodon/api/tags';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
|
export const fetchFollowedHashtags = createDataLoadingThunk(
|
||||||
|
'tags/fetch-followed',
|
||||||
|
async ({ next }: { next?: string } = {}) => {
|
||||||
|
const response = await apiGetFollowedTags(next);
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
replace: !next,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const markFollowedHashtagsStale = createAction(
|
||||||
|
'tags/mark-followed-stale',
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchHashtag = createDataLoadingThunk(
|
export const fetchHashtag = createDataLoadingThunk(
|
||||||
'tags/fetch',
|
'tags/fetch',
|
||||||
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
||||||
|
@ -15,6 +33,9 @@ export const fetchHashtag = createDataLoadingThunk(
|
||||||
export const followHashtag = createDataLoadingThunk(
|
export const followHashtag = createDataLoadingThunk(
|
||||||
'tags/follow',
|
'tags/follow',
|
||||||
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
||||||
|
(_, { dispatch }) => {
|
||||||
|
void dispatch(markFollowedHashtagsStale());
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const unfollowHashtag = createDataLoadingThunk(
|
export const unfollowHashtag = createDataLoadingThunk(
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
@ -7,8 +7,10 @@ import { Helmet } from 'react-helmet';
|
||||||
import { isFulfilled } from '@reduxjs/toolkit';
|
import { isFulfilled } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||||
import { unfollowHashtag } from 'mastodon/actions/tags_typed';
|
import {
|
||||||
import { apiGetFollowedTags } from 'mastodon/api/tags';
|
fetchFollowedHashtags,
|
||||||
|
unfollowHashtag,
|
||||||
|
} from 'mastodon/actions/tags_typed';
|
||||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
|
@ -16,7 +18,7 @@ import type { ColumnRef } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { Hashtag } from 'mastodon/components/hashtag';
|
import { Hashtag } from 'mastodon/components/hashtag';
|
||||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||||
|
@ -59,55 +61,32 @@ const FollowedTag: React.FC<{
|
||||||
|
|
||||||
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
const dispatch = useAppDispatch();
|
||||||
const [loading, setLoading] = useState(false);
|
const { tags, loading, next, stale } = useAppSelector(
|
||||||
const [next, setNext] = useState<string | undefined>();
|
(state) => state.followedTags,
|
||||||
|
);
|
||||||
const hasMore = !!next;
|
const hasMore = !!next;
|
||||||
const columnRef = useRef<ColumnRef>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
if (stale) {
|
||||||
|
void dispatch(fetchFollowedHashtags());
|
||||||
void apiGetFollowedTags()
|
}
|
||||||
.then(({ tags, links }) => {
|
}, [dispatch, stale]);
|
||||||
const next = links.refs.find((link) => link.rel === 'next');
|
|
||||||
|
|
||||||
setTags(tags);
|
|
||||||
setLoading(false);
|
|
||||||
setNext(next?.uri);
|
|
||||||
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [setTags, setLoading, setNext]);
|
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
setLoading(true);
|
if (next) {
|
||||||
|
void dispatch(fetchFollowedHashtags({ next }));
|
||||||
void apiGetFollowedTags(next)
|
}
|
||||||
.then(({ tags, links }) => {
|
}, [dispatch, next]);
|
||||||
const next = links.refs.find((link) => link.rel === 'next');
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
setTags((previousTags) => [...previousTags, ...tags]);
|
|
||||||
setNext(next?.uri);
|
|
||||||
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [setTags, setLoading, setNext, next]);
|
|
||||||
|
|
||||||
const handleUnfollow = useCallback(
|
const handleUnfollow = useCallback(
|
||||||
(tagId: string) => {
|
(tagId: string) => {
|
||||||
setTags((tags) => tags.filter((tag) => tag.name !== tagId));
|
void dispatch(unfollowHashtag({ tagId }));
|
||||||
},
|
},
|
||||||
[setTags],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const columnRef = useRef<ColumnRef>(null);
|
||||||
const handleHeaderClick = useCallback(() => {
|
const handleHeaderClick = useCallback(() => {
|
||||||
columnRef.current?.scrollTop();
|
columnRef.current?.scrollTop();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||||
import { apiGetFollowedTags } from 'mastodon/api/tags';
|
import { fetchFollowedHashtags } from 'mastodon/actions/tags_typed';
|
||||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
|
||||||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import { CollapsiblePanel } from './collapsible_panel';
|
import { CollapsiblePanel } from './collapsible_panel';
|
||||||
|
|
||||||
|
@ -24,25 +24,20 @@ const messages = defineMessages({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const TAG_LIMIT = 4;
|
||||||
|
|
||||||
export const FollowedTagsPanel: React.FC = () => {
|
export const FollowedTagsPanel: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
const dispatch = useAppDispatch();
|
||||||
const [loading, setLoading] = useState(false);
|
const { tags, stale, loading } = useAppSelector(
|
||||||
|
(state) => state.followedTags,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
if (stale) {
|
||||||
|
void dispatch(fetchFollowedHashtags());
|
||||||
void apiGetFollowedTags(undefined, 4)
|
}
|
||||||
.then(({ tags }) => {
|
}, [dispatch, stale]);
|
||||||
setTags(tags);
|
|
||||||
setLoading(false);
|
|
||||||
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, [setLoading, setTags]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsiblePanel
|
<CollapsiblePanel
|
||||||
|
@ -54,14 +49,14 @@ export const FollowedTagsPanel: React.FC = () => {
|
||||||
expandTitle={intl.formatMessage(messages.expand)}
|
expandTitle={intl.formatMessage(messages.expand)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
{tags.map((tag) => (
|
{tags.slice(0, TAG_LIMIT).map((tag) => (
|
||||||
<ColumnLink
|
<ColumnLink
|
||||||
|
transparent
|
||||||
icon='hashtag'
|
icon='hashtag'
|
||||||
key={tag.name}
|
key={tag.name}
|
||||||
iconComponent={TagIcon}
|
iconComponent={TagIcon}
|
||||||
text={`#${tag.name}`}
|
text={`#${tag.name}`}
|
||||||
to={`/tags/${tag.name}`}
|
to={`/tags/${tag.name}`}
|
||||||
transparent
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsiblePanel>
|
</CollapsiblePanel>
|
||||||
|
|
|
@ -16,7 +16,6 @@ export const ColumnLink: React.FC<{
|
||||||
method?: string;
|
method?: string;
|
||||||
badge?: React.ReactNode;
|
badge?: React.ReactNode;
|
||||||
transparent?: boolean;
|
transparent?: boolean;
|
||||||
optional?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
}> = ({
|
}> = ({
|
||||||
|
@ -30,13 +29,11 @@ export const ColumnLink: React.FC<{
|
||||||
method,
|
method,
|
||||||
badge,
|
badge,
|
||||||
transparent,
|
transparent,
|
||||||
optional,
|
|
||||||
...other
|
...other
|
||||||
}) => {
|
}) => {
|
||||||
const match = useRouteMatch(to ?? '');
|
const match = useRouteMatch(to ?? '');
|
||||||
const className = classNames('column-link', {
|
const className = classNames('column-link', {
|
||||||
'column-link--transparent': transparent,
|
'column-link--transparent': transparent,
|
||||||
'column-link--optional': optional,
|
|
||||||
});
|
});
|
||||||
const badgeElement =
|
const badgeElement =
|
||||||
typeof badge !== 'undefined' ? (
|
typeof badge !== 'undefined' ? (
|
||||||
|
|
|
@ -36,6 +36,7 @@ import settings from './settings';
|
||||||
import status_lists from './status_lists';
|
import status_lists from './status_lists';
|
||||||
import statuses from './statuses';
|
import statuses from './statuses';
|
||||||
import { suggestionsReducer } from './suggestions';
|
import { suggestionsReducer } from './suggestions';
|
||||||
|
import { followedTagsReducer } from './tags';
|
||||||
import timelines from './timelines';
|
import timelines from './timelines';
|
||||||
import trends from './trends';
|
import trends from './trends';
|
||||||
import user_lists from './user_lists';
|
import user_lists from './user_lists';
|
||||||
|
@ -67,6 +68,7 @@ const reducers = {
|
||||||
height_cache,
|
height_cache,
|
||||||
custom_emojis,
|
custom_emojis,
|
||||||
lists: listsReducer,
|
lists: listsReducer,
|
||||||
|
followedTags: followedTagsReducer,
|
||||||
filters,
|
filters,
|
||||||
conversations,
|
conversations,
|
||||||
suggestions: suggestionsReducer,
|
suggestions: suggestionsReducer,
|
||||||
|
|
48
app/javascript/mastodon/reducers/tags.ts
Normal file
48
app/javascript/mastodon/reducers/tags.ts
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { createReducer } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchFollowedHashtags,
|
||||||
|
markFollowedHashtagsStale,
|
||||||
|
unfollowHashtag,
|
||||||
|
} from 'mastodon/actions/tags_typed';
|
||||||
|
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||||
|
|
||||||
|
export interface TagsQuery {
|
||||||
|
tags: ApiHashtagJSON[];
|
||||||
|
loading: boolean;
|
||||||
|
stale: boolean;
|
||||||
|
next: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: TagsQuery = {
|
||||||
|
tags: [],
|
||||||
|
loading: false,
|
||||||
|
stale: true,
|
||||||
|
next: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const followedTagsReducer = createReducer(initialState, (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchFollowedHashtags.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
})
|
||||||
|
.addCase(fetchFollowedHashtags.rejected, (state) => {
|
||||||
|
state.loading = false;
|
||||||
|
})
|
||||||
|
.addCase(markFollowedHashtagsStale, (state) => {
|
||||||
|
state.stale = true;
|
||||||
|
})
|
||||||
|
.addCase(unfollowHashtag.fulfilled, (state, action) => {
|
||||||
|
const tagId = action.payload.id;
|
||||||
|
state.tags = state.tags.filter((tag) => tag.id !== tagId);
|
||||||
|
})
|
||||||
|
.addCase(fetchFollowedHashtags.fulfilled, (state, action) => {
|
||||||
|
const { tags, links, replace } = action.payload;
|
||||||
|
const next = links.refs.find((link) => link.rel === 'next');
|
||||||
|
|
||||||
|
state.tags = replace ? tags : [...state.tags, ...tags];
|
||||||
|
state.next = next?.uri;
|
||||||
|
state.stale = false;
|
||||||
|
state.loading = false;
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user