// @ts-check import { getLocale } from '../locales'; import { connectStream } from '../stream'; import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction, deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups'; import { updateNotifications, expandNotifications } from './notifications'; import { updateStatus } from './statuses'; import { updateTimeline, deleteFromTimelines, expandHomeTimeline, connectTimeline, disconnectTimeline, fillHomeTimelineGaps, fillPublicTimelineGaps, fillCommunityTimelineGaps, fillListTimelineGaps, } from './timelines'; /** * @param {number} max * @returns {number} */ const randomUpTo = max => Math.floor(Math.random() * Math.floor(max)); /** * @param {string} timelineId * @param {string} channelName * @param {Object.} params * @param {Object} options * @param {function(Function, Function): Promise} [options.fallback] * @param {function(): void} [options.fillGaps] * @param {function(object): boolean} [options.accept] * @returns {function(): void} */ export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => { const { messages } = getLocale(); return connectStream(channelName, params, (dispatch, getState) => { const locale = getState().getIn(['meta', 'locale']); // @ts-expect-error let pollingId; /** * @param {function(Function, Function): Promise} fallback */ const useFallback = async fallback => { await fallback(dispatch, getState); // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000)); }; return { onConnect() { dispatch(connectTimeline(timelineId)); // @ts-expect-error if (pollingId) { // @ts-ignore clearTimeout(pollingId); pollingId = null; } if (options.fillGaps) { dispatch(options.fillGaps()); } }, onDisconnect() { dispatch(disconnectTimeline({ timeline: timelineId })); if (options.fallback) { // @ts-expect-error pollingId = setTimeout(() => useFallback(options.fallback), randomUpTo(40000)); } }, onReceive(data) { switch (data.event) { case 'update': // @ts-expect-error dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); break; case 'status.update': // @ts-expect-error dispatch(updateStatus(JSON.parse(data.payload))); break; case 'delete': dispatch(deleteFromTimelines(data.payload)); break; case 'notification': { // @ts-expect-error const notificationJSON = JSON.parse(data.payload); dispatch(updateNotifications(notificationJSON, messages, locale)); // TODO: remove this once the groups feature replaces the previous one dispatch(processNewNotificationForGroups(notificationJSON)); break; } case 'notifications_merged': const state = getState(); if (state.notifications.top || !state.notifications.mounted) dispatch(expandNotifications({ forceLoad: true, maxId: undefined })); dispatch(refreshStaleNotificationGroups()); break; case 'conversation': // @ts-expect-error dispatch(updateConversations(JSON.parse(data.payload))); break; case 'announcement': // @ts-expect-error dispatch(updateAnnouncements(JSON.parse(data.payload))); break; case 'announcement.reaction': // @ts-expect-error dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); break; case 'announcement.delete': dispatch(deleteAnnouncement(data.payload)); break; } }, }; }); }; /** * @param {Function} dispatch */ async function refreshHomeTimelineAndNotification(dispatch) { await dispatch(expandHomeTimeline({ maxId: undefined })); // TODO: polling for merged notifications try { await dispatch(pollRecentGroupNotifications()); } catch { // TODO } await dispatch(fetchAnnouncements()); } /** * @returns {function(): void} */ export const connectUserStream = () => connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps }); /** * @param {Object} options * @param {boolean} [options.onlyMedia] * @returns {function(): void} */ export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) }); /** * @param {Object} options * @param {boolean} [options.onlyMedia] * @param {boolean} [options.onlyRemote] * @param {boolean} [options.allowLocalOnly] * @returns {function(): void} */ export const connectPublicStream = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => fillPublicTimelineGaps({ onlyMedia, onlyRemote, allowLocalOnly }) }); /** * @param {string} columnId * @param {string} tagName * @param {boolean} onlyLocal * @param {function(object): boolean} accept * @returns {function(): void} */ export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) => connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept }); /** * @returns {function(): void} */ export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); /** * @param {string} listId * @returns {function(): void} */ export const connectListStream = listId => connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) });