mirror of
https://github.com/mastodon/mastodon.git
synced 2024-12-22 22:15:23 +01:00
Rewrite markers reducer in Typescript (#27644)
This commit is contained in:
parent
d49343ed11
commit
27d014a7fa
9 changed files with 202 additions and 185 deletions
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
163
app/javascript/mastodon/actions/markers.ts
Normal file
163
app/javascript/mastodon/actions/markers.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
);
|
|
@ -29,9 +29,14 @@ const setCSRFHeader = () => {
|
||||||
|
|
||||||
void ready(setCSRFHeader);
|
void ready(setCSRFHeader);
|
||||||
|
|
||||||
|
export const authorizationTokenFromState = (getState?: GetState) => {
|
||||||
|
return (
|
||||||
|
getState && (getState().meta.get('access_token', '') as string | false)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const authorizationHeaderFromState = (getState?: GetState) => {
|
const authorizationHeaderFromState = (getState?: GetState) => {
|
||||||
const accessToken =
|
const accessToken = authorizationTokenFromState(getState);
|
||||||
getState && (getState().meta.get('access_token', '') as string);
|
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return {};
|
return {};
|
||||||
|
|
7
app/javascript/mastodon/api_types/markers.ts
Normal file
7
app/javascript/mastodon/api_types/markers.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
// See app/serializers/rest/account_serializer.rb
|
||||||
|
|
||||||
|
export interface MarkerJSON {
|
||||||
|
last_read_id: string;
|
||||||
|
version: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
|
@ -21,7 +21,7 @@ import history from './history';
|
||||||
import listAdder from './list_adder';
|
import listAdder from './list_adder';
|
||||||
import listEditor from './list_editor';
|
import listEditor from './list_editor';
|
||||||
import lists from './lists';
|
import lists from './lists';
|
||||||
import markers from './markers';
|
import { markersReducer } from './markers';
|
||||||
import media_attachments from './media_attachments';
|
import media_attachments from './media_attachments';
|
||||||
import meta from './meta';
|
import meta from './meta';
|
||||||
import { modalReducer } from './modal';
|
import { modalReducer } from './modal';
|
||||||
|
@ -77,7 +77,7 @@ const reducers = {
|
||||||
suggestions,
|
suggestions,
|
||||||
polls,
|
polls,
|
||||||
trends,
|
trends,
|
||||||
markers,
|
markers: markersReducer,
|
||||||
picture_in_picture,
|
picture_in_picture,
|
||||||
history,
|
history,
|
||||||
tags,
|
tags,
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
18
app/javascript/mastodon/reducers/markers.ts
Normal file
18
app/javascript/mastodon/reducers/markers.ts
Normal 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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
|
@ -13,7 +13,7 @@ import {
|
||||||
unfocusApp,
|
unfocusApp,
|
||||||
} from '../actions/app';
|
} from '../actions/app';
|
||||||
import {
|
import {
|
||||||
MARKERS_FETCH_SUCCESS,
|
fetchMarkers,
|
||||||
} from '../actions/markers';
|
} from '../actions/markers';
|
||||||
import {
|
import {
|
||||||
notificationsUpdate,
|
notificationsUpdate,
|
||||||
|
@ -255,8 +255,8 @@ const recountUnread = (state, last_read_id) => {
|
||||||
|
|
||||||
export default function notifications(state = initialState, action) {
|
export default function notifications(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case MARKERS_FETCH_SUCCESS:
|
case fetchMarkers.fulfilled.type:
|
||||||
return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state;
|
return action.payload.markers.notifications ? recountUnread(state, action.payload.markers.notifications.last_read_id) : state;
|
||||||
case NOTIFICATIONS_MOUNT:
|
case NOTIFICATIONS_MOUNT:
|
||||||
return updateMounted(state);
|
return updateMounted(state);
|
||||||
case NOTIFICATIONS_UNMOUNT:
|
case NOTIFICATIONS_UNMOUNT:
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class REST::MarkerSerializer < ActiveModel::Serializer
|
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
|
attributes :last_read_id, :version, :updated_at
|
||||||
|
|
||||||
def last_read_id
|
def last_read_id
|
||||||
|
|
Loading…
Reference in a new issue