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