diff --git a/app/soapbox/components/hashtag.tsx b/app/soapbox/components/hashtag.tsx index 8206f3f62..ff2156a1f 100644 --- a/app/soapbox/components/hashtag.tsx +++ b/app/soapbox/components/hashtag.tsx @@ -10,16 +10,14 @@ import { shortNumberFormat } from '../utils/numbers'; import Permalink from './permalink'; import { HStack, Stack, Text } from './ui'; -import type { Hashtag as HashtagEntity } from 'soapbox/reducers/search'; -import type { TrendingHashtag } from 'soapbox/reducers/trends'; +import type { Tag } from 'soapbox/types/entities'; interface IHashtag { - hashtag: HashtagEntity | TrendingHashtag, + hashtag: Tag, } const Hashtag: React.FC = ({ hashtag }) => { - const history = (hashtag as TrendingHashtag).history; - const count = Number(history?.get(0)?.accounts); + const count = Number(hashtag.history?.get(0)?.accounts); const brandColor = useSelector((state) => getSoapboxConfig(state).brandColor); return ( @@ -29,7 +27,7 @@ const Hashtag: React.FC = ({ hashtag }) => { #{hashtag.name} - {history && ( + {hashtag.history && ( = ({ hashtag }) => { )} - {history && ( + {hashtag.history && (
+day.uses).toArray()} + data={hashtag.history.reverse().map((day) => +day.uses).toArray()} > diff --git a/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx b/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx index 6701c928d..03ae48553 100644 --- a/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx +++ b/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx @@ -1,22 +1,26 @@ -import { List as ImmutableList } from 'immutable'; +import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; import React from 'react'; import { render, screen } from '../../../../jest/test-helpers'; +import { normalizeTag } from '../../../../normalizers'; import TrendsPanel from '../trends-panel'; describe('', () => { it('renders trending hashtags', () => { const store = { - trends: { - items: ImmutableList([{ - name: 'hashtag 1', - history: ImmutableList([{ - day: '1652745600', - uses: '294', - accounts: '180', - }]), - }]), - }, + trends: ImmutableRecord({ + items: ImmutableList([ + normalizeTag({ + name: 'hashtag 1', + history: [{ + day: '1652745600', + uses: '294', + accounts: '180', + }], + }), + ]), + isLoading: false, + })(), }; render(, null, store); @@ -27,18 +31,19 @@ describe('', () => { it('renders multiple trends', () => { const store = { - trends: { + trends: ImmutableRecord({ items: ImmutableList([ - { + normalizeTag({ name: 'hashtag 1', history: ImmutableList([{ accounts: [] }]), - }, - { + }), + normalizeTag({ name: 'hashtag 2', history: ImmutableList([{ accounts: [] }]), - }, + }), ]), - }, + isLoading: false, + })(), }; render(, null, store); @@ -47,18 +52,19 @@ describe('', () => { it('respects the limit prop', () => { const store = { - trends: { + trends: ImmutableRecord({ items: ImmutableList([ - { + normalizeTag({ name: 'hashtag 1', - history: ImmutableList([{ accounts: [] }]), - }, - { + history: [{ accounts: [] }], + }), + normalizeTag({ name: 'hashtag 2', - history: ImmutableList([{ accounts: [] }]), - }, + history: [{ accounts: [] }], + }), ]), - }, + isLoading: false, + })(), }; render(, null, store); @@ -67,9 +73,10 @@ describe('', () => { it('renders empty', () => { const store = { - trends: { + trends: ImmutableRecord({ items: ImmutableList([]), - }, + isLoading: false, + })(), }; render(, null, store); diff --git a/app/soapbox/features/ui/components/modals/report-modal/__tests__/report-modal.test.tsx b/app/soapbox/features/ui/components/modals/report-modal/__tests__/report-modal.test.tsx index 28c36e58f..1fe62d7f2 100644 --- a/app/soapbox/features/ui/components/modals/report-modal/__tests__/report-modal.test.tsx +++ b/app/soapbox/features/ui/components/modals/report-modal/__tests__/report-modal.test.tsx @@ -1,5 +1,5 @@ import userEvent from '@testing-library/user-event'; -import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; +import { Map as ImmutableMap, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable'; import React from 'react'; import { __stub } from 'soapbox/api'; @@ -24,13 +24,13 @@ describe('', () => { avatar: 'test.jpg', }), }), - reports: ImmutableMap({ - new: { + reports: ImmutableRecord({ + new: ImmutableRecord({ account_id: '1', status_ids: ImmutableSet(['1']), rule_ids: ImmutableSet(), - }, - }), + })(), + })(), statuses: ImmutableMap({ '1': normalizeStatus(status), }), diff --git a/app/soapbox/features/ui/components/trends-panel.tsx b/app/soapbox/features/ui/components/trends-panel.tsx index cc8d78392..49b68887d 100644 --- a/app/soapbox/features/ui/components/trends-panel.tsx +++ b/app/soapbox/features/ui/components/trends-panel.tsx @@ -14,7 +14,7 @@ interface ITrendsPanel { const TrendsPanel = ({ limit }: ITrendsPanel) => { const dispatch = useDispatch(); - const trends = useAppSelector((state) => state.trends.get('items')); + const trends = useAppSelector((state) => state.trends.items); const sortedTrends = React.useMemo(() => { return trends.sort((a, b) => { diff --git a/app/soapbox/normalizers/history.ts b/app/soapbox/normalizers/history.ts new file mode 100644 index 000000000..9afe13c3a --- /dev/null +++ b/app/soapbox/normalizers/history.ts @@ -0,0 +1,22 @@ +/** + * History normalizer: + * Converts API daily usage history of a hashtag into our internal format. + * @see {@link https://docs.joinmastodon.org/entities/history/} + */ +import { + Map as ImmutableMap, + Record as ImmutableRecord, + fromJS, +} from 'immutable'; + +// https://docs.joinmastodon.org/entities/history/ +export const HistoryRecord = ImmutableRecord({ + accounts: '', + day: '', + uses: '', +}); +export const normalizeHistory = (history: Record) => { + return HistoryRecord( + ImmutableMap(fromJS(history)), + ); +}; diff --git a/app/soapbox/normalizers/index.ts b/app/soapbox/normalizers/index.ts index e408c74d7..d660df0f2 100644 --- a/app/soapbox/normalizers/index.ts +++ b/app/soapbox/normalizers/index.ts @@ -6,6 +6,7 @@ export { CardRecord, normalizeCard } from './card'; export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat_message'; export { EmojiRecord, normalizeEmoji } from './emoji'; +export { HistoryRecord, normalizeHistory } from './history'; export { InstanceRecord, normalizeInstance } from './instance'; export { ListRecord, normalizeList } from './list'; export { MentionRecord, normalizeMention } from './mention'; @@ -14,5 +15,6 @@ export { PollRecord, PollOptionRecord, normalizePoll } from './poll'; export { RelationshipRecord, normalizeRelationship } from './relationship'; export { StatusRecord, normalizeStatus } from './status'; export { StatusEditRecord, normalizeStatusEdit } from './status_edit'; +export { TagRecord, normalizeTag } from './tag'; export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox_config'; diff --git a/app/soapbox/normalizers/tag.ts b/app/soapbox/normalizers/tag.ts new file mode 100644 index 000000000..6d0ebae14 --- /dev/null +++ b/app/soapbox/normalizers/tag.ts @@ -0,0 +1,40 @@ +/** + * Tag normalizer: + * Converts API tags into our internal format. + * @see {@link https://docs.joinmastodon.org/entities/tag/} + */ +import { + List as ImmutableList, + Map as ImmutableMap, + Record as ImmutableRecord, + fromJS, +} from 'immutable'; + +import { normalizeHistory } from './history'; + +import type { History } from 'soapbox/types/entities'; + +// https://docs.joinmastodon.org/entities/tag/ +export const TagRecord = ImmutableRecord({ + name: '', + url: '', + history: null as ImmutableList | null, +}); + +const normalizeHistoryList = (tag: ImmutableMap) => { + if (tag.get('history')){ + return tag.update('history', ImmutableList(), attachments => { + return attachments.map(normalizeHistory); + }); + } else { + return tag.set('history', null); + } +}; + +export const normalizeTag = (tag: Record) => { + return TagRecord( + ImmutableMap(fromJS(tag)).withMutations(tag => { + normalizeHistoryList(tag); + }), + ); +}; diff --git a/app/soapbox/reducers/__tests__/search-test.js b/app/soapbox/reducers/__tests__/search-test.js index b1138b663..aac06f4b4 100644 --- a/app/soapbox/reducers/__tests__/search-test.js +++ b/app/soapbox/reducers/__tests__/search-test.js @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord } from 'immutable'; import { SEARCH_CHANGE, @@ -10,14 +10,24 @@ import reducer from '../search'; describe('search reducer', () => { it('should return the initial state', () => { - expect(reducer(undefined, {})).toEqual(ImmutableMap({ + expect(reducer(undefined, {}).toJS()).toEqual({ value: '', submitted: false, submittedValue: '', hidden: false, - results: ImmutableMap(), + results: { + accounts: [], + statuses: [], + hashtags: [], + accountsHasMore: false, + statusesHasMore: false, + hashtagsHasMore: false, + accountsLoaded: false, + statusesLoaded: false, + hashtagsLoaded: false, + }, filter: 'accounts', - })); + }); }); describe('SEARCH_CHANGE', () => { @@ -30,42 +40,54 @@ describe('search reducer', () => { describe('SEARCH_CLEAR', () => { it('resets the state', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ value: 'hello world', submitted: true, submittedValue: 'hello world', hidden: false, - results: ImmutableMap(), + results: ImmutableRecord({})(), filter: 'statuses', - }); + })(); const action = { type: SEARCH_CLEAR }; - const expected = ImmutableMap({ + const expected = { value: '', submitted: false, submittedValue: '', hidden: false, - results: ImmutableMap(), + results: { + accounts: [], + statuses: [], + hashtags: [], + accountsHasMore: false, + statusesHasMore: false, + hashtagsHasMore: false, + accountsLoaded: false, + statusesLoaded: false, + hashtagsLoaded: false, + }, filter: 'accounts', - }); + }; - expect(reducer(state, action)).toEqual(expected); + expect(reducer(state, action).toJS()).toEqual(expected); }); }); describe(SEARCH_EXPAND_SUCCESS, () => { it('imports hashtags as maps', () => { - const state = ImmutableMap({ + const state = ImmutableRecord({ value: 'artist', submitted: true, submittedValue: 'artist', hidden: false, - results: ImmutableMap({ + results: ImmutableRecord({ hashtags: ImmutableList(), - }), + hashtagsHasMore: false, + hashtagsLoaded: true, + })(), filter: 'hashtags', - }); + })(); const action = { type: SEARCH_EXPAND_SUCCESS, @@ -82,24 +104,26 @@ describe('search reducer', () => { searchType: 'hashtags', }; - const expected = ImmutableMap({ + const expected = { value: 'artist', submitted: true, submittedValue: 'artist', hidden: false, - results: ImmutableMap({ - hashtags: fromJS([{ - name: 'artist', - url: 'https://gleasonator.com/tags/artist', - history: [], - }]), + results: { + hashtags: [ + { + name: 'artist', + url: 'https://gleasonator.com/tags/artist', + history: [], + }, + ], hashtagsHasMore: false, hashtagsLoaded: true, - }), + }, filter: 'hashtags', - }); + }; - expect(reducer(state, action)).toEqual(expected); + expect(reducer(state, action).toJS()).toEqual(expected); }); }); }); diff --git a/app/soapbox/reducers/search.ts b/app/soapbox/reducers/search.ts index 8941b7fcf..cf76797e2 100644 --- a/app/soapbox/reducers/search.ts +++ b/app/soapbox/reducers/search.ts @@ -1,6 +1,6 @@ import { OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord, fromJS } from 'immutable'; -import { APIEntity } from 'soapbox/types/entities'; +import { normalizeTag } from 'soapbox/normalizers'; import { COMPOSE_MENTION, @@ -20,16 +20,12 @@ import { } from '../actions/search'; import type { AnyAction } from 'redux'; - -const HashtagRecord = ImmutableRecord({ - name: '', - url: '', -}); +import type { APIEntity, Tag } from 'soapbox/types/entities'; const ResultsRecord = ImmutableRecord({ accounts: ImmutableOrderedSet(), statuses: ImmutableOrderedSet(), - hashtags: ImmutableOrderedSet(), // it's a list of maps + hashtags: ImmutableOrderedSet(), // it's a list of maps accountsHasMore: false, statusesHasMore: false, hashtagsHasMore: false, @@ -49,7 +45,6 @@ const ReducerRecord = ImmutableRecord({ type State = ReturnType; type APIEntities = Array; -export type Hashtag = ReturnType; export type SearchFilter = 'accounts' | 'statuses' | 'hashtags'; const toIds = (items: APIEntities) => { @@ -62,7 +57,7 @@ const importResults = (state: State, results: APIEntity, searchTerm: string, sea state.set('results', ResultsRecord({ accounts: toIds(results.accounts), statuses: toIds(results.statuses), - hashtags: ImmutableOrderedSet(results.hashtags.map(HashtagRecord)), // it's a list of maps + hashtags: ImmutableOrderedSet(results.hashtags.map(normalizeTag)), // it's a list of records accountsHasMore: results.accounts.length >= 20, statusesHasMore: results.statuses.length >= 20, hashtagsHasMore: results.hashtags.length >= 20, diff --git a/app/soapbox/reducers/trends.ts b/app/soapbox/reducers/trends.ts index 2c654e4fe..c43d5b7fd 100644 --- a/app/soapbox/reducers/trends.ts +++ b/app/soapbox/reducers/trends.ts @@ -1,5 +1,7 @@ import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; +import { normalizeTag } from 'soapbox/normalizers'; + import { TRENDS_FETCH_REQUEST, TRENDS_FETCH_SUCCESS, @@ -7,28 +9,14 @@ import { } from '../actions/trends'; import type { AnyAction } from 'redux'; -import type { APIEntity } from 'soapbox/types/entities'; - -const HistoryRecord = ImmutableRecord({ - accounts: '', - day: '', - uses: '', -}); - -const TrendingHashtagRecord = ImmutableRecord({ - name: '', - url: '', - history: ImmutableList(), -}); +import type { APIEntity, Tag } from 'soapbox/types/entities'; const ReducerRecord = ImmutableRecord({ - items: ImmutableList(), + items: ImmutableList(), isLoading: false, }); type State = ReturnType; -type History = ReturnType; -export type TrendingHashtag = ReturnType; export default function trendsReducer(state: State = ReducerRecord(), action: AnyAction) { switch (action.type) { @@ -36,7 +24,7 @@ export default function trendsReducer(state: State = ReducerRecord(), action: An return state.set('isLoading', true); case TRENDS_FETCH_SUCCESS: return state.withMutations(map => { - map.set('items', ImmutableList(action.tags.map((item: APIEntity) => TrendingHashtagRecord({ ...item, history: ImmutableList(item.history.map(HistoryRecord)) })))); + map.set('items', ImmutableList(action.tags.map((item: APIEntity) => normalizeTag(item)))); map.set('isLoading', false); }); case TRENDS_FETCH_FAIL: diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index 1e8096c8d..023139cef 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -8,6 +8,7 @@ import { ChatMessageRecord, EmojiRecord, FieldRecord, + HistoryRecord, InstanceRecord, ListRecord, MentionRecord, @@ -17,6 +18,7 @@ import { RelationshipRecord, StatusEditRecord, StatusRecord, + TagRecord, } from 'soapbox/normalizers'; import type { Record as ImmutableRecord } from 'immutable'; @@ -29,6 +31,7 @@ type Chat = ReturnType; type ChatMessage = ReturnType; type Emoji = ReturnType; type Field = ReturnType; +type History = ReturnType; type Instance = ReturnType; type List = ReturnType; type Mention = ReturnType; @@ -37,6 +40,7 @@ type Poll = ReturnType; type PollOption = ReturnType; type Relationship = ReturnType; type StatusEdit = ReturnType; +type Tag = ReturnType; interface Account extends ReturnType { // HACK: we can't do a circular reference in the Record definition itself, @@ -64,6 +68,7 @@ export { ChatMessage, Emoji, Field, + History, Instance, List, Mention, @@ -73,6 +78,7 @@ export { Relationship, Status, StatusEdit, + Tag, // Utility types APIEntity,