diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js deleted file mode 100644 index cfc329a8b7..0000000000 --- a/app/javascript/mastodon/actions/markers.js +++ /dev/null @@ -1,152 +0,0 @@ -import { List as ImmutableList } from 'immutable'; - -import { debounce } from 'lodash'; - -import api from '../api'; -import { compareId } from '../compare_id'; - -export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; -export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; -export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL'; -export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; - -export const synchronouslySubmitMarkers = () => (dispatch, getState) => { - const accessToken = getState().getIn(['meta', 'access_token'], ''); - const params = _buildParams(getState()); - - if (Object.keys(params).length === 0 || accessToken === '') { - return; - } - - // The Fetch API allows us to perform requests that will be carried out - // after the page closes. But that only works if the `keepalive` attribute - // is supported. - if (window.fetch && 'keepalive' in new Request('')) { - fetch('/api/v1/markers', { - keepalive: true, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - }, - body: JSON.stringify(params), - }); - - return; - } else if (navigator && navigator.sendBeacon) { - // Failing that, we can use sendBeacon, but we have to encode the data as - // FormData for DoorKeeper to recognize the token. - const formData = new FormData(); - - formData.append('bearer_token', accessToken); - - for (const [id, value] of Object.entries(params)) { - formData.append(`${id}[last_read_id]`, value.last_read_id); - } - - if (navigator.sendBeacon('/api/v1/markers', formData)) { - return; - } - } - - // If neither Fetch nor sendBeacon worked, try to perform a synchronous - // request. - try { - const client = new XMLHttpRequest(); - - client.open('POST', '/api/v1/markers', false); - client.setRequestHeader('Content-Type', 'application/json'); - client.setRequestHeader('Authorization', `Bearer ${accessToken}`); - client.send(JSON.stringify(params)); - } catch (e) { - // Do not make the BeforeUnload handler error out - } -}; - -const _buildParams = (state) => { - const params = {}; - - const lastHomeId = state.getIn(['timelines', 'home', 'items'], ImmutableList()).find(item => item !== null); - const lastNotificationId = state.getIn(['notifications', 'lastReadId']); - - if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { - params.home = { - last_read_id: lastHomeId, - }; - } - - if (lastNotificationId && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) { - params.notifications = { - last_read_id: lastNotificationId, - }; - } - - return params; -}; - -const debouncedSubmitMarkers = debounce((dispatch, getState) => { - const accessToken = getState().getIn(['meta', 'access_token'], ''); - const params = _buildParams(getState()); - - if (Object.keys(params).length === 0 || accessToken === '') { - return; - } - - api(getState).post('/api/v1/markers', params).then(() => { - dispatch(submitMarkersSuccess(params)); - }).catch(() => {}); -}, 300000, { leading: true, trailing: true }); - -export function submitMarkersSuccess({ home, notifications }) { - return { - type: MARKERS_SUBMIT_SUCCESS, - home: (home || {}).last_read_id, - notifications: (notifications || {}).last_read_id, - }; -} - -export function submitMarkers(params = {}) { - const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); - - if (params.immediate === true) { - debouncedSubmitMarkers.flush(); - } - - return result; -} - -export const fetchMarkers = () => (dispatch, getState) => { - const params = { timeline: ['notifications'] }; - - dispatch(fetchMarkersRequest()); - - api(getState).get('/api/v1/markers', { params }).then(response => { - dispatch(fetchMarkersSuccess(response.data)); - }).catch(error => { - dispatch(fetchMarkersFail(error)); - }); -}; - -export function fetchMarkersRequest() { - return { - type: MARKERS_FETCH_REQUEST, - skipLoading: true, - }; -} - -export function fetchMarkersSuccess(markers) { - return { - type: MARKERS_FETCH_SUCCESS, - markers, - skipLoading: true, - }; -} - -export function fetchMarkersFail(error) { - return { - type: MARKERS_FETCH_FAIL, - error, - skipLoading: true, - skipAlert: true, - }; -} diff --git a/app/javascript/mastodon/actions/markers.ts b/app/javascript/mastodon/actions/markers.ts new file mode 100644 index 0000000000..4e90a9c6bd --- /dev/null +++ b/app/javascript/mastodon/actions/markers.ts @@ -0,0 +1,163 @@ +import { List as ImmutableList } from 'immutable'; + +import { debounce } from 'lodash'; + +import type { MarkerJSON } from 'mastodon/api_types/markers'; +import type { RootState } from 'mastodon/store'; +import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; + +import api, { authorizationTokenFromState } from '../api'; +import { compareId } from '../compare_id'; + +export const synchronouslySubmitMarkers = createAppAsyncThunk( + 'markers/submit', + async (_args, { getState }) => { + const accessToken = authorizationTokenFromState(getState); + const params = buildPostMarkersParams(getState()); + + if (Object.keys(params).length === 0 || !accessToken) { + return; + } + + // The Fetch API allows us to perform requests that will be carried out + // after the page closes. But that only works if the `keepalive` attribute + // is supported. + if ('fetch' in window && 'keepalive' in new Request('')) { + await fetch('/api/v1/markers', { + keepalive: true, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(params), + }); + + return; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if ('navigator' && 'sendBeacon' in navigator) { + // Failing that, we can use sendBeacon, but we have to encode the data as + // FormData for DoorKeeper to recognize the token. + const formData = new FormData(); + + formData.append('bearer_token', accessToken); + + for (const [id, value] of Object.entries(params)) { + if (value.last_read_id) + formData.append(`${id}[last_read_id]`, value.last_read_id); + } + + if (navigator.sendBeacon('/api/v1/markers', formData)) { + return; + } + } + + // If neither Fetch nor sendBeacon worked, try to perform a synchronous + // request. + try { + const client = new XMLHttpRequest(); + + client.open('POST', '/api/v1/markers', false); + client.setRequestHeader('Content-Type', 'application/json'); + client.setRequestHeader('Authorization', `Bearer ${accessToken}`); + client.send(JSON.stringify(params)); + } catch (e) { + // Do not make the BeforeUnload handler error out + } + }, +); + +interface MarkerParam { + last_read_id?: string; +} + +function getLastHomeId(state: RootState): string | undefined { + /* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ + return ( + state + // @ts-expect-error state.timelines is not yet typed + .getIn(['timelines', 'home', 'items'], ImmutableList()) + // @ts-expect-error state.timelines is not yet typed + .find((item) => item !== null) + ); +} + +function getLastNotificationId(state: RootState): string | undefined { + // @ts-expect-error state.notifications is not yet typed + return state.getIn(['notifications', 'lastReadId']); +} + +const buildPostMarkersParams = (state: RootState) => { + const params = {} as { home?: MarkerParam; notifications?: MarkerParam }; + + const lastHomeId = getLastHomeId(state); + const lastNotificationId = getLastNotificationId(state); + + if (lastHomeId && compareId(lastHomeId, state.markers.home) > 0) { + params.home = { + last_read_id: lastHomeId, + }; + } + + if ( + lastNotificationId && + compareId(lastNotificationId, state.markers.notifications) > 0 + ) { + params.notifications = { + last_read_id: lastNotificationId, + }; + } + + return params; +}; + +export const submitMarkersAction = createAppAsyncThunk<{ + home: string | undefined; + notifications: string | undefined; +}>('markers/submitAction', async (_args, { getState }) => { + const accessToken = authorizationTokenFromState(getState); + const params = buildPostMarkersParams(getState()); + + if (Object.keys(params).length === 0 || accessToken === '') { + return { home: undefined, notifications: undefined }; + } + + await api(getState).post('/api/v1/markers', params); + + return { + home: params.home?.last_read_id, + notifications: params.notifications?.last_read_id, + }; +}); + +const debouncedSubmitMarkers = debounce( + (dispatch) => { + dispatch(submitMarkersAction()); + }, + 300000, + { + leading: true, + trailing: true, + }, +); + +export const submitMarkers = createAppAsyncThunk( + 'markers/submit', + (params: { immediate?: boolean }, { dispatch }) => { + debouncedSubmitMarkers(dispatch); + + if (params.immediate) { + debouncedSubmitMarkers.flush(); + } + }, +); + +export const fetchMarkers = createAppAsyncThunk( + 'markers/fetch', + async (_args, { getState }) => { + const response = + await api(getState).get>(`/api/v1/markers`); + + return { markers: response.data }; + }, +); diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index f262fd8570..de597a3e3b 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -29,9 +29,14 @@ const setCSRFHeader = () => { void ready(setCSRFHeader); +export const authorizationTokenFromState = (getState?: GetState) => { + return ( + getState && (getState().meta.get('access_token', '') as string | false) + ); +}; + const authorizationHeaderFromState = (getState?: GetState) => { - const accessToken = - getState && (getState().meta.get('access_token', '') as string); + const accessToken = authorizationTokenFromState(getState); if (!accessToken) { return {}; diff --git a/app/javascript/mastodon/api_types/markers.ts b/app/javascript/mastodon/api_types/markers.ts new file mode 100644 index 0000000000..f7664fd7c1 --- /dev/null +++ b/app/javascript/mastodon/api_types/markers.ts @@ -0,0 +1,7 @@ +// See app/serializers/rest/account_serializer.rb + +export interface MarkerJSON { + last_read_id: string; + version: string; + updated_at: string; +} diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 3d42cc83f8..b88175e777 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -21,7 +21,7 @@ import history from './history'; import listAdder from './list_adder'; import listEditor from './list_editor'; import lists from './lists'; -import markers from './markers'; +import { markersReducer } from './markers'; import media_attachments from './media_attachments'; import meta from './meta'; import { modalReducer } from './modal'; @@ -77,7 +77,7 @@ const reducers = { suggestions, polls, trends, - markers, + markers: markersReducer, picture_in_picture, history, tags, diff --git a/app/javascript/mastodon/reducers/markers.js b/app/javascript/mastodon/reducers/markers.js deleted file mode 100644 index c7c5d99f61..0000000000 --- a/app/javascript/mastodon/reducers/markers.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Map as ImmutableMap } from 'immutable'; - -import { - MARKERS_SUBMIT_SUCCESS, -} from '../actions/markers'; - - -const initialState = ImmutableMap({ - home: '0', - notifications: '0', -}); - -export default function markers(state = initialState, action) { - switch(action.type) { - case MARKERS_SUBMIT_SUCCESS: - if (action.home) { - state = state.set('home', action.home); - } - if (action.notifications) { - state = state.set('notifications', action.notifications); - } - return state; - default: - return state; - } -} diff --git a/app/javascript/mastodon/reducers/markers.ts b/app/javascript/mastodon/reducers/markers.ts new file mode 100644 index 0000000000..ec85d0f173 --- /dev/null +++ b/app/javascript/mastodon/reducers/markers.ts @@ -0,0 +1,18 @@ +import { createReducer } from '@reduxjs/toolkit'; + +import { submitMarkersAction } from 'mastodon/actions/markers'; + +const initialState = { + home: '0', + notifications: '0', +}; + +export const markersReducer = createReducer(initialState, (builder) => { + builder.addCase( + submitMarkersAction.fulfilled, + (state, { payload: { home, notifications } }) => { + if (home) state.home = home; + if (notifications) state.notifications = notifications; + }, + ); +}); diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index bc85936439..7230fabcae 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -13,7 +13,7 @@ import { unfocusApp, } from '../actions/app'; import { - MARKERS_FETCH_SUCCESS, + fetchMarkers, } from '../actions/markers'; import { notificationsUpdate, @@ -255,8 +255,8 @@ const recountUnread = (state, last_read_id) => { export default function notifications(state = initialState, action) { switch(action.type) { - case MARKERS_FETCH_SUCCESS: - return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state; + case fetchMarkers.fulfilled.type: + return action.payload.markers.notifications ? recountUnread(state, action.payload.markers.notifications.last_read_id) : state; case NOTIFICATIONS_MOUNT: return updateMounted(state); case NOTIFICATIONS_UNMOUNT: diff --git a/app/serializers/rest/marker_serializer.rb b/app/serializers/rest/marker_serializer.rb index 2eaf3d5076..b0c1c553ff 100644 --- a/app/serializers/rest/marker_serializer.rb +++ b/app/serializers/rest/marker_serializer.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class REST::MarkerSerializer < ActiveModel::Serializer + # Please update `app/javascript/mastodon/api_types/markers.ts` when making changes to the attributes + attributes :last_read_id, :version, :updated_at def last_read_id