Rewrite markers reducer in Typescript (#27644)
This commit is contained in:
		
							parent
							
								
									d49343ed11
								
							
						
					
					
						commit
						27d014a7fa
					
				@ -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);
 | 
			
		||||
 | 
			
		||||
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 {};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										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 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,
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
} 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:
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user