diff --git a/src/actions/admin.ts b/src/actions/admin.ts index c5eb1150a..45b5076fb 100644 --- a/src/actions/admin.ts +++ b/src/actions/admin.ts @@ -1,19 +1,15 @@ -import { defineMessages } from 'react-intl'; import { fetchRelationships } from 'soapbox/actions/accounts'; import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer'; import { accountIdsToAccts } from 'soapbox/selectors'; -import toast from 'soapbox/toast'; import { filterBadges, getTagDiff } from 'soapbox/utils/badges'; import { getFeatures } from 'soapbox/utils/features'; import api, { getLinks } from '../api'; -import { openModal } from './modals'; - import type { AxiosResponse } from 'axios'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity, Announcement } from 'soapbox/types/entities'; +import type { APIEntity } from 'soapbox/types/entities'; const ADMIN_CONFIG_FETCH_REQUEST = 'ADMIN_CONFIG_FETCH_REQUEST'; const ADMIN_CONFIG_FETCH_SUCCESS = 'ADMIN_CONFIG_FETCH_SUCCESS'; @@ -81,35 +77,6 @@ const ADMIN_USER_INDEX_FETCH_SUCCESS = 'ADMIN_USER_INDEX_FETCH_SUCCESS'; const ADMIN_USER_INDEX_QUERY_SET = 'ADMIN_USER_INDEX_QUERY_SET'; -const ADMIN_ANNOUNCEMENTS_FETCH_FAIL = 'ADMIN_ANNOUNCEMENTS_FETCH_FAILS'; -const ADMIN_ANNOUNCEMENTS_FETCH_REQUEST = 'ADMIN_ANNOUNCEMENTS_FETCH_REQUEST'; -const ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS = 'ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS'; - -const ADMIN_ANNOUNCEMENTS_EXPAND_FAIL = 'ADMIN_ANNOUNCEMENTS_EXPAND_FAILS'; -const ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST = 'ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST'; -const ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS = 'ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS'; - -const ADMIN_ANNOUNCEMENT_CHANGE_CONTENT = 'ADMIN_ANNOUNCEMENT_CHANGE_CONTENT'; -const ADMIN_ANNOUNCEMENT_CHANGE_START_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_START_TIME'; -const ADMIN_ANNOUNCEMENT_CHANGE_END_TIME = 'ADMIN_ANNOUNCEMENT_CHANGE_END_TIME'; -const ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY = 'ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY'; - -const ADMIN_ANNOUNCEMENT_CREATE_REQUEST = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST'; -const ADMIN_ANNOUNCEMENT_CREATE_SUCCESS = 'ADMIN_ANNOUNCEMENT_CREATE_REQUEST'; -const ADMIN_ANNOUNCEMENT_CREATE_FAIL = 'ADMIN_ANNOUNCEMENT_CREATE_FAIL'; - -const ADMIN_ANNOUNCEMENT_DELETE_REQUEST = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST'; -const ADMIN_ANNOUNCEMENT_DELETE_SUCCESS = 'ADMIN_ANNOUNCEMENT_DELETE_REQUEST'; -const ADMIN_ANNOUNCEMENT_DELETE_FAIL = 'ADMIN_ANNOUNCEMENT_DELETE_FAIL'; - -const ADMIN_ANNOUNCEMENT_MODAL_INIT = 'ADMIN_ANNOUNCEMENT_MODAL_INIT'; - -const messages = defineMessages({ - announcementCreateSuccess: { id: 'admin.edit_announcement.created', defaultMessage: 'Announcement created' }, - announcementDeleteSuccess: { id: 'admin.edit_announcement.deleted', defaultMessage: 'Announcement deleted' }, - announcementUpdateSuccess: { id: 'admin.edit_announcement.updated', defaultMessage: 'Announcement edited' }, -}); - const fetchConfig = () => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST }); @@ -572,92 +539,6 @@ const expandUserIndex = () => }); }; -const fetchAdminAnnouncements = () => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_REQUEST }); - return api(getState) - .get('/api/v1/pleroma/admin/announcements', { params: { limit: 50 } }) - .then(({ data }) => { - dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, announcements: data }); - return data; - }).catch(error => { - dispatch({ type: ADMIN_ANNOUNCEMENTS_FETCH_FAIL, error }); - }); - }; - -const expandAdminAnnouncements = () => - (dispatch: AppDispatch, getState: () => RootState) => { - const page = getState().admin_announcements.page; - - dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST }); - return api(getState) - .get('/api/v1/pleroma/admin/announcements', { params: { limit: 50, offset: page * 50 } }) - .then(({ data }) => { - dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, announcements: data }); - return data; - }).catch(error => { - dispatch({ type: ADMIN_ANNOUNCEMENTS_EXPAND_FAIL, error }); - }); - }; - -const changeAnnouncementContent = (content: string) => ({ - type: ADMIN_ANNOUNCEMENT_CHANGE_CONTENT, - value: content, -}); - -const changeAnnouncementStartTime = (time: Date | null) => ({ - type: ADMIN_ANNOUNCEMENT_CHANGE_START_TIME, - value: time, -}); - -const changeAnnouncementEndTime = (time: Date | null) => ({ - type: ADMIN_ANNOUNCEMENT_CHANGE_END_TIME, - value: time, -}); - -const changeAnnouncementAllDay = (allDay: boolean) => ({ - type: ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY, - value: allDay, -}); - -const handleCreateAnnouncement = () => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_REQUEST }); - - const { id, content, starts_at, ends_at, all_day } = getState().admin_announcements.form; - - return api(getState)[id ? 'patch' : 'post']( - id ? `/api/v1/pleroma/admin/announcements/${id}` : '/api/v1/pleroma/admin/announcements', - { content, starts_at, ends_at, all_day }, - ).then(({ data }) => { - dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, announcement: data }); - toast.success(id ? messages.announcementUpdateSuccess : messages.announcementCreateSuccess); - dispatch(fetchAdminAnnouncements()); - return data; - }).catch(error => { - dispatch({ type: ADMIN_ANNOUNCEMENT_CREATE_FAIL, error }); - }); - }; - -const deleteAnnouncement = (id: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_REQUEST, id }); - - return api(getState).delete(`/api/v1/pleroma/admin/announcements/${id}`).then(({ data }) => { - dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, id }); - toast.success(messages.announcementDeleteSuccess); - dispatch(fetchAdminAnnouncements()); - return data; - }).catch(error => { - dispatch({ type: ADMIN_ANNOUNCEMENT_DELETE_FAIL, id, error }); - }); - }; - -const initAnnouncementModal = (announcement?: Announcement) => - (dispatch: AppDispatch) => { - dispatch({ type: ADMIN_ANNOUNCEMENT_MODAL_INIT, announcement }); - dispatch(openModal('EDIT_ANNOUNCEMENT')); - }; export { ADMIN_CONFIG_FETCH_REQUEST, @@ -709,23 +590,6 @@ export { ADMIN_USER_INDEX_FETCH_REQUEST, ADMIN_USER_INDEX_FETCH_SUCCESS, ADMIN_USER_INDEX_QUERY_SET, - ADMIN_ANNOUNCEMENTS_FETCH_FAIL, - ADMIN_ANNOUNCEMENTS_FETCH_REQUEST, - ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, - ADMIN_ANNOUNCEMENTS_EXPAND_FAIL, - ADMIN_ANNOUNCEMENTS_EXPAND_REQUEST, - ADMIN_ANNOUNCEMENTS_EXPAND_SUCCESS, - ADMIN_ANNOUNCEMENT_CHANGE_CONTENT, - ADMIN_ANNOUNCEMENT_CHANGE_START_TIME, - ADMIN_ANNOUNCEMENT_CHANGE_END_TIME, - ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY, - ADMIN_ANNOUNCEMENT_CREATE_FAIL, - ADMIN_ANNOUNCEMENT_CREATE_REQUEST, - ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, - ADMIN_ANNOUNCEMENT_DELETE_FAIL, - ADMIN_ANNOUNCEMENT_DELETE_REQUEST, - ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, - ADMIN_ANNOUNCEMENT_MODAL_INIT, fetchConfig, updateConfig, updateSoapboxConfig, @@ -750,13 +614,4 @@ export { setUserIndexQuery, fetchUserIndex, expandUserIndex, - fetchAdminAnnouncements, - expandAdminAnnouncements, - changeAnnouncementContent, - changeAnnouncementStartTime, - changeAnnouncementEndTime, - changeAnnouncementAllDay, - handleCreateAnnouncement, - deleteAnnouncement, - initAnnouncementModal, }; diff --git a/src/actions/announcements.test.ts b/src/actions/announcements.test.ts deleted file mode 100644 index e90dbca77..000000000 --- a/src/actions/announcements.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { List as ImmutableList } from 'immutable'; - -import announcements from 'soapbox/__fixtures__/announcements.json'; -import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'soapbox/actions/announcements'; -import { __stub } from 'soapbox/api'; -import { buildInstance } from 'soapbox/jest/factory'; -import { mockStore, rootState } from 'soapbox/jest/test-helpers'; -import { normalizeAnnouncement } from 'soapbox/normalizers'; - -import type { APIEntity } from 'soapbox/types/entities'; - -describe('fetchAnnouncements()', () => { - describe('with a successful API request', () => { - it('should fetch announcements from the API', async() => { - const state = rootState - .set('instance', buildInstance({ version: '3.5.3' })); - const store = mockStore(state); - - __stub((mock) => { - mock.onGet('/api/v1/announcements').reply(200, announcements); - }); - - const expectedActions = [ - { type: 'ANNOUNCEMENTS_FETCH_REQUEST', skipLoading: true }, - { type: 'ANNOUNCEMENTS_FETCH_SUCCESS', announcements, skipLoading: true }, - { type: 'POLLS_IMPORT', polls: [] }, - { type: 'ACCOUNTS_IMPORT', accounts: [] }, - { type: 'STATUSES_IMPORT', statuses: [], expandSpoilers: false }, - ]; - await store.dispatch(fetchAnnouncements()); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); - -describe('dismissAnnouncement', () => { - describe('with a successful API request', () => { - it('should mark announcement as dismissed', async() => { - const store = mockStore(rootState); - - __stub((mock) => { - mock.onPost('/api/v1/announcements/1/dismiss').reply(200); - }); - - const expectedActions = [ - { type: 'ANNOUNCEMENTS_DISMISS_REQUEST', id: '1' }, - { type: 'ANNOUNCEMENTS_DISMISS_SUCCESS', id: '1' }, - ]; - await store.dispatch(dismissAnnouncement('1')); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); - -describe('addReaction', () => { - let store: ReturnType; - - beforeEach(() => { - const state = rootState - .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)))) - .setIn(['announcements', 'isLoading'], false); - store = mockStore(state); - }); - - describe('with a successful API request', () => { - it('should add reaction to a post', async() => { - __stub((mock) => { - mock.onPut('/api/v1/announcements/2/reactions/📉').reply(200); - }); - - const expectedActions = [ - { type: 'ANNOUNCEMENTS_REACTION_ADD_REQUEST', id: '2', name: '📉', skipLoading: true }, - { type: 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS', id: '2', name: '📉', skipLoading: true }, - ]; - await store.dispatch(addReaction('2', '📉')); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); - -describe('removeReaction', () => { - let store: ReturnType; - - beforeEach(() => { - const state = rootState - .setIn(['announcements', 'items'], ImmutableList((announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement)))) - .setIn(['announcements', 'isLoading'], false); - store = mockStore(state); - }); - - describe('with a successful API request', () => { - it('should remove reaction from a post', async() => { - __stub((mock) => { - mock.onDelete('/api/v1/announcements/2/reactions/📉').reply(200); - }); - - const expectedActions = [ - { type: 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST', id: '2', name: '📉', skipLoading: true }, - { type: 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS', id: '2', name: '📉', skipLoading: true }, - ]; - await store.dispatch(removeReaction('2', '📉')); - const actions = store.getActions(); - - expect(actions).toEqual(expectedActions); - }); - }); -}); diff --git a/src/actions/announcements.ts b/src/actions/announcements.ts deleted file mode 100644 index de2d9fd4f..000000000 --- a/src/actions/announcements.ts +++ /dev/null @@ -1,196 +0,0 @@ -import api from 'soapbox/api'; -import { getFeatures } from 'soapbox/utils/features'; - -import { importFetchedStatuses } from './importer'; - -import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity } from 'soapbox/types/entities'; - -export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; -export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; -export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; -export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; -export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; - -export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST'; -export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS'; -export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL'; - -export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; -export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; -export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; - -export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; -export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; -export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; - -export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; - -export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW'; - -const noOp = () => {}; - -export const fetchAnnouncements = (done = noOp) => - (dispatch: AppDispatch, getState: () => RootState) => { - const { instance } = getState(); - const features = getFeatures(instance); - - if (!features.announcements) return null; - - dispatch(fetchAnnouncementsRequest()); - - return api(getState).get('/api/v1/announcements').then(response => { - dispatch(fetchAnnouncementsSuccess(response.data)); - dispatch(importFetchedStatuses(response.data.map(({ statuses }: APIEntity) => statuses))); - }).catch(error => { - dispatch(fetchAnnouncementsFail(error)); - }).finally(() => { - done(); - }); - }; - -export const fetchAnnouncementsRequest = () => ({ - type: ANNOUNCEMENTS_FETCH_REQUEST, - skipLoading: true, -}); - -export const fetchAnnouncementsSuccess = (announcements: APIEntity) => ({ - type: ANNOUNCEMENTS_FETCH_SUCCESS, - announcements, - skipLoading: true, -}); - -export const fetchAnnouncementsFail = (error: unknown) => ({ - type: ANNOUNCEMENTS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, -}); - -export const updateAnnouncements = (announcement: APIEntity) => ({ - type: ANNOUNCEMENTS_UPDATE, - announcement: announcement, -}); - -export const dismissAnnouncement = (announcementId: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch(dismissAnnouncementRequest(announcementId)); - - return api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { - dispatch(dismissAnnouncementSuccess(announcementId)); - }).catch(error => { - dispatch(dismissAnnouncementFail(announcementId, error)); - }); - }; - -export const dismissAnnouncementRequest = (announcementId: string) => ({ - type: ANNOUNCEMENTS_DISMISS_REQUEST, - id: announcementId, -}); - -export const dismissAnnouncementSuccess = (announcementId: string) => ({ - type: ANNOUNCEMENTS_DISMISS_SUCCESS, - id: announcementId, -}); - -export const dismissAnnouncementFail = (announcementId: string, error: unknown) => ({ - type: ANNOUNCEMENTS_DISMISS_FAIL, - id: announcementId, - error, -}); - -export const addReaction = (announcementId: string, name: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - const announcement = getState().announcements.items.find(x => x.get('id') === announcementId); - - let alreadyAdded = false; - - if (announcement) { - const reaction = announcement.reactions.find(x => x.name === name); - - if (reaction && reaction.me) { - alreadyAdded = true; - } - } - - if (!alreadyAdded) { - dispatch(addReactionRequest(announcementId, name, alreadyAdded)); - } - - return api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { - dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); - }).catch(err => { - if (!alreadyAdded) { - dispatch(addReactionFail(announcementId, name, err)); - } - }); - }; - -export const addReactionRequest = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ - type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, - id: announcementId, - name, - skipLoading: true, -}); - -export const addReactionSuccess = (announcementId: string, name: string, alreadyAdded?: boolean) => ({ - type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, - id: announcementId, - name, - skipLoading: true, -}); - -export const addReactionFail = (announcementId: string, name: string, error: unknown) => ({ - type: ANNOUNCEMENTS_REACTION_ADD_FAIL, - id: announcementId, - name, - error, - skipLoading: true, -}); - -export const removeReaction = (announcementId: string, name: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch(removeReactionRequest(announcementId, name)); - - return api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { - dispatch(removeReactionSuccess(announcementId, name)); - }).catch(err => { - dispatch(removeReactionFail(announcementId, name, err)); - }); - }; - -export const removeReactionRequest = (announcementId: string, name: string) => ({ - type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, - id: announcementId, - name, - skipLoading: true, -}); - -export const removeReactionSuccess = (announcementId: string, name: string) => ({ - type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, - id: announcementId, - name, - skipLoading: true, -}); - -export const removeReactionFail = (announcementId: string, name: string, error: unknown) => ({ - type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, - id: announcementId, - name, - error, - skipLoading: true, -}); - -export const updateReaction = (reaction: APIEntity) => ({ - type: ANNOUNCEMENTS_REACTION_UPDATE, - reaction, -}); - -export const toggleShowAnnouncements = () => ({ - type: ANNOUNCEMENTS_TOGGLE_SHOW, -}); - -export const deleteAnnouncement = (id: string) => ({ - type: ANNOUNCEMENTS_DELETE, - id, -}); diff --git a/src/actions/streaming.ts b/src/actions/streaming.ts index 2c6603bef..925832265 100644 --- a/src/actions/streaming.ts +++ b/src/actions/streaming.ts @@ -1,21 +1,18 @@ import { getLocale, getSettings } from 'soapbox/actions/settings'; +import { updateReactions } from 'soapbox/api/hooks/announcements/useAnnouncements'; import { importEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; import { selectEntity } from 'soapbox/entity-store/selectors'; import messages from 'soapbox/messages'; import { ChatKeys, IChat, isLastMessage } from 'soapbox/queries/chats'; import { queryClient } from 'soapbox/queries/client'; +import { announcementSchema, type Announcement, type Relationship } from 'soapbox/schemas'; import { getUnreadChatsCount, updateChatListItem, updateChatMessage } from 'soapbox/utils/chats'; import { removePageItem } from 'soapbox/utils/queries'; import { play, soundCache } from 'soapbox/utils/sounds'; import { connectStream } from '../stream'; -import { - deleteAnnouncement, - updateAnnouncements, - updateReaction as updateAnnouncementsReaction, -} from './announcements'; import { updateConversations } from './conversations'; import { fetchFilters } from './filters'; import { MARKER_FETCH_SUCCESS } from './markers'; @@ -29,7 +26,6 @@ import { } from './timelines'; import type { IStatContext } from 'soapbox/contexts/stat-context'; -import type { Relationship } from 'soapbox/schemas'; import type { AppDispatch, RootState } from 'soapbox/store'; import type { APIEntity, Chat } from 'soapbox/types/entities'; @@ -66,6 +62,35 @@ const updateChatQuery = (chat: IChat) => { queryClient.setQueryData(ChatKeys.chat(chat.id), newChat as any); }; +const updateAnnouncementReactions = ({ announcement_id: id, name, count }: APIEntity) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => { + if (value.id !== id) return value; + + return announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, -1, true), + }); + }), + ); +}; + +const updateAnnouncement = (announcement: APIEntity) => + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => { + let updated = false; + + const result = prevResult.map(value => value.id === announcement.id + ? (updated = true, announcementSchema.parse(announcement)) + : value); + + if (!updated) return [announcementSchema.parse(announcement), ...result]; + }); + +const deleteAnnouncement = (id: string) => + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.filter(value => value.id !== id), + ); + interface TimelineStreamOpts { statContext?: IStatContext; enabled?: boolean; @@ -164,13 +189,13 @@ const connectTimelineStream = ( dispatch(updateFollowRelationships(JSON.parse(data.payload))); break; case 'announcement': - dispatch(updateAnnouncements(JSON.parse(data.payload))); + updateAnnouncement(JSON.parse(data.payload)); break; case 'announcement.reaction': - dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); + updateAnnouncementReactions(JSON.parse(data.payload)); break; case 'announcement.delete': - dispatch(deleteAnnouncement(data.payload)); + deleteAnnouncement(data.payload); break; case 'marker': dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) }); diff --git a/src/api/hooks/admin/useAnnouncements.ts b/src/api/hooks/admin/useAnnouncements.ts new file mode 100644 index 000000000..5cbf70e60 --- /dev/null +++ b/src/api/hooks/admin/useAnnouncements.ts @@ -0,0 +1,89 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks'; +import { queryClient } from 'soapbox/queries/client'; +import { adminAnnouncementSchema, type AdminAnnouncement } from 'soapbox/schemas'; + +import { useAnnouncements as useUserAnnouncements } from '../announcements'; + +import type { AxiosResponse } from 'axios'; + +interface CreateAnnouncementParams { + content: string; + starts_at?: string | null; + ends_at?: string | null; + all_day?: boolean; +} + +interface UpdateAnnouncementParams extends CreateAnnouncementParams { + id: string; +} + +const useAnnouncements = () => { + const api = useApi(); + const userAnnouncements = useUserAnnouncements(); + + const getAnnouncements = async () => { + const { data } = await api.get('/api/v1/pleroma/admin/announcements'); + + const normalizedData = data.map((announcement) => adminAnnouncementSchema.parse(announcement)); + return normalizedData; + }; + + const result = useQuery>({ + queryKey: ['admin', 'announcements'], + queryFn: getAnnouncements, + placeholderData: [], + }); + + const { + mutate: createAnnouncement, + isPending: isCreating, + } = useMutation({ + mutationFn: (params: CreateAnnouncementParams) => api.post('/api/v1/pleroma/admin/announcements', params), + retry: false, + onSuccess: ({ data }: AxiosResponse) => + queryClient.setQueryData(['admin', 'announcements'], (prevResult: ReadonlyArray) => + [...prevResult, adminAnnouncementSchema.parse(data)], + ), + onSettled: () => userAnnouncements.refetch(), + }); + + const { + mutate: updateAnnouncement, + isPending: isUpdating, + } = useMutation({ + mutationFn: ({ id, ...params }: UpdateAnnouncementParams) => api.patch(`/api/v1/pleroma/admin/announcements/${id}`, params), + retry: false, + onSuccess: ({ data }: AxiosResponse) => + queryClient.setQueryData(['admin', 'announcements'], (prevResult: ReadonlyArray) => + prevResult.map((announcement) => announcement.id === data.id ? adminAnnouncementSchema.parse(data) : announcement), + ), + onSettled: () => userAnnouncements.refetch(), + }); + + const { + mutate: deleteAnnouncement, + isPending: isDeleting, + } = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/pleroma/admin/announcements/${id}`), + retry: false, + onSuccess: (_, id) => + queryClient.setQueryData(['admin', 'announcements'], (prevResult: ReadonlyArray) => + prevResult.filter(({ id: announcementId }) => announcementId !== id), + ), + onSettled: () => userAnnouncements.refetch(), + }); + + return { + ...result, + createAnnouncement, + isCreating, + updateAnnouncement, + isUpdating, + deleteAnnouncement, + isDeleting, + }; +}; + +export { useAnnouncements }; diff --git a/src/api/hooks/announcements/index.ts b/src/api/hooks/announcements/index.ts new file mode 100644 index 000000000..021ac3ea0 --- /dev/null +++ b/src/api/hooks/announcements/index.ts @@ -0,0 +1 @@ +export { useAnnouncements } from './useAnnouncements'; diff --git a/src/api/hooks/announcements/useAnnouncements.ts b/src/api/hooks/announcements/useAnnouncements.ts new file mode 100644 index 000000000..3db0431e6 --- /dev/null +++ b/src/api/hooks/announcements/useAnnouncements.ts @@ -0,0 +1,95 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks'; +import { queryClient } from 'soapbox/queries/client'; +import { announcementReactionSchema, announcementSchema, type Announcement, type AnnouncementReaction } from 'soapbox/schemas'; + +const updateReaction = (reaction: AnnouncementReaction, count: number, me?: boolean, overwrite?: boolean) => announcementReactionSchema.parse({ + ...reaction, + me: typeof me === 'boolean' ? me : reaction.me, + count: overwrite ? count : (reaction.count + count), +}); + +export const updateReactions = (reactions: AnnouncementReaction[], name: string, count: number, me?: boolean, overwrite?: boolean) => { + const idx = reactions.findIndex(reaction => reaction.name === name); + + if (idx > -1) { + reactions = reactions.map(reaction => reaction.name === name ? updateReaction(reaction, count, me, overwrite) : reaction); + } + + return [...reactions, updateReaction(announcementReactionSchema.parse({ name }), count, me, overwrite)]; +}; + +const useAnnouncements = () => { + const api = useApi(); + + const getAnnouncements = async () => { + const { data } = await api.get('/api/v1/announcements'); + + const normalizedData = data?.map((announcement) => announcementSchema.parse(announcement)); + return normalizedData; + }; + + const { data, ...result } = useQuery>({ + queryKey: ['announcements'], + queryFn: getAnnouncements, + placeholderData: [], + }); + + const { + mutate: addReaction, + } = useMutation({ + mutationFn: ({ announcementId, name }: { announcementId: string; name: string }) => + api.put(`/api/v1/announcements/${announcementId}/reactions/${name}`), + retry: false, + onMutate: ({ announcementId: id, name }) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => value.id !== id ? value : announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, 1, true), + })), + ); + }, + onError: (_, { announcementId: id, name }) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => value.id !== id ? value : announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, -1, false), + })), + ); + }, + }); + + const { + mutate: removeReaction, + } = useMutation({ + mutationFn: ({ announcementId, name }: { announcementId: string; name: string }) => + api.delete(`/api/v1/announcements/${announcementId}/reactions/${name}`), + retry: false, + onMutate: ({ announcementId: id, name }) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => value.id !== id ? value : announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, -1, false), + })), + ); + }, + onError: (_, { announcementId: id, name }) => { + queryClient.setQueryData(['announcements'], (prevResult: Announcement[]) => + prevResult.map(value => value.id !== id ? value : announcementSchema.parse({ + ...value, + reactions: updateReactions(value.reactions, name, 1, true), + })), + ); + }, + }); + + return { + data: data?.toSorted((a, b) => new Date(a.starts_at || a.published_at).getDate() - new Date(b.starts_at || b.published_at).getDate()), + ...result, + addReaction, + removeReaction, + }; +}; + +export { useAnnouncements }; diff --git a/src/api/hooks/streaming/useUserStream.ts b/src/api/hooks/streaming/useUserStream.ts index cededf2aa..f068b3c1c 100644 --- a/src/api/hooks/streaming/useUserStream.ts +++ b/src/api/hooks/streaming/useUserStream.ts @@ -1,4 +1,3 @@ -import { fetchAnnouncements } from 'soapbox/actions/announcements'; import { expandNotifications } from 'soapbox/actions/notifications'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; import { useStatContext } from 'soapbox/contexts/stat-context'; @@ -24,8 +23,7 @@ function useUserStream() { /** Refresh home timeline and notifications. */ function refresh(dispatch: AppDispatch, done?: () => void) { return dispatch(expandHomeTimeline({}, () => - dispatch(expandNotifications({}, () => - dispatch(fetchAnnouncements(done)))))); + dispatch(expandNotifications({}, done)))); } export { useUserStream }; \ No newline at end of file diff --git a/src/components/announcements/announcement-content.tsx b/src/components/announcements/announcement-content.tsx index ca21dd88e..07908c9b9 100644 --- a/src/components/announcements/announcement-content.tsx +++ b/src/components/announcements/announcement-content.tsx @@ -3,7 +3,7 @@ import { useHistory } from 'react-router-dom'; import { getTextDirection } from 'soapbox/utils/rtl'; -import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/types/entities'; +import type { Announcement as AnnouncementEntity, Mention as MentionEntity } from 'soapbox/schemas'; interface IAnnouncementContent { announcement: AnnouncementEntity; @@ -67,7 +67,7 @@ const AnnouncementContent: React.FC = ({ announcement }) = } else if (link.textContent?.charAt(0) === '#' || (link.previousSibling?.textContent?.charAt(link.previousSibling.textContent.length - 1) === '#')) { link.addEventListener('click', onHashtagClick.bind(link, link.text), false); } else { - const status = announcement.statuses.get(link.href); + const status = announcement.statuses[link.href]; if (status) { link.addEventListener('click', onStatusClick.bind(this, status), false); } diff --git a/src/components/announcements/announcement.tsx b/src/components/announcements/announcement.tsx index 7ffda2a8a..3b07e8ece 100644 --- a/src/components/announcements/announcement.tsx +++ b/src/components/announcements/announcement.tsx @@ -9,16 +9,14 @@ import AnnouncementContent from './announcement-content'; import ReactionsBar from './reactions-bar'; import type { Map as ImmutableMap } from 'immutable'; -import type { Announcement as AnnouncementEntity } from 'soapbox/types/entities'; +import type { Announcement as AnnouncementEntity } from 'soapbox/schemas'; interface IAnnouncement { announcement: AnnouncementEntity; - addReaction: (id: string, name: string) => void; - removeReaction: (id: string, name: string) => void; emojiMap: ImmutableMap>; } -const Announcement: React.FC = ({ announcement, addReaction, removeReaction, emojiMap }) => { +const Announcement: React.FC = ({ announcement, emojiMap }) => { const features = useFeatures(); const startsAt = announcement.starts_at && new Date(announcement.starts_at); @@ -64,8 +62,6 @@ const Announcement: React.FC = ({ announcement, addReaction, remo )} diff --git a/src/components/announcements/announcements-panel.tsx b/src/components/announcements/announcements-panel.tsx index 052b99f47..966df4479 100644 --- a/src/components/announcements/announcements-panel.tsx +++ b/src/components/announcements/announcements-panel.tsx @@ -5,9 +5,9 @@ import { FormattedMessage } from 'react-intl'; import ReactSwipeableViews from 'react-swipeable-views'; import { createSelector } from 'reselect'; -import { addReaction as addReactionAction, removeReaction as removeReactionAction } from 'soapbox/actions/announcements'; +import { useAnnouncements } from 'soapbox/api/hooks/announcements'; import { Card, HStack, Widget } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppSelector } from 'soapbox/hooks'; import Announcement from './announcement'; @@ -16,36 +16,30 @@ import type { RootState } from 'soapbox/store'; const customEmojiMap = createSelector([(state: RootState) => state.custom_emojis], items => (items as ImmutableList>).reduce((map, emoji) => map.set(emoji.get('shortcode')!, emoji), ImmutableMap>())); const AnnouncementsPanel = () => { - const dispatch = useAppDispatch(); const emojiMap = useAppSelector(state => customEmojiMap(state)); const [index, setIndex] = useState(0); - const announcements = useAppSelector((state) => state.announcements.items); + const { data: announcements } = useAnnouncements(); - const addReaction = (id: string, name: string) => dispatch(addReactionAction(id, name)); - const removeReaction = (id: string, name: string) => dispatch(removeReactionAction(id, name)); - - if (announcements.size === 0) return null; + if (!announcements || announcements.length === 0) return null; const handleChangeIndex = (index: number) => { - setIndex(index % announcements.size); + setIndex(index % announcements.length); }; return ( }> - {announcements.map((announcement) => ( + {announcements!.map((announcement) => ( )).reverse()} - {announcements.size > 1 && ( + {announcements.length > 1 && ( {announcements.map((_, i) => ( - @@ -84,15 +89,11 @@ const Announcements: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const announcements = useAppSelector((state) => state.admin_announcements.items); - const isLoading = useAppSelector((state) => state.admin_announcements.isLoading); + const { data: announcements, isLoading } = useAnnouncements(); - useEffect(() => { - dispatch(fetchAdminAnnouncements()); - }, []); const handleCreateAnnouncement = () => { - dispatch(initAnnouncementModal()); + dispatch(openModal('EDIT_ANNOUNCEMENT')); }; const emptyMessage = ; @@ -114,9 +115,9 @@ const Announcements: React.FC = () => { emptyMessage={emptyMessage} itemClassName='py-3 first:pt-0 last:pb-0' isLoading={isLoading} - showLoading={isLoading && !announcements.count()} + showLoading={isLoading && !announcements?.length} > - {announcements.map((announcement) => ( + {announcements!.map((announcement) => ( ))} diff --git a/src/features/ui/components/modals/edit-announcement-modal.tsx b/src/features/ui/components/modals/edit-announcement-modal.tsx index b0d7bdeec..3188b4e2b 100644 --- a/src/features/ui/components/modals/edit-announcement-modal.tsx +++ b/src/features/ui/components/modals/edit-announcement-modal.tsx @@ -1,52 +1,80 @@ -import React from 'react'; +import React, { useState } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; -import { changeAnnouncementAllDay, changeAnnouncementContent, changeAnnouncementEndTime, changeAnnouncementStartTime, handleCreateAnnouncement } from 'soapbox/actions/admin'; import { closeModal } from 'soapbox/actions/modals'; +import { useAnnouncements } from 'soapbox/api/hooks/admin/useAnnouncements'; import { Form, FormGroup, HStack, Modal, Stack, Text, Textarea, Toggle } from 'soapbox/components/ui'; import { DatePicker } from 'soapbox/features/ui/util/async-components'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch } from 'soapbox/hooks'; +import toast from 'soapbox/toast'; + +import type { AdminAnnouncement } from 'soapbox/schemas'; const messages = defineMessages({ save: { id: 'admin.edit_announcement.save', defaultMessage: 'Save' }, announcementContentPlaceholder: { id: 'admin.edit_announcement.fields.content_placeholder', defaultMessage: 'Announcement content' }, announcementStartTimePlaceholder: { id: 'admin.edit_announcement.fields.start_time_placeholder', defaultMessage: 'Announcement starts on:' }, announcementEndTimePlaceholder: { id: 'admin.edit_announcement.fields.end_time_placeholder', defaultMessage: 'Announcement ends on:' }, + announcementCreateSuccess: { id: 'admin.edit_announcement.created', defaultMessage: 'Announcement created' }, + announcementUpdateSuccess: { id: 'admin.edit_announcement.updated', defaultMessage: 'Announcement edited' }, }); interface IEditAnnouncementModal { onClose: (type?: string) => void; + announcement?: AdminAnnouncement; } -const EditAnnouncementModal: React.FC = ({ onClose }) => { +const EditAnnouncementModal: React.FC = ({ onClose, announcement }) => { const dispatch = useAppDispatch(); + const { createAnnouncement, updateAnnouncement } = useAnnouncements(); const intl = useIntl(); - const id = useAppSelector((state) => state.admin_announcements.form.id); - const content = useAppSelector((state) => state.admin_announcements.form.content); - const startTime = useAppSelector((state) => state.admin_announcements.form.starts_at); - const endTime = useAppSelector((state) => state.admin_announcements.form.ends_at); - const allDay = useAppSelector((state) => state.admin_announcements.form.all_day); + const [content, setContent] = useState(announcement?.content || ''); + const [startTime, setStartTime] = useState(announcement?.starts_at ? new Date(announcement.starts_at) : null); + const [endTime, setEndTime] = useState(announcement?.ends_at ? new Date(announcement.ends_at) : null); + const [allDay, setAllDay] = useState(announcement?.all_day || false); - const onChangeContent: React.ChangeEventHandler = ({ target }) => - dispatch(changeAnnouncementContent(target.value)); + const onChangeContent: React.ChangeEventHandler = ({ target }) => setContent(target.value); - const onChangeStartTime = (date: Date | null) => dispatch(changeAnnouncementStartTime(date)); + const onChangeStartTime = (date: Date | null) => setStartTime(date); - const onChangeEndTime = (date: Date | null) => dispatch(changeAnnouncementEndTime(date)); + const onChangeEndTime = (date: Date | null) => setEndTime(date); - const onChangeAllDay: React.ChangeEventHandler = ({ target }) => dispatch(changeAnnouncementAllDay(target.checked)); + const onChangeAllDay: React.ChangeEventHandler = ({ target }) => setAllDay(target.checked); const onClickClose = () => { onClose('EDIT_ANNOUNCEMENT'); }; - const handleSubmit = () => dispatch(handleCreateAnnouncement()).then(() => dispatch(closeModal('EDIT_ANNOUNCEMENT'))); + const handleSubmit = () => { + const form = { + content, + starts_at: startTime?.toISOString() || null, + ends_at: endTime?.toISOString() || null, + all_day: allDay, + }; + + if (announcement) { + updateAnnouncement({ ...form, id: announcement.id }, { + onSuccess: () => { + dispatch(closeModal('EDIT_ANNOUNCEMENT')); + toast.success(messages.announcementUpdateSuccess); + }, + }); + } else { + createAnnouncement(form, { + onSuccess: () => { + dispatch(closeModal('EDIT_ANNOUNCEMENT')); + toast.success(messages.announcementCreateSuccess); + }, + }); + } + }; return ( : } confirmationAction={handleSubmit} diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index 49bb1f566..f31315c02 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -4,7 +4,6 @@ import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom'; import { fetchFollowRequests } from 'soapbox/actions/accounts'; import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin'; -import { fetchAnnouncements } from 'soapbox/actions/announcements'; import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis'; import { fetchFilters } from 'soapbox/actions/filters'; import { fetchMarker } from 'soapbox/actions/markers'; @@ -418,8 +417,6 @@ const UI: React.FC = ({ children }) => { .then(() => dispatch(fetchMarker(['notifications']))) .catch(console.error); - dispatch(fetchAnnouncements()); - if (account.staff) { dispatch(fetchReports({ resolved: false })); dispatch(fetchUsers(['local', 'need_approval'])); diff --git a/src/normalizers/announcement-reaction.ts b/src/normalizers/announcement-reaction.ts deleted file mode 100644 index 56827d28c..000000000 --- a/src/normalizers/announcement-reaction.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Announcement reaction normalizer: - * Converts API announcement emoji reactions into our internal format. - * @see {@link https://docs.joinmastodon.org/entities/announcementreaction/} - */ -import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable'; - -// https://docs.joinmastodon.org/entities/announcement/ -export const AnnouncementReactionRecord = ImmutableRecord({ - name: '', - count: 0, - me: false, - url: null as string | null, - static_url: null as string | null, - announcement_id: '', -}); - -export const normalizeAnnouncementReaction = (announcementReaction: Record, announcementId?: string) => { - return AnnouncementReactionRecord(ImmutableMap(fromJS(announcementReaction)).withMutations(reaction => { - reaction.set('announcement_id', announcementId as any); - })); -}; diff --git a/src/normalizers/announcement.ts b/src/normalizers/announcement.ts deleted file mode 100644 index 97eed98be..000000000 --- a/src/normalizers/announcement.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Announcement normalizer: - * Converts API announcements into our internal format. - * @see {@link https://docs.joinmastodon.org/entities/announcement/} - */ -import { - Map as ImmutableMap, - List as ImmutableList, - Record as ImmutableRecord, - fromJS, -} from 'immutable'; - -import emojify from 'soapbox/features/emoji'; -import { normalizeEmoji } from 'soapbox/normalizers/emoji'; -import { makeEmojiMap } from 'soapbox/utils/normalizers'; - -import { normalizeAnnouncementReaction } from './announcement-reaction'; -import { normalizeMention } from './mention'; - -import type { AnnouncementReaction, Emoji, Mention } from 'soapbox/types/entities'; - -// https://docs.joinmastodon.org/entities/announcement/ -export const AnnouncementRecord = ImmutableRecord({ - id: '', - content: '', - starts_at: null as Date | null, - ends_at: null as Date | null, - all_day: false, - read: false, - published_at: Date, - reactions: ImmutableList(), - statuses: ImmutableMap(), - mentions: ImmutableList(), - tags: ImmutableList>(), - emojis: ImmutableList(), - updated_at: Date, - - pleroma: ImmutableMap(), - - // Internal fields - contentHtml: '', -}); - -const normalizeMentions = (announcement: ImmutableMap) => { - return announcement.update('mentions', ImmutableList(), mentions => { - return mentions.map(normalizeMention); - }); -}; - -// Normalize reactions -const normalizeReactions = (announcement: ImmutableMap) => { - return announcement.update('reactions', ImmutableList(), reactions => { - return reactions.map((reaction: ImmutableMap) => normalizeAnnouncementReaction(reaction, announcement.get('id'))); - }); -}; - -// Normalize emojis -const normalizeEmojis = (announcement: ImmutableMap) => { - return announcement.update('emojis', ImmutableList(), emojis => { - return emojis.map(normalizeEmoji); - }); -}; - -const normalizeContent = (announcement: ImmutableMap) => { - const emojiMap = makeEmojiMap(announcement.get('emojis')); - const contentHtml = emojify(announcement.get('content'), emojiMap); - - return announcement.set('contentHtml', contentHtml); -}; - -const normalizeStatuses = (announcement: ImmutableMap) => { - const statuses = announcement - .get('statuses', ImmutableList()) - .reduce((acc: ImmutableMap, curr: ImmutableMap) => acc.set(curr.get('url'), `/@${curr.getIn(['account', 'acct'])}/${curr.get('id')}`), ImmutableMap()); - - return announcement.set('statuses', statuses); -}; - -export const normalizeAnnouncement = (announcement: Record) => { - return AnnouncementRecord( - ImmutableMap(fromJS(announcement)).withMutations(announcement => { - normalizeMentions(announcement); - normalizeReactions(announcement); - normalizeEmojis(announcement); - normalizeContent(announcement); - normalizeStatuses(announcement); - }), - ); -}; diff --git a/src/normalizers/index.ts b/src/normalizers/index.ts index 03fb83021..d64ff3ae9 100644 --- a/src/normalizers/index.ts +++ b/src/normalizers/index.ts @@ -1,8 +1,6 @@ export { AccountRecord, FieldRecord, normalizeAccount } from './account'; export { AdminAccountRecord, normalizeAdminAccount } from './admin-account'; export { AdminReportRecord, normalizeAdminReport } from './admin-report'; -export { AnnouncementRecord, normalizeAnnouncement } from './announcement'; -export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement-reaction'; export { AttachmentRecord, normalizeAttachment } from './attachment'; export { ChatRecord, normalizeChat } from './chat'; export { ChatMessageRecord, normalizeChatMessage } from './chat-message'; diff --git a/src/reducers/admin-announcements.ts b/src/reducers/admin-announcements.ts deleted file mode 100644 index 33926effc..000000000 --- a/src/reducers/admin-announcements.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; - -import { - ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY, - ADMIN_ANNOUNCEMENT_CHANGE_CONTENT, - ADMIN_ANNOUNCEMENT_CHANGE_END_TIME, - ADMIN_ANNOUNCEMENT_CHANGE_START_TIME, - ADMIN_ANNOUNCEMENT_CREATE_FAIL, - ADMIN_ANNOUNCEMENT_CREATE_REQUEST, - ADMIN_ANNOUNCEMENT_CREATE_SUCCESS, - ADMIN_ANNOUNCEMENT_DELETE_SUCCESS, - ADMIN_ANNOUNCEMENT_MODAL_INIT, - ADMIN_ANNOUNCEMENTS_FETCH_FAIL, - ADMIN_ANNOUNCEMENTS_FETCH_REQUEST, - ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS, -} from 'soapbox/actions/admin'; -import { normalizeAnnouncement } from 'soapbox/normalizers'; - -import type { AnyAction } from 'redux'; -import type { Announcement, APIEntity } from 'soapbox/types/entities'; - -const AnnouncementFormRecord = ImmutableRecord({ - id: null as string | null, - content: '', - starts_at: null as Date | null, - ends_at: null as Date | null, - all_day: false, - is_submitting: false, -}); - -const ReducerRecord = ImmutableRecord({ - items: ImmutableList(), - isLoading: false, - page: -1, - form: AnnouncementFormRecord(), -}); - -export default function adminAnnouncementsReducer(state = ReducerRecord(), action: AnyAction) { - switch (action.type) { - case ADMIN_ANNOUNCEMENTS_FETCH_REQUEST: - return state.set('isLoading', true); - case ADMIN_ANNOUNCEMENTS_FETCH_SUCCESS: - return state.withMutations(map => { - const items = ImmutableList((action.announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement))); - - map.set('items', items); - map.set('isLoading', false); - }); - case ADMIN_ANNOUNCEMENTS_FETCH_FAIL: - return state.set('isLoading', false); - case ADMIN_ANNOUNCEMENT_DELETE_SUCCESS: - return state.update('items', list => { - const idx = list.findIndex(x => x.id === action.id); - - if (idx > -1) { - return list.delete(idx); - } - - return list; - }); - case ADMIN_ANNOUNCEMENT_CHANGE_CONTENT: - return state.setIn(['form', 'content'], action.value); - case ADMIN_ANNOUNCEMENT_CHANGE_START_TIME: - return state.setIn(['form', 'starts_at'], action.value); - case ADMIN_ANNOUNCEMENT_CHANGE_END_TIME: - return state.setIn(['form', 'ends_at'], action.value); - case ADMIN_ANNOUNCEMENT_CHANGE_ALL_DAY: - return state.setIn(['form', 'all_day'], action.value); - case ADMIN_ANNOUNCEMENT_CREATE_REQUEST: - return state.setIn(['form', 'is_submitting'], true); - case ADMIN_ANNOUNCEMENT_CREATE_SUCCESS: - case ADMIN_ANNOUNCEMENT_CREATE_FAIL: - return state.setIn(['form', 'is_submitting'], true); - case ADMIN_ANNOUNCEMENT_MODAL_INIT: - return state.set('form', action.announcement ? AnnouncementFormRecord({ - id: action.announcement.id, - content: action.announcement.content, - starts_at: action.announcement.starts_at ? new Date(action.announcement.starts_at) : null, - ends_at: action.announcement.ends_at ? new Date(action.announcement.ends_at) : null, - all_day: action.announcement.all_day, - }) : AnnouncementFormRecord()); - default: - return state; - } -} diff --git a/src/reducers/announcements.test.ts b/src/reducers/announcements.test.ts deleted file mode 100644 index 70358f333..000000000 --- a/src/reducers/announcements.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { List as ImmutableList, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable'; - -import announcements from 'soapbox/__fixtures__/announcements.json'; -import { - ANNOUNCEMENTS_FETCH_SUCCESS, - ANNOUNCEMENTS_UPDATE, -} from 'soapbox/actions/announcements'; - -import reducer from './announcements'; - - -describe('accounts reducer', () => { - it('should return the initial state', () => { - expect(reducer(undefined, {} as any)).toMatchObject({ - items: ImmutableList(), - isLoading: false, - show: false, - unread: ImmutableSet(), - }); - }); - - describe('ANNOUNCEMENTS_FETCH_SUCCESS', () => { - it('parses announcements as Records', () => { - const action = { type: ANNOUNCEMENTS_FETCH_SUCCESS, announcements }; - const result = reducer(undefined, action).items; - - expect(result.every((announcement) => ImmutableRecord.isRecord(announcement))).toBe(true); - }); - }); - - describe('ANNOUNCEMENTS_UPDATE', () => { - it('updates announcements', () => { - const state = reducer(undefined, { type: ANNOUNCEMENTS_FETCH_SUCCESS, announcements: [announcements[0]] }); - - const action = { type: ANNOUNCEMENTS_UPDATE, announcement: { ...announcements[0], content: '

Updated to Soapbox v3.0.0.

' } }; - const result = reducer(state, action).items; - - expect(result.size === 1); - expect(result.first()?.content === '

Updated to Soapbox v3.0.0.

'); - }); - }); -}); diff --git a/src/reducers/announcements.ts b/src/reducers/announcements.ts deleted file mode 100644 index c82a5abcb..000000000 --- a/src/reducers/announcements.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { List as ImmutableList, Record as ImmutableRecord, Set as ImmutableSet } from 'immutable'; - -import { - ANNOUNCEMENTS_FETCH_REQUEST, - ANNOUNCEMENTS_FETCH_SUCCESS, - ANNOUNCEMENTS_FETCH_FAIL, - ANNOUNCEMENTS_UPDATE, - ANNOUNCEMENTS_REACTION_UPDATE, - ANNOUNCEMENTS_REACTION_ADD_REQUEST, - ANNOUNCEMENTS_REACTION_ADD_FAIL, - ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, - ANNOUNCEMENTS_REACTION_REMOVE_FAIL, - ANNOUNCEMENTS_TOGGLE_SHOW, - ANNOUNCEMENTS_DELETE, - ANNOUNCEMENTS_DISMISS_SUCCESS, -} from 'soapbox/actions/announcements'; -import { normalizeAnnouncement, normalizeAnnouncementReaction } from 'soapbox/normalizers'; - -import type { AnyAction } from 'redux'; -import type { Announcement, AnnouncementReaction, APIEntity } from 'soapbox/types/entities'; - -const ReducerRecord = ImmutableRecord({ - items: ImmutableList(), - isLoading: false, - show: false, - unread: ImmutableSet(), -}); - -type State = ReturnType; - -const updateReaction = (state: State, id: string, name: string, updater: (a: AnnouncementReaction) => AnnouncementReaction) => state.update('items', list => list.map(announcement => { - if (announcement.id === id) { - return announcement.update('reactions', reactions => { - const idx = reactions.findIndex(reaction => reaction.name === name); - - if (idx > -1) { - return reactions.update(idx, reaction => updater(reaction!)); - } - - return reactions.push(updater(normalizeAnnouncementReaction({ name, count: 0 }))); - }); - } - - return announcement; -})); - -const updateReactionCount = (state: State, reaction: AnnouncementReaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count)); - -const addReaction = (state: State, id: string, name: string) => updateReaction(state, id, name, (x: AnnouncementReaction) => x.set('me', true).update('count', y => y + 1)); - -const removeReaction = (state: State, id: string, name: string) => updateReaction(state, id, name, (x: AnnouncementReaction) => x.set('me', false).update('count', y => y - 1)); - -const sortAnnouncements = (list: ImmutableList) => list.sortBy(x => x.starts_at || x.published_at); - -const updateAnnouncement = (state: State, announcement: Announcement) => { - const idx = state.items.findIndex(x => x.id === announcement.id); - - if (idx > -1) { - // Deep merge is used because announcements from the streaming API do not contain - // personalized data about which reactions have been selected by the given user, - // and that is information we want to preserve - return state.update('items', list => sortAnnouncements(list.update(idx, x => x!.mergeDeep(announcement)))); - } - - return state.update('items', list => sortAnnouncements(list.unshift(announcement))); -}; - -export default function announcementsReducer(state = ReducerRecord(), action: AnyAction) { - switch (action.type) { - case ANNOUNCEMENTS_TOGGLE_SHOW: - return state.withMutations(map => { - map.set('show', !map.show); - }); - case ANNOUNCEMENTS_FETCH_REQUEST: - return state.set('isLoading', true); - case ANNOUNCEMENTS_FETCH_SUCCESS: - return state.withMutations(map => { - const items = ImmutableList((action.announcements).map((announcement: APIEntity) => normalizeAnnouncement(announcement))); - - map.set('items', items); - map.set('isLoading', false); - }); - case ANNOUNCEMENTS_FETCH_FAIL: - return state.set('isLoading', false); - case ANNOUNCEMENTS_UPDATE: - return updateAnnouncement(state, normalizeAnnouncement(action.announcement)); - case ANNOUNCEMENTS_REACTION_UPDATE: - return updateReactionCount(state, action.reaction); - case ANNOUNCEMENTS_REACTION_ADD_REQUEST: - case ANNOUNCEMENTS_REACTION_REMOVE_FAIL: - return addReaction(state, action.id, action.name); - case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST: - case ANNOUNCEMENTS_REACTION_ADD_FAIL: - return removeReaction(state, action.id, action.name); - case ANNOUNCEMENTS_DISMISS_SUCCESS: - return updateAnnouncement(state, normalizeAnnouncement({ id: action.id, read: true })); - case ANNOUNCEMENTS_DELETE: - return state.update('items', list => { - const idx = list.findIndex(x => x.id === action.id); - - if (idx > -1) { - return list.delete(idx); - } - - return list; - }); - default: - return state; - } -} diff --git a/src/reducers/index.ts b/src/reducers/index.ts index d0e71d428..a5d45c95f 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -7,10 +7,8 @@ import entities from 'soapbox/entity-store/reducer'; import accounts_meta from './accounts-meta'; import admin from './admin'; -import admin_announcements from './admin-announcements'; import admin_user_index from './admin-user-index'; import aliases from './aliases'; -import announcements from './announcements'; import auth from './auth'; import backups from './backups'; import chat_message_lists from './chat-message-lists'; @@ -66,10 +64,8 @@ import user_lists from './user-lists'; const reducers = { accounts_meta, admin, - admin_announcements, admin_user_index, aliases, - announcements, auth, backups, chat_message_lists, diff --git a/src/schemas/announcement-reaction.ts b/src/schemas/announcement-reaction.ts new file mode 100644 index 000000000..c8a5cfc23 --- /dev/null +++ b/src/schemas/announcement-reaction.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +import type { Resolve } from 'soapbox/utils/types'; + +// https://docs.joinmastodon.org/entities/announcement/ +const announcementReactionSchema = z.object({ + name: z.string().catch(''), + count: z.number().int().nonnegative().catch(0), + me: z.boolean().catch(false), + url: z.string().nullable().catch(null), + static_url: z.string().nullable().catch(null), + announcement_id: z.string().catch(''), +}); + +type AnnouncementReaction = Resolve>; + +export { announcementReactionSchema, type AnnouncementReaction }; diff --git a/src/schemas/announcement.ts b/src/schemas/announcement.ts new file mode 100644 index 000000000..e40b9c011 --- /dev/null +++ b/src/schemas/announcement.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +import emojify from 'soapbox/features/emoji'; + +import { announcementReactionSchema } from './announcement-reaction'; +import { customEmojiSchema } from './custom-emoji'; +import { mentionSchema } from './mention'; +import { tagSchema } from './tag'; +import { dateSchema, filteredArray, makeCustomEmojiMap } from './utils'; + +import type { Resolve } from 'soapbox/utils/types'; + +const transformAnnouncement = (announcement: Resolve>) => { + const emojiMap = makeCustomEmojiMap(announcement.emojis); + + const contentHtml = emojify(announcement.content, emojiMap); + + return { + ...announcement, + contentHtml, + }; +}; + +// https://docs.joinmastodon.org/entities/announcement/ +const baseAnnouncementSchema = z.object({ + id: z.string(), + content: z.string().catch(''), + starts_at: z.string().datetime().nullable().catch(null), + ends_at: z.string().datetime().nullable().catch(null), + all_day: z.boolean().catch(false), + read: z.boolean().catch(false), + published_at: dateSchema, + reactions: filteredArray(announcementReactionSchema), + statuses: z.preprocess( + (statuses: any) => Array.isArray(statuses) + ? Object.fromEntries(statuses.map((status: any) => [status.url, status.account?.acct]) || []) + : statuses, + z.record(z.string(), z.string()), + ), + mentions: filteredArray(mentionSchema), + tags: filteredArray(tagSchema), + emojis: filteredArray(customEmojiSchema), + updated_at: dateSchema, +}); + +const announcementSchema = baseAnnouncementSchema.transform(transformAnnouncement); + +type Announcement = Resolve>; + +const adminAnnouncementSchema = baseAnnouncementSchema.extend({ + pleroma: z.object({ + raw_content: z.string().catch(''), + }), +}).transform(transformAnnouncement); + +type AdminAnnouncement = Resolve>; + +export { announcementSchema, adminAnnouncementSchema, type Announcement, type AdminAnnouncement }; diff --git a/src/schemas/index.ts b/src/schemas/index.ts index e20fd4d4b..3e8d00f7f 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -1,4 +1,6 @@ export { accountSchema, type Account } from './account'; +export { announcementSchema, adminAnnouncementSchema, type Announcement, type AdminAnnouncement } from './announcement'; +export { announcementReactionSchema, type AnnouncementReaction } from './announcement-reaction'; export { attachmentSchema, type Attachment } from './attachment'; export { bookmarkFolderSchema, type BookmarkFolder } from './bookmark-folder'; export { cardSchema, type Card } from './card'; diff --git a/src/types/entities.ts b/src/types/entities.ts index 4f784d7da..3936df856 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -1,8 +1,6 @@ import { AdminAccountRecord, AdminReportRecord, - AnnouncementRecord, - AnnouncementReactionRecord, AttachmentRecord, ChatRecord, ChatMessageRecord, @@ -27,8 +25,6 @@ import type { LegacyMap } from 'soapbox/utils/legacy'; type AdminAccount = ReturnType; type AdminReport = ReturnType; -type Announcement = ReturnType; -type AnnouncementReaction = ReturnType; type Attachment = ReturnType; type Chat = ReturnType; type ChatMessage = ReturnType; @@ -61,8 +57,6 @@ export { Account, AdminAccount, AdminReport, - Announcement, - AnnouncementReaction, Attachment, Chat, ChatMessage, diff --git a/src/utils/features.ts b/src/utils/features.ts index 71a64293b..9e04d0821 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -231,7 +231,7 @@ const getInstanceFeatures = (instance: Instance) => { * @see DELETE /api/v1/announcements/:id/reactions/:name * @see {@link https://docs.joinmastodon.org/methods/announcements/} */ - announcementsReactions: v.software === MASTODON && gte(v.compatVersion, '3.1.0'), + announcementsReactions: true, // v.software === MASTODON && gte(v.compatVersion, '3.1.0'), /** * Pleroma backups.