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