From 0115f064a013e240a3c1c90e89fd3fc46c23ee09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 18 Jun 2022 20:58:42 +0200 Subject: [PATCH] Actions: TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/actions/bookmarks.ts | 4 +- app/soapbox/actions/scheduled_statuses.js | 102 ------- app/soapbox/actions/scheduled_statuses.ts | 112 +++++++ app/soapbox/actions/search.js | 158 ---------- app/soapbox/actions/search.ts | 177 +++++++++++ app/soapbox/actions/streaming.js | 112 ------- app/soapbox/actions/streaming.ts | 144 +++++++++ app/soapbox/actions/timelines.js | 246 --------------- app/soapbox/actions/timelines.ts | 281 ++++++++++++++++++ app/soapbox/features/list_timeline/index.tsx | 17 +- .../features/remote_timeline/index.tsx | 2 +- app/soapbox/{stream.js => stream.ts} | 42 ++- 12 files changed, 753 insertions(+), 644 deletions(-) delete mode 100644 app/soapbox/actions/scheduled_statuses.js create mode 100644 app/soapbox/actions/scheduled_statuses.ts delete mode 100644 app/soapbox/actions/search.js create mode 100644 app/soapbox/actions/search.ts delete mode 100644 app/soapbox/actions/streaming.js create mode 100644 app/soapbox/actions/streaming.ts delete mode 100644 app/soapbox/actions/timelines.js create mode 100644 app/soapbox/actions/timelines.ts rename app/soapbox/{stream.js => stream.ts} (56%) diff --git a/app/soapbox/actions/bookmarks.ts b/app/soapbox/actions/bookmarks.ts index 8a0b6e2ab..090196e7b 100644 --- a/app/soapbox/actions/bookmarks.ts +++ b/app/soapbox/actions/bookmarks.ts @@ -18,7 +18,7 @@ const noOp = () => new Promise(f => f(undefined)); const fetchBookmarkedStatuses = () => (dispatch: AppDispatch, getState: () => RootState) => { - if (getState().status_lists.getIn(['bookmarks', 'isLoading'])) { + if (getState().status_lists.get('bookmarks')?.isLoading) { return dispatch(noOp); } @@ -52,7 +52,7 @@ const expandBookmarkedStatuses = () => (dispatch: AppDispatch, getState: () => RootState) => { const url = getState().status_lists.get('bookmarks')?.next || null; - if (url === null || getState().status_lists.getIn(['bookmarks', 'isLoading'])) { + if (url === null || getState().status_lists.get('bookmarks')?.isLoading) { return dispatch(noOp); } diff --git a/app/soapbox/actions/scheduled_statuses.js b/app/soapbox/actions/scheduled_statuses.js deleted file mode 100644 index fd6f3a241..000000000 --- a/app/soapbox/actions/scheduled_statuses.js +++ /dev/null @@ -1,102 +0,0 @@ -import api, { getLinks } from '../api'; - -export const SCHEDULED_STATUSES_FETCH_REQUEST = 'SCHEDULED_STATUSES_FETCH_REQUEST'; -export const SCHEDULED_STATUSES_FETCH_SUCCESS = 'SCHEDULED_STATUSES_FETCH_SUCCESS'; -export const SCHEDULED_STATUSES_FETCH_FAIL = 'SCHEDULED_STATUSES_FETCH_FAIL'; - -export const SCHEDULED_STATUSES_EXPAND_REQUEST = 'SCHEDULED_STATUSES_EXPAND_REQUEST'; -export const SCHEDULED_STATUSES_EXPAND_SUCCESS = 'SCHEDULED_STATUSES_EXPAND_SUCCESS'; -export const SCHEDULED_STATUSES_EXPAND_FAIL = 'SCHEDULED_STATUSES_EXPAND_FAIL'; - -export const SCHEDULED_STATUS_CANCEL_REQUEST = 'SCHEDULED_STATUS_CANCEL_REQUEST'; -export const SCHEDULED_STATUS_CANCEL_SUCCESS = 'SCHEDULED_STATUS_CANCEL_SUCCESS'; -export const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL'; - -export function fetchScheduledStatuses() { - return (dispatch, getState) => { - if (getState().getIn(['status_lists', 'scheduled_statuses', 'isLoading'])) { - return; - } - - dispatch(fetchScheduledStatusesRequest()); - - api(getState).get('/api/v1/scheduled_statuses').then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(fetchScheduledStatusesSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(fetchScheduledStatusesFail(error)); - }); - }; -} - -export function cancelScheduledStatus(id) { - return (dispatch, getState) => { - dispatch({ type: SCHEDULED_STATUS_CANCEL_REQUEST, id }); - api(getState).delete(`/api/v1/scheduled_statuses/${id}`).then(({ data }) => { - dispatch({ type: SCHEDULED_STATUS_CANCEL_SUCCESS, id, data }); - }).catch(error => { - dispatch({ type: SCHEDULED_STATUS_CANCEL_FAIL, id, error }); - }); - }; -} - -export function fetchScheduledStatusesRequest() { - return { - type: SCHEDULED_STATUSES_FETCH_REQUEST, - }; -} - -export function fetchScheduledStatusesSuccess(statuses, next) { - return { - type: SCHEDULED_STATUSES_FETCH_SUCCESS, - statuses, - next, - }; -} - -export function fetchScheduledStatusesFail(error) { - return { - type: SCHEDULED_STATUSES_FETCH_FAIL, - error, - }; -} - -export function expandScheduledStatuses() { - return (dispatch, getState) => { - const url = getState().getIn(['status_lists', 'scheduled_statuses', 'next'], null); - - if (url === null || getState().getIn(['status_lists', 'scheduled_statuses', 'isLoading'])) { - return; - } - - dispatch(expandScheduledStatusesRequest()); - - api(getState).get(url).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(expandScheduledStatusesSuccess(response.data, next ? next.uri : null)); - }).catch(error => { - dispatch(expandScheduledStatusesFail(error)); - }); - }; -} - -export function expandScheduledStatusesRequest() { - return { - type: SCHEDULED_STATUSES_EXPAND_REQUEST, - }; -} - -export function expandScheduledStatusesSuccess(statuses, next) { - return { - type: SCHEDULED_STATUSES_EXPAND_SUCCESS, - statuses, - next, - }; -} - -export function expandScheduledStatusesFail(error) { - return { - type: SCHEDULED_STATUSES_EXPAND_FAIL, - error, - }; -} diff --git a/app/soapbox/actions/scheduled_statuses.ts b/app/soapbox/actions/scheduled_statuses.ts new file mode 100644 index 000000000..ddc550105 --- /dev/null +++ b/app/soapbox/actions/scheduled_statuses.ts @@ -0,0 +1,112 @@ +import api, { getLinks } from '../api'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const SCHEDULED_STATUSES_FETCH_REQUEST = 'SCHEDULED_STATUSES_FETCH_REQUEST'; +const SCHEDULED_STATUSES_FETCH_SUCCESS = 'SCHEDULED_STATUSES_FETCH_SUCCESS'; +const SCHEDULED_STATUSES_FETCH_FAIL = 'SCHEDULED_STATUSES_FETCH_FAIL'; + +const SCHEDULED_STATUSES_EXPAND_REQUEST = 'SCHEDULED_STATUSES_EXPAND_REQUEST'; +const SCHEDULED_STATUSES_EXPAND_SUCCESS = 'SCHEDULED_STATUSES_EXPAND_SUCCESS'; +const SCHEDULED_STATUSES_EXPAND_FAIL = 'SCHEDULED_STATUSES_EXPAND_FAIL'; + +const SCHEDULED_STATUS_CANCEL_REQUEST = 'SCHEDULED_STATUS_CANCEL_REQUEST'; +const SCHEDULED_STATUS_CANCEL_SUCCESS = 'SCHEDULED_STATUS_CANCEL_SUCCESS'; +const SCHEDULED_STATUS_CANCEL_FAIL = 'SCHEDULED_STATUS_CANCEL_FAIL'; + +const fetchScheduledStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + if (getState().status_lists.get('scheduled_statuses')?.isLoading) { + return; + } + + dispatch(fetchScheduledStatusesRequest()); + + api(getState).get('/api/v1/scheduled_statuses').then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(fetchScheduledStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(fetchScheduledStatusesFail(error)); + }); + }; + +const cancelScheduledStatus = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: SCHEDULED_STATUS_CANCEL_REQUEST, id }); + api(getState).delete(`/api/v1/scheduled_statuses/${id}`).then(({ data }) => { + dispatch({ type: SCHEDULED_STATUS_CANCEL_SUCCESS, id, data }); + }).catch(error => { + dispatch({ type: SCHEDULED_STATUS_CANCEL_FAIL, id, error }); + }); + }; + +const fetchScheduledStatusesRequest = () => ({ + type: SCHEDULED_STATUSES_FETCH_REQUEST, +}); + +const fetchScheduledStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: SCHEDULED_STATUSES_FETCH_SUCCESS, + statuses, + next, +}); + +const fetchScheduledStatusesFail = (error: AxiosError) => ({ + type: SCHEDULED_STATUSES_FETCH_FAIL, + error, +}); + +const expandScheduledStatuses = () => + (dispatch: AppDispatch, getState: () => RootState) => { + const url = getState().status_lists.get('scheduled_statuses')?.next || null; + + if (url === null || getState().status_lists.get('scheduled_statuses')?.isLoading) { + return; + } + + dispatch(expandScheduledStatusesRequest()); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(expandScheduledStatusesSuccess(response.data, next ? next.uri : null)); + }).catch(error => { + dispatch(expandScheduledStatusesFail(error)); + }); + }; + +const expandScheduledStatusesRequest = () => ({ + type: SCHEDULED_STATUSES_EXPAND_REQUEST, +}); + +const expandScheduledStatusesSuccess = (statuses: APIEntity[], next: string | null) => ({ + type: SCHEDULED_STATUSES_EXPAND_SUCCESS, + statuses, + next, +}); + +const expandScheduledStatusesFail = (error: AxiosError) => ({ + type: SCHEDULED_STATUSES_EXPAND_FAIL, + error, +}); + +export { + SCHEDULED_STATUSES_FETCH_REQUEST, + SCHEDULED_STATUSES_FETCH_SUCCESS, + SCHEDULED_STATUSES_FETCH_FAIL, + SCHEDULED_STATUSES_EXPAND_REQUEST, + SCHEDULED_STATUSES_EXPAND_SUCCESS, + SCHEDULED_STATUSES_EXPAND_FAIL, + SCHEDULED_STATUS_CANCEL_REQUEST, + SCHEDULED_STATUS_CANCEL_SUCCESS, + SCHEDULED_STATUS_CANCEL_FAIL, + fetchScheduledStatuses, + cancelScheduledStatus, + fetchScheduledStatusesRequest, + fetchScheduledStatusesSuccess, + fetchScheduledStatusesFail, + expandScheduledStatuses, + expandScheduledStatusesRequest, + expandScheduledStatusesSuccess, + expandScheduledStatusesFail, +}; diff --git a/app/soapbox/actions/search.js b/app/soapbox/actions/search.js deleted file mode 100644 index 27cb4bdbd..000000000 --- a/app/soapbox/actions/search.js +++ /dev/null @@ -1,158 +0,0 @@ -import api from '../api'; - -import { fetchRelationships } from './accounts'; -import { importFetchedAccounts, importFetchedStatuses } from './importer'; - -export const SEARCH_CHANGE = 'SEARCH_CHANGE'; -export const SEARCH_CLEAR = 'SEARCH_CLEAR'; -export const SEARCH_SHOW = 'SEARCH_SHOW'; - -export const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; -export const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; -export const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; - -export const SEARCH_FILTER_SET = 'SEARCH_FILTER_SET'; - -export const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; -export const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; -export const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; - -export function changeSearch(value) { - return (dispatch, getState) => { - // If backspaced all the way, clear the search - if (value.length === 0) { - return dispatch(clearSearch()); - } else { - return dispatch({ - type: SEARCH_CHANGE, - value, - }); - } - }; -} - -export function clearSearch() { - return { - type: SEARCH_CLEAR, - }; -} - -export function submitSearch(filter) { - return (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const type = filter || getState().getIn(['search', 'filter'], 'accounts'); - - // An empty search doesn't return any results - if (value.length === 0) { - return; - } - - dispatch(fetchSearchRequest(value)); - - api(getState).get('/api/v2/search', { - params: { - q: value, - resolve: true, - limit: 20, - type, - }, - }).then(response => { - if (response.data.accounts) { - dispatch(importFetchedAccounts(response.data.accounts)); - } - - if (response.data.statuses) { - dispatch(importFetchedStatuses(response.data.statuses)); - } - - dispatch(fetchSearchSuccess(response.data, value, type)); - dispatch(fetchRelationships(response.data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(fetchSearchFail(error)); - }); - }; -} - -export function fetchSearchRequest(value) { - return { - type: SEARCH_FETCH_REQUEST, - value, - }; -} - -export function fetchSearchSuccess(results, searchTerm, searchType) { - return { - type: SEARCH_FETCH_SUCCESS, - results, - searchTerm, - searchType, - }; -} - -export function fetchSearchFail(error) { - return { - type: SEARCH_FETCH_FAIL, - error, - }; -} - -export function setFilter(filterType) { - return (dispatch) => { - dispatch(submitSearch(filterType)); - - dispatch({ - type: SEARCH_FILTER_SET, - path: ['search', 'filter'], - value: filterType, - }); - }; -} - -export const expandSearch = type => (dispatch, getState) => { - const value = getState().getIn(['search', 'value']); - const offset = getState().getIn(['search', 'results', type]).size; - - dispatch(expandSearchRequest(type)); - - api(getState).get('/api/v2/search', { - params: { - q: value, - type, - offset, - }, - }).then(({ data }) => { - if (data.accounts) { - dispatch(importFetchedAccounts(data.accounts)); - } - - if (data.statuses) { - dispatch(importFetchedStatuses(data.statuses)); - } - - dispatch(expandSearchSuccess(data, value, type)); - dispatch(fetchRelationships(data.accounts.map(item => item.id))); - }).catch(error => { - dispatch(expandSearchFail(error)); - }); -}; - -export const expandSearchRequest = (searchType) => ({ - type: SEARCH_EXPAND_REQUEST, - searchType, -}); - -export const expandSearchSuccess = (results, searchTerm, searchType) => ({ - type: SEARCH_EXPAND_SUCCESS, - results, - searchTerm, - searchType, -}); - -export const expandSearchFail = error => ({ - type: SEARCH_EXPAND_FAIL, - error, -}); - -export const showSearch = () => ({ - type: SEARCH_SHOW, -}); diff --git a/app/soapbox/actions/search.ts b/app/soapbox/actions/search.ts new file mode 100644 index 000000000..1eb749e78 --- /dev/null +++ b/app/soapbox/actions/search.ts @@ -0,0 +1,177 @@ +import api from '../api'; + +import { fetchRelationships } from './accounts'; +import { importFetchedAccounts, importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { SearchFilter } from 'soapbox/reducers/search'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const SEARCH_CHANGE = 'SEARCH_CHANGE'; +const SEARCH_CLEAR = 'SEARCH_CLEAR'; +const SEARCH_SHOW = 'SEARCH_SHOW'; + +const SEARCH_FETCH_REQUEST = 'SEARCH_FETCH_REQUEST'; +const SEARCH_FETCH_SUCCESS = 'SEARCH_FETCH_SUCCESS'; +const SEARCH_FETCH_FAIL = 'SEARCH_FETCH_FAIL'; + +const SEARCH_FILTER_SET = 'SEARCH_FILTER_SET'; + +const SEARCH_EXPAND_REQUEST = 'SEARCH_EXPAND_REQUEST'; +const SEARCH_EXPAND_SUCCESS = 'SEARCH_EXPAND_SUCCESS'; +const SEARCH_EXPAND_FAIL = 'SEARCH_EXPAND_FAIL'; + +const changeSearch = (value: string) => + (dispatch: AppDispatch) => { + // If backspaced all the way, clear the search + if (value.length === 0) { + return dispatch(clearSearch()); + } else { + return dispatch({ + type: SEARCH_CHANGE, + value, + }); + } + }; + +const clearSearch = () => ({ + type: SEARCH_CLEAR, +}); + +const submitSearch = (filter?: SearchFilter) => + (dispatch: AppDispatch, getState: () => RootState) => { + const value = getState().search.value; + const type = filter || getState().search.filter || 'accounts'; + + // An empty search doesn't return any results + if (value.length === 0) { + return; + } + + dispatch(fetchSearchRequest(value)); + + api(getState).get('/api/v2/search', { + params: { + q: value, + resolve: true, + limit: 20, + type, + }, + }).then(response => { + if (response.data.accounts) { + dispatch(importFetchedAccounts(response.data.accounts)); + } + + if (response.data.statuses) { + dispatch(importFetchedStatuses(response.data.statuses)); + } + + dispatch(fetchSearchSuccess(response.data, value, type)); + dispatch(fetchRelationships(response.data.accounts.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(fetchSearchFail(error)); + }); + }; + +const fetchSearchRequest = (value: string) => ({ + type: SEARCH_FETCH_REQUEST, + value, +}); + +const fetchSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter) => ({ + type: SEARCH_FETCH_SUCCESS, + results, + searchTerm, + searchType, +}); + +const fetchSearchFail = (error: AxiosError) => ({ + type: SEARCH_FETCH_FAIL, + error, +}); + +const setFilter = (filterType: SearchFilter) => + (dispatch: AppDispatch) => { + dispatch(submitSearch(filterType)); + + dispatch({ + type: SEARCH_FILTER_SET, + path: ['search', 'filter'], + value: filterType, + }); + }; + +const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => { + const value = getState().search.value; + const offset = getState().search.results[type].size; + + dispatch(expandSearchRequest(type)); + + api(getState).get('/api/v2/search', { + params: { + q: value, + type, + offset, + }, + }).then(({ data }) => { + if (data.accounts) { + dispatch(importFetchedAccounts(data.accounts)); + } + + if (data.statuses) { + dispatch(importFetchedStatuses(data.statuses)); + } + + dispatch(expandSearchSuccess(data, value, type)); + dispatch(fetchRelationships(data.accounts.map((item: APIEntity) => item.id))); + }).catch(error => { + dispatch(expandSearchFail(error)); + }); +}; + +const expandSearchRequest = (searchType: SearchFilter) => ({ + type: SEARCH_EXPAND_REQUEST, + searchType, +}); + +const expandSearchSuccess = (results: APIEntity[], searchTerm: string, searchType: SearchFilter) => ({ + type: SEARCH_EXPAND_SUCCESS, + results, + searchTerm, + searchType, +}); + +const expandSearchFail = (error: AxiosError) => ({ + type: SEARCH_EXPAND_FAIL, + error, +}); + +const showSearch = () => ({ + type: SEARCH_SHOW, +}); + +export { + SEARCH_CHANGE, + SEARCH_CLEAR, + SEARCH_SHOW, + SEARCH_FETCH_REQUEST, + SEARCH_FETCH_SUCCESS, + SEARCH_FETCH_FAIL, + SEARCH_FILTER_SET, + SEARCH_EXPAND_REQUEST, + SEARCH_EXPAND_SUCCESS, + SEARCH_EXPAND_FAIL, + changeSearch, + clearSearch, + submitSearch, + fetchSearchRequest, + fetchSearchSuccess, + fetchSearchFail, + setFilter, + expandSearch, + expandSearchRequest, + expandSearchSuccess, + expandSearchFail, + showSearch, +}; diff --git a/app/soapbox/actions/streaming.js b/app/soapbox/actions/streaming.js deleted file mode 100644 index 51b09fca8..000000000 --- a/app/soapbox/actions/streaming.js +++ /dev/null @@ -1,112 +0,0 @@ -import { getSettings } from 'soapbox/actions/settings'; -import messages from 'soapbox/locales/messages'; - -import { connectStream } from '../stream'; - -import { updateConversations } from './conversations'; -import { fetchFilters } from './filters'; -import { updateNotificationsQueue, expandNotifications } from './notifications'; -import { updateStatus } from './statuses'; -import { - deleteFromTimelines, - expandHomeTimeline, - connectTimeline, - disconnectTimeline, - processTimelineUpdate, -} from './timelines'; - -export const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; -export const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE'; - -const validLocale = locale => Object.keys(messages).includes(locale); - -const getLocale = state => { - const locale = getSettings(state).get('locale'); - return validLocale(locale) ? locale : 'en'; -}; - -function updateFollowRelationships(relationships) { - return (dispatch, getState) => { - const me = getState().get('me'); - return dispatch({ - type: STREAMING_FOLLOW_RELATIONSHIPS_UPDATE, - me, - ...relationships, - }); - }; -} - -export function connectTimelineStream(timelineId, path, pollingRefresh = null, accept = null) { - - return connectStream (path, pollingRefresh, (dispatch, getState) => { - const locale = getLocale(getState()); - - return { - onConnect() { - dispatch(connectTimeline(timelineId)); - }, - - onDisconnect() { - dispatch(disconnectTimeline(timelineId)); - }, - - onReceive(data) { - switch (data.event) { - case 'update': - dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept)); - break; - case 'status.update': - dispatch(updateStatus(JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - case 'notification': - messages[locale]().then(messages => { - dispatch(updateNotificationsQueue(JSON.parse(data.payload), messages, locale, window.location.pathname)); - }).catch(error => { - console.error(error); - }); - break; - case 'conversation': - dispatch(updateConversations(JSON.parse(data.payload))); - break; - case 'filters_changed': - dispatch(fetchFilters()); - break; - case 'pleroma:chat_update': - dispatch((dispatch, getState) => { - const chat = JSON.parse(data.payload); - const me = getState().get('me'); - const messageOwned = !(chat.last_message && chat.last_message.account_id !== me); - - dispatch({ - type: STREAMING_CHAT_UPDATE, - chat, - me, - // Only play sounds for recipient messages - meta: !messageOwned && getSettings(getState()).getIn(['chats', 'sound']) && { sound: 'chat' }, - }); - }); - break; - case 'pleroma:follow_relationships_update': - dispatch(updateFollowRelationships(JSON.parse(data.payload))); - break; - } - }, - }; - }); -} - -const refreshHomeTimelineAndNotification = (dispatch, done) => { - dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done)))); -}; - -export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); -export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); -export const connectPublicStream = ({ onlyMedia } = {}) => connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`); -export const connectRemoteStream = (instance, { onlyMedia } = {}) => connectTimelineStream(`remote${onlyMedia ? ':media' : ''}:${instance}`, `public:remote${onlyMedia ? ':media' : ''}&instance=${instance}`); -export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept); -export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); -export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); -export const connectGroupStream = id => connectTimelineStream(`group:${id}`, `group&group=${id}`); diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts new file mode 100644 index 000000000..58739eec5 --- /dev/null +++ b/app/soapbox/actions/streaming.ts @@ -0,0 +1,144 @@ +import { getSettings } from 'soapbox/actions/settings'; +import messages from 'soapbox/locales/messages'; + +import { connectStream } from '../stream'; + +import { updateConversations } from './conversations'; +import { fetchFilters } from './filters'; +import { updateNotificationsQueue, expandNotifications } from './notifications'; +import { updateStatus } from './statuses'; +import { + deleteFromTimelines, + expandHomeTimeline, + connectTimeline, + disconnectTimeline, + processTimelineUpdate, +} from './timelines'; + +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity } from 'soapbox/types/entities'; + +const STREAMING_CHAT_UPDATE = 'STREAMING_CHAT_UPDATE'; +const STREAMING_FOLLOW_RELATIONSHIPS_UPDATE = 'STREAMING_FOLLOW_RELATIONSHIPS_UPDATE'; + +const validLocale = (locale: string) => Object.keys(messages).includes(locale); + +const getLocale = (state: RootState) => { + const locale = getSettings(state).get('locale') as string; + return validLocale(locale) ? locale : 'en'; +}; + +const updateFollowRelationships = (relationships: APIEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + const me = getState().me; + return dispatch({ + type: STREAMING_FOLLOW_RELATIONSHIPS_UPDATE, + me, + ...relationships, + }); + }; + +const connectTimelineStream = ( + timelineId: string, + path: string, + pollingRefresh: ((dispatch: AppDispatch, done?: () => void) => void) | null = null, + accept: ((status: APIEntity) => boolean) | null = null, +) => connectStream(path, pollingRefresh, (dispatch: AppDispatch, getState: () => RootState) => { + const locale = getLocale(getState()); + + return { + onConnect() { + dispatch(connectTimeline(timelineId)); + }, + + onDisconnect() { + dispatch(disconnectTimeline(timelineId)); + }, + + onReceive(data: any) { + switch (data.event) { + case 'update': + dispatch(processTimelineUpdate(timelineId, JSON.parse(data.payload), accept)); + break; + case 'status.update': + dispatch(updateStatus(JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + messages[locale]().then(messages => { + dispatch(updateNotificationsQueue(JSON.parse(data.payload), messages, locale, window.location.pathname)); + }).catch(error => { + console.error(error); + }); + break; + case 'conversation': + dispatch(updateConversations(JSON.parse(data.payload))); + break; + case 'filters_changed': + dispatch(fetchFilters()); + break; + case 'pleroma:chat_update': + dispatch((dispatch: AppDispatch, getState: () => RootState) => { + const chat = JSON.parse(data.payload); + const me = getState().me; + const messageOwned = !(chat.last_message && chat.last_message.account_id !== me); + + dispatch({ + type: STREAMING_CHAT_UPDATE, + chat, + me, + // Only play sounds for recipient messages + meta: !messageOwned && getSettings(getState()).getIn(['chats', 'sound']) && { sound: 'chat' }, + }); + }); + break; + case 'pleroma:follow_relationships_update': + dispatch(updateFollowRelationships(JSON.parse(data.payload))); + break; + } + }, + }; +}); + +const refreshHomeTimelineAndNotification = (dispatch: AppDispatch, done?: () => void) => + dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({} as any, done)))); + +const connectUserStream = () => + connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); + +const connectCommunityStream = ({ onlyMedia }: Record = {}) => + connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`); + +const connectPublicStream = ({ onlyMedia }: Record = {}) => + connectTimelineStream(`public${onlyMedia ? ':media' : ''}`, `public${onlyMedia ? ':media' : ''}`); + +const connectRemoteStream = (instance: string, { onlyMedia }: Record = {}) => + connectTimelineStream(`remote${onlyMedia ? ':media' : ''}:${instance}`, `public:remote${onlyMedia ? ':media' : ''}&instance=${instance}`); + +const connectHashtagStream = (id: string, tag: string, accept: (status: APIEntity) => boolean) => + connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept); + +const connectDirectStream = () => + connectTimelineStream('direct', 'direct'); + +const connectListStream = (id: string) => + connectTimelineStream(`list:${id}`, `list&list=${id}`); + +const connectGroupStream = (id: string) => + connectTimelineStream(`group:${id}`, `group&group=${id}`); + +export { + STREAMING_CHAT_UPDATE, + STREAMING_FOLLOW_RELATIONSHIPS_UPDATE, + connectTimelineStream, + connectUserStream, + connectCommunityStream, + connectPublicStream, + connectRemoteStream, + connectHashtagStream, + connectDirectStream, + connectListStream, + connectGroupStream, +}; diff --git a/app/soapbox/actions/timelines.js b/app/soapbox/actions/timelines.js deleted file mode 100644 index 12223c0ba..000000000 --- a/app/soapbox/actions/timelines.js +++ /dev/null @@ -1,246 +0,0 @@ -import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; - -import { getSettings } from 'soapbox/actions/settings'; -import { shouldFilter } from 'soapbox/utils/timelines'; - -import api, { getLinks } from '../api'; - -import { importFetchedStatus, importFetchedStatuses } from './importer'; - -export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; -export const TIMELINE_DELETE = 'TIMELINE_DELETE'; -export const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; -export const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE'; -export const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE'; -export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; - -export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; -export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; -export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; - -export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; -export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; - -export const MAX_QUEUED_ITEMS = 40; - -export function processTimelineUpdate(timeline, status, accept) { - return (dispatch, getState) => { - const me = getState().get('me'); - const ownStatus = status.account?.id === me; - const hasPendingStatuses = !getState().get('pending_statuses').isEmpty(); - - const columnSettings = getSettings(getState()).get(timeline, ImmutableMap()); - const shouldSkipQueue = shouldFilter(fromJS(status), columnSettings); - - if (ownStatus && hasPendingStatuses) { - // WebSockets push statuses without the Idempotency-Key, - // so if we have pending statuses, don't import it from here. - // We implement optimistic non-blocking statuses. - return; - } - - dispatch(importFetchedStatus(status)); - - if (shouldSkipQueue) { - dispatch(updateTimeline(timeline, status.id, accept)); - } else { - dispatch(updateTimelineQueue(timeline, status.id, accept)); - } - }; -} - -export function updateTimeline(timeline, statusId, accept) { - return dispatch => { - if (typeof accept === 'function' && !accept(status)) { - return; - } - - dispatch({ - type: TIMELINE_UPDATE, - timeline, - statusId, - }); - }; -} - -export function updateTimelineQueue(timeline, statusId, accept) { - return dispatch => { - if (typeof accept === 'function' && !accept(status)) { - return; - } - - dispatch({ - type: TIMELINE_UPDATE_QUEUE, - timeline, - statusId, - }); - }; -} - -export function dequeueTimeline(timelineId, expandFunc, optionalExpandArgs) { - return (dispatch, getState) => { - const state = getState(); - const queuedCount = state.getIn(['timelines', timelineId, 'totalQueuedItemsCount'], 0); - - if (queuedCount <= 0) return; - - if (queuedCount <= MAX_QUEUED_ITEMS) { - dispatch({ type: TIMELINE_DEQUEUE, timeline: timelineId }); - return; - } - - if (typeof expandFunc === 'function') { - dispatch(clearTimeline(timelineId)); - expandFunc(); - } else { - if (timelineId === 'home') { - dispatch(clearTimeline(timelineId)); - dispatch(expandHomeTimeline(optionalExpandArgs)); - } else if (timelineId === 'community') { - dispatch(clearTimeline(timelineId)); - dispatch(expandCommunityTimeline(optionalExpandArgs)); - } - } - }; -} - -export function deleteFromTimelines(id) { - return (dispatch, getState) => { - const accountId = getState().getIn(['statuses', id, 'account']); - const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); - const reblogOf = getState().getIn(['statuses', id, 'reblog'], null); - - dispatch({ - type: TIMELINE_DELETE, - id, - accountId, - references, - reblogOf, - }); - }; -} - -export function clearTimeline(timeline) { - return (dispatch) => { - dispatch({ type: TIMELINE_CLEAR, timeline }); - }; -} - -const noOp = () => {}; -const noOpAsync = () => () => new Promise(f => f()); - -const parseTags = (tags = {}, mode) => { - return (tags[mode] || []).map((tag) => { - return tag.value; - }); -}; - -export function expandTimeline(timelineId, path, params = {}, done = noOp) { - return (dispatch, getState) => { - const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); - const isLoadingMore = !!params.max_id; - - if (timeline.get('isLoading')) { - done(); - return dispatch(noOpAsync()); - } - - if (!params.max_id && !params.pinned && timeline.get('items', ImmutableOrderedSet()).size > 0) { - params.since_id = timeline.getIn(['items', 0]); - } - - const isLoadingRecent = !!params.since_id; - - dispatch(expandTimelineRequest(timelineId, isLoadingMore)); - - return api(getState).get(path, { params }).then(response => { - const next = getLinks(response).refs.find(link => link.rel === 'next'); - dispatch(importFetchedStatuses(response.data)); - dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore)); - done(); - }).catch(error => { - dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); - done(); - }); - }; -} - -export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); - -export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); - -export const expandRemoteTimeline = (instance, { maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, '/api/v1/timelines/public', { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done); - -export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); - -export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); - -export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId, with_muted: true }); - -export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, with_muted: true }); - -export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); - -export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); - -export const expandGroupTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); - -export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { - return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { - max_id: maxId, - any: parseTags(tags, 'any'), - all: parseTags(tags, 'all'), - none: parseTags(tags, 'none'), - }, done); -}; - -export function expandTimelineRequest(timeline, isLoadingMore) { - return { - type: TIMELINE_EXPAND_REQUEST, - timeline, - skipLoading: !isLoadingMore, - }; -} - -export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) { - return { - type: TIMELINE_EXPAND_SUCCESS, - timeline, - statuses, - next, - partial, - isLoadingRecent, - skipLoading: !isLoadingMore, - }; -} - -export function expandTimelineFail(timeline, error, isLoadingMore) { - return { - type: TIMELINE_EXPAND_FAIL, - timeline, - error, - skipLoading: !isLoadingMore, - }; -} - -export function connectTimeline(timeline) { - return { - type: TIMELINE_CONNECT, - timeline, - }; -} - -export function disconnectTimeline(timeline) { - return { - type: TIMELINE_DISCONNECT, - timeline, - }; -} - -export function scrollTopTimeline(timeline, top) { - return { - type: TIMELINE_SCROLL_TOP, - timeline, - top, - }; -} diff --git a/app/soapbox/actions/timelines.ts b/app/soapbox/actions/timelines.ts new file mode 100644 index 000000000..ec8615def --- /dev/null +++ b/app/soapbox/actions/timelines.ts @@ -0,0 +1,281 @@ +import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; + +import { getSettings } from 'soapbox/actions/settings'; +import { normalizeStatus } from 'soapbox/normalizers'; +import { shouldFilter } from 'soapbox/utils/timelines'; + +import api, { getLinks } from '../api'; + +import { importFetchedStatus, importFetchedStatuses } from './importer'; + +import type { AxiosError } from 'axios'; +import type { AppDispatch, RootState } from 'soapbox/store'; +import type { APIEntity, Status } from 'soapbox/types/entities'; + +const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; +const TIMELINE_DELETE = 'TIMELINE_DELETE'; +const TIMELINE_CLEAR = 'TIMELINE_CLEAR'; +const TIMELINE_UPDATE_QUEUE = 'TIMELINE_UPDATE_QUEUE'; +const TIMELINE_DEQUEUE = 'TIMELINE_DEQUEUE'; +const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; + +const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; +const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; +const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; + +const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; +const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; + +const MAX_QUEUED_ITEMS = 40; + +const processTimelineUpdate = (timeline: string, status: APIEntity, accept: ((status: APIEntity) => boolean) | null) => + (dispatch: AppDispatch, getState: () => RootState) => { + const me = getState().me; + const ownStatus = status.account?.id === me; + const hasPendingStatuses = !getState().pending_statuses.isEmpty(); + + const columnSettings = getSettings(getState()).get(timeline, ImmutableMap()); + const shouldSkipQueue = shouldFilter(normalizeStatus(status) as Status, columnSettings); + + if (ownStatus && hasPendingStatuses) { + // WebSockets push statuses without the Idempotency-Key, + // so if we have pending statuses, don't import it from here. + // We implement optimistic non-blocking statuses. + return; + } + + dispatch(importFetchedStatus(status)); + + if (shouldSkipQueue) { + dispatch(updateTimeline(timeline, status.id, accept)); + } else { + dispatch(updateTimelineQueue(timeline, status.id, accept)); + } + }; + +const updateTimeline = (timeline: string, statusId: string, accept: ((status: APIEntity) => boolean) | null) => + (dispatch: AppDispatch) => { + // if (typeof accept === 'function' && !accept(status)) { + // return; + // } + + dispatch({ + type: TIMELINE_UPDATE, + timeline, + statusId, + }); + }; + +const updateTimelineQueue = (timeline: string, statusId: string, accept: ((status: APIEntity) => boolean) | null) => + (dispatch: AppDispatch) => { + // if (typeof accept === 'function' && !accept(status)) { + // return; + // } + + dispatch({ + type: TIMELINE_UPDATE_QUEUE, + timeline, + statusId, + }); + }; + +const dequeueTimeline = (timelineId: string, expandFunc?: (lastStatusId: string) => void, optionalExpandArgs?: any) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + const queuedCount = state.timelines.getIn([timelineId, 'totalQueuedItemsCount'], 0); + + if (queuedCount <= 0) return; + + if (queuedCount <= MAX_QUEUED_ITEMS) { + dispatch({ type: TIMELINE_DEQUEUE, timeline: timelineId }); + return; + } + + if (typeof expandFunc === 'function') { + dispatch(clearTimeline(timelineId)); + // @ts-ignore + expandFunc(); + } else { + if (timelineId === 'home') { + dispatch(clearTimeline(timelineId)); + dispatch(expandHomeTimeline(optionalExpandArgs)); + } else if (timelineId === 'community') { + dispatch(clearTimeline(timelineId)); + dispatch(expandCommunityTimeline(optionalExpandArgs)); + } + } + }; + +const deleteFromTimelines = (id: string) => + (dispatch: AppDispatch, getState: () => RootState) => { + const accountId = getState().statuses.get(id)?.account; + const references = getState().statuses.filter(status => status.get('reblog') === id).map(status => [status.get('id'), status.get('account')]); + const reblogOf = getState().statuses.getIn([id, 'reblog'], null); + + dispatch({ + type: TIMELINE_DELETE, + id, + accountId, + references, + reblogOf, + }); + }; + +const clearTimeline = (timeline: string) => + (dispatch: AppDispatch) => + dispatch({ type: TIMELINE_CLEAR, timeline }); + +const noOp = () => {}; +const noOpAsync = () => () => new Promise(f => f(undefined)); + +const parseTags = (tags: Record = {}, mode: 'any' | 'all' | 'none') => { + return (tags[mode] || []).map((tag) => { + return tag.value; + }); +}; + +const expandTimeline = (timelineId: string, path: string, params: Record = {}, done = noOp) => + (dispatch: AppDispatch, getState: () => RootState) => { + const timeline = getState().timelines.get(timelineId) || ImmutableMap(); + const isLoadingMore = !!params.max_id; + + if (timeline.get('isLoading')) { + done(); + return dispatch(noOpAsync()); + } + + if (!params.max_id && !params.pinned && timeline.get('items', ImmutableOrderedSet()).size > 0) { + params.since_id = timeline.getIn(['items', 0]); + } + + const isLoadingRecent = !!params.since_id; + + dispatch(expandTimelineRequest(timelineId, isLoadingMore)); + + return api(getState).get(path, { params }).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedStatuses(response.data)); + dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore)); + done(); + }).catch(error => { + dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); + done(); + }); + }; + +const expandHomeTimeline = ({ maxId }: Record = {}, done = noOp) => + expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); + +const expandPublicTimeline = ({ maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); + +const expandRemoteTimeline = (instance: string, { maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`remote${onlyMedia ? ':media' : ''}:${instance}`, '/api/v1/timelines/public', { local: false, instance: instance, max_id: maxId, only_media: !!onlyMedia }, done); + +const expandCommunityTimeline = ({ maxId, onlyMedia }: Record = {}, done = noOp) => + expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); + +const expandDirectTimeline = ({ maxId }: Record = {}, done = noOp) => + expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); + +const expandAccountTimeline = (accountId: string, { maxId, withReplies }: Record = {}) => + expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId, with_muted: true }); + +const expandAccountFeaturedTimeline = (accountId: string) => + expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, with_muted: true }); + +const expandAccountMediaTimeline = (accountId: string | number, { maxId }: Record = {}) => + expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40, with_muted: true }); + +const expandListTimeline = (id: string, { maxId }: Record = {}, done = noOp) => + expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); + +const expandGroupTimeline = (id: string, { maxId }: Record = {}, done = noOp) => + expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); + +const expandHashtagTimeline = (hashtag: string, { maxId, tags }: Record = {}, done = noOp) => { + return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { + max_id: maxId, + any: parseTags(tags, 'any'), + all: parseTags(tags, 'all'), + none: parseTags(tags, 'none'), + }, done); +}; + +const expandTimelineRequest = (timeline: string, isLoadingMore: boolean) => ({ + type: TIMELINE_EXPAND_REQUEST, + timeline, + skipLoading: !isLoadingMore, +}); + +const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: string | null, partial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => ({ + type: TIMELINE_EXPAND_SUCCESS, + timeline, + statuses, + next, + partial, + isLoadingRecent, + skipLoading: !isLoadingMore, +}); + +const expandTimelineFail = (timeline: string, error: AxiosError, isLoadingMore: boolean) => ({ + type: TIMELINE_EXPAND_FAIL, + timeline, + error, + skipLoading: !isLoadingMore, +}); + +const connectTimeline = (timeline: string) => ({ + type: TIMELINE_CONNECT, + timeline, +}); + +const disconnectTimeline = (timeline: string) => ({ + type: TIMELINE_DISCONNECT, + timeline, +}); + +const scrollTopTimeline = (timeline: string, top: boolean) => ({ + type: TIMELINE_SCROLL_TOP, + timeline, + top, +}); + +export { + TIMELINE_UPDATE, + TIMELINE_DELETE, + TIMELINE_CLEAR, + TIMELINE_UPDATE_QUEUE, + TIMELINE_DEQUEUE, + TIMELINE_SCROLL_TOP, + TIMELINE_EXPAND_REQUEST, + TIMELINE_EXPAND_SUCCESS, + TIMELINE_EXPAND_FAIL, + TIMELINE_CONNECT, + TIMELINE_DISCONNECT, + MAX_QUEUED_ITEMS, + processTimelineUpdate, + updateTimeline, + updateTimelineQueue, + dequeueTimeline, + deleteFromTimelines, + clearTimeline, + expandTimeline, + expandHomeTimeline, + expandPublicTimeline, + expandRemoteTimeline, + expandCommunityTimeline, + expandDirectTimeline, + expandAccountTimeline, + expandAccountFeaturedTimeline, + expandAccountMediaTimeline, + expandListTimeline, + expandGroupTimeline, + expandHashtagTimeline, + expandTimelineRequest, + expandTimelineSuccess, + expandTimelineFail, + connectTimeline, + disconnectTimeline, + scrollTopTimeline, +}; diff --git a/app/soapbox/features/list_timeline/index.tsx b/app/soapbox/features/list_timeline/index.tsx index af8a2f8a3..42b8d6fe4 100644 --- a/app/soapbox/features/list_timeline/index.tsx +++ b/app/soapbox/features/list_timeline/index.tsx @@ -1,6 +1,5 @@ import React, { useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; import { fetchList } from 'soapbox/actions/lists'; @@ -10,7 +9,7 @@ import { expandListTimeline } from 'soapbox/actions/timelines'; import MissingIndicator from 'soapbox/components/missing_indicator'; import { Button, Spinner } from 'soapbox/components/ui'; import Column from 'soapbox/features/ui/components/column'; -import { useAppSelector } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import Timeline from '../ui/components/timeline'; @@ -21,7 +20,7 @@ import Timeline from '../ui/components/timeline'; // }); const ListTimeline: React.FC = () => { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const { id } = useParams<{ id: string }>(); // const intl = useIntl(); // const history = useHistory(); @@ -30,20 +29,16 @@ const ListTimeline: React.FC = () => { // const hasUnread = useAppSelector((state) => state.timelines.getIn([`list:${props.params.id}`, 'unread']) > 0); useEffect(() => { - const disconnect = handleConnect(id); + dispatch(fetchList(id)); + dispatch(expandListTimeline(id)); + + const disconnect = dispatch(connectListStream(id)); return () => { disconnect(); }; }, [id]); - const handleConnect = (id: string) => { - dispatch(fetchList(id)); - dispatch(expandListTimeline(id)); - - return dispatch(connectListStream(id)); - }; - const handleLoadMore = (maxId: string) => { dispatch(expandListTimeline(id, { maxId })); }; diff --git a/app/soapbox/features/remote_timeline/index.tsx b/app/soapbox/features/remote_timeline/index.tsx index 2f2869d26..d6443fc86 100644 --- a/app/soapbox/features/remote_timeline/index.tsx +++ b/app/soapbox/features/remote_timeline/index.tsx @@ -29,7 +29,7 @@ const RemoteTimeline: React.FC = ({ params }) => { const history = useHistory(); const dispatch = useAppDispatch(); - const instance = params?.instance; + const instance = params?.instance as string; const settings = useSettings(); const stream = useRef(null); diff --git a/app/soapbox/stream.js b/app/soapbox/stream.ts similarity index 56% rename from app/soapbox/stream.js rename to app/soapbox/stream.ts index 5d201e314..5f4ced235 100644 --- a/app/soapbox/stream.js +++ b/app/soapbox/stream.ts @@ -4,20 +4,28 @@ import WebSocketClient from '@gamestdio/websocket'; import { getAccessToken } from 'soapbox/utils/auth'; -const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); +import type { AppDispatch, RootState } from 'soapbox/store'; -export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { - return (dispatch, getState) => { - const streamingAPIBaseURL = getState().getIn(['instance', 'urls', 'streaming_api']); +const randomIntUpTo = (max: number) => Math.floor(Math.random() * Math.floor(max)); + +export function connectStream( + path: string, + pollingRefresh: ((dispatch: AppDispatch, done?: () => void) => void) | null = null, + callbacks: (dispatch: AppDispatch, getState: () => RootState) => Record = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} }), +) { + return (dispatch: AppDispatch, getState: () => RootState) => { + const streamingAPIBaseURL = getState().instance.urls.get('streaming_api'); const accessToken = getAccessToken(getState()); const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); - let polling = null; + let polling: NodeJS.Timeout | null = null; const setupPolling = () => { - pollingRefresh(dispatch, () => { - polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000)); - }); + if (pollingRefresh) { + pollingRefresh(dispatch, () => { + polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000)); + }); + } }; const clearPolling = () => { @@ -27,12 +35,12 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ } }; - let subscription; + let subscription: WebSocketClient; // If the WebSocket fails to be created, don't crash the whole page, // just proceed without a subscription. try { - subscription = getStream(streamingAPIBaseURL, accessToken, path, { + subscription = getStream(streamingAPIBaseURL!, accessToken, path, { connected() { if (pollingRefresh) { clearPolling(); @@ -80,10 +88,20 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ } -export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) { +export default function getStream( + streamingAPIBaseURL: string, + accessToken: string, + stream: string, + { connected, received, disconnected, reconnected }: { + connected: ((this: WebSocket, ev: Event) => any) | null, + received: (data: any) => void, + disconnected: ((this: WebSocket, ev: Event) => any) | null, + reconnected: ((this: WebSocket, ev: Event) => any), + }, +) { const params = [ `stream=${stream}` ]; - const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken); + const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken as any); ws.onopen = connected; ws.onclose = disconnected;