Rewrite markers reducer in Typescript (#27644)

pull/2699/head
Renaud Chaput 2024-03-27 13:47:09 +01:00 committed by GitHub
parent d49343ed11
commit 27d014a7fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 202 additions and 185 deletions

View File

@ -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,
};
}

View File

@ -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<MarkerJSON>('/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<Record<string, MarkerJSON>>(`/api/v1/markers`);
return { markers: response.data };
},
);

View File

@ -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 {};

View File

@ -0,0 +1,7 @@
// See app/serializers/rest/account_serializer.rb
export interface MarkerJSON {
last_read_id: string;
version: string;
updated_at: string;
}

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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;
},
);
});

View File

@ -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:

View File

@ -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