diff --git a/.babelrc b/.babelrc index 44b8ddb2..1841a9eb 100644 --- a/.babelrc +++ b/.babelrc @@ -1,7 +1,8 @@ { "presets": ["@babel/env", "@babel/typescript", "@babel/react"], "plugins": [ - "@babel/plugin-proposal-class-properties", + ["@babel/plugin-proposal-decorators", {"legacy": true}], + ["@babel/plugin-proposal-class-properties", {"loose": false}], "@babel/proposal-object-rest-spread", "@babel/plugin-proposal-optional-chaining" ] diff --git a/client/src/javascript/actions/AuthActions.ts b/client/src/javascript/actions/AuthActions.ts index 5df79bf8..0bed4bc2 100644 --- a/client/src/javascript/actions/AuthActions.ts +++ b/client/src/javascript/actions/AuthActions.ts @@ -8,11 +8,11 @@ import type { } from '@shared/schema/api/auth'; import type {Credentials} from '@shared/schema/Auth'; -import AppDispatcher from '../dispatcher/AppDispatcher'; +import AuthStore from '../stores/AuthStore'; import ClientActions from './ClientActions'; import ConfigStore from '../stores/ConfigStore'; import FloodActions from './FloodActions'; -import SettingsActions from './SettingsActions'; +import SettingActions from './SettingActions'; const baseURI = ConfigStore.getBaseURI(); @@ -23,10 +23,7 @@ const AuthActions = { .then((json) => json.data) .then( (data) => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_LOGIN_SUCCESS', - data, - }); + AuthStore.handleLoginSuccess(data); }, (error) => { // TODO: Handle errors consistently in API, then create a client-side class to get meaningful messages from @@ -41,10 +38,7 @@ const AuthActions = { errorMessage = 'An unknown error occurred.'; } - AppDispatcher.dispatchServerAction({ - type: 'AUTH_LOGIN_ERROR', - error: errorMessage, - }); + AuthStore.handleLoginError(); throw new Error(errorMessage); }, @@ -52,7 +46,7 @@ const AuthActions = { .then(() => { return Promise.all([ ClientActions.fetchSettings(), - SettingsActions.fetchSettings(), + SettingActions.fetchSettings(), FloodActions.restartActivityStream(), ]); }), @@ -62,10 +56,7 @@ const AuthActions = { .post(`${baseURI}api/auth/register?cookie=false`, options) .then((json) => json.data) .then((data) => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_CREATE_USER_SUCCESS', - data, - }); + AuthStore.handleCreateUserSuccess(data); }), updateUser: (username: Credentials['username'], options: AuthUpdateUserOptions) => @@ -76,23 +67,11 @@ const AuthActions = { .delete(`${baseURI}api/auth/users/${encodeURIComponent(username)}`) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_DELETE_USER_SUCCESS', - data: { - username, - ...data, - }, - }); + () => { + // do nothing. }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_DELETE_USER_ERROR', - error: { - username, - ...error, - }, - }); + () => { + // do nothing. }, ), @@ -101,24 +80,16 @@ const AuthActions = { .get(`${baseURI}api/auth/users`) .then((json) => json.data) .then((data) => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_LIST_USERS_SUCCESS', - data, - }); + AuthStore.handleListUsersSuccess(data); }), logout: () => axios.get(`${baseURI}api/auth/logout`).then( () => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_LOGOUT_SUCCESS', - }); + // do nothing. }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_LOGOUT_ERROR', - error, - }); + () => { + // do nothing. }, ), @@ -128,10 +99,7 @@ const AuthActions = { .then((json) => json.data) .then( (data) => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_REGISTER_SUCCESS', - data, - }); + AuthStore.handleRegisterSuccess(data); }, (error: AxiosError) => { throw error; @@ -144,24 +112,18 @@ const AuthActions = { .then((json) => json.data) .then( (data: AuthVerificationResponse) => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_VERIFY_SUCCESS', - data, - }); + AuthStore.handleAuthVerificationSuccess(data); - return Promise.all([ClientActions.fetchSettings(), SettingsActions.fetchSettings()]).then(() => { + return Promise.all([ClientActions.fetchSettings(), SettingActions.fetchSettings()]).then(() => { return data; }); }, (error) => { - AppDispatcher.dispatchServerAction({ - type: 'AUTH_VERIFY_ERROR', - error, - }); + AuthStore.handleAuthVerificationError(); throw error; }, ), -}; +} as const; export default AuthActions; diff --git a/client/src/javascript/actions/ClientActions.ts b/client/src/javascript/actions/ClientActions.ts index 5d3e1c50..e2108ba4 100644 --- a/client/src/javascript/actions/ClientActions.ts +++ b/client/src/javascript/actions/ClientActions.ts @@ -1,75 +1,80 @@ import axios from 'axios'; +import type {ClientSetting, ClientSettings} from '@shared/types/ClientSettings'; import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings'; import type {SetClientSettingsOptions} from '@shared/types/api/client'; -import AppDispatcher from '../dispatcher/AppDispatcher'; import ConfigStore from '../stores/ConfigStore'; - -import type {ClientSettingsSaveSuccessAction} from '../constants/ServerActions'; +import SettingStore from '../stores/SettingStore'; +import AlertStore from '../stores/AlertStore'; const baseURI = ConfigStore.getBaseURI(); const ClientActions = { - fetchSettings: (property?: Record) => + fetchSettings: async (): Promise => axios - .get(`${baseURI}api/client/settings`, {params: {property}}) + .get(`${baseURI}api/client/settings`) .then((json) => json.data) .then( (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS', - data, - }); + SettingStore.handleClientSettingsFetchSuccess(data); }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SETTINGS_FETCH_REQUEST_ERROR', - error, - }); + () => { + // do nothing. }, ), - saveSettings: (settings: SetClientSettingsOptions, options: ClientSettingsSaveSuccessAction['options']) => - axios - .patch(`${baseURI}api/client/settings`, settings) - .then((json) => json.data) - .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SETTINGS_SAVE_SUCCESS', - data, - options, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SETTINGS_SAVE_ERROR', - error, - options, - }); - }, - ), + saveSettings: async (settings: SetClientSettingsOptions, options?: {alert?: boolean}): Promise => { + if (Object.keys(settings).length > 0) { + SettingStore.saveClientSettings(settings); - testClientConnectionSettings: (connectionSettings: ClientConnectionSettings) => + let err = false; + await axios + .patch(`${baseURI}api/client/settings`, settings) + .then((json) => json.data) + .then( + () => { + // do nothing. + }, + () => { + err = true; + }, + ); + + if (options?.alert) { + // TODO: More precise error message. + AlertStore.add( + err + ? { + id: 'general.error.unknown', + } + : { + id: 'alert.settings.saved', + }, + ); + } + } + }, + + saveSetting: async (property: T, data: ClientSettings[T]): Promise => { + return ClientActions.saveSettings({[property]: data}); + }, + + testClientConnectionSettings: async (connectionSettings: ClientConnectionSettings): Promise<{isConnected: boolean}> => axios.post(`${baseURI}api/client/connection-test`, connectionSettings).then((json) => json.data), - testConnection: () => + testConnection: async (): Promise => axios .get(`${baseURI}api/client/connection-test`) .then((json) => json.data) .then( () => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_CONNECTION_TEST_SUCCESS', - }); + // do nothing. }, () => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_CONNECTION_TEST_ERROR', - }); + // do nothing. }, ), -}; +} as const; export default ClientActions; diff --git a/client/src/javascript/actions/FeedActions.ts b/client/src/javascript/actions/FeedActions.ts new file mode 100644 index 00000000..ed4b4699 --- /dev/null +++ b/client/src/javascript/actions/FeedActions.ts @@ -0,0 +1,94 @@ +import axios from 'axios'; + +import type {AddFeedOptions, AddRuleOptions, ModifyFeedOptions} from '@shared/types/api/feed-monitor'; + +import ConfigStore from '../stores/ConfigStore'; +import FeedStore from '../stores/FeedStore'; + +const baseURI = ConfigStore.getBaseURI(); + +const FeedActions = { + addFeed: (options: AddFeedOptions) => + axios + .put(`${baseURI}api/feed-monitor/feeds`, options) + .then((json) => json.data) + .then( + () => { + FeedActions.fetchFeedMonitors(); + }, + () => { + // do nothing. + }, + ), + + modifyFeed: (id: string, options: ModifyFeedOptions) => + axios + .patch(`${baseURI}api/feed-monitor/feeds/${id}`, options) + .then((json) => json.data) + .then( + () => { + FeedActions.fetchFeedMonitors(); + }, + () => { + // do nothing. + }, + ), + + addRule: (options: AddRuleOptions) => + axios + .put(`${baseURI}api/feed-monitor/rules`, options) + .then((json) => json.data) + .then( + () => { + FeedActions.fetchFeedMonitors(); + }, + () => { + // do nothing. + }, + ), + + fetchFeedMonitors: () => + axios + .get(`${baseURI}api/feed-monitor`) + .then((json) => json.data) + .then( + (data) => { + FeedStore.handleFeedMonitorsFetchSuccess(data); + }, + () => { + // do nothing. + }, + ), + + fetchItems: ({id, search}: {id: string; search: string}) => + axios + .get(`${baseURI}api/feed-monitor/feeds/${id}/items`, { + params: { + search, + }, + }) + .then((json) => json.data) + .then( + (data) => { + FeedStore.handleItemsFetchSuccess(data); + }, + () => { + // do nothing. + }, + ), + + removeFeedMonitor: (id: string) => + axios + .delete(`${baseURI}api/feed-monitor/${id}`) + .then((json) => json.data) + .then( + () => { + FeedActions.fetchFeedMonitors(); + }, + () => { + // do nothing. + }, + ), +} as const; + +export default FeedActions; diff --git a/client/src/javascript/actions/FloodActions.ts b/client/src/javascript/actions/FloodActions.ts index 65ab415f..f535bdd6 100644 --- a/client/src/javascript/actions/FloodActions.ts +++ b/client/src/javascript/actions/FloodActions.ts @@ -4,8 +4,14 @@ import type {HistorySnapshot} from '@shared/constants/historySnapshotTypes'; import type {NotificationFetchOptions} from '@shared/types/Notification'; import type {ServerEvents} from '@shared/types/ServerEvents'; -import AppDispatcher from '../dispatcher/AppDispatcher'; +import ClientStatusStore from '../stores/ClientStatusStore'; import ConfigStore from '../stores/ConfigStore'; +import DiskUsageStore from '../stores/DiskUsageStore'; +import NotificationStore from '../stores/NotificationStore'; +import TorrentFilterStore from '../stores/TorrentFilterStore'; +import TorrentStore from '../stores/TorrentStore'; +import TransferDataStore from '../stores/TransferDataStore'; +import UIStore from '../stores/UIStore'; interface ActivityStreamOptions { historySnapshot: HistorySnapshot; @@ -20,100 +26,66 @@ let visibilityChangeTimeout: NodeJS.Timeout; // TODO: Use standard Event interfaces const ServerEventHandlers: Record void> = { CLIENT_CONNECTIVITY_STATUS_CHANGE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_CONNECTIVITY_STATUS_CHANGE', - data: JSON.parse((event as {data: string}).data), - }); + ClientStatusStore.handleConnectivityStatusChange(JSON.parse((event as {data: string}).data)); }, DISK_USAGE_CHANGE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'DISK_USAGE_CHANGE', - data: JSON.parse((event as {data: string}).data), - }); + DiskUsageStore.setDiskUsage(JSON.parse((event as {data: string}).data)); }, NOTIFICATION_COUNT_CHANGE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'NOTIFICATION_COUNT_CHANGE', - data: JSON.parse((event as {data: string}).data), - }); + NotificationStore.handleNotificationCountChange(JSON.parse((event as {data: string}).data)); + UIStore.satisfyDependency('notifications'); }, TORRENT_LIST_DIFF_CHANGE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'TORRENT_LIST_DIFF_CHANGE', - data: JSON.parse((event as {data: string}).data), - }); + TorrentStore.handleTorrentListDiffChange(JSON.parse((event as {data: string}).data)); }, TORRENT_LIST_FULL_UPDATE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'TORRENT_LIST_FULL_UPDATE', - data: JSON.parse((event as {data: string}).data), - }); + TorrentStore.handleTorrentListFullUpdate(JSON.parse((event as {data: string}).data)); + UIStore.satisfyDependency('torrent-list'); }, TAXONOMY_DIFF_CHANGE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'TAXONOMY_DIFF_CHANGE', - data: JSON.parse((event as {data: string}).data), - }); + TorrentFilterStore.handleTorrentTaxonomyDiffChange(JSON.parse((event as {data: string}).data)); }, TAXONOMY_FULL_UPDATE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'TAXONOMY_FULL_UPDATE', - data: JSON.parse((event as {data: string}).data), - }); + TorrentFilterStore.handleTorrentTaxonomyFullUpdate(JSON.parse((event as {data: string}).data)); + UIStore.satisfyDependency('torrent-taxonomy'); }, TRANSFER_SUMMARY_DIFF_CHANGE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'TRANSFER_SUMMARY_DIFF_CHANGE', - data: JSON.parse((event as {data: string}).data), - }); + TransferDataStore.handleTransferSummaryDiffChange(JSON.parse((event as {data: string}).data)); }, TRANSFER_SUMMARY_FULL_UPDATE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'TRANSFER_SUMMARY_FULL_UPDATE', - data: JSON.parse((event as {data: string}).data), - }); + TransferDataStore.handleTransferSummaryFullUpdate(JSON.parse((event as {data: string}).data)); + UIStore.satisfyDependency('transfer-data'); }, TRANSFER_HISTORY_FULL_UPDATE: (event: unknown) => { - AppDispatcher.dispatchServerAction({ - type: 'TRANSFER_HISTORY_FULL_UPDATE', - data: JSON.parse((event as {data: string}).data), - }); + TransferDataStore.handleFetchTransferHistorySuccess(JSON.parse((event as {data: string}).data)); + UIStore.satisfyDependency('transfer-history'); }, -}; +} as const; const FloodActions = { - clearNotifications: (options: NotificationFetchOptions) => - axios + clearNotifications: (options: NotificationFetchOptions) => { + NotificationStore.clearAll(); + return axios .delete(`${baseURI}api/notifications`) .then((json) => json.data) .then( - (response = {}) => { - AppDispatcher.dispatchServerAction({ - type: 'FLOOD_CLEAR_NOTIFICATIONS_SUCCESS', - data: { - ...response, - ...options, - }, - }); + () => { + FloodActions.fetchNotifications(options); }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'FLOOD_CLEAR_NOTIFICATIONS_ERROR', - data: { - error, - }, - }); + () => { + // do nothing. }, - ), + ); + }, closeActivityStream() { if (activityStreamEventSource == null) { @@ -154,22 +126,11 @@ const FloodActions = { }) .then((json) => json.data) .then( - (response) => { - AppDispatcher.dispatchServerAction({ - type: 'FLOOD_FETCH_NOTIFICATIONS_SUCCESS', - data: { - ...response, - ...options, - }, - }); + (data) => { + NotificationStore.handleNotificationsFetchSuccess(data); }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'FLOOD_FETCH_NOTIFICATIONS_ERROR', - data: { - error, - }, - }); + () => { + // do nothing. }, ), @@ -194,13 +155,6 @@ const FloodActions = { // If the user requested a new history snapshot, or the event source has not // alraedy been created, we open the event stream. if (didHistorySnapshotChange || activityStreamEventSource == null) { - import(/* webpackPrefetch: true */ '../stores/ClientStatusStore'); - import(/* webpackPrefetch: true */ '../stores/DiskUsageStore'); - import(/* webpackPrefetch: true */ '../stores/NotificationStore'); - import(/* webpackPrefetch: true */ '../stores/TorrentStore'); - import(/* webpackPrefetch: true */ '../stores/TorrentFilterStore'); - import(/* webpackPrefetch: true */ '../stores/TransferDataStore'); - import(/* webpackPrefetch: true */ '../stores/UIStore'); activityStreamEventSource = new EventSource(`${baseURI}api/activity-stream?historySnapshot=${historySnapshot}`); Object.entries(ServerEventHandlers).forEach(([event, handler]) => { @@ -210,7 +164,7 @@ const FloodActions = { }); } }, -}; +} as const; const handleProlongedInactivity = () => { FloodActions.closeActivityStream(); diff --git a/client/src/javascript/actions/SettingActions.ts b/client/src/javascript/actions/SettingActions.ts new file mode 100644 index 00000000..83c1c396 --- /dev/null +++ b/client/src/javascript/actions/SettingActions.ts @@ -0,0 +1,63 @@ +import axios from 'axios'; + +import type {FloodSetting, FloodSettings} from '@shared/types/FloodSettings'; +import type {SetFloodSettingsOptions} from '@shared/types/api/index'; + +import AlertStore from '../stores/AlertStore'; +import ConfigStore from '../stores/ConfigStore'; +import SettingStore from '../stores/SettingStore'; + +const baseURI = ConfigStore.getBaseURI(); + +const SettingActions = { + fetchSettings: async (): Promise => + axios + .get(`${baseURI}api/settings`) + .then((json) => json.data) + .then( + (data) => { + SettingStore.handleSettingsFetchSuccess(data); + }, + () => { + // do nothing. + }, + ), + + saveSettings: async (settings: SetFloodSettingsOptions, options?: {alert?: boolean}): Promise => { + if (Object.keys(settings).length > 0) { + SettingStore.saveFloodSettings(settings); + + let err = false; + await axios + .patch(`${baseURI}api/settings`, settings) + .then((json) => json.data) + .then( + () => { + // do nothing. + }, + () => { + err = true; + }, + ); + + if (options?.alert) { + // TODO: More precise error message. + AlertStore.add( + err + ? { + id: 'general.error.unknown', + } + : { + id: 'alert.settings.saved', + }, + ); + } + } + }, + + saveSetting: async (property: T, data: FloodSettings[T]): Promise => { + return SettingActions.saveSettings({[property]: data}); + }, +} as const; + +export default SettingActions; diff --git a/client/src/javascript/actions/SettingsActions.ts b/client/src/javascript/actions/SettingsActions.ts deleted file mode 100644 index d80387dd..00000000 --- a/client/src/javascript/actions/SettingsActions.ts +++ /dev/null @@ -1,175 +0,0 @@ -import axios from 'axios'; - -import type {AddFeedOptions, AddRuleOptions, ModifyFeedOptions} from '@shared/types/api/feed-monitor'; -import type {SetFloodSettingsOptions} from '@shared/types/api/index'; - -import AppDispatcher from '../dispatcher/AppDispatcher'; -import ConfigStore from '../stores/ConfigStore'; - -import type {SettingsSaveRequestSuccessAction} from '../constants/ServerActions'; - -const baseURI = ConfigStore.getBaseURI(); - -const SettingsActions = { - addFeed: (options: AddFeedOptions) => - axios - .put(`${baseURI}api/feed-monitor/feeds`, options) - .then((json) => json.data) - .then( - () => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_FEED_ADD_SUCCESS', - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_FEED_ADD_ERROR', - error, - }); - }, - ), - - modifyFeed: (id: string, options: ModifyFeedOptions) => - axios - .patch(`${baseURI}api/feed-monitor/feeds/${id}`, options) - .then((json) => json.data) - .then( - () => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_FEED_MODIFY_SUCCESS', - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_FEED_MODIFY_ERROR', - error, - }); - }, - ), - - addRule: (options: AddRuleOptions) => - axios - .put(`${baseURI}api/feed-monitor/rules`, options) - .then((json) => json.data) - .then( - () => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_RULE_ADD_SUCCESS', - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_RULE_ADD_ERROR', - error, - }); - }, - ), - - fetchFeedMonitors: () => - axios - .get(`${baseURI}api/feed-monitor`) - .then((json) => json.data) - .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITORS_FETCH_SUCCESS', - data, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITORS_FETCH_ERROR', - error, - }); - }, - ), - - fetchItems: ({id, search}: {id: string; search: string}) => - axios - .get(`${baseURI}api/feed-monitor/feeds/${id}/items`, { - params: { - search, - }, - }) - .then((json) => json.data) - .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_SUCCESS', - data, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR', - error, - }); - }, - ), - - fetchSettings: (property?: Record) => - axios - .get(`${baseURI}api/settings`, {params: {property}}) - .then((json) => json.data) - .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FETCH_REQUEST_SUCCESS', - data, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FETCH_REQUEST_ERROR', - error, - }); - }, - ), - - removeFeedMonitor: (id: string) => - axios - .delete(`${baseURI}api/feed-monitor/${id}`) - .then((json) => json.data) - .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_REMOVE_SUCCESS', - data: { - ...data, - id, - }, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_FEED_MONITOR_REMOVE_ERROR', - error: { - ...error, - id, - }, - }); - }, - ), - - saveSettings: (settings: SetFloodSettingsOptions, options: SettingsSaveRequestSuccessAction['options']) => - axios - .patch(`${baseURI}api/settings`, settings) - .then((json) => json.data) - .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_SAVE_REQUEST_SUCCESS', - data, - options, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'SETTINGS_SAVE_REQUEST_ERROR', - error, - }); - }, - ), -}; - -export default SettingsActions; diff --git a/client/src/javascript/actions/TorrentActions.ts b/client/src/javascript/actions/TorrentActions.ts index 2acaee3b..d779b518 100644 --- a/client/src/javascript/actions/TorrentActions.ts +++ b/client/src/javascript/actions/TorrentActions.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, {CancelToken} from 'axios'; import download from 'js-file-download'; import type { @@ -14,35 +14,38 @@ import type { StartTorrentsOptions, StopTorrentsOptions, } from '@shared/types/api/torrents'; +import type {TorrentContent} from '@shared/types/TorrentContent'; +import type {TorrentPeer} from '@shared/types/TorrentPeer'; +import type {TorrentTracker} from '@shared/types/TorrentTracker'; import type {TorrentProperties} from '@shared/types/Torrent'; -import AppDispatcher from '../dispatcher/AppDispatcher'; +import AlertStore from '../stores/AlertStore'; import ConfigStore from '../stores/ConfigStore'; +import UIStore from '../stores/UIStore'; const baseURI = ConfigStore.getBaseURI(); +const emitTorrentAddedAlert = (count: number) => { + AlertStore.add({ + accumulation: { + id: 'alert.torrent.add', + value: count, + }, + id: 'alert.torrent.add', + }); +}; + const TorrentActions = { addTorrentsByUrls: (options: AddTorrentByURLOptions) => axios .post(`${baseURI}api/torrents/add-urls`, options) .then((json) => json.data) .then( - (response) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_ADD_TORRENT_SUCCESS', - data: { - count: options.urls.length, - response, - }, - }); + () => { + emitTorrentAddedAlert(options.urls.length); }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_ADD_TORRENT_ERROR', - data: { - error, - }, - }); + () => { + // do nothing. }, ), @@ -51,22 +54,11 @@ const TorrentActions = { .post(`${baseURI}api/torrents/add-files`, options) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_ADD_TORRENT_SUCCESS', - data: { - count: options.files.length, - data, - }, - }); + () => { + emitTorrentAddedAlert(options.files.length); }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_ADD_TORRENT_ERROR', - data: { - error, - }, - }); + () => { + // do nothing. }, ), @@ -74,17 +66,10 @@ const TorrentActions = { axios.post(`${baseURI}api/torrents/create`, options, {responseType: 'blob'}).then( (response) => { download(response.data, (options.name || `${Date.now()}`).concat('.torrent')); - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_ADD_TORRENT_SUCCESS', - data: { - count: 1, - }, - }); + emitTorrentAddedAlert(1); }, () => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_ADD_TORRENT_ERROR', - }); + // do nothing. }, ), @@ -93,22 +78,22 @@ const TorrentActions = { .post(`${baseURI}api/torrents/delete`, options) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_REMOVE_TORRENT_SUCCESS', - data: { - data, - count: options.hashes.length, + () => { + AlertStore.add({ + accumulation: { + id: 'alert.torrent.remove', + value: options.hashes.length, }, + id: 'alert.torrent.remove', }); }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_REMOVE_TORRENT_ERROR', - error: { - error, - count: options.hashes.length, + () => { + AlertStore.add({ + accumulation: { + id: 'alert.torrent.remove.failed', + value: options.hashes.length, }, + id: 'alert.torrent.remove.failed', }); }, ), @@ -118,61 +103,53 @@ const TorrentActions = { .post(`${baseURI}api/torrents/check-hash`, options) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_CHECK_HASH_SUCCESS', - data: { - data, - count: options.hashes.length, - }, - }); + () => { + // do nothing. }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_CHECK_HASH_ERROR', - error: { - error, - count: options.hashes.length, - }, - }); + () => { + // do nothing. }, ), - fetchMediainfo: (hash: TorrentProperties['hash']) => - axios - .get(`${baseURI}api/torrents/${hash}/mediainfo`) - .then((json) => json.data) - .then((response) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_FETCH_TORRENT_MEDIAINFO_SUCCESS', - data: { - ...response, - hash, - }, - }); - }), + fetchMediainfo: (hash: TorrentProperties['hash'], cancelToken?: CancelToken): Promise<{output: string}> => + axios.get(`${baseURI}api/torrents/${hash}/mediainfo`, {cancelToken}).then<{output: string}>((json) => json.data), - fetchTorrentDetails: (hash: TorrentProperties['hash']) => + fetchTorrentContents: (hash: TorrentProperties['hash']): Promise | null> => axios - .get(`${baseURI}api/torrents/${hash}/details`) - .then((json) => json.data) + .get(`${baseURI}api/torrents/${hash}/contents`) + .then>((json) => json.data) .then( - (torrentDetails) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_FETCH_TORRENT_DETAILS_SUCCESS', - data: { - hash, - torrentDetails, - }, - }); + (contents) => { + return contents; }, () => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_FETCH_TORRENT_DETAILS_ERROR', - data: { - hash, - }, - }); + return null; + }, + ), + + fetchTorrentPeers: (hash: TorrentProperties['hash']): Promise | null> => + axios + .get(`${baseURI}api/torrents/${hash}/peers`) + .then>((json) => json.data) + .then( + (peers) => { + return peers; + }, + () => { + return null; + }, + ), + + fetchTorrentTrackers: (hash: TorrentProperties['hash']): Promise | null> => + axios + .get(`${baseURI}api/torrents/${hash}/trackers`) + .then>((json) => json.data) + .then( + (trackers) => { + return trackers; + }, + () => { + return null; }, ), @@ -181,22 +158,22 @@ const TorrentActions = { .post(`${baseURI}api/torrents/move`, options) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_MOVE_TORRENTS_SUCCESS', - data: { - data, - count: options.hashes.length, + () => { + AlertStore.add({ + accumulation: { + id: 'alert.torrent.move', + value: options.hashes.length, }, + id: 'alert.torrent.move', }); }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_MOVE_TORRENTS_ERROR', - error: { - error, - count: options.hashes.length, + () => { + AlertStore.add({ + accumulation: { + id: 'alert.torrent.move.failed', + value: options.hashes.length, }, + id: 'alert.torrent.move.failed', }); }, ); @@ -207,17 +184,11 @@ const TorrentActions = { .post(`${baseURI}api/torrents/start`, options) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_START_TORRENT_SUCCESS', - data, - }); + () => { + // do nothing. }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_START_TORRENT_ERROR', - error, - }); + () => { + // do nothing. }, ), @@ -226,17 +197,11 @@ const TorrentActions = { .post(`${baseURI}api/torrents/stop`, options) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_STOP_TORRENT_SUCCESS', - data, - }); + () => { + // do nothing. }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_STOP_TORRENT_ERROR', - error, - }); + () => { + // do nothing. }, ), @@ -245,17 +210,11 @@ const TorrentActions = { .patch(`${baseURI}api/torrents/priority`, options) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_TORRENT_PRIORITY_SUCCESS', - data, - }); + () => { + // do nothing. }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_TORRENT_PRIORITY_ERROR', - error, - }); + () => { + // do nothing. }, ), @@ -265,18 +224,10 @@ const TorrentActions = { .then((json) => json.data) .then( () => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_FILE_PRIORITY_SUCCESS', - data: { - hash, - }, - }); + // do nothing. }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_FILE_PRIORITY_ERROR', - error, - }); + () => { + // do nothing. }, ), @@ -285,17 +236,11 @@ const TorrentActions = { .patch(`${baseURI}api/torrents/tags`, options) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_TAXONOMY_SUCCESS', - data, - }); + () => { + UIStore.handleSetTaxonomySuccess(); }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_TAXONOMY_ERROR', - error, - }); + () => { + // do nothing. }, ), @@ -308,17 +253,11 @@ const TorrentActions = { }) .then((json) => json.data) .then( - (data) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_TRACKER_SUCCESS', - data, - }); + () => { + // do nothing. }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: 'CLIENT_SET_TRACKER_ERROR', - error, - }); + () => { + // do nothing. }, ), }; diff --git a/client/src/javascript/actions/UIActions.ts b/client/src/javascript/actions/UIActions.ts index 1ed36daf..9f3eeef5 100644 --- a/client/src/javascript/actions/UIActions.ts +++ b/client/src/javascript/actions/UIActions.ts @@ -1,169 +1,58 @@ import debounce from 'lodash/debounce'; import React from 'react'; -import type {FloodSettings} from '@shared/types/FloodSettings'; import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; -import AppDispatcher from '../dispatcher/AppDispatcher'; +import TorrentFilterStore from '../stores/TorrentFilterStore'; +import TorrentStore from '../stores/TorrentStore'; +import UIStore from '../stores/UIStore'; import type {ContextMenu, Modal} from '../stores/UIStore'; -export interface UIClickTorrentAction { - type: 'UI_CLICK_TORRENT'; - data: {event: React.MouseEvent | React.TouchEvent; hash: string}; -} - -export interface UIClickTorrentDetailsAction { - type: 'UI_CLICK_TORRENT_DETAILS'; - data: {event: React.MouseEvent; hash: string}; -} - -export interface UIDismissContextMenuAction { - type: 'UI_DISMISS_CONTEXT_MENU'; - data: ContextMenu['id']; -} - -export interface UIDisplayContextMenuAction { - type: 'UI_DISPLAY_CONTEXT_MENU'; - data: ContextMenu; -} - -export interface UIDisplayDropdownMenuAction { - type: 'UI_DISPLAY_DROPDOWN_MENU'; - data: string; -} - -export interface UIDisplayModalAction { - type: 'UI_DISPLAY_MODAL'; - data: Modal | null; -} - -export interface UISetTorrentSortAction { - type: 'UI_SET_TORRENT_SORT'; - data: FloodSettings['sortTorrents']; -} - -export interface UISetTorrentSearchFilterAction { - type: 'UI_SET_TORRENT_SEARCH_FILTER'; - data: string; -} - -export interface UISetTorrentStatusFilterAction { - type: 'UI_SET_TORRENT_STATUS_FILTER'; - data: TorrentStatus; -} - -export interface UISetTorrentTagFilterAction { - type: 'UI_SET_TORRENT_TAG_FILTER'; - data: string; -} - -export interface UISetTorrentTrackerFilterAction { - type: 'UI_SET_TORRENT_TRACKER_FILTER'; - data: string; -} - -export type UIAction = - | UIClickTorrentAction - | UIClickTorrentDetailsAction - | UIDismissContextMenuAction - | UIDisplayContextMenuAction - | UIDisplayDropdownMenuAction - | UIDisplayModalAction - | UISetTorrentSortAction - | UISetTorrentSearchFilterAction - | UISetTorrentStatusFilterAction - | UISetTorrentTagFilterAction - | UISetTorrentTrackerFilterAction; - const UIActions = { - displayContextMenu: (data: UIDisplayContextMenuAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_DISPLAY_CONTEXT_MENU', - data, - }); + displayContextMenu: (contextMenu: ContextMenu) => { + UIStore.setActiveContextMenu(contextMenu); }, - displayDropdownMenu: (data: UIDisplayDropdownMenuAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_DISPLAY_DROPDOWN_MENU', - data, - }); + displayDropdownMenu: (id: string) => { + UIStore.setActiveDropdownMenu(id); }, - displayModal: (data: Exclude) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_DISPLAY_MODAL', - data, - }); + displayModal: (modal: Modal) => { + UIStore.setActiveModal(modal); }, - dismissContextMenu: (contextMenuID: UIDismissContextMenuAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_DISMISS_CONTEXT_MENU', - data: contextMenuID, - }); + dismissContextMenu: (id: string) => { + UIStore.dismissContextMenu(id); }, dismissModal: () => { - AppDispatcher.dispatchUIAction({ - type: 'UI_DISPLAY_MODAL', - data: null, - }); + UIStore.dismissModal(); }, - handleDetailsClick: (data: UIClickTorrentDetailsAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_CLICK_TORRENT_DETAILS', - data, - }); + handleTorrentClick: (data: {event: React.MouseEvent | React.TouchEvent; hash: string}) => { + TorrentStore.setSelectedTorrents(data); }, - handleTorrentClick: (data: UIClickTorrentAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_CLICK_TORRENT', - data, - }); + setTorrentStatusFilter: (status: TorrentStatus) => { + TorrentFilterStore.setStatusFilter(status); }, - setTorrentStatusFilter: (data: UISetTorrentStatusFilterAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_SET_TORRENT_STATUS_FILTER', - data, - }); + setTorrentTagFilter: (tag: string) => { + TorrentFilterStore.setTagFilter(tag); }, - setTorrentTagFilter: (data: UISetTorrentTagFilterAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_SET_TORRENT_TAG_FILTER', - data, - }); - }, - - setTorrentTrackerFilter: (data: UISetTorrentTrackerFilterAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_SET_TORRENT_TRACKER_FILTER', - data, - }); + setTorrentTrackerFilter: (tracker: string) => { + TorrentFilterStore.setTrackerFilter(tracker); }, setTorrentsSearchFilter: debounce( - (data: UISetTorrentSearchFilterAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_SET_TORRENT_SEARCH_FILTER', - data, - }); + (search: string) => { + TorrentFilterStore.setSearchFilter(search); }, 250, {trailing: true}, ), - - setTorrentsSort: (data: UISetTorrentSortAction['data']) => { - AppDispatcher.dispatchUIAction({ - type: 'UI_SET_TORRENT_SORT', - data, - }); - }, -}; +} as const; export default UIActions; diff --git a/client/src/javascript/app.tsx b/client/src/javascript/app.tsx index c5505cca..a06360d0 100644 --- a/client/src/javascript/app.tsx +++ b/client/src/javascript/app.tsx @@ -1,66 +1,55 @@ +import {observer} from 'mobx-react'; import {Router} from 'react-router-dom'; -import {FormattedMessage} from 'react-intl'; import {Route, Switch} from 'react-router'; import React from 'react'; import ReactDOM from 'react-dom'; import AsyncIntlProvider from './i18n/languages'; -import connectStores from './util/connectStores'; import AppWrapper from './components/AppWrapper'; import AuthActions from './actions/AuthActions'; import history from './util/history'; -import LoadingIndicator from './components/general/LoadingIndicator'; -import SettingsStore from './stores/SettingsStore'; +import LoadingOverlay from './components/general/LoadingOverlay'; +import SettingStore from './stores/SettingStore'; import UIStore from './stores/UIStore'; -import type {Language} from './constants/Languages'; - import '../sass/style.scss'; -const Login = React.lazy(() => import('./components/views/Login')); -const Register = React.lazy(() => import('./components/views/Register')); -const TorrentClientOverview = React.lazy(() => import('./components/views/TorrentClientOverview')); - -interface FloodAppProps { - locale?: Language; -} - -const loadingOverlay = ( -
- -
+const Login = React.lazy(() => import(/* webpackPrefetch: true */ './components/views/Login')); +const Register = React.lazy(() => import(/* webpackPrefetch: true */ './components/views/Register')); +const TorrentClientOverview = React.lazy( + () => import(/* webpackPrefetch: true */ './components/views/TorrentClientOverview'), ); -const initialize = (): void => { +const initialize = async (): Promise => { UIStore.registerDependency([ { id: 'notifications', - message: , + message: {id: 'dependency.loading.notifications'}, }, ]); UIStore.registerDependency([ { id: 'torrent-taxonomy', - message: , + message: {id: 'dependency.loading.torrent.taxonomy'}, }, ]); UIStore.registerDependency([ { id: 'transfer-data', - message: , + message: {id: 'dependency.loading.transfer.rate.details'}, }, { id: 'transfer-history', - message: , + message: {id: 'dependency.loading.transfer.history'}, }, ]); UIStore.registerDependency([ { id: 'torrent-list', - message: , + message: {id: 'dependency.loading.torrent.list'}, }, ]); @@ -90,34 +79,19 @@ const appRoutes = ( ); -class FloodApp extends React.Component { - public componentDidMount(): void { +@observer +class FloodApp extends React.Component { + componentDidMount(): void { initialize(); } - public render(): React.ReactNode { - const {locale} = this.props; - + render(): React.ReactNode { return ( - - {appRoutes} + }> + {appRoutes} ); } } -const ConnectedFloodApp = connectStores(FloodApp, () => { - return [ - { - store: SettingsStore, - event: 'SETTINGS_CHANGE', - getValue: () => { - return { - locale: SettingsStore.getFloodSetting('language'), - }; - }, - }, - ]; -}); - -ReactDOM.render(, document.getElementById('app')); +ReactDOM.render(, document.getElementById('app')); diff --git a/client/src/javascript/components/AppWrapper.tsx b/client/src/javascript/components/AppWrapper.tsx index 4d041aaa..67bab55a 100644 --- a/client/src/javascript/components/AppWrapper.tsx +++ b/client/src/javascript/components/AppWrapper.tsx @@ -1,162 +1,51 @@ -import classnames from 'classnames'; import {CSSTransition, TransitionGroup} from 'react-transition-group'; +import {observer} from 'mobx-react'; import React from 'react'; import AuthStore from '../stores/AuthStore'; import ConfigStore from '../stores/ConfigStore'; -import Checkmark from './icons/Checkmark'; import ClientConnectionInterruption from './general/ClientConnectionInterruption'; import ClientStatusStore from '../stores/ClientStatusStore'; -import connectStores from '../util/connectStores'; -import LoadingIndicator from './general/LoadingIndicator'; import UIStore from '../stores/UIStore'; import WindowTitle from './general/WindowTitle'; +import LoadingOverlay from './general/LoadingOverlay'; -import type {Dependencies} from '../stores/UIStore'; - -const ICONS = { - satisfied: , -}; - -interface AuthEnforcerProps { +interface AppWrapperProps { children: React.ReactNode; - dependencies?: Dependencies; - dependenciesLoaded?: boolean; - isAuthenticated?: boolean; - isAuthenticating?: boolean; - isClientConnected?: boolean; } -class AuthEnforcer extends React.Component { - isLoading() { - const {dependencies, dependenciesLoaded, isAuthenticated, isAuthenticating} = this.props; - // If the auth status is undetermined, show the loading indicator. - if (!isAuthenticating) return true; - // Allow the UI to load if the user is not authenticated. - if (!isAuthenticated) return false; - // Iterate over current dependencies looking for unsatisified dependencies. - let isDependencyActive; - if (dependencies != null) { - isDependencyActive = Object.keys(dependencies).some((dependencyKey) => !dependencies[dependencyKey].satisfied); - } - // If any dependency is unsatisfied, show the loading indicator. - if (isDependencyActive) return true; - // Dismiss the loading indicator if the UI store thinks all dependencies - // are loaded. - return !dependenciesLoaded; +const AppWrapper: React.FC = (props: AppWrapperProps) => { + const {children} = props; + + let overlay: React.ReactNode = null; + if (!AuthStore.isAuthenticating || (AuthStore.isAuthenticated && !UIStore.haveUIDependenciesResolved)) { + overlay = ; } - renderOverlay() { - const {isAuthenticated, isClientConnected} = this.props; - let content; - - if (this.isLoading()) { - content = ( -
- - {this.renderDependencyList()} + // TODO: disableUsersAndAuth is server's config not user's + if (AuthStore.isAuthenticated && !ClientStatusStore.isConnected && !ConfigStore.getDisableAuth()) { + overlay = ( +
+
+
- ); - } - - // TODO: disableUsersAndAuth is server's config not user's - if (isAuthenticated && !isClientConnected && !ConfigStore.getDisableAuth()) { - content = ( -
-
- -
-
- ); - } - - return content != null ? ( - - {content} - - ) : null; - } - - renderDependencyList() { - const {dependencies} = this.props; - let listItems; - if (dependencies != null) { - listItems = Object.keys(dependencies).map((id: string) => { - const {message, satisfied} = dependencies[id]; - const statusIcon = ICONS.satisfied; - const classes = classnames('dependency-list__dependency', { - 'dependency-list__dependency--satisfied': satisfied, - }); - - return ( -
  • - {statusIcon} - {message} -
  • - ); - }); - } - - return
      {listItems}
    ; - } - - render() { - const {children} = this.props; - - return ( -
    - - {this.renderOverlay()} - {children}
    ); } -} -const ConnectedAuthEnforcer = connectStores(AuthEnforcer, () => { - return [ - { - store: AuthStore, - event: ['AUTH_LOGIN_SUCCESS', 'AUTH_REGISTER_SUCCESS', 'AUTH_VERIFY_SUCCESS', 'AUTH_VERIFY_ERROR'], - getValue: ({store}) => { - const storeAuth = store as typeof AuthStore; - return { - isAuthenticating: storeAuth.getIsAuthenticating(), - isAuthenticated: storeAuth.getIsAuthenticated(), - }; - }, - }, - { - store: UIStore, - event: 'UI_DEPENDENCIES_CHANGE', - getValue: ({store}) => { - const storeUI = store as typeof UIStore; - return { - dependencies: storeUI.getDependencies(), - }; - }, - }, - { - store: UIStore, - event: 'UI_DEPENDENCIES_LOADED', - getValue: ({store}) => { - const storeUI = store as typeof UIStore; - return { - dependenciesLoaded: storeUI.haveUIDependenciesResolved, - }; - }, - }, - { - store: ClientStatusStore, - event: 'CLIENT_CONNECTION_STATUS_CHANGE', - getValue: ({store}) => { - const storeClientStatus = store as typeof ClientStatusStore; - return { - isClientConnected: storeClientStatus.getIsConnected(), - }; - }, - }, - ]; -}); + return ( +
    + + + {overlay != null ? ( + + {overlay} + + ) : null} + + {children} +
    + ); +}; -export default ConnectedAuthEnforcer; +export default observer(AppWrapper); diff --git a/client/src/javascript/components/alerts/Alerts.tsx b/client/src/javascript/components/alerts/Alerts.tsx index dda8e76a..3921935c 100644 --- a/client/src/javascript/components/alerts/Alerts.tsx +++ b/client/src/javascript/components/alerts/Alerts.tsx @@ -1,53 +1,38 @@ import {CSSTransition, TransitionGroup} from 'react-transition-group'; +import {observer} from 'mobx-react'; import React from 'react'; import Alert from './Alert'; import AlertStore from '../../stores/AlertStore'; -import connectStores from '../../util/connectStores'; -import type {Alert as AlertType} from '../../stores/AlertStore'; +const Alerts: React.FC = () => { + const {alerts, accumulation} = AlertStore; -interface AlertsProps { - alerts?: Array; -} + const sortedAlerts = Object.keys(alerts) + .sort() + .map((id) => { + const alert = alerts[id]; -class Alerts extends React.Component { - renderAlerts() { - const {alerts} = this.props; + if (alert.accumulation) { + alert.count = accumulation[alert.accumulation.id]; + } - if (alerts != null && alerts.length > 0) { - return ( + return alert; + }); + + return ( + + {sortedAlerts != null && sortedAlerts.length > 0 ? (
      - {alerts.map((alert) => ( + {sortedAlerts.map((alert) => ( ))}
    - ); - } + ) : null} +
    + ); +}; - return null; - } - - render() { - return {this.renderAlerts()}; - } -} - -const ConnectedAlerts = connectStores(Alerts, () => { - return [ - { - store: AlertStore, - event: 'ALERTS_CHANGE', - getValue: ({store}) => { - const storeAlert = store as typeof AlertStore; - return { - alerts: storeAlert.getAlerts(), - }; - }, - }, - ]; -}); - -export default ConnectedAlerts; +export default observer(Alerts); diff --git a/client/src/javascript/components/general/ClientConnectionInterruption.tsx b/client/src/javascript/components/general/ClientConnectionInterruption.tsx index f5d7db5a..55118d61 100644 --- a/client/src/javascript/components/general/ClientConnectionInterruption.tsx +++ b/client/src/javascript/components/general/ClientConnectionInterruption.tsx @@ -1,4 +1,5 @@ import {FormattedMessage} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; import {Button, Form, FormError, FormRow, FormRowItem, Panel, PanelContent, PanelHeader, PanelFooter} from '../../ui'; @@ -7,31 +8,24 @@ import AuthStore from '../../stores/AuthStore'; import Checkmark from '../icons/Checkmark'; import ClientActions from '../../actions/ClientActions'; import ClientConnectionSettingsForm from './connection-settings/ClientConnectionSettingsForm'; -import connectStores from '../../util/connectStores'; import FloodActions from '../../actions/FloodActions'; import type {ClientConnectionSettingsFormType} from './connection-settings/ClientConnectionSettingsForm'; -interface ClientConnectionInterruptionProps { - isAdmin?: boolean; - isInitialUser?: boolean; -} - interface ClientConnectionInterruptionStates { hasTestedConnection: boolean; isConnectionVerified: boolean; isTestingConnection: boolean; } -class ClientConnectionInterruption extends React.Component< - ClientConnectionInterruptionProps, - ClientConnectionInterruptionStates -> { +@observer +class ClientConnectionInterruption extends React.Component { formRef?: Form | null; settingsFormRef: React.RefObject = React.createRef(); - constructor(props: ClientConnectionInterruptionProps) { + constructor(props: unknown) { super(props); + this.state = { hasTestedConnection: false, isConnectionVerified: false, @@ -49,7 +43,7 @@ class ClientConnectionInterruption extends React.Component< }; handleFormSubmit = () => { - const currentUsername = AuthStore.getCurrentUsername(); + const currentUsername = AuthStore.currentUser.username; if (currentUsername == null || this.settingsFormRef.current == null) { return; @@ -129,10 +123,9 @@ class ClientConnectionInterruption extends React.Component< } render() { - const {isAdmin, isInitialUser} = this.props; const {isConnectionVerified, isTestingConnection} = this.state; - if (!isAdmin && !isInitialUser) { + if (!AuthStore.currentUser.isAdmin && !AuthStore.currentUser.isInitialUser) { return ( @@ -186,19 +179,4 @@ class ClientConnectionInterruption extends React.Component< } } -const ConnectedClientConnectionInterruption = connectStores(ClientConnectionInterruption, () => { - return [ - { - store: AuthStore, - event: ['AUTH_LOGIN_SUCCESS', 'AUTH_REGISTER_SUCCESS', 'AUTH_VERIFY_SUCCESS', 'AUTH_VERIFY_ERROR'], - getValue: () => { - return { - isAdmin: AuthStore.isAdmin(), - isInitialUser: AuthStore.getIsInitialUser(), - }; - }, - }, - ]; -}); - -export default ConnectedClientConnectionInterruption; +export default ClientConnectionInterruption; diff --git a/client/src/javascript/components/general/CustomScrollbars.tsx b/client/src/javascript/components/general/CustomScrollbars.tsx index 2af08274..5f6fce3c 100644 --- a/client/src/javascript/components/general/CustomScrollbars.tsx +++ b/client/src/javascript/components/general/CustomScrollbars.tsx @@ -18,7 +18,7 @@ const renderView: React.StatelessComponent = (props) => { ); }; -interface CustomScrollbarProps { +interface CustomScrollbarsProps { children: React.ReactNode; className?: string; style?: React.CSSProperties; @@ -34,7 +34,7 @@ interface CustomScrollbarProps { onScrollStop?: () => void; } -const CustomScrollbar = React.forwardRef((props: CustomScrollbarProps, ref) => { +const CustomScrollbars = React.forwardRef((props: CustomScrollbarsProps, ref) => { const { children, className, @@ -74,7 +74,7 @@ const CustomScrollbar = React.forwardRef((prop ); }); -CustomScrollbar.defaultProps = { +CustomScrollbars.defaultProps = { className: '', style: undefined, autoHeight: undefined, @@ -89,4 +89,4 @@ CustomScrollbar.defaultProps = { onScrollStop: undefined, }; -export default CustomScrollbar; +export default CustomScrollbars; diff --git a/client/src/javascript/components/general/GlobalContextMenuMountPoint.tsx b/client/src/javascript/components/general/GlobalContextMenuMountPoint.tsx index 5283a97b..d4532b15 100644 --- a/client/src/javascript/components/general/GlobalContextMenuMountPoint.tsx +++ b/client/src/javascript/components/general/GlobalContextMenuMountPoint.tsx @@ -1,4 +1,5 @@ import classnames from 'classnames'; +import {reaction} from 'mobx'; import React from 'react'; import {ContextMenu} from '../../ui'; @@ -25,6 +26,9 @@ class GlobalContextMenuMountPoint extends React.Component< > { constructor(props: GlobalContextMenuMountPointProps) { super(props); + + reaction(() => UIStore.activeContextMenu, this.handleContextMenuChange); + this.state = { clickPosition: { x: 0, @@ -35,10 +39,6 @@ class GlobalContextMenuMountPoint extends React.Component< }; } - componentDidMount() { - UIStore.listen('UI_CONTEXT_MENU_CHANGE', this.handleContextMenuChange); - } - shouldComponentUpdate(_nextProps: GlobalContextMenuMountPointProps, nextState: GlobalContextMenuMountPointStates) { const {isOpen, clickPosition, items} = this.state; @@ -81,10 +81,6 @@ class GlobalContextMenuMountPoint extends React.Component< } } - componentWillUnmount() { - UIStore.unlisten('UI_CONTEXT_MENU_CHANGE', this.handleContextMenuChange); - } - getMenuItems() { const {items} = this.state; @@ -104,10 +100,16 @@ class GlobalContextMenuMountPoint extends React.Component< 'has-action': item.labelAction, })}> {item.label} - {item.labelAction ? {item.labelAction} : undefined} + {item.labelAction ? ( + + + + ) : undefined} {item.labelSecondary ? ( - {item.labelSecondary} + + + ) : undefined} ); @@ -132,7 +134,7 @@ class GlobalContextMenuMountPoint extends React.Component< } handleContextMenuChange = () => { - const activeContextMenu = UIStore.getActiveContextMenu(); + const {activeContextMenu} = UIStore; if (activeContextMenu != null && activeContextMenu.id === this.props.id) { this.setState({ diff --git a/client/src/javascript/components/general/LoadingOverlay.tsx b/client/src/javascript/components/general/LoadingOverlay.tsx new file mode 100644 index 00000000..cb983792 --- /dev/null +++ b/client/src/javascript/components/general/LoadingOverlay.tsx @@ -0,0 +1,52 @@ +import classnames from 'classnames'; +import {useIntl} from 'react-intl'; +import React from 'react'; + +import Checkmark from '../icons/Checkmark'; +import LoadingIndicator from './LoadingIndicator'; + +import type {Dependencies} from '../../stores/UIStore'; + +const ICONS = { + satisfied: , +}; + +interface LoadingOverlayProps { + dependencies?: Dependencies; +} + +const LoadingOverlay: React.FC = (props: LoadingOverlayProps) => { + const {dependencies} = props; + + return ( +
    + +
      + {dependencies != null + ? Object.keys(dependencies).map((id: string) => { + const {message, satisfied} = dependencies[id]; + const statusIcon = ICONS.satisfied; + const classes = classnames('dependency-list__dependency', { + 'dependency-list__dependency--satisfied': satisfied, + }); + + return ( +
    • + {satisfied != null ? {statusIcon} : null} + + {typeof message === 'string' ? message : useIntl().formatMessage(message)} + +
    • + ); + }) + : null} +
    +
    + ); +}; + +LoadingOverlay.defaultProps = { + dependencies: undefined, +}; + +export default LoadingOverlay; diff --git a/client/src/javascript/components/general/WindowTitle.tsx b/client/src/javascript/components/general/WindowTitle.tsx index 20a6a56b..7eca5438 100644 --- a/client/src/javascript/components/general/WindowTitle.tsx +++ b/client/src/javascript/components/general/WindowTitle.tsx @@ -1,18 +1,12 @@ import {useIntl} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; -import type {TransferSummary} from '@shared/types/TransferData'; - -import connectStores from '../../util/connectStores'; import {compute, getTranslationString} from '../../util/size'; import TransferDataStore from '../../stores/TransferDataStore'; -interface WindowTitleProps { - summary?: TransferSummary; -} - -const WindowTitleFunc: React.FC = (props: WindowTitleProps) => { - const {summary} = props; +const WindowTitle: React.FC = () => { + const {transferSummary: summary} = TransferDataStore; const intl = useIntl(); React.useEffect(() => { @@ -60,19 +54,4 @@ const WindowTitleFunc: React.FC = (props: WindowTitleProps) => return null; }; -const WindowTitle = connectStores(WindowTitleFunc, () => { - return [ - { - store: TransferDataStore, - event: 'CLIENT_TRANSFER_SUMMARY_CHANGE', - getValue: ({store}) => { - const storeTransferData = store as typeof TransferDataStore; - return { - summary: storeTransferData.getTransferSummary(), - }; - }, - }, - ]; -}); - -export default WindowTitle; +export default observer(WindowTitle); diff --git a/client/src/javascript/components/general/filesystem/DirectoryFileList.tsx b/client/src/javascript/components/general/filesystem/DirectoryFileList.tsx index 032710b8..5c60695d 100644 --- a/client/src/javascript/components/general/filesystem/DirectoryFileList.tsx +++ b/client/src/javascript/components/general/filesystem/DirectoryFileList.tsx @@ -15,7 +15,6 @@ interface DirectoryFilesProps { hash: TorrentProperties['hash']; items: TorrentContentSelectionTree['files']; path: Array; - onPriorityChange: () => void; onItemSelect: (selection: TorrentContentSelection) => void; } @@ -74,9 +73,8 @@ class DirectoryFiles extends React.Component { } handlePriorityChange(fileIndex: React.ReactText, priorityLevel: number) { - const {hash, onPriorityChange} = this.props; + const {hash} = this.props; - onPriorityChange(); TorrentActions.setFilePriority(hash, {indices: [Number(fileIndex)], priority: priorityLevel}); } diff --git a/client/src/javascript/components/general/filesystem/DirectoryTree.tsx b/client/src/javascript/components/general/filesystem/DirectoryTree.tsx index 990f83f0..89f70d70 100644 --- a/client/src/javascript/components/general/filesystem/DirectoryTree.tsx +++ b/client/src/javascript/components/general/filesystem/DirectoryTree.tsx @@ -13,12 +13,11 @@ interface DirectoryTreeProps { path?: Array; hash: TorrentProperties['hash']; itemsTree: TorrentContentSelectionTree; - onPriorityChange: () => void; onItemSelect: (selection: TorrentContentSelection) => void; } const DirectoryTree: React.FC = (props: DirectoryTreeProps) => { - const {depth = 0, itemsTree, hash, path, onItemSelect, onPriorityChange} = props; + const {depth = 0, itemsTree, hash, path, onItemSelect} = props; const {files, directories} = itemsTree; const childDepth = depth + 1; @@ -47,7 +46,6 @@ const DirectoryTree: React.FC = (props: DirectoryTreeProps) key={id} itemsTree={subSelectedItems} onItemSelect={onItemSelect} - onPriorityChange={onPriorityChange} path={path} /> ); @@ -62,7 +60,6 @@ const DirectoryTree: React.FC = (props: DirectoryTreeProps) hash={hash} key={`files-${childDepth}`} onItemSelect={onItemSelect} - onPriorityChange={onPriorityChange} path={path} items={itemsTree.files} /> diff --git a/client/src/javascript/components/general/filesystem/DirectoryTreeNode.tsx b/client/src/javascript/components/general/filesystem/DirectoryTreeNode.tsx index f81f45bc..3a72f072 100644 --- a/client/src/javascript/components/general/filesystem/DirectoryTreeNode.tsx +++ b/client/src/javascript/components/general/filesystem/DirectoryTreeNode.tsx @@ -19,7 +19,6 @@ interface DirectoryTreeNodeProps { directoryName: string; itemsTree: TorrentContentSelectionTree; isSelected: boolean; - onPriorityChange: () => void; onItemSelect: (selection: TorrentContentSelection) => void; } @@ -81,7 +80,7 @@ class DirectoryTreeNode extends React.Component UIStore.activeModal == null, + () => this.handleModalDismiss, + ); + const destination: string = props.suggested || - SettingsStore.getFloodSetting('torrentDestination') || - (SettingsStore.getClientSetting('directoryDefault') as string | undefined) || + SettingStore.floodSettings.torrentDestination || + SettingStore.clientSettings?.directoryDefault || ''; this.state = { @@ -46,7 +52,6 @@ class FilesystemBrowserTextbox extends React.Component + , ); @@ -156,7 +160,7 @@ class FilesystemBrowserTextbox extends React.Component + , ); diff --git a/client/src/javascript/components/general/form-elements/Dropdown.tsx b/client/src/javascript/components/general/form-elements/Dropdown.tsx index 480a7af2..074055c6 100644 --- a/client/src/javascript/components/general/form-elements/Dropdown.tsx +++ b/client/src/javascript/components/general/form-elements/Dropdown.tsx @@ -3,6 +3,7 @@ import {CSSTransition, TransitionGroup} from 'react-transition-group'; import React from 'react'; import throttle from 'lodash/throttle'; import uniqueId from 'lodash/uniqueId'; +import {when} from 'mobx'; import UIActions from '../../../actions/UIActions'; import UIStore from '../../../stores/UIStore'; @@ -41,7 +42,6 @@ interface DropdownStates { const METHODS_TO_BIND = [ 'closeDropdown', 'openDropdown', - 'handleActiveDropdownChange', 'handleDropdownClick', 'handleItemSelect', 'handleKeyPress', @@ -62,15 +62,20 @@ class Dropdown extends React.Component, DropdownSta constructor(props: DropdownProps) { super(props); - this.state = { - isOpen: false, - }; - METHODS_TO_BIND.forEach((methodName: M) => { this[methodName] = this[methodName].bind(this); }); this.handleKeyPress = throttle(this.handleKeyPress, 200); + + this.state = { + isOpen: false, + }; + + when( + () => this.state.isOpen && UIStore.activeDropdownMenu !== this.id, + () => this.closeDropdown, + ); } private getDropdownButton(options: {header?: boolean; trigger?: boolean} = {}) { @@ -144,7 +149,6 @@ class Dropdown extends React.Component, DropdownSta closeDropdown() { window.removeEventListener('keydown', this.handleKeyPress); window.removeEventListener('click', this.closeDropdown); - UIStore.unlisten('UI_DROPDOWN_MENU_CHANGE', this.handleActiveDropdownChange); this.setState({isOpen: false}); } @@ -152,7 +156,6 @@ class Dropdown extends React.Component, DropdownSta openDropdown() { window.addEventListener('keydown', this.handleKeyPress); window.addEventListener('click', this.closeDropdown); - UIStore.listen('UI_DROPDOWN_MENU_CHANGE', this.handleActiveDropdownChange); this.setState({isOpen: true}); @@ -177,13 +180,6 @@ class Dropdown extends React.Component, DropdownSta } } - handleActiveDropdownChange() { - const {isOpen} = this.state; - if (isOpen && UIStore.getActiveDropdownMenu() !== this.id) { - this.closeDropdown(); - } - } - handleItemSelect(item: DropdownItem) { const {handleItemSelect} = this.props; diff --git a/client/src/javascript/components/general/form-elements/TagSelect.tsx b/client/src/javascript/components/general/form-elements/TagSelect.tsx index 75874e16..858a5ef8 100644 --- a/client/src/javascript/components/general/form-elements/TagSelect.tsx +++ b/client/src/javascript/components/general/form-elements/TagSelect.tsx @@ -25,30 +25,27 @@ export default class TagSelect extends Component { - if (tag === 'all') { - return accumulator; - } - - if (tag === 'untagged') { - accumulator.push( - - - , - ); - return accumulator; - } + tagMenuItems = Object.keys(TorrentFilterStore.taxonomy.tagCounts).reduce((accumulator: React.ReactNodeArray, tag) => { + if (tag === '') { + return accumulator; + } + if (tag === 'untagged') { accumulator.push( - {tag} + , ); return accumulator; - }, - [], - ); + } + + accumulator.push( + + {tag} + , + ); + return accumulator; + }, []); constructor(props: TagSelectProps) { super(props); diff --git a/client/src/javascript/components/icons/CountryFlagIcon.tsx b/client/src/javascript/components/icons/CountryFlagIcon.tsx new file mode 100644 index 00000000..a3781b46 --- /dev/null +++ b/client/src/javascript/components/icons/CountryFlagIcon.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +const flagsCache: Record = {}; + +const getFlag = (countryCode?: string): string | null => { + if (countryCode == null) { + return null; + } + + if (flagsCache[countryCode] !== undefined) { + return flagsCache[countryCode]; + } + + const loadFlag = async () => { + let flag: string | null = null; + await import(`../../../images/flags/${countryCode.toLowerCase()}.png`) + .then( + ({default: image}: {default: string}) => { + flag = image; + }, + () => { + flag = null; + }, + ) + .finally(() => { + flagsCache[countryCode] = flag; + }); + return flag; + }; + + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw loadFlag(); +}; + +interface CountryFlagIconProps { + countryCode: string; +} + +const CountryFlagIcon: React.FC = ({countryCode}: CountryFlagIconProps) => { + const flag = getFlag(countryCode); + if (flag == null) { + return null; + } + return {countryCode}; +}; + +export default CountryFlagIcon; diff --git a/client/src/javascript/components/modals/Modal.tsx b/client/src/javascript/components/modals/Modal.tsx index 3a7c0869..84427599 100644 --- a/client/src/javascript/components/modals/Modal.tsx +++ b/client/src/javascript/components/modals/Modal.tsx @@ -4,7 +4,7 @@ import React from 'react'; import ModalActions from './ModalActions'; import ModalTabs from './ModalTabs'; -import type {ModalAction} from './ModalActions'; +import type {ModalAction} from '../../stores/UIStore'; import type {Tab} from './ModalTabs'; interface ModalProps { diff --git a/client/src/javascript/components/modals/ModalActions.tsx b/client/src/javascript/components/modals/ModalActions.tsx index f6557334..1aafd0a0 100644 --- a/client/src/javascript/components/modals/ModalActions.tsx +++ b/client/src/javascript/components/modals/ModalActions.tsx @@ -3,26 +3,7 @@ import React from 'react'; import {Button, Checkbox} from '../../ui'; import UIActions from '../../actions/UIActions'; -interface BaseAction { - content: React.ReactNode; - triggerDismiss?: boolean; -} - -interface CheckboxAction extends BaseAction { - type: 'checkbox'; - id?: string; - checked?: boolean; - clickHandler?: ((event: React.MouseEvent | KeyboardEvent) => void) | null; -} - -interface ButtonAction extends BaseAction { - type: 'primary' | 'tertiary'; - isLoading?: Button['props']['isLoading']; - submit?: boolean; - clickHandler?: ((event: React.MouseEvent) => void) | null; -} - -export type ModalAction = CheckboxAction | ButtonAction; +import type {ModalAction} from '../../stores/UIStore'; interface ModalActionsProps { actions: Array; diff --git a/client/src/javascript/components/modals/Modals.tsx b/client/src/javascript/components/modals/Modals.tsx index 2459f95b..36c84434 100644 --- a/client/src/javascript/components/modals/Modals.tsx +++ b/client/src/javascript/components/modals/Modals.tsx @@ -1,10 +1,9 @@ import {CSSTransition, TransitionGroup} from 'react-transition-group'; +import {observer} from 'mobx-react'; import React from 'react'; -import throttle from 'lodash/throttle'; import AddTorrentsModal from './add-torrents-modal/AddTorrentsModal'; import ConfirmModal from './confirm-modal/ConfirmModal'; -import connectStores from '../../util/connectStores'; import FeedsModal from './feeds-modal/FeedsModal'; import MoveTorrentsModal from './move-torrents-modal/MoveTorrentsModal'; import RemoveTorrentsModal from './remove-torrents-modal/RemoveTorrentsModal'; @@ -17,16 +16,12 @@ import UIStore from '../../stores/UIStore'; import type {Modal} from '../../stores/UIStore'; -interface ModalsProps { - activeModal?: Modal | null; -} - -const createModal = (activeModal: Modal): React.ReactNode => { - switch (activeModal.id) { +const createModal = (id: Modal['id']): React.ReactNode => { + switch (id) { case 'add-torrents': - return ; + return ; case 'confirm': - return ; + return ; case 'feeds': return ; case 'move-torrents': @@ -40,7 +35,7 @@ const createModal = (activeModal: Modal): React.ReactNode => { case 'settings': return ; case 'torrent-details': - return ; + return ; default: return null; } @@ -50,13 +45,8 @@ const dismissModal = () => { UIActions.dismissModal(); }; -class Modals extends React.Component { - constructor(props: ModalsProps) { - super(props); - - this.handleKeyPress = throttle(this.handleKeyPress, 1000); - } - +@observer +class Modals extends React.Component { componentDidMount() { window.addEventListener('keydown', this.handleKeyPress); } @@ -66,7 +56,7 @@ class Modals extends React.Component { } handleKeyPress = (event: KeyboardEvent) => { - if (this.props.activeModal != null && event.keyCode === 27) { + if (UIStore.activeModal != null && event.key === 'Escape') { dismissModal(); } }; @@ -76,15 +66,15 @@ class Modals extends React.Component { }; render() { - const {activeModal} = this.props; + const id = UIStore.activeModal?.id; let modal; - if (activeModal != null) { + if (id != null) { modal = ( - +
    - {createModal(activeModal)} + {createModal(id)}
    ); @@ -94,18 +84,4 @@ class Modals extends React.Component { } } -const ConnectedModals = connectStores(Modals, () => { - return [ - { - store: UIStore, - event: 'UI_MODAL_CHANGE', - getValue: () => { - return { - activeModal: UIStore.getActiveModal(), - }; - }, - }, - ]; -}); - -export default ConnectedModals; +export default Modals; diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsActions.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsActions.tsx index e0c60637..766a5795 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsActions.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsActions.tsx @@ -2,9 +2,9 @@ import {injectIntl, WrappedComponentProps} from 'react-intl'; import React, {PureComponent} from 'react'; import ModalActions from '../ModalActions'; -import SettingsStore from '../../../stores/SettingsStore'; +import SettingStore from '../../../stores/SettingStore'; -import type {ModalAction} from '../ModalActions'; +import type {ModalAction} from '../../../stores/UIStore'; interface AddTorrentsActionsProps extends WrappedComponentProps { isAddingTorrents: boolean; @@ -15,7 +15,7 @@ class AddTorrentsActions extends PureComponent { getActions(): Array { return [ { - checked: Boolean(SettingsStore.getFloodSetting('startTorrentsOnLoad')), + checked: Boolean(SettingStore.floodSettings.startTorrentsOnLoad), clickHandler: null, content: this.props.intl.formatMessage({ id: 'torrents.add.start.label', diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByCreation.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByCreation.tsx index d0e58186..52fdf623 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByCreation.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByCreation.tsx @@ -3,11 +3,12 @@ import React from 'react'; import AddTorrentsActions from './AddTorrentsActions'; import {Checkbox, Form, FormRow, Textbox} from '../../../ui'; +import FilesystemBrowserTextbox from '../../general/filesystem/FilesystemBrowserTextbox'; import {saveAddTorrentsUserPreferences} from '../../../util/userPreferences'; import TagSelect from '../../general/form-elements/TagSelect'; import TextboxRepeater, {getTextArray} from '../../general/form-elements/TextboxRepeater'; import TorrentActions from '../../../actions/TorrentActions'; -import FilesystemBrowserTextbox from '../../general/filesystem/FilesystemBrowserTextbox'; +import UIStore from '../../../stores/UIStore'; type AddTorrentsByCreationFormData = { [trackers: string]: string; @@ -59,6 +60,8 @@ class AddTorrentsByCreation extends React.Component { + UIStore.dismissModal(); }); saveAddTorrentsUserPreferences({start: formData.start, destination: formData.sourcePath}); diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx index 363a533b..1e3f0868 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByFile.tsx @@ -11,6 +11,7 @@ import {Form, FormRow, FormRowItem} from '../../../ui'; import {saveAddTorrentsUserPreferences} from '../../../util/userPreferences'; import TagSelect from '../../general/form-elements/TagSelect'; import TorrentActions from '../../../actions/TorrentActions'; +import UIStore from '../../../stores/UIStore'; interface AddTorrentsByFileFormData { destination: string; @@ -145,6 +146,7 @@ class AddTorrentsByFile extends React.Component { + UIStore.dismissModal(); }); saveAddTorrentsUserPreferences({start, destination}); diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx index 392674f0..d2c154ad 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx @@ -8,6 +8,7 @@ import {saveAddTorrentsUserPreferences} from '../../../util/userPreferences'; import TagSelect from '../../general/form-elements/TagSelect'; import TextboxRepeater, {getTextArray} from '../../general/form-elements/TextboxRepeater'; import TorrentActions from '../../../actions/TorrentActions'; +import UIStore from '../../../stores/UIStore'; type AddTorrentsByURLFormData = { [urls: string]: string; @@ -21,24 +22,22 @@ type AddTorrentsByURLFormData = { tags: string; }; -interface AddTorrentsByURLProps extends WrappedComponentProps { - initialURLs?: Array<{id: number; value: string}>; -} - interface AddTorrentsByURLStates { isAddingTorrents: boolean; urlTextboxes: Array<{id: number; value: string}>; } -class AddTorrentsByURL extends React.Component { +class AddTorrentsByURL extends React.Component { formRef: Form | null = null; - constructor(props: AddTorrentsByURLProps) { + constructor(props: WrappedComponentProps) { super(props); this.state = { isAddingTorrents: false, - urlTextboxes: this.props.initialURLs || [{id: 0, value: ''}], + urlTextboxes: (UIStore.activeModal?.id === 'add-torrents' && UIStore.activeModal?.initialURLs) || [ + {id: 0, value: ''}, + ], }; } @@ -50,11 +49,13 @@ class AddTorrentsByURL extends React.Component; this.setState({isAddingTorrents: true}); - if (formData.destination == null) { + const urls = getTextArray(formData, 'urls').filter((url) => url !== ''); + + if (urls.length === 0 || formData.destination == null) { + this.setState({isAddingTorrents: false}); return; } - const urls = getTextArray(formData, 'urls'); const cookies = getTextArray(formData, 'cookies'); // TODO: handle multiple domain names @@ -73,6 +74,8 @@ class AddTorrentsByURL extends React.Component { + UIStore.dismissModal(); }); saveAddTorrentsUserPreferences({start: formData.start, destination: formData.destination}); diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsModal.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsModal.tsx index 682a496b..d6e41eaf 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsModal.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsModal.tsx @@ -6,12 +6,7 @@ import AddTorrentsByURL from './AddTorrentsByURL'; import Modal from '../Modal'; import AddTorrentsByCreation from './AddTorrentsByCreation'; -export interface AddTorrentsModalProps { - initialURLs?: Array<{id: number; value: string}>; -} - -const AddTorrentsModal: React.FC = (props: AddTorrentsModalProps) => { - const {initialURLs} = props; +const AddTorrentsModal: React.FC = () => { const intl = useIntl(); const tabs = { @@ -20,7 +15,6 @@ const AddTorrentsModal: React.FC = (props: AddTorrentsMod label: intl.formatMessage({ id: 'torrents.add.tab.url.title', }), - props: {initialURLs}, }, 'by-file': { content: AddTorrentsByFile, diff --git a/client/src/javascript/components/modals/confirm-modal/ConfirmModal.tsx b/client/src/javascript/components/modals/confirm-modal/ConfirmModal.tsx index 844f6b71..46e490a9 100644 --- a/client/src/javascript/components/modals/confirm-modal/ConfirmModal.tsx +++ b/client/src/javascript/components/modals/confirm-modal/ConfirmModal.tsx @@ -1,30 +1,24 @@ +import {observer} from 'mobx-react'; import React from 'react'; import Modal from '../Modal'; +import UIStore from '../../../stores/UIStore'; -import type {ModalAction} from '../ModalActions'; - -export interface ConfirmModalProps { - options: { - content: React.ReactNode; - heading: React.ReactNode; - actions: Array; - }; -} - -export default class ConfirmModal extends React.Component { - getContent() { - return
    {this.props.options.content}
    ; +const ConfirmModal: React.FC = () => { + if (UIStore.activeModal?.id !== 'confirm') { + return null; } - render() { - return ( - - ); - } -} + const {actions, content, heading} = UIStore.activeModal || {}; + + return ( + {content}
    } + heading={heading} + /> + ); +}; + +export default observer(ConfirmModal); diff --git a/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx b/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx index 00080287..c7fddaa1 100644 --- a/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx +++ b/client/src/javascript/components/modals/feeds-modal/DownloadRulesTab.tsx @@ -1,9 +1,10 @@ import {defineMessages, FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; import throttle from 'lodash/throttle'; import type {AddRuleOptions} from '@shared/types/api/feed-monitor'; -import type {Feed, Rule} from '@shared/types/Feed'; +import type {Rule} from '@shared/types/Feed'; import { Button, @@ -18,14 +19,13 @@ import { SelectItem, Textbox, } from '../../../ui'; -import connectStores from '../../../util/connectStores'; import Checkmark from '../../icons/Checkmark'; import Close from '../../icons/Close'; import Edit from '../../icons/Edit'; -import FeedsStore from '../../../stores/FeedsStore'; +import FeedActions from '../../../actions/FeedActions'; +import FeedStore from '../../../stores/FeedStore'; import FilesystemBrowserTextbox from '../../general/filesystem/FilesystemBrowserTextbox'; import ModalFormSectionHeader from '../ModalFormSectionHeader'; -import SettingsActions from '../../../actions/SettingsActions'; import TagSelect from '../../general/form-elements/TagSelect'; import * as validators from '../../../util/validators'; @@ -37,11 +37,6 @@ interface RuleFormData extends Omit { tags: string; } -interface DownloadRulesTabProps extends WrappedComponentProps { - feeds: Array; - rules: Array; -} - interface DownloadRulesTabStates { errors?: { [field in ValidatedFields]?: string; @@ -90,7 +85,8 @@ const defaultRule = { startOnLoad: false, }; -class DownloadRulesTab extends React.Component { +@observer +class DownloadRulesTab extends React.Component { formRef: Form | null = null; validatedFields = { @@ -135,8 +131,9 @@ class DownloadRulesTab extends React.Component - - - - , - ]; - } - - return feeds.reduce( - (feedOptions, feed) => - feedOptions.concat( - - {feed.label} - , - ), - [ - - - - - , - ], - ); - } - getModifyRuleForm(rule: Partial) { const {doesPatternMatchTest, currentlyEditingRule} = this.state; + const {feeds} = FeedStore; return ( @@ -217,13 +185,35 @@ class DownloadRulesTab extends React.Component @@ -381,7 +371,7 @@ class DownloadRulesTab extends React.Component, DownloadRulesTabStates>( - injectIntl(DownloadRulesTab), - () => { - return [ - { - store: FeedsStore, - event: 'SETTINGS_FEED_MONITORS_FETCH_SUCCESS', - getValue: ({store}) => { - const storeFeeds = store as typeof FeedsStore; - return { - feeds: storeFeeds.getFeeds(), - rules: storeFeeds.getRules(), - }; - }, - }, - ]; - }, -); - -export default ConnectedDownloadRulesTab; +export default injectIntl(DownloadRulesTab); diff --git a/client/src/javascript/components/modals/feeds-modal/FeedsModal.tsx b/client/src/javascript/components/modals/feeds-modal/FeedsModal.tsx index eaf41087..2d29ed1b 100644 --- a/client/src/javascript/components/modals/feeds-modal/FeedsModal.tsx +++ b/client/src/javascript/components/modals/feeds-modal/FeedsModal.tsx @@ -2,25 +2,32 @@ import {injectIntl, WrappedComponentProps} from 'react-intl'; import React from 'react'; import DownloadRulesTab from './DownloadRulesTab'; +import FeedActions from '../../../actions/FeedActions'; +import FeedStore from '../../../stores/FeedStore'; import FeedsTab from './FeedsTab'; import Modal from '../Modal'; -import SettingsActions from '../../../actions/SettingsActions'; class FeedsModal extends React.Component { componentDidMount() { - SettingsActions.fetchFeedMonitors(); + FeedActions.fetchFeedMonitors(); } render() { const tabs = { feeds: { content: FeedsTab, + props: { + feedStore: FeedStore, + }, label: this.props.intl.formatMessage({ id: 'feeds.tabs.feeds', }), }, downloadRules: { content: DownloadRulesTab, + props: { + feedStore: FeedStore, + }, label: this.props.intl.formatMessage({ id: 'feeds.tabs.download.rules', }), diff --git a/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx b/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx index 8a3a4959..e5a3c5ee 100644 --- a/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx +++ b/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx @@ -1,8 +1,9 @@ import {defineMessages, FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; import throttle from 'lodash/throttle'; -import type {Feed, Item} from '@shared/types/Feed'; +import type {Feed} from '@shared/types/Feed'; import { Button, @@ -16,13 +17,12 @@ import { SelectItem, Textbox, } from '../../../ui'; -import connectStores from '../../../util/connectStores'; import Close from '../../icons/Close'; import Edit from '../../icons/Edit'; -import FeedsStore from '../../../stores/FeedsStore'; +import FeedActions from '../../../actions/FeedActions'; +import FeedStore from '../../../stores/FeedStore'; import {minToHumanReadable} from '../../../i18n/languages'; import ModalFormSectionHeader from '../ModalFormSectionHeader'; -import SettingsActions from '../../../actions/SettingsActions'; import UIActions from '../../../actions/UIActions'; import * as validators from '../../../util/validators'; @@ -40,11 +40,6 @@ interface FeedFormData extends Feed { intervalMultiplier: number; } -interface FeedsTabProps extends WrappedComponentProps { - feeds: Array; - items: Array; -} - interface FeedsTabStates { errors?: { [field in ValidatedFields]?: string; @@ -96,7 +91,8 @@ const defaultFeed = { url: '', }; -class FeedsTab extends React.Component { +@observer +class FeedsTab extends React.Component { formRef: Form | null = null; manualAddingFormRef: Form | null = null; @@ -129,8 +125,9 @@ class FeedsTab extends React.Component { } }, 150); - constructor(props: FeedsTabProps) { + constructor(props: WrappedComponentProps) { super(props); + this.state = { errors: {}, intervalMultipliers: [ @@ -180,41 +177,6 @@ class FeedsTab extends React.Component { )); } - getAvailableFeedsOptions() { - const {feeds} = this.props; - - if (!feeds.length) { - return [ - - - - - , - ]; - } - - return feeds.reduce( - (feedOptions, feed) => { - if (feed._id == null) { - return feedOptions; - } - - return feedOptions.concat( - - {feed.label} - , - ); - }, - [ - - - - - , - ], - ); - } - getModifyFeedForm(feed: Partial) { const feedInterval = feed.interval || defaultFeed.interval; const isDayInterval = feedInterval % 1440; @@ -350,7 +312,7 @@ class FeedsTab extends React.Component { } getFeedsList() { - const {feeds} = this.props; + const {feeds} = FeedStore; if (feeds.length === 0) { return ( @@ -368,6 +330,8 @@ class FeedsTab extends React.Component { } getFeedItemsForm() { + const {feeds, items} = FeedStore; + return (
    { {this.renderSearchField()} {this.renderDownloadButton()} - {this.state.selectedFeedID && {this.getFeedItemsList()}} + {this.state.selectedFeedID && ( + + {items.length === 0 ? ( +
      +
    • +
      + +
      +
    • +
    + ) : ( +
      + {items.map((item, index) => ( +
    • +
      {item.title}
      + +
    • + ))} +
    + )} +
    + )}
    ); } - getFeedItemsList() { - const {items} = this.props; - - if (items.length === 0) { - return ( -
      -
    • -
      - -
      -
    • -
    - ); - } - - const itemsList = items.map((item, index) => ( -
  • -
    {item.title}
    - -
  • - )); - - return
      {itemsList}
    ; - } - handleFormSubmit = () => { const {errors, isValid} = this.validateForm(); @@ -434,9 +423,9 @@ class FeedsTab extends React.Component { if (formData != null) { if (currentFeed === defaultFeed) { - SettingsActions.addFeed(formData); + FeedActions.addFeed(formData); } else if (currentFeed?._id != null) { - SettingsActions.modifyFeed(currentFeed._id, formData); + FeedActions.modifyFeed(currentFeed._id, formData); } } if (this.formRef != null) { @@ -460,7 +449,7 @@ class FeedsTab extends React.Component { handleRemoveFeedClick = (feed: Feed) => { if (feed._id != null) { - SettingsActions.removeFeedMonitor(feed._id); + FeedActions.removeFeedMonitor(feed._id); } if (feed === this.state.currentlyEditingFeed) { @@ -483,7 +472,7 @@ class FeedsTab extends React.Component { const feedBrowseForm = input.formData as {feedID: string; search: string}; if ((input.event.target as HTMLInputElement).type !== 'checkbox') { this.setState({selectedFeedID: feedBrowseForm.feedID}); - SettingsActions.fetchItems({id: feedBrowseForm.feedID, search: feedBrowseForm.search}); + FeedActions.fetchItems({id: feedBrowseForm.feedID, search: feedBrowseForm.search}); } }; @@ -495,7 +484,7 @@ class FeedsTab extends React.Component { const formData = this.manualAddingFormRef.getFormData(); // TODO: Properly handle array of array of URLs - const torrentsToDownload = this.props.items + const torrentsToDownload = FeedStore.items .filter((_item, index) => formData[index]) .map((item, index) => ({id: index, value: item.urls[0]})); @@ -579,29 +568,4 @@ class FeedsTab extends React.Component { } } -const ConnectedFeedsTab = connectStores, FeedsTabStates>(injectIntl(FeedsTab), () => { - return [ - { - store: FeedsStore, - event: 'SETTINGS_FEED_MONITORS_FETCH_SUCCESS', - getValue: ({store}) => { - const storeFeeds = store as typeof FeedsStore; - return { - feeds: storeFeeds.getFeeds(), - }; - }, - }, - { - store: FeedsStore, - event: 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_SUCCESS', - getValue: ({store}) => { - const storeFeeds = store as typeof FeedsStore; - return { - items: storeFeeds.getItems() || [], - }; - }, - }, - ]; -}); - -export default ConnectedFeedsTab; +export default injectIntl(FeedsTab); diff --git a/client/src/javascript/components/modals/move-torrents-modal/MoveTorrentsModal.tsx b/client/src/javascript/components/modals/move-torrents-modal/MoveTorrentsModal.tsx index f106e3d4..66d1fc99 100644 --- a/client/src/javascript/components/modals/move-torrents-modal/MoveTorrentsModal.tsx +++ b/client/src/javascript/components/modals/move-torrents-modal/MoveTorrentsModal.tsx @@ -9,8 +9,9 @@ import Modal from '../Modal'; import ModalActions from '../ModalActions'; import TorrentActions from '../../../actions/TorrentActions'; import TorrentStore from '../../../stores/TorrentStore'; +import UIStore from '../../../stores/UIStore'; -import type {ModalAction} from '../ModalActions'; +import type {ModalAction} from '../../../stores/UIStore'; interface MoveTorrentsStates { isSettingDownloadPath: boolean; @@ -49,8 +50,8 @@ class MoveTorrents extends React.Component TorrentStore.torrents[hash].basePath), + TorrentStore.selectedTorrents.map((hash: string) => TorrentStore.torrents[hash].baseFilename), ), }; } @@ -112,7 +113,7 @@ class MoveTorrents extends React.Component { - const hashes = TorrentStore.getSelectedTorrents(); + const hashes = TorrentStore.selectedTorrents; if (hashes.length > 0) { this.setState({isSettingDownloadPath: true}); TorrentActions.moveTorrents({ @@ -122,6 +123,7 @@ class MoveTorrents extends React.Component { + UIStore.dismissModal(); this.setState({isSettingDownloadPath: false}); }); } diff --git a/client/src/javascript/components/modals/remove-torrents-modal/RemoveTorrentsModal.tsx b/client/src/javascript/components/modals/remove-torrents-modal/RemoveTorrentsModal.tsx index 5910eb9c..0498e812 100644 --- a/client/src/javascript/components/modals/remove-torrents-modal/RemoveTorrentsModal.tsx +++ b/client/src/javascript/components/modals/remove-torrents-modal/RemoveTorrentsModal.tsx @@ -4,11 +4,11 @@ import React from 'react'; import {Checkbox, Form, FormRow} from '../../../ui'; import Modal from '../Modal'; import {saveDeleteTorrentsUserPreferences} from '../../../util/userPreferences'; -import SettingsStore from '../../../stores/SettingsStore'; +import SettingStore from '../../../stores/SettingStore'; import TorrentActions from '../../../actions/TorrentActions'; import TorrentStore from '../../../stores/TorrentStore'; -import type {ModalAction} from '../ModalActions'; +import type {ModalAction} from '../../../stores/UIStore'; class RemoveTorrentsModal extends React.Component { formRef?: Form | null; @@ -56,7 +56,7 @@ class RemoveTorrentsModal extends React.Component { deleteDataContent = ( - + @@ -83,7 +83,7 @@ class RemoveTorrentsModal extends React.Component { } TorrentActions.deleteTorrents({ - hashes: TorrentStore.getSelectedTorrents(), + hashes: TorrentStore.selectedTorrents, deleteData, }); @@ -91,7 +91,7 @@ class RemoveTorrentsModal extends React.Component { }; render() { - const selectedTorrents = TorrentStore.getSelectedTorrents(); + const {selectedTorrents} = TorrentStore; const modalHeading = this.props.intl.formatMessage({ id: 'torrents.remove', }); diff --git a/client/src/javascript/components/modals/set-tags-modal/SetTagsModal.tsx b/client/src/javascript/components/modals/set-tags-modal/SetTagsModal.tsx index 8ec4e4c3..ba34b478 100644 --- a/client/src/javascript/components/modals/set-tags-modal/SetTagsModal.tsx +++ b/client/src/javascript/components/modals/set-tags-modal/SetTagsModal.tsx @@ -7,7 +7,7 @@ import TagSelect from '../../general/form-elements/TagSelect'; import TorrentActions from '../../../actions/TorrentActions'; import TorrentStore from '../../../stores/TorrentStore'; -import type {ModalAction} from '../ModalActions'; +import type {ModalAction} from '../../../stores/UIStore'; interface SetTagsModalStates { isSettingTags: boolean; @@ -56,7 +56,7 @@ class SetTagsModal extends React.Component TorrentStore.torrents[hash].tags)[0]} id="tags" placeholder={this.props.intl.formatMessage({ id: 'torrents.set.tags.enter.tags', @@ -76,9 +76,7 @@ class SetTagsModal extends React.Component - TorrentActions.setTags({hashes: TorrentStore.getSelectedTorrents(), tags}), - ); + this.setState({isSettingTags: true}, () => TorrentActions.setTags({hashes: TorrentStore.selectedTorrents, tags})); }; render() { diff --git a/client/src/javascript/components/modals/set-tracker-modal/SetTrackerModal.tsx b/client/src/javascript/components/modals/set-tracker-modal/SetTrackerModal.tsx index 8c8c1f3e..db8c4801 100644 --- a/client/src/javascript/components/modals/set-tracker-modal/SetTrackerModal.tsx +++ b/client/src/javascript/components/modals/set-tracker-modal/SetTrackerModal.tsx @@ -5,8 +5,9 @@ import {Form, FormRow, Textbox} from '../../../ui'; import Modal from '../Modal'; import TorrentActions from '../../../actions/TorrentActions'; import TorrentStore from '../../../stores/TorrentStore'; +import UIStore from '../../../stores/UIStore'; -import type {ModalAction} from '../ModalActions'; +import type {ModalAction} from '../../../stores/UIStore'; interface SetTrackerModalStates { isSettingTracker: boolean; @@ -47,7 +48,9 @@ class SetTrackerModal extends React.Component TorrentStore.torrents[hash].trackerURIs)[0] + .join(', '); return (
    @@ -78,7 +81,9 @@ class SetTrackerModal extends React.Component - TorrentActions.setTracker(TorrentStore.getSelectedTorrents(), tracker), + TorrentActions.setTracker(TorrentStore.selectedTorrents, tracker).then(() => { + UIStore.dismissModal(); + }), ); }; diff --git a/client/src/javascript/components/modals/settings-modal/AuthTab.tsx b/client/src/javascript/components/modals/settings-modal/AuthTab.tsx index 5ed86664..34ec55c7 100644 --- a/client/src/javascript/components/modals/settings-modal/AuthTab.tsx +++ b/client/src/javascript/components/modals/settings-modal/AuthTab.tsx @@ -1,6 +1,7 @@ import classnames from 'classnames'; import {CSSTransition, TransitionGroup} from 'react-transition-group'; import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; import {AccessLevel} from '@shared/schema/Auth'; @@ -11,7 +12,6 @@ import AuthActions from '../../../actions/AuthActions'; import AuthStore from '../../../stores/AuthStore'; import ClientConnectionSettingsForm from '../../general/connection-settings/ClientConnectionSettingsForm'; import Close from '../../icons/Close'; -import connectStores from '../../../util/connectStores'; import ModalFormSectionHeader from '../ModalFormSectionHeader'; import type {ClientConnectionSettingsFormType} from '../../general/connection-settings/ClientConnectionSettingsForm'; @@ -22,25 +22,21 @@ interface AuthTabFormData { isAdmin: boolean; } -interface AuthTabProps extends WrappedComponentProps { - users: Array; - isAdmin: boolean; -} - interface AuthTabStates { addUserError: string | null; hasFetchedUserList: boolean; isAddingUser: boolean; } -class AuthTab extends React.Component { +@observer +class AuthTab extends React.Component { formData?: Partial; formRef?: Form | null = null; settingsFormRef: React.RefObject = React.createRef(); - constructor(props: AuthTabProps) { + constructor(props: WrappedComponentProps) { super(props); this.state = { @@ -51,55 +47,15 @@ class AuthTab extends React.Component { } componentDidMount() { - if (!this.props.isAdmin) return; + if (!AuthStore.currentUser.isAdmin) { + return; + } AuthActions.fetchUsers().then(() => { this.setState({hasFetchedUserList: true}); }); } - getUserList() { - const userList = this.props.users.sort((a: Credentials, b: Credentials) => a.username.localeCompare(b.username)); - - const currentUsername = AuthStore.getCurrentUsername(); - - return userList.map((user: Credentials) => { - const isCurrentUser = user.username === currentUsername; - let badge = null; - let removeIcon = null; - - if (!isCurrentUser) { - removeIcon = ( - AuthActions.deleteUser(user.username).then(AuthActions.fetchUsers)}> - - - ); - } else { - badge = ( - - - - ); - } - - const classes = classnames('interactive-list__item', { - 'interactive-list__item--disabled': isCurrentUser, - }); - - return ( -
  • - -
    {user.username}
    - {badge} -
    - {removeIcon} -
  • - ); - }); - } - handleFormChange = ({formData}: {formData: Record}) => { this.formData = formData as Partial; }; @@ -154,7 +110,9 @@ class AuthTab extends React.Component { }; render() { - if (!this.props.isAdmin) { + const {addUserError, hasFetchedUserList} = this.state; + + if (!AuthStore.currentUser.isAdmin) { return (
    @@ -169,17 +127,17 @@ class AuthTab extends React.Component { ); } - const isLoading = !this.state.hasFetchedUserList && this.props.users.length === 0; + const isLoading = !hasFetchedUserList && AuthStore.users.length === 0; const interactiveListClasses = classnames('interactive-list', { 'interactive-list--loading': isLoading, }); let errorElement = null; let loadingIndicator = null; - if (this.state.addUserError) { + if (addUserError) { errorElement = ( - {this.state.addUserError} + {addUserError} ); } @@ -208,7 +166,44 @@ class AuthTab extends React.Component {
      {loadingIndicator} - {this.getUserList()} + {AuthStore.users + .slice() + .sort((a: Credentials, b: Credentials) => a.username.localeCompare(b.username)) + .map((user: Credentials) => { + const isCurrentUser = user.username === AuthStore.currentUser.username; + let badge = null; + let removeIcon = null; + + if (!isCurrentUser) { + removeIcon = ( + AuthActions.deleteUser(user.username).then(AuthActions.fetchUsers)}> + + + ); + } else { + badge = ( + + + + ); + } + + const classes = classnames('interactive-list__item', { + 'interactive-list__item--disabled': isCurrentUser, + }); + + return ( +
    • + +
      {user.username}
      + {badge} +
      + {removeIcon} +
    • + ); + })}
    @@ -249,19 +244,4 @@ class AuthTab extends React.Component { } } -const ConnectedAuthTab = connectStores(injectIntl(AuthTab), () => { - return [ - { - store: AuthStore, - event: 'AUTH_LIST_USERS_SUCCESS', - getValue: () => { - return { - users: AuthStore.getUsers(), - isAdmin: AuthStore.isAdmin(), - }; - }, - }, - ]; -}); - -export default ConnectedAuthTab; +export default injectIntl(AuthTab); diff --git a/client/src/javascript/components/modals/settings-modal/BandwidthTab.tsx b/client/src/javascript/components/modals/settings-modal/BandwidthTab.tsx index 8ce4897f..8fec3a24 100644 --- a/client/src/javascript/components/modals/settings-modal/BandwidthTab.tsx +++ b/client/src/javascript/components/modals/settings-modal/BandwidthTab.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {Form, FormRow, Textbox} from '../../../ui'; import ModalFormSectionHeader from '../ModalFormSectionHeader'; +import SettingStore from '../../../stores/SettingStore'; import SettingsTab from './SettingsTab'; const processSpeedsForDisplay = (speeds: number[]) => { @@ -48,22 +49,6 @@ export default class BandwidthTab extends SettingsTab { this.handleClientSettingChange(event); }; - getDownloadValue() { - if (this.props.floodSettings.speedLimits != null) { - return processSpeedsForDisplay(this.props.floodSettings.speedLimits.download); - } - - return 0; - } - - getUploadValue() { - if (this.props.floodSettings.speedLimits != null) { - return processSpeedsForDisplay(this.props.floodSettings.speedLimits.upload); - } - - return 0; - } - render() { return ( @@ -72,14 +57,22 @@ export default class BandwidthTab extends SettingsTab {
    } id="dropdownPresetDownload" /> } id="dropdownPresetUpload" /> diff --git a/client/src/javascript/components/modals/settings-modal/SettingsModal.tsx b/client/src/javascript/components/modals/settings-modal/SettingsModal.tsx index 72934cd8..26c24e1f 100644 --- a/client/src/javascript/components/modals/settings-modal/SettingsModal.tsx +++ b/client/src/javascript/components/modals/settings-modal/SettingsModal.tsx @@ -7,21 +7,17 @@ import type {FloodSettings} from '@shared/types/FloodSettings'; import AboutTab from './AboutTab'; import AuthTab from './AuthTab'; import BandwidthTab from './BandwidthTab'; +import ConfigStore from '../../../stores/ConfigStore'; import ConnectivityTab from './ConnectivityTab'; -import connectStores from '../../../util/connectStores'; +import ClientActions from '../../../actions/ClientActions'; +import DiskUsageTab from './DiskUsageTab'; import Modal from '../Modal'; import ResourcesTab from './ResourcesTab'; -import ConfigStore from '../../../stores/ConfigStore'; -import SettingsStore from '../../../stores/SettingsStore'; +import SettingActions from '../../../actions/SettingActions'; import UITab from './UITab'; -import DiskUsageTab from './DiskUsageTab'; +import UIStore from '../../../stores/UIStore'; -import type {ModalAction} from '../ModalActions'; - -interface SettingsModalProps extends WrappedComponentProps { - clientSettings?: ClientSettings | null; - floodSettings?: FloodSettings | null; -} +import type {ModalAction} from '../../../stores/UIStore'; interface SettingsModalStates { isSavingSettings: boolean; @@ -29,9 +25,10 @@ interface SettingsModalStates { changedFloodSettings: Partial; } -class SettingsModal extends React.Component { - constructor(props: SettingsModalProps) { +class SettingsModal extends React.Component { + constructor(props: WrappedComponentProps) { super(props); + this.state = { isSavingSettings: false, changedClientSettings: {}, @@ -64,16 +61,15 @@ class SettingsModal extends React.Component { this.setState({isSavingSettings: true}, () => { Promise.all([ - SettingsStore.saveFloodSettings(this.state.changedFloodSettings, { - dismissModal: true, + SettingActions.saveSettings(this.state.changedFloodSettings, { alert: true, }), - SettingsStore.saveClientSettings(this.state.changedClientSettings, { - dismissModal: true, + ClientActions.saveSettings(this.state.changedClientSettings, { alert: true, }), ]).then(() => { this.setState({isSavingSettings: false}); + UIStore.dismissModal(); }); }); }; @@ -101,7 +97,7 @@ class SettingsModal extends React.Component, SettingsModalStates>( - injectIntl(SettingsModal), - () => { - return [ - { - store: SettingsStore, - event: 'SETTINGS_CHANGE', - getValue: () => { - return { - clientSettings: SettingsStore.getClientSettings(), - floodSettings: SettingsStore.getFloodSettings(), - }; - }, - }, - ]; - }, -); - -export default ConnectedSettingsModal; +export default injectIntl(SettingsModal); diff --git a/client/src/javascript/components/modals/settings-modal/SettingsTab.tsx b/client/src/javascript/components/modals/settings-modal/SettingsTab.tsx index 9c7d32a1..e8e10bb7 100644 --- a/client/src/javascript/components/modals/settings-modal/SettingsTab.tsx +++ b/client/src/javascript/components/modals/settings-modal/SettingsTab.tsx @@ -4,9 +4,9 @@ import {WrappedComponentProps} from 'react-intl'; import type {ClientSetting, ClientSettings} from '@shared/types/ClientSettings'; import type {FloodSettings} from '@shared/types/FloodSettings'; +import SettingStore from '../../../stores/SettingStore'; + interface SettingsTabProps extends WrappedComponentProps { - clientSettings: ClientSettings; - floodSettings: FloodSettings; onSettingsChange: (changeSettings: Partial) => void; onClientSettingsChange: (changeSettings: Partial) => void; } @@ -15,7 +15,7 @@ interface SettingsTabStates { changedClientSettings: Partial; } -export default class SettingsTab extends React.Component { +class SettingsTab extends React.Component { constructor(props: SettingsTabProps) { super(props); @@ -29,7 +29,7 @@ export default class SettingsTab extends React.Component | Event) { @@ -55,3 +55,5 @@ export default class SettingsTab extends React.Component { diff --git a/client/src/javascript/components/modals/settings-modal/lists/MountPointsList.tsx b/client/src/javascript/components/modals/settings-modal/lists/MountPointsList.tsx index 324880f8..da385d1d 100644 --- a/client/src/javascript/components/modals/settings-modal/lists/MountPointsList.tsx +++ b/client/src/javascript/components/modals/settings-modal/lists/MountPointsList.tsx @@ -5,7 +5,7 @@ import type {FloodSettings} from '@shared/types/FloodSettings'; import {Checkbox} from '../../../../ui'; import DiskUsageStore from '../../../../stores/DiskUsageStore'; -import SettingsStore from '../../../../stores/SettingsStore'; +import SettingStore from '../../../../stores/SettingStore'; import SortableList, {ListItem} from '../../../general/SortableList'; interface MountPointsListProps { @@ -20,10 +20,10 @@ class MountPointsList extends React.Component { + ...DiskUsageStore.disks.map((disk) => { return { [disk.target]: disk, }; @@ -114,7 +114,7 @@ class MountPointsList extends React.Component { + contents = observable.array([]); + itemsTree = observable.object({}); + selectedIndices = observable.array([]); + polling = setInterval(() => { + // TODO: itemsTree is not regenerated as that would override user's selection. + // As a result, percentage of contents of an active torrent is not updated. + // this.fetchTorrentContents(); + }, ConfigStore.getPollInterval()); + + constructor(props: WrappedComponentProps) { + super(props); + + this.fetchTorrentContents(true); + } + + componentWillUnmount() { + clearInterval(this.polling); + } + + fetchTorrentContents = (populateTree = false) => { + if (UIStore.activeModal?.id === 'torrent-details') { + TorrentActions.fetchTorrentContents(UIStore.activeModal?.hash).then((contents) => { + if (contents != null) { + runInAction(() => { + this.contents.replace(contents); + if (populateTree) { + this.itemsTree = selectionTree.getSelectionTree(this.contents); + } + }); + } + }); + } + }; + + handleDownloadButtonClick = (hash: string, event: React.MouseEvent): void => { + event.preventDefault(); + const baseURI = ConfigStore.getBaseURI(); + const link = document.createElement('a'); + const {name} = TorrentStore.torrents[hash] || {}; + + if (name == null) { + return; + } + + link.download = `${name}.tar`; + link.href = `${baseURI}api/torrents/${hash}/contents/${this.selectedIndices.join(',')}/data`; + link.style.display = 'none'; + + document.body.appendChild(link); // Fix for Firefox 58+ + + link.click(); + }; + + handleFormChange = (hash: string, {event}: {event: Event | React.FormEvent}): void => { + if (event.target != null && (event.target as HTMLInputElement).name === 'file-priority') { + const inputElement = event.target as HTMLInputElement; + if (inputElement.name === 'file-priority') { + TorrentActions.setFilePriority(hash, { + indices: this.selectedIndices, + priority: Number(inputElement.value), + }); + } + } + }; + + handleItemSelect = (selectedItem: TorrentContentSelection) => { + runInAction(() => { + this.itemsTree = selectionTree.applySelection(this.itemsTree, selectedItem); + this.selectedIndices.replace(selectionTree.getSelectedItems(this.itemsTree)); + }); + }; + + handleSelectAllClick = () => { + runInAction(() => { + this.itemsTree = selectionTree.getSelectionTree( + this.contents, + this.selectedIndices.length < this.contents.length, + ); + this.selectedIndices.replace(selectionTree.getSelectedItems(this.itemsTree)); + }); + }; + + render() { + if (UIStore.activeModal?.id !== 'torrent-details') { + return null; + } + + const {hash} = UIStore?.activeModal; + + let directoryHeadingIconContent = null; + let fileDetailContent = null; + + let allSelected = false; + if (this.contents?.length > 0) { + allSelected = this.selectedIndices.length >= this.contents.length; + directoryHeadingIconContent = ( +
    +
    + + + +
    +
    + +
    +
    + ); + fileDetailContent = ( + + ); + } else { + directoryHeadingIconContent = ; + fileDetailContent = ( +
    + +
    + ); + } + + const directoryHeadingClasses = classnames( + 'directory-tree__node', + 'directory-tree__parent-directory torrent-details__section__heading', + { + 'directory-tree__node--selected': allSelected, + }, + ); + + const directoryHeading = ( +
    +
    + {directoryHeadingIconContent} +
    {TorrentStore.torrents?.[hash].directory}
    +
    +
    + ); + + const wrapperClasses = classnames('inverse directory-tree__wrapper', { + 'directory-tree__wrapper--toolbar-visible': this.selectedIndices.length > 0, + }); + + return ( + this.handleFormChange(hash, e)}> +
    + + + {this.selectedIndices.length} + ), + }} + /> + + + + +
    +
    + {directoryHeading} + {fileDetailContent} +
    + + ); + } +} + +export default injectIntl(TorrentContents); diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.tsx b/client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.tsx index cc31025b..a9376643 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.tsx +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.tsx @@ -1,122 +1,60 @@ -import {injectIntl, WrappedComponentProps} from 'react-intl'; +import {useIntl} from 'react-intl'; import React from 'react'; -import type {TorrentDetails, TorrentProperties} from '@shared/types/Torrent'; - -import connectStores from '../../../util/connectStores'; import Modal from '../Modal'; import TorrentMediainfo from './TorrentMediainfo'; -import TorrentFiles from './TorrentFiles'; +import TorrentContents from './TorrentContents'; import TorrentGeneralInfo from './TorrentGeneralInfo'; import TorrentHeading from './TorrentHeading'; import TorrentPeers from './TorrentPeers'; -import TorrentStore from '../../../stores/TorrentStore'; import TorrentTrackers from './TorrentTrackers'; -export interface TorrentDetailsModalProps extends WrappedComponentProps { - options: {hash: TorrentProperties['hash']}; - torrent?: TorrentProperties; - torrentDetails?: TorrentDetails | null; -} +const TorrentDetailsModal: React.FC = () => { + const intl = useIntl(); -class TorrentDetailsModal extends React.Component { - componentDidMount() { - TorrentStore.fetchTorrentDetails(this.props.options.hash); - } + const tabs = { + 'torrent-details': { + content: TorrentGeneralInfo, + label: intl.formatMessage({ + id: 'torrents.details.details', + }), + }, + 'torrent-contents': { + content: TorrentContents, + label: intl.formatMessage({ + id: 'torrents.details.files', + }), + modalContentClasses: 'modal__content--nested-scroll', + }, + 'torrent-peers': { + content: TorrentPeers, + label: intl.formatMessage({ + id: 'torrents.details.peers', + }), + }, + 'torrent-trackers': { + content: TorrentTrackers, + label: intl.formatMessage({ + id: 'torrents.details.trackers', + }), + }, + 'torrent-mediainfo': { + content: TorrentMediainfo, + label: intl.formatMessage({ + id: 'torrents.details.mediainfo', + }), + }, + }; - componentWillUnmount() { - TorrentStore.stopPollingTorrentDetails(); - } + return ( + } + size="large" + tabs={tabs} + orientation={window.matchMedia('(max-width: 720px)').matches ? 'horizontal' : 'vertical'} + {...(window.matchMedia('(max-width: 720px)').matches ? [] : {tabsInBody: true})} + /> + ); +}; - getModalHeading() { - if (this.props.torrent != null) { - return ; - } - return null; - } - - render() { - const props = { - ...this.props.options, - torrent: this.props.torrent, - ...this.props.torrentDetails, - }; - - const tabs = { - 'torrent-details': { - content: TorrentGeneralInfo, - label: this.props.intl.formatMessage({ - id: 'torrents.details.details', - }), - props, - }, - 'torrent-files': { - content: TorrentFiles, - label: this.props.intl.formatMessage({ - id: 'torrents.details.files', - }), - modalContentClasses: 'modal__content--nested-scroll', - props, - }, - 'torrent-peers': { - content: TorrentPeers, - label: this.props.intl.formatMessage({ - id: 'torrents.details.peers', - }), - props, - }, - 'torrent-trackers': { - content: TorrentTrackers, - label: this.props.intl.formatMessage({ - id: 'torrents.details.trackers', - }), - props, - }, - 'torrent-mediainfo': { - content: TorrentMediainfo, - label: this.props.intl.formatMessage({ - id: 'torrents.details.mediainfo', - }), - props, - }, - }; - - return ( - - ); - } -} - -const ConnectedTorrentDetailsModal = connectStores, Record>( - injectIntl(TorrentDetailsModal), - () => { - return [ - { - store: TorrentStore, - event: 'CLIENT_TORRENT_DETAILS_CHANGE', - getValue: ({props}) => { - return { - torrentDetails: TorrentStore.getTorrentDetails(props.options.hash), - }; - }, - }, - { - store: TorrentStore, - event: 'CLIENT_TORRENTS_REQUEST_SUCCESS', - getValue: ({props}) => { - return { - torrent: TorrentStore.getTorrent(props.options.hash), - }; - }, - }, - ]; - }, -); - -export default ConnectedTorrentDetailsModal; +export default TorrentDetailsModal; diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentFiles.tsx b/client/src/javascript/components/modals/torrent-details-modal/TorrentFiles.tsx deleted file mode 100644 index 76957346..00000000 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentFiles.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import classnames from 'classnames'; -import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; -import React from 'react'; - -import type {TorrentContent, TorrentContentSelection, TorrentContentSelectionTree} from '@shared/types/TorrentContent'; -import type {TorrentProperties} from '@shared/types/Torrent'; - -import {Button, Checkbox, Form, FormRow, FormRowItem, Select, SelectItem} from '../../../ui'; -import ConfigStore from '../../../stores/ConfigStore'; -import Disk from '../../icons/Disk'; -import DirectoryTree from '../../general/filesystem/DirectoryTree'; -import selectionTree from '../../../util/selectionTree'; -import TorrentActions from '../../../actions/TorrentActions'; - -interface TorrentFilesProps extends WrappedComponentProps { - contents: Array; - torrent: TorrentProperties; -} - -interface TorrentFilesStates { - allSelected: boolean; - itemsTree: TorrentContentSelectionTree; - selectedIndices: Array; -} - -const TORRENT_PROPS_TO_CHECK = ['bytesDone'] as const; -const METHODS_TO_BIND = ['handleItemSelect', 'handlePriorityChange', 'handleSelectAllClick'] as const; - -class TorrentFiles extends React.Component { - hasSelectionChanged = false; - hasPriorityChanged = false; - - constructor(props: TorrentFilesProps) { - super(props); - - this.state = { - allSelected: false, - itemsTree: selectionTree.getSelectionTree(this.props.contents, false), - selectedIndices: [], - }; - - METHODS_TO_BIND.forEach((methodName: T) => { - this[methodName] = this[methodName].bind(this); - }); - } - - shouldComponentUpdate(nextProps: this['props']) { - if (this.hasSelectionChanged) { - this.hasSelectionChanged = false; - return true; - } - - // Update when the priority has changed - if (this.hasPriorityChanged) { - this.hasPriorityChanged = false; - return true; - } - - // Update when the previous props weren't defined and the next are. - if ((!this.props.torrent && nextProps.torrent) || (!this.props.contents && nextProps.contents)) { - return true; - } - - // Check specific properties to re-render when the torrent is active. - if (nextProps.torrent) { - return TORRENT_PROPS_TO_CHECK.some((property) => this.props.torrent[property] !== nextProps.torrent[property]); - } - - return true; - } - - getSelectedFiles(tree: TorrentContentSelectionTree) { - const indices: Array = []; - - if (tree.files != null) { - const {files} = tree; - Object.keys(files).forEach((fileName) => { - const file = files[fileName]; - - if (file.isSelected) { - indices.push(file.index); - } - }); - } - - if (tree.directories != null) { - const {directories} = tree; - Object.keys(directories).forEach((directoryName) => { - indices.push(...this.getSelectedFiles(directories[directoryName])); - }); - } - - return indices; - } - - handleDownloadButtonClick = (event: React.MouseEvent): void => { - event.preventDefault(); - const baseURI = ConfigStore.getBaseURI(); - const link = document.createElement('a'); - link.download = `${this.props.torrent.name}.tar`; - link.href = `${baseURI}api/torrents/${this.props.torrent.hash}/contents/${this.state.selectedIndices.join( - ',', - )}/data`; - link.style.display = 'none'; - document.body.appendChild(link); // Fix for Firefox 58+ - link.click(); - }; - - handleFormChange = ({event}: {event: Event | React.FormEvent}): void => { - if (event.target != null && (event.target as HTMLInputElement).name === 'file-priority') { - const inputElement = event.target as HTMLInputElement; - if (inputElement.name === 'file-priority') { - this.handlePriorityChange(); - TorrentActions.setFilePriority(this.props.torrent.hash, { - indices: this.state.selectedIndices, - priority: Number(inputElement.value), - }); - } - } - }; - - handleItemSelect(selectedItem: TorrentContentSelection) { - this.hasSelectionChanged = true; - this.setState((state) => { - const selectedItems = selectionTree.applySelection(state.itemsTree, selectedItem); - const selectedFiles = this.getSelectedFiles(selectedItems); - - return { - itemsTree: selectedItems, - allSelected: false, - selectedIndices: selectedFiles, - }; - }); - } - - handlePriorityChange() { - this.hasPriorityChanged = true; - } - - handleSelectAllClick() { - this.hasSelectionChanged = true; - - this.setState((state, props) => { - const selectedItems = selectionTree.getSelectionTree(props.contents, state.allSelected); - const selectedFiles = this.getSelectedFiles(selectedItems); - - return { - itemsTree: selectedItems, - allSelected: !state.allSelected, - selectedIndices: selectedFiles, - }; - }); - } - - isLoaded() { - return this.props.contents != null; - } - - render() { - const {torrent} = this.props; - let directoryHeadingIconContent = null; - let fileDetailContent = null; - - if (this.isLoaded()) { - directoryHeadingIconContent = ( -
    -
    - - - -
    -
    - -
    -
    - ); - fileDetailContent = ( - - ); - } else { - directoryHeadingIconContent = ; - fileDetailContent = ( -
    - -
    - ); - } - - const directoryHeadingClasses = classnames( - 'directory-tree__node', - 'directory-tree__parent-directory torrent-details__section__heading', - { - 'directory-tree__node--selected': this.state.allSelected, - }, - ); - - const directoryHeading = ( -
    -
    - {directoryHeadingIconContent} -
    {torrent.directory}
    -
    -
    - ); - - const wrapperClasses = classnames('inverse directory-tree__wrapper', { - 'directory-tree__wrapper--toolbar-visible': this.state.selectedIndices.length > 0, - }); - - return ( -
    -
    - - - - {this.state.selectedIndices.length} - - ), - }} - /> - - - - -
    -
    - {directoryHeading} - {fileDetailContent} -
    -
    - ); - } -} - -export default injectIntl(TorrentFiles); diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.tsx b/client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.tsx index 6cdd1ed3..fc622dd6 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.tsx +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.tsx @@ -1,13 +1,12 @@ import {FormattedMessage, FormattedNumber, useIntl} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; import type {TorrentProperties} from '@shared/types/Torrent'; import Size from '../../general/Size'; - -interface TorrentGeneralInfoProps { - torrent: TorrentProperties; -} +import TorrentStore from '../../../stores/TorrentStore'; +import UIStore from '../../../stores/UIStore'; const getTags = (tags: TorrentProperties['tags']) => { return tags.map((tag) => ( @@ -17,7 +16,16 @@ const getTags = (tags: TorrentProperties['tags']) => { )); }; -const TorrentGeneralInfo: React.FC = ({torrent}: TorrentGeneralInfoProps) => { +const TorrentGeneralInfo: React.FC = () => { + if (UIStore.activeModal?.id !== 'torrent-details') { + return null; + } + + const torrent = TorrentStore.torrents[UIStore?.activeModal?.hash]; + if (torrent == null) { + return null; + } + const intl = useIntl(); let dateAdded = null; @@ -183,4 +191,4 @@ const TorrentGeneralInfo: React.FC = ({torrent}: Torren ); }; -export default TorrentGeneralInfo; +export default observer(TorrentGeneralInfo); diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.tsx b/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.tsx index 8979dfd6..aef41922 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.tsx +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.tsx @@ -1,5 +1,7 @@ import {FormattedMessage} from 'react-intl'; import classnames from 'classnames'; +import {observable} from 'mobx'; +import {observer} from 'mobx-react'; import React from 'react'; import type {TorrentProperties} from '@shared/types/Torrent'; @@ -17,15 +19,9 @@ import StopIcon from '../../icons/StopIcon'; import TorrentActions from '../../../actions/TorrentActions'; import torrentStatusClasses from '../../../util/torrentStatusClasses'; import torrentStatusIcons from '../../../util/torrentStatusIcons'; +import TorrentStore from '../../../stores/TorrentStore'; import UploadThickIcon from '../../icons/UploadThickIcon'; - -interface TorrentHeadingProps { - torrent: TorrentProperties; -} - -interface TorrentHeadingStates { - optimisticData: {currentStatus: 'start' | 'stop' | null}; -} +import UIStore from '../../../stores/UIStore'; const getCurrentStatus = (statuses: TorrentProperties['status']) => { if (statuses.includes('stopped')) { @@ -34,88 +30,23 @@ const getCurrentStatus = (statuses: TorrentProperties['status']) => { return 'start'; }; -const METHODS_TO_BIND = ['handleStart', 'handleStop'] as const; - -export default class TorrentHeading extends React.Component { - constructor(props: TorrentHeadingProps) { - super(props); - - this.state = { - optimisticData: {currentStatus: null}, - }; - - METHODS_TO_BIND.forEach((method) => { - this[method] = this[method].bind(this); - }); - } - - getTorrentActions(torrent: TorrentProperties) { - const currentStatus = this.state.optimisticData.currentStatus || getCurrentStatus(torrent.status); - const statusIcons = { - start: , - stop: , - } as const; - const torrentActions = ['start', 'stop'] as const; - const torrentActionElements = [ -
  • - { - TorrentActions.setPriority({hashes: [`${hash}`], priority: level}); - }} - /> -
  • , - ]; - - torrentActions.forEach((torrentAction) => { - const classes = classnames('torrent-details__sub-heading__tertiary', 'torrent-details__action', { - 'is-active': torrentAction === currentStatus, - }); - - let clickHandler = null; - switch (torrentAction) { - case 'start': - clickHandler = this.handleStart; - break; - case 'stop': - clickHandler = this.handleStop; - break; - default: - return; - } - - torrentActionElements.push( -
  • - {statusIcons[torrentAction]} - -
  • , - ); - }); - - return torrentActionElements; - } - - handleStart() { - this.setState({optimisticData: {currentStatus: 'start'}}); - TorrentActions.startTorrents({ - hashes: [this.props.torrent.hash], - }); - } - - handleStop() { - this.setState({optimisticData: {currentStatus: 'stop'}}); - TorrentActions.stopTorrents({ - hashes: [this.props.torrent.hash], - }); - } +@observer +class TorrentHeading extends React.Component { + @observable torrentStatus: 'start' | 'stop' = 'stop'; render() { - const {torrent} = this.props; + if (UIStore.activeModal?.id !== 'torrent-details') { + return null; + } + + const torrent = TorrentStore.torrents[UIStore?.activeModal?.hash]; + if (torrent == null) { + return null; + } + const torrentClasses = torrentStatusClasses(torrent, 'torrent-details__header'); const torrentStatusIcon = torrentStatusIcons(torrent.status); + this.torrentStatus = getCurrentStatus(torrent.status); return (
    @@ -143,10 +74,52 @@ export default class TorrentHeading extends React.Component -
      {this.getTorrentActions(torrent)}
    +
      +
    • + { + TorrentActions.setPriority({hashes: [`${hash}`], priority: level}); + }} + /> +
    • +
    • { + this.torrentStatus = 'start'; + TorrentActions.startTorrents({ + hashes: [torrent.hash], + }); + }}> + + +
    • +
    • { + this.torrentStatus = 'stop'; + TorrentActions.stopTorrents({ + hashes: [torrent.hash], + }); + }}> + + +
    • +
    ); } } + +export default TorrentHeading; diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.tsx b/client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.tsx index 0767c460..459f966f 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.tsx +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.tsx @@ -1,26 +1,13 @@ +import axios from 'axios'; import Clipboard from 'clipboard'; import {defineMessages, FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; import React from 'react'; -import type {TorrentProperties} from '@shared/types/Torrent'; - import {Button} from '../../../ui'; import ClipboardIcon from '../../icons/ClipboardIcon'; -import connectStores from '../../../util/connectStores'; import Tooltip from '../../general/Tooltip'; import TorrentActions from '../../../actions/TorrentActions'; -import TorrentStore from '../../../stores/TorrentStore'; - -interface TorrentMediainfoProps extends WrappedComponentProps { - hash: TorrentProperties['hash']; - mediainfo: string; -} - -interface TorrentMediainfoStates { - copiedToClipboard: boolean; - isFetchingMediainfo: boolean; - fetchMediainfoError: {data: {error: unknown}} | null; -} +import UIStore from '../../../stores/UIStore'; const MESSAGES = defineMessages({ copy: { @@ -40,41 +27,54 @@ const MESSAGES = defineMessages({ }, }); -class TorrentMediainfo extends React.Component { +interface TorrentMediainfoStates { + copiedToClipboard: boolean; +} + +class TorrentMediainfo extends React.Component { + mediainfo: string | null = null; + isFetchingMediainfo = true; + fetchMediainfoError: Error | null = null; + + cancelToken = axios.CancelToken.source(); clipboard: Clipboard | null = null; copyButtonRef: HTMLButtonElement | null = null; timeoutId: NodeJS.Timeout | null = null; - constructor(props: TorrentMediainfoProps) { + constructor(props: WrappedComponentProps) { super(props); + this.state = { copiedToClipboard: false, - isFetchingMediainfo: true, - fetchMediainfoError: null, }; - } - componentDidMount() { - TorrentActions.fetchMediainfo(this.props.hash).then( - () => { - this.setState({ - isFetchingMediainfo: false, - fetchMediainfoError: null, - }); - }, - (error) => { - this.setState({ - isFetchingMediainfo: false, - fetchMediainfoError: error, - }); - }, - ); + if (UIStore.activeModal?.id === 'torrent-details') { + TorrentActions.fetchMediainfo(UIStore.activeModal?.hash, this.cancelToken.token).then( + (mediainfo) => { + this.fetchMediainfoError = null; + this.mediainfo = mediainfo.output; + this.isFetchingMediainfo = false; + this.forceUpdate(); + }, + (error) => { + if (!axios.isCancel(error)) { + this.fetchMediainfoError = error.response.data; + this.isFetchingMediainfo = false; + this.forceUpdate(); + } + }, + ); + } } componentDidUpdate() { + if (this.mediainfo === null) { + return; + } + if (this.copyButtonRef && this.clipboard == null) { this.clipboard = new Clipboard(this.copyButtonRef, { - text: () => this.props.mediainfo, + text: () => this.mediainfo as string, }); this.clipboard.on('success', this.handleCopySuccess); @@ -82,6 +82,7 @@ class TorrentMediainfo extends React.Component @@ -110,15 +111,13 @@ class TorrentMediainfo extends React.Component

    -
    {JSON.stringify(errorData.error, null, 2)}
    +
    {this.fetchMediainfoError.message}
    ); } @@ -150,27 +149,10 @@ class TorrentMediainfo extends React.Component
    -
    {this.props.mediainfo}
    +
    {this.mediainfo}
    ); } } -const ConnectedTorrentMediainfo = connectStores, TorrentMediainfoStates>( - injectIntl(TorrentMediainfo), - () => { - return [ - { - store: TorrentStore, - event: 'CLIENT_FETCH_TORRENT_MEDIAINFO_SUCCESS', - getValue: ({props}) => { - return { - mediainfo: TorrentStore.getMediainfo(props.hash), - }; - }, - }, - ]; - }, -); - -export default ConnectedTorrentMediainfo; +export default injectIntl(TorrentMediainfo); diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx b/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx index af7fe961..cb4b1b97 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx @@ -1,119 +1,100 @@ import {FormattedMessage} from 'react-intl'; +import {observable, runInAction} from 'mobx'; +import {observer} from 'mobx-react'; import React from 'react'; import type {TorrentPeer} from '@shared/types/TorrentPeer'; import Badge from '../../general/Badge'; +import ConfigStore from '../../../stores/ConfigStore'; +import CountryFlagIcon from '../../icons/CountryFlagIcon'; import Size from '../../general/Size'; import Checkmark from '../../icons/Checkmark'; import LockIcon from '../../icons/LockIcon'; import SpinnerIcon from '../../icons/SpinnerIcon'; +import TorrentActions from '../../../actions/TorrentActions'; +import UIStore from '../../../stores/UIStore'; -interface TorrentPeersProps { - peers: Array; -} +@observer +class TorrentPeers extends React.Component { + peers = observable.array([]); + polling = setInterval(() => this.fetchPeers(), ConfigStore.getPollInterval()); -const flagsCache: Record = {}; + constructor(props: unknown) { + super(props); -export default class TorrentPeers extends React.Component { - private static getFlag(countryCode?: string): string | null { - if (countryCode == null) { - return null; - } - - if (flagsCache[countryCode] !== undefined) { - return flagsCache[countryCode]; - } - - const loadFlag = async () => { - let flag: string | null = null; - await import(`../../../../images/flags/${countryCode.toLowerCase()}.png`) - .then( - ({default: image}: {default: string}) => { - flag = image; - }, - () => { - flag = null; - }, - ) - .finally(() => { - flagsCache[countryCode] = flag; - }); - return flag; - }; - - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw loadFlag(); + this.fetchPeers(); } - private static CountryFlag({countryCode}: {countryCode?: string}): JSX.Element | null { - const flag = TorrentPeers.getFlag(countryCode); - if (flag == null) { - return null; - } - return {countryCode}; + componentWillUnmount() { + clearInterval(this.polling); } + fetchPeers = () => { + if (UIStore.activeModal?.id === 'torrent-details') { + TorrentActions.fetchTorrentPeers(UIStore.activeModal?.hash).then((peers) => { + if (peers != null) { + runInAction(() => { + this.peers.replace(peers); + }); + } + }); + } + }; + render() { - const {peers} = this.props; - - if (peers) { - const peerList = peers.map((peer) => { - const {country: countryCode} = peer; - const encryptedIcon = peer.isEncrypted ? : null; - const incomingIcon = peer.isIncoming ? : null; - - return ( - - - - }> - - - {countryCode} - - {peer.address} - - - - - - - - {`${peer.completedPercent}%`} - {peer.clientVersion} - {encryptedIcon} - {incomingIcon} - - ); - }); + const peerList = this.peers.map((peer) => { + const {country: countryCode} = peer; + const encryptedIcon = peer.isEncrypted ? : null; + const incomingIcon = peer.isIncoming ? : null; return ( -
    - - - - - - - - - - - - - {peerList} -
    - - {peers.length} - DLUL%ClientEncIn
    -
    + + + + }> + + + {countryCode} + + {peer.address} + + + + + + + + {`${peer.completedPercent}%`} + {peer.clientVersion} + {encryptedIcon} + {incomingIcon} + ); - } + }); + return ( - - - +
    + + + + + + + + + + + + + {peerList} +
    + + {this.peers.length} + DLUL%ClientEncIn
    +
    ); } } + +export default TorrentPeers; diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.tsx b/client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.tsx index 0e30cccc..f74da3dc 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.tsx +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.tsx @@ -1,26 +1,43 @@ import {FormattedMessage} from 'react-intl'; +import {observable, runInAction} from 'mobx'; +import {observer} from 'mobx-react'; import React from 'react'; import type {TorrentTracker} from '@shared/types/TorrentTracker'; import Badge from '../../general/Badge'; +import TorrentActions from '../../../actions/TorrentActions'; +import UIStore from '../../../stores/UIStore'; -interface TorrentTrackersProps { - trackers: Array; -} +@observer +class TorrentTrackers extends React.Component { + trackers = observable.array([]); -const TorrentTrackers: React.FC = ({trackers}: TorrentTrackersProps) => { - const trackerCount = trackers.length; - const trackerTypes = ['http', 'udp', 'dht']; + constructor(props: unknown) { + super(props); - const trackerDetails = trackers.map((tracker) => ( - - {tracker.url} - {trackerTypes[tracker.type - 1]} - - )); + if (UIStore.activeModal?.id === 'torrent-details') { + TorrentActions.fetchTorrentTrackers(UIStore.activeModal?.hash).then((trackers) => { + if (trackers != null) { + runInAction(() => { + this.trackers.replace(trackers); + }); + } + }); + } + } + + render() { + const trackerCount = this.trackers.length; + const trackerTypes = ['http', 'udp', 'dht']; + + const trackerDetails = this.trackers.map((tracker) => ( + + {tracker.url} + {trackerTypes[tracker.type - 1]} + + )); - if (trackerCount) { return (
    @@ -40,11 +57,6 @@ const TorrentTrackers: React.FC = ({trackers}: TorrentTrac ); } - return ( - - - - ); -}; +} export default TorrentTrackers; diff --git a/client/src/javascript/components/sidebar/DiskUsage.tsx b/client/src/javascript/components/sidebar/DiskUsage.tsx index 9e3ea3e9..1bab8ec0 100644 --- a/client/src/javascript/components/sidebar/DiskUsage.tsx +++ b/client/src/javascript/components/sidebar/DiskUsage.tsx @@ -1,19 +1,14 @@ import {FormattedMessage} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; -import type {Disk, Disks} from '@shared/types/DiskUsage'; +import type {Disk} from '@shared/types/DiskUsage'; import DiskUsageStore from '../../stores/DiskUsageStore'; import Size from '../general/Size'; import Tooltip from '../general/Tooltip'; -import connectStores from '../../util/connectStores'; import ProgressBar from '../general/ProgressBar'; -import SettingsStore from '../../stores/SettingsStore'; - -interface DiskUsageProps { - disks?: Disks; - mountPoints?: Array; -} +import SettingStore from '../../stores/SettingStore'; interface DiskUsageTooltipItemProps { label: React.ReactNode; @@ -29,85 +24,59 @@ const DiskUsageTooltipItem: React.FC = ({label, value ); }; -class DiskUsage extends React.Component { - getDisks() { - const {disks, mountPoints} = this.props; +const DiskUsage: React.FC = () => { + const {disks} = DiskUsageStore; + const {mountPoints} = SettingStore.floodSettings; - if (disks == null || mountPoints == null) { - return null; - } - - const diskMap = disks.reduce((disksByTarget: Record, disk: Disk) => { - return { - ...disksByTarget, - [disk.target]: disk, - }; - }, {}); - - return mountPoints - .filter((target) => target in diskMap) - .map((target) => diskMap[target]) - .map((d) => { - return ( -
  • - - } /> - } /> - } /> - - } - position="top" - wrapperClassName="diskusage__item"> -
    - {d.target} - {Math.round((100 * d.used) / d.size)}% -
    - -
    -
  • - ); - }); + if (disks == null || mountPoints == null) { + return null; } - render() { - const disks = this.getDisks(); + const diskMap = disks.reduce((disksByTarget: Record, disk: Disk) => { + return { + ...disksByTarget, + [disk.target]: disk, + }; + }, {}); - if (disks == null || disks.length === 0) { - return null; - } - - return ( -
      -
    • - + const diskNodes: React.ReactNodeArray = mountPoints + .filter((target) => target in diskMap) + .map((target) => diskMap[target]) + .map((d) => { + return ( +
    • + + } /> + } /> + } /> +
    + } + position="top" + wrapperClassName="diskusage__item"> +
    + {d.target} + {Math.round((100 * d.used) / d.size)}% +
    + + - {disks} - - ); - } -} + ); + }); -export default connectStores(DiskUsage, () => [ - { - store: DiskUsageStore, - event: 'DISK_USAGE_CHANGE', - getValue: ({store}) => { - const storeDiskUsage = store as typeof DiskUsageStore; - return { - disks: storeDiskUsage.getDiskUsage(), - }; - }, - }, - { - store: SettingsStore, - event: 'SETTINGS_CHANGE', - getValue: ({store}) => { - const storeSettings = store as typeof SettingsStore; - return { - mountPoints: storeSettings.getFloodSetting('mountPoints'), - }; - }, - }, -]); + if (diskNodes == null || diskNodes.length === 0) { + return null; + } + + return ( +
      +
    • + +
    • + {diskNodes} +
    + ); +}; + +export default observer(DiskUsage); diff --git a/client/src/javascript/components/sidebar/NotificationsButton.tsx b/client/src/javascript/components/sidebar/NotificationsButton.tsx index 62711034..8157c3fb 100644 --- a/client/src/javascript/components/sidebar/NotificationsButton.tsx +++ b/client/src/javascript/components/sidebar/NotificationsButton.tsx @@ -1,24 +1,20 @@ import classnames from 'classnames'; import {defineMessages, injectIntl, WrappedComponentProps} from 'react-intl'; +import {observer} from 'mobx-react'; +import {reaction} from 'mobx'; import React from 'react'; -import type {Notification, NotificationCount} from '@shared/types/Notification'; +import type {Notification} from '@shared/types/Notification'; import FloodActions from '../../actions/FloodActions'; import ChevronLeftIcon from '../icons/ChevronLeftIcon'; import ChevronRightIcon from '../icons/ChevronRightIcon'; -import connectStores from '../../util/connectStores'; import CustomScrollbars from '../general/CustomScrollbars'; import LoadingIndicatorDots from '../icons/LoadingIndicatorDots'; import NotificationIcon from '../icons/NotificationIcon'; import NotificationStore from '../../stores/NotificationStore'; import Tooltip from '../general/Tooltip'; -interface NotificationsButtonProps extends WrappedComponentProps { - count?: NotificationCount; - notifications?: Array; -} - interface NotificationsButtonStates { isLoading: boolean; paginationStart: number; @@ -67,37 +63,25 @@ const MESSAGES = defineMessages({ const NOTIFICATIONS_PER_PAGE = 10; -class NotificationsButton extends React.Component { +@observer +class NotificationsButton extends React.Component { tooltipRef: Tooltip | null = null; - constructor(props: NotificationsButtonProps) { + constructor(props: WrappedComponentProps) { super(props); + + reaction(() => NotificationStore.notificationCount, this.handleNotificationCountChange); + this.state = { isLoading: false, paginationStart: 0, }; } - componentDidMount() { - NotificationStore.listen('NOTIFICATIONS_COUNT_CHANGE', this.handleNotificationCountChange); - } - - componentWillUnmount() { - NotificationStore.unlisten('NOTIFICATIONS_COUNT_CHANGE', this.handleNotificationCountChange); - } - - getBadge() { - const {count} = this.props; - - if (count != null && count.total > 0) { - return {count.total}; - } - - return null; - } - getBottomToolbar = () => { - if (this.props.count != null && this.props.count.total > 0) { + const {notificationCount} = NotificationStore; + + if (notificationCount != null && notificationCount.total > 0) { const newerButtonClass = classnames( 'toolbar__item toolbar__item--button', 'tooltip__content--padding-surrogate', @@ -109,7 +93,7 @@ class NotificationsButton extends React.Component= this.props.count.total, + 'is-disabled': this.state.paginationStart + NOTIFICATIONS_PER_PAGE >= notificationCount.total, }, ); @@ -118,8 +102,8 @@ class NotificationsButton extends React.Component this.props.count.total) { - olderTo = this.props.count.total; + if (olderTo > notificationCount.total) { + olderTo = notificationCount.total; } if (newerFrom < 0) { @@ -178,18 +162,21 @@ class NotificationsButton extends React.Component NOTIFICATIONS_PER_PAGE) { + + const {notificationCount} = NotificationStore; + + if (notificationCount != null && notificationCount.total > NOTIFICATIONS_PER_PAGE) { let countStart = paginationStart + 1; let countEnd = paginationStart + NOTIFICATIONS_PER_PAGE; - if (countStart > count.total) { - countStart = count.total; + if (countStart > notificationCount.total) { + countStart = notificationCount.total; } - if (countEnd > count.total) { - countEnd = count.total; + if (countEnd > notificationCount.total) { + countEnd = notificationCount.total; } return ( @@ -202,7 +189,7 @@ class NotificationsButton extends React.Component {` ${intl.formatMessage(MESSAGES.of)} `} - {count.total} + {notificationCount.total} ); @@ -212,7 +199,11 @@ class NotificationsButton extends React.Component { - if (this.props.count == null || this.props.count.total === 0) { + const {isLoading} = this.state; + + const {notifications, notificationCount} = NotificationStore; + + if (notificationCount == null || notificationCount.total === 0) { return (
    { this.tooltipRef = ref; }} - width={count == null || count.total === 0 ? undefined : 340} + width={notificationCount == null || notificationCount.total === 0 ? undefined : 340} position="bottom" wrapperClassName="sidebar__action sidebar__icon-button tooltip__wrapper"> - {this.getBadge()} + {notificationCount != null && notificationCount.total > 0 ? ( + {notificationCount.total} + ) : null} ); } } -const ConnectedNotificationsButton = connectStores, NotificationsButtonStates>( - injectIntl(NotificationsButton), - () => { - return [ - { - store: NotificationStore, - event: 'NOTIFICATIONS_FETCH_SUCCESS', - getValue: ({store}) => { - const storeNotification = store as typeof NotificationStore; - const tooltipNotificationState = storeNotification.getNotifications('notification-tooltip'); - - return { - count: tooltipNotificationState.count, - limit: tooltipNotificationState.limit, - notifications: tooltipNotificationState.notifications, - start: tooltipNotificationState.start, - }; - }, - }, - { - store: NotificationStore, - event: 'NOTIFICATIONS_COUNT_CHANGE', - getValue: ({store}) => { - const storeNotification = store as typeof NotificationStore; - return { - count: storeNotification.getNotificationCount(), - }; - }, - }, - ]; - }, -); - -export default ConnectedNotificationsButton; +export default injectIntl(NotificationsButton); diff --git a/client/src/javascript/components/sidebar/SearchTorrents.tsx b/client/src/javascript/components/sidebar/SearchBox.tsx similarity index 74% rename from client/src/javascript/components/sidebar/SearchTorrents.tsx rename to client/src/javascript/components/sidebar/SearchBox.tsx index 079f4c71..577a78c9 100644 --- a/client/src/javascript/components/sidebar/SearchTorrents.tsx +++ b/client/src/javascript/components/sidebar/SearchBox.tsx @@ -1,9 +1,9 @@ import {injectIntl, WrappedComponentProps} from 'react-intl'; import classnames from 'classnames'; +import {reaction} from 'mobx'; import React from 'react'; import Close from '../icons/Close'; -import connectStores from '../../util/connectStores'; import Search from '../icons/Search'; import TorrentFilterStore from '../../stores/TorrentFilterStore'; import UIActions from '../../actions/UIActions'; @@ -16,20 +16,22 @@ interface SearchBoxStates { class SearchBox extends React.Component { constructor(props: WrappedComponentProps) { super(props); + + reaction( + () => TorrentFilterStore.filters.searchFilter, + (searchFilter) => { + if (searchFilter === '') { + this.resetSearch(); + } + }, + ); + this.state = { inputFieldKey: 0, isSearchActive: false, }; } - componentDidMount() { - TorrentFilterStore.listen('UI_TORRENTS_FILTER_CLEAR', this.resetSearch); - } - - componentWillUnmount() { - TorrentFilterStore.unlisten('UI_TORRENTS_FILTER_CLEAR', this.resetSearch); - } - handleSearchChange = (event: React.ChangeEvent) => { const {value} = event.target; this.setState({isSearchActive: value !== ''}); @@ -86,19 +88,4 @@ class SearchBox extends React.Component } } -const ConnectedSearchBox = connectStores(injectIntl(SearchBox), () => { - return [ - { - store: TorrentFilterStore, - event: 'UI_TORRENTS_FILTER_SEARCH_CHANGE', - getValue: ({store}) => { - const storeTorrentFilter = store as typeof TorrentFilterStore; - return { - searchValue: storeTorrentFilter.getSearchFilter(), - }; - }, - }, - ]; -}); - -export default ConnectedSearchBox; +export default injectIntl(SearchBox); diff --git a/client/src/javascript/components/sidebar/Sidebar.tsx b/client/src/javascript/components/sidebar/Sidebar.tsx index 74586a40..5111fb75 100644 --- a/client/src/javascript/components/sidebar/Sidebar.tsx +++ b/client/src/javascript/components/sidebar/Sidebar.tsx @@ -4,7 +4,7 @@ import CustomScrollbars from '../general/CustomScrollbars'; import FeedsButton from './FeedsButton'; import LogoutButton from './LogoutButton'; import NotificationsButton from './NotificationsButton'; -import SearchTorrents from './SearchTorrents'; +import SearchBox from './SearchBox'; import SettingsButton from './SettingsButton'; import SidebarActions from './SidebarActions'; import SpeedLimitDropdown from './SpeedLimitDropdown'; @@ -25,7 +25,7 @@ const Sidebar = () => { - + diff --git a/client/src/javascript/components/sidebar/SidebarFilter.tsx b/client/src/javascript/components/sidebar/SidebarFilter.tsx index 73fe2937..bc7aacc9 100644 --- a/client/src/javascript/components/sidebar/SidebarFilter.tsx +++ b/client/src/javascript/components/sidebar/SidebarFilter.tsx @@ -30,7 +30,7 @@ class SidebarFilter extends React.Component { }); let {name} = this.props; - if (this.props.name === 'all') { + if (this.props.name === '') { name = this.props.intl.formatMessage({ id: 'filter.all', }); diff --git a/client/src/javascript/components/sidebar/SpeedLimitDropdown.tsx b/client/src/javascript/components/sidebar/SpeedLimitDropdown.tsx index 552ae384..7b006afd 100644 --- a/client/src/javascript/components/sidebar/SpeedLimitDropdown.tsx +++ b/client/src/javascript/components/sidebar/SpeedLimitDropdown.tsx @@ -1,24 +1,20 @@ import {defineMessages, FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; import sortedIndex from 'lodash/sortedIndex'; import type {TransferDirection} from '@shared/types/TransferData'; -import connectStores from '../../util/connectStores'; +import ClientActions from '../../actions/ClientActions'; import Dropdown from '../general/form-elements/Dropdown'; import LimitsIcon from '../icons/Limits'; -import SettingsStore from '../../stores/SettingsStore'; +import SettingStore from '../../stores/SettingStore'; import Size from '../general/Size'; import Tooltip from '../general/Tooltip'; import TransferDataStore from '../../stores/TransferDataStore'; import type {DropdownItem} from '../general/form-elements/Dropdown'; -interface SpeedLimitDropdownProps extends WrappedComponentProps { - currentThrottles?: Record; - speedLimits?: Record>; -} - const MESSAGES = defineMessages({ speedLimits: { id: 'sidebar.button.speedlimits', @@ -34,13 +30,14 @@ const MESSAGES = defineMessages({ }, }); -class SpeedLimitDropdown extends React.Component { +@observer +class SpeedLimitDropdown extends React.Component { static handleItemSelect(item: DropdownItem) { if (item.value != null) { if (item.property === 'download') { - SettingsStore.setClientSetting('throttleGlobalDownMax', item.value); + ClientActions.saveSetting('throttleGlobalDownMax', item.value); } else if (item.property === 'upload') { - SettingsStore.setClientSetting('throttleGlobalUpMax', item.value); + ClientActions.saveSetting('throttleGlobalUpMax', item.value); } } } @@ -84,6 +81,9 @@ class SpeedLimitDropdown extends React.Component { } getSpeedList(direction: TransferDirection): Array> { + const {speedLimits} = SettingStore.floodSettings; + const {transferSummary} = TransferDataStore; + const heading = { className: `dropdown__label dropdown__label--${direction}`, ...(direction === 'download' @@ -94,8 +94,11 @@ class SpeedLimitDropdown extends React.Component { }; let insertCurrentThrottle = true; - const currentThrottle: Record = this.props.currentThrottles || {download: 0, upload: 0}; - const speeds: number[] = (this.props.speedLimits != null && this.props.speedLimits[direction]) || [0]; + const currentThrottle: Record = { + download: transferSummary.downThrottle, + upload: transferSummary.upThrottle, + } || {download: 0, upload: 0}; + const speeds: number[] = (speedLimits != null && speedLimits[direction]) || [0]; const items: Array> = speeds.map((bytes) => { let selected = false; @@ -161,34 +164,4 @@ class SpeedLimitDropdown extends React.Component { } } -const ConnectedSpeedLimitDropdown = connectStores(injectIntl(SpeedLimitDropdown), () => { - return [ - { - store: SettingsStore, - event: 'SETTINGS_CHANGE', - getValue: ({store}) => { - const storeSettings = store as typeof SettingsStore; - return { - speedLimits: storeSettings.getFloodSetting('speedLimits'), - }; - }, - }, - { - store: TransferDataStore, - event: 'CLIENT_TRANSFER_SUMMARY_CHANGE', - getValue: ({store}) => { - const storeTransferData = store as typeof TransferDataStore; - const transferSummary = storeTransferData.getTransferSummary(); - - return { - currentThrottles: { - upload: transferSummary.upThrottle, - download: transferSummary.downThrottle, - }, - }; - }, - }, - ]; -}); - -export default ConnectedSpeedLimitDropdown; +export default injectIntl(SpeedLimitDropdown); diff --git a/client/src/javascript/components/sidebar/StatusFilters.tsx b/client/src/javascript/components/sidebar/StatusFilters.tsx index 11f2fb00..78fc6bb5 100644 --- a/client/src/javascript/components/sidebar/StatusFilters.tsx +++ b/client/src/javascript/components/sidebar/StatusFilters.tsx @@ -1,4 +1,5 @@ -import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; @@ -6,7 +7,6 @@ import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; import Active from '../icons/Active'; import All from '../icons/All'; import Completed from '../icons/Completed'; -import connectStores from '../../util/connectStores'; import DownloadSmall from '../icons/DownloadSmall'; import ErrorIcon from '../icons/ErrorIcon'; import Inactive from '../icons/Inactive'; @@ -17,142 +17,99 @@ import TorrentFilterStore from '../../stores/TorrentFilterStore'; import UIActions from '../../actions/UIActions'; import UploadSmall from '../icons/UploadSmall'; -interface StatusFiltersProps extends WrappedComponentProps { - statusCount?: Record; - statusFilter?: string; -} +const StatusFilters: React.FC = () => { + const intl = useIntl(); -class StatusFilters extends React.Component { - static handleClick(filter: string) { - UIActions.setTorrentStatusFilter(filter as TorrentStatus); - } + const filters: Array<{ + label: string; + slug: TorrentStatus | ''; + icon: JSX.Element; + }> = [ + { + label: intl.formatMessage({ + id: 'filter.all', + }), + slug: '', + icon: , + }, + { + label: intl.formatMessage({ + id: 'filter.status.downloading', + }), + slug: 'downloading', + icon: , + }, + { + label: intl.formatMessage({ + id: 'filter.status.seeding', + }), + slug: 'seeding', + icon: , + }, + { + label: intl.formatMessage({ + id: 'filter.status.checking', + }), + slug: 'checking', + icon: , + }, + { + label: intl.formatMessage({ + id: 'filter.status.completed', + }), + slug: 'complete', + icon: , + }, + { + label: intl.formatMessage({ + id: 'filter.status.stopped', + }), + slug: 'stopped', + icon: , + }, + { + label: intl.formatMessage({ + id: 'filter.status.active', + }), + slug: 'active', + icon: , + }, + { + label: intl.formatMessage({ + id: 'filter.status.inactive', + }), + slug: 'inactive', + icon: , + }, + { + label: intl.formatMessage({ + id: 'filter.status.error', + }), + slug: 'error', + icon: , + }, + ]; - getFilters() { - const filters: Array<{ - label: string; - slug: TorrentStatus; - icon: JSX.Element; - }> = [ - { - label: this.props.intl.formatMessage({ - id: 'filter.all', - }), - slug: 'all', - icon: , - }, - { - label: this.props.intl.formatMessage({ - id: 'filter.status.downloading', - }), - slug: 'downloading', - icon: , - }, - { - label: this.props.intl.formatMessage({ - id: 'filter.status.seeding', - }), - slug: 'seeding', - icon: , - }, - { - label: this.props.intl.formatMessage({ - id: 'filter.status.checking', - }), - slug: 'checking', - icon: , - }, - { - label: this.props.intl.formatMessage({ - id: 'filter.status.completed', - }), - slug: 'complete', - icon: , - }, - { - label: this.props.intl.formatMessage({ - id: 'filter.status.stopped', - }), - slug: 'stopped', - icon: , - }, - { - label: this.props.intl.formatMessage({ - id: 'filter.status.active', - }), - slug: 'active', - icon: , - }, - { - label: this.props.intl.formatMessage({ - id: 'filter.status.inactive', - }), - slug: 'inactive', - icon: , - }, - { - label: this.props.intl.formatMessage({ - id: 'filter.status.error', - }), - slug: 'error', - icon: , - }, - ]; + const filterElements = filters.map((filter) => ( + UIActions.setTorrentStatusFilter(selection as TorrentStatus)} + count={TorrentFilterStore.taxonomy.statusCounts[filter.slug] || 0} + key={filter.slug} + icon={filter.icon} + isActive={filter.slug === TorrentFilterStore.filters.statusFilter} + name={filter.label} + slug={filter.slug} + /> + )); - const filterElements = filters.map((filter) => ( - - )); + return ( +
      +
    • + +
    • + {filterElements} +
    + ); +}; - return filterElements; - } - - render() { - const filters = this.getFilters(); - - return ( -
      -
    • - -
    • - {filters} -
    - ); - } -} - -const ConnectedStatusFilters = connectStores, Record>( - injectIntl(StatusFilters), - () => { - return [ - { - store: TorrentFilterStore, - event: 'CLIENT_FETCH_TORRENT_TAXONOMY_SUCCESS', - getValue: ({store}) => { - const storeTorrentFilter = store as typeof TorrentFilterStore; - return { - statusCount: storeTorrentFilter.getTorrentStatusCount(), - }; - }, - }, - { - store: TorrentFilterStore, - event: 'UI_TORRENTS_FILTER_STATUS_CHANGE', - getValue: ({store}) => { - const storeTorrentFilter = store as typeof TorrentFilterStore; - return { - statusFilter: storeTorrentFilter.getStatusFilter(), - }; - }, - }, - ]; - }, -); - -export default ConnectedStatusFilters; +export default observer(StatusFilters); diff --git a/client/src/javascript/components/sidebar/TagFilters.tsx b/client/src/javascript/components/sidebar/TagFilters.tsx index 06977d84..ff859f33 100644 --- a/client/src/javascript/components/sidebar/TagFilters.tsx +++ b/client/src/javascript/components/sidebar/TagFilters.tsx @@ -1,100 +1,49 @@ import {FormattedMessage} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; -import connectStores from '../../util/connectStores'; import SidebarFilter from './SidebarFilter'; import TorrentFilterStore from '../../stores/TorrentFilterStore'; import UIActions from '../../actions/UIActions'; -interface TagFiltersProps { - tagCount?: Record; - tagFilter?: string; -} +const TagFilters: React.FC = () => { + const tags = Object.keys(TorrentFilterStore.taxonomy.tagCounts); -class TagFilters extends React.Component { - static handleClick(filter: string) { - UIActions.setTorrentTagFilter(filter); + if ((tags.length === 1 && tags[0] === '') || (tags.length === 2 && tags[1] === 'untagged')) { + return null; } - getFilters() { - if (this.props.tagCount == null) { - return null; + const filterItems = tags.slice().sort((a, b) => { + if (a === '' || a === 'untagged') { + return -1; } - const filterItems = Object.keys(this.props.tagCount).sort((a, b) => { - if (a === 'all' || a === 'untagged') { - return -1; - } - if (b === 'all' || b === 'untagged') { - return 1; - } - - return a.localeCompare(b); - }); - - const filterElements = filterItems.map((filter) => ( - - )); - - return filterElements; - } - - hasTags(): boolean { - if (this.props.tagCount == null) { - return false; + if (b === '' || b === 'untagged') { + return 1; } - const tags = Object.keys(this.props.tagCount); + return a.localeCompare(b); + }); - return !((tags.length === 1 && tags[0] === 'all') || (tags.length === 2 && tags[1] === 'untagged')); - } + const filterElements = filterItems.map((filter) => ( + + )); - render() { - if (!this.hasTags()) { - return null; - } + return ( +
      +
    • + +
    • + {filterElements} +
    + ); +}; - return ( -
      -
    • - -
    • - {this.getFilters()} -
    - ); - } -} - -const ConnectedTagFilters = connectStores(TagFilters, () => { - return [ - { - store: TorrentFilterStore, - event: 'CLIENT_FETCH_TORRENT_TAXONOMY_SUCCESS', - getValue: ({store}) => { - const storeTorrentFilter = store as typeof TorrentFilterStore; - return { - tagCount: storeTorrentFilter.getTorrentTagCount(), - }; - }, - }, - { - store: TorrentFilterStore, - event: 'UI_TORRENTS_FILTER_TAG_CHANGE', - getValue: ({store}) => { - const storeTorrentFilter = store as typeof TorrentFilterStore; - return { - tagFilter: storeTorrentFilter.getTagFilter(), - }; - }, - }, - ]; -}); - -export default ConnectedTagFilters; +export default observer(TagFilters); diff --git a/client/src/javascript/components/sidebar/TrackerFilters.tsx b/client/src/javascript/components/sidebar/TrackerFilters.tsx index 0e7e6bd4..fb300252 100644 --- a/client/src/javascript/components/sidebar/TrackerFilters.tsx +++ b/client/src/javascript/components/sidebar/TrackerFilters.tsx @@ -1,102 +1,48 @@ import {FormattedMessage} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; -import connectStores from '../../util/connectStores'; import SidebarFilter from './SidebarFilter'; import TorrentFilterStore from '../../stores/TorrentFilterStore'; import UIActions from '../../actions/UIActions'; -interface TrackerFiltersProps { - trackerCount?: Record; - trackerFilter?: string; -} +const TrackerFilters: React.FC = () => { + const trackers = Object.keys(TorrentFilterStore.taxonomy.trackerCounts); -class TrackerFilters extends React.Component { - static handleClick(filter: string): void { - UIActions.setTorrentTrackerFilter(filter); + if (trackers.length === 1 && trackers[0] === '') { + return null; } - getFilters(): React.ReactNode { - if (this.props.trackerCount == null) { - return null; + const filterItems = trackers.slice().sort((a, b) => { + if (a === '') { + return -1; + } + if (b === '') { + return 1; } - const filterItems = Object.keys(this.props.trackerCount).sort((a, b) => { - if (a === 'all') { - return -1; - } - if (b === 'all') { - return 1; - } + return a.localeCompare(b); + }); - return a.localeCompare(b); - }); + const filterElements = filterItems.map((filter) => ( + + )); - const filterElements = filterItems.map((filter) => ( - - )); + return ( +
      +
    • + +
    • + {filterElements} +
    + ); +}; - return filterElements; - } - - hasTrackers(): boolean { - if (this.props.trackerCount == null) { - return false; - } - - const trackers = Object.keys(this.props.trackerCount); - - return !(trackers.length === 1 && trackers[0] === 'all'); - } - - render(): React.ReactNode { - const filters = this.getFilters(); - - if (!this.hasTrackers()) { - return null; - } - - return ( -
      -
    • - -
    • - {filters} -
    - ); - } -} - -const ConnectedTrackerFilters = connectStores(TrackerFilters, () => { - return [ - { - store: TorrentFilterStore, - event: 'CLIENT_FETCH_TORRENT_TAXONOMY_SUCCESS', - getValue: ({store}) => { - const storeTorrentFilter = store as typeof TorrentFilterStore; - return { - trackerCount: storeTorrentFilter.getTorrentTrackerCount(), - }; - }, - }, - { - store: TorrentFilterStore, - event: 'UI_TORRENTS_FILTER_TRACKER_CHANGE', - getValue: ({store}) => { - const storeTorrentFilter = store as typeof TorrentFilterStore; - return { - trackerFilter: storeTorrentFilter.getTrackerFilter(), - }; - }, - }, - ]; -}); - -export default ConnectedTrackerFilters; +export default observer(TrackerFilters); diff --git a/client/src/javascript/components/sidebar/TransferData.tsx b/client/src/javascript/components/sidebar/TransferData.tsx index dc4195b8..1e0bbd73 100644 --- a/client/src/javascript/components/sidebar/TransferData.tsx +++ b/client/src/javascript/components/sidebar/TransferData.tsx @@ -1,27 +1,25 @@ +import {observer} from 'mobx-react'; import React from 'react'; import Measure from 'react-measure'; import ClientStatusStore from '../../stores/ClientStatusStore'; -import connectStores from '../../util/connectStores'; import TransferRateDetails from './TransferRateDetails'; import TransferRateGraph from './TransferRateGraph'; import type {TransferRateGraphInspectorPoint} from './TransferRateGraph'; -interface TransferDataProps { - isClientConnected?: boolean; -} - interface TransferDataStates { graphInspectorPoint: TransferRateGraphInspectorPoint | null; sidebarWidth: number; } -class TransferData extends React.Component { +@observer +class TransferData extends React.Component { rateGraphRef: TransferRateGraph | null = null; - constructor(props: TransferDataProps) { + constructor(props: unknown) { super(props); + this.state = { graphInspectorPoint: null, sidebarWidth: 0, @@ -39,7 +37,7 @@ class TransferData extends React.Component { if ( this.rateGraphRef != null && - this.props.isClientConnected && + ClientStatusStore.isConnected && event && event.nativeEvent && event.nativeEvent.clientX != null @@ -49,22 +47,21 @@ class TransferData extends React.Component { - if (this.rateGraphRef != null && this.props.isClientConnected) { + if (this.rateGraphRef != null && ClientStatusStore.isConnected) { this.rateGraphRef.handleMouseOut(); } }; handleMouseOver = () => { - if (this.rateGraphRef != null && this.props.isClientConnected) { + if (this.rateGraphRef != null && ClientStatusStore.isConnected) { this.rateGraphRef.handleMouseOver(); } }; renderTransferRateGraph() { - const {isClientConnected} = this.props; const {sidebarWidth} = this.state; - if (!isClientConnected) return null; + if (!ClientStatusStore.isConnected) return null; return ( { - return [ - { - store: ClientStatusStore, - event: 'CLIENT_CONNECTION_STATUS_CHANGE', - getValue: ({store}) => { - const storeClientStatus = store as typeof ClientStatusStore; - return { - isClientConnected: storeClientStatus.getIsConnected(), - }; - }, - }, - ]; -}); - -export default ConnectedTransferData; +export default TransferData; diff --git a/client/src/javascript/components/sidebar/TransferRateDetails.tsx b/client/src/javascript/components/sidebar/TransferRateDetails.tsx index dcd19e50..c35856dc 100644 --- a/client/src/javascript/components/sidebar/TransferRateDetails.tsx +++ b/client/src/javascript/components/sidebar/TransferRateDetails.tsx @@ -3,12 +3,12 @@ import {defineMessages, injectIntl, WrappedComponentProps} from 'react-intl'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import formatUtil from '@shared/util/formatUtil'; +import {observer} from 'mobx-react'; import React from 'react'; -import type {TransferDirection, TransferSummary} from '@shared/types/TransferData'; +import type {TransferDirection} from '@shared/types/TransferData'; import ClientStatusStore from '../../stores/ClientStatusStore'; -import connectStores from '../../util/connectStores'; import Download from '../icons/Download'; import Duration from '../general/Duration'; import InfinityIcon from '../icons/InfinityIcon'; @@ -20,8 +20,6 @@ import type {TransferRateGraphInspectorPoint} from './TransferRateGraph'; interface TransferRateDetailsProps extends WrappedComponentProps { inspectorPoint: TransferRateGraphInspectorPoint | null; - isClientConnected?: boolean; - transferSummary?: TransferSummary; } const messages = defineMessages({ @@ -36,9 +34,11 @@ const icons = { upload: , }; +@observer class TransferRateDetails extends React.Component { getCurrentTransferRate(direction: TransferDirection, options: {showHoverDuration?: boolean} = {}) { - const {inspectorPoint, intl, isClientConnected, transferSummary} = this.props; + const {inspectorPoint, intl} = this.props; + const {transferSummary} = TransferDataStore; const throttles = { download: transferSummary != null ? transferSummary.downThrottle : 0, @@ -63,7 +63,7 @@ class TransferRateDetails extends React.Component { } const secondaryDataClasses = classnames('client-stats__rate__data--secondary', { - 'is-visible': inspectorPoint == null && isClientConnected, + 'is-visible': inspectorPoint == null && ClientStatusStore.isConnected, }); const timestampClasses = classnames('client-stats__rate__data--timestamp', { @@ -122,32 +122,4 @@ class TransferRateDetails extends React.Component { dayjs.extend(duration); -const ConnectedTransferRateDetails = connectStores, Record>( - injectIntl(TransferRateDetails), - () => { - return [ - { - store: ClientStatusStore, - event: 'CLIENT_CONNECTION_STATUS_CHANGE', - getValue: ({store}) => { - const storeClientStatus = store as typeof ClientStatusStore; - return { - isClientConnected: storeClientStatus.getIsConnected(), - }; - }, - }, - { - store: TransferDataStore, - event: 'CLIENT_TRANSFER_SUMMARY_CHANGE', - getValue: ({store}) => { - const storeTransferData = store as typeof TransferDataStore; - return { - transferSummary: storeTransferData.getTransferSummary(), - }; - }, - }, - ]; - }, -); - -export default ConnectedTransferRateDetails; +export default injectIntl(TransferRateDetails); diff --git a/client/src/javascript/components/sidebar/TransferRateGraph.tsx b/client/src/javascript/components/sidebar/TransferRateGraph.tsx index 24b67017..6d1680b9 100644 --- a/client/src/javascript/components/sidebar/TransferRateGraph.tsx +++ b/client/src/javascript/components/sidebar/TransferRateGraph.tsx @@ -1,5 +1,7 @@ import {area, curveMonotoneX, line} from 'd3-shape'; import {max} from 'd3-array'; +import {observer} from 'mobx-react'; +import {reaction} from 'mobx'; import {ScaleLinear, scaleLinear} from 'd3-scale'; import {Selection, select} from 'd3-selection'; import React from 'react'; @@ -29,6 +31,7 @@ const METHODS_TO_BIND = [ 'handleMouseMove', ] as const; +@observer class TransferRateGraph extends React.Component { private static getGradient(slug: TransferDirection): React.ReactNode { return ( @@ -62,14 +65,19 @@ class TransferRateGraph extends React.Component { constructor(props: TransferRateGraphProps) { super(props); + reaction( + () => TransferDataStore.transferRates, + () => { + this.handleTransferHistoryChange(); + }, + ); + METHODS_TO_BIND.forEach((methodName: T) => { this[methodName] = this[methodName].bind(this); }); } componentDidMount(): void { - TransferDataStore.listen('CLIENT_TRANSFER_HISTORY_REQUEST_SUCCESS', this.handleTransferHistoryChange); - this.renderGraphData(); } @@ -77,10 +85,6 @@ class TransferRateGraph extends React.Component { this.renderGraphData(); } - componentWillUnmount(): void { - TransferDataStore.unlisten('CLIENT_TRANSFER_HISTORY_REQUEST_SUCCESS', this.handleTransferHistoryChange); - } - private setInspectorCoordinates(slug: TransferDirection, hoverPoint: number): number { const { graphRefs: { @@ -94,7 +98,7 @@ class TransferRateGraph extends React.Component { return 0; } - const historicalData = TransferDataStore.getTransferRates(); + const historicalData = TransferDataStore.transferRates; const upperSpeed = historicalData[slug][Math.ceil(hoverPoint)]; const lowerSpeed = historicalData[slug][Math.floor(hoverPoint)]; @@ -192,7 +196,7 @@ class TransferRateGraph extends React.Component { return; } - const historicalData = TransferDataStore.getTransferRates(); + const historicalData = TransferDataStore.transferRates; const hoverPoint = xScale.invert(lastMouseX); const uploadSpeed = this.setInspectorCoordinates('upload', hoverPoint); const downloadSpeed = this.setInspectorCoordinates('download', hoverPoint); @@ -208,7 +212,7 @@ class TransferRateGraph extends React.Component { } private renderGraphData(): void { - const historicalData = TransferDataStore.getTransferRates(); + const historicalData = TransferDataStore.transferRates; const {height, width} = this.props; const margin = {bottom: 10, top: 10}; diff --git a/client/src/javascript/components/torrent-list/ActionBar.tsx b/client/src/javascript/components/torrent-list/ActionBar.tsx index 7ea7b41a..2d57b3d5 100644 --- a/client/src/javascript/components/torrent-list/ActionBar.tsx +++ b/client/src/javascript/components/torrent-list/ActionBar.tsx @@ -1,15 +1,16 @@ import classnames from 'classnames'; import {injectIntl, WrappedComponentProps} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; import type {FloodSettings} from '@shared/types/FloodSettings'; import Action from './Action'; import Add from '../icons/Add'; -import connectStores from '../../util/connectStores'; import MenuIcon from '../icons/MenuIcon'; import Remove from '../icons/Remove'; -import SettingsStore from '../../stores/SettingsStore'; +import SettingActions from '../../actions/SettingActions'; +import SettingStore from '../../stores/SettingStore'; import SortDropdown from './SortDropdown'; import StartIcon from '../icons/StartIcon'; import StopIcon from '../icons/StopIcon'; @@ -17,12 +18,8 @@ import TorrentActions from '../../actions/TorrentActions'; import TorrentStore from '../../stores/TorrentStore'; import UIActions from '../../actions/UIActions'; -interface ActionBarProps extends WrappedComponentProps { - sortBy?: FloodSettings['sortTorrents']; - torrentListViewSize?: FloodSettings['torrentListViewSize']; -} - -class ActionBar extends React.Component { +@observer +class ActionBar extends React.Component { static handleAddTorrents() { UIActions.displayModal({id: 'add-torrents'}); } @@ -34,19 +31,18 @@ class ActionBar extends React.Component { } static handleSortChange(sortBy: FloodSettings['sortTorrents']) { - SettingsStore.setFloodSetting('sortTorrents', sortBy); - UIActions.setTorrentsSort(sortBy); + SettingActions.saveSetting('sortTorrents', sortBy); } static handleStart() { TorrentActions.startTorrents({ - hashes: TorrentStore.getSelectedTorrents(), + hashes: TorrentStore.selectedTorrents, }); } static handleStop() { TorrentActions.stopTorrents({ - hashes: TorrentStore.getSelectedTorrents(), + hashes: TorrentStore.selectedTorrents, }); } @@ -58,7 +54,8 @@ class ActionBar extends React.Component { } render() { - const {sortBy, torrentListViewSize, intl} = this.props; + const {intl} = this.props; + const {sortTorrents: sortBy, torrentListViewSize} = SettingStore.floodSettings; const classes = classnames('action-bar', { 'action-bar--is-condensed': torrentListViewSize === 'condensed', @@ -125,20 +122,4 @@ class ActionBar extends React.Component { } } -const ConnectedActionBar = connectStores(injectIntl(ActionBar), () => { - return [ - { - store: SettingsStore, - event: 'SETTINGS_CHANGE', - getValue: ({store}) => { - const storeSettings = store as typeof SettingsStore; - return { - sortBy: storeSettings.getFloodSetting('sortTorrents'), - torrentListViewSize: storeSettings.getFloodSetting('torrentListViewSize'), - }; - }, - }, - ]; -}); - -export default ConnectedActionBar; +export default injectIntl(ActionBar); diff --git a/client/src/javascript/components/torrent-list/TableHeading.tsx b/client/src/javascript/components/torrent-list/TableHeading.tsx index 1b6798a5..cabbdb39 100644 --- a/client/src/javascript/components/torrent-list/TableHeading.tsx +++ b/client/src/javascript/components/torrent-list/TableHeading.tsx @@ -1,12 +1,10 @@ import classnames from 'classnames'; import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; +import {observer} from 'mobx-react'; import React from 'react'; -import defaultFloodSettings from '@shared/constants/defaultFloodSettings'; - -import type {FloodSettings} from '@shared/types/FloodSettings'; - import TorrentListColumns, {TorrentListColumn} from '../../constants/TorrentListColumns'; +import SettingStore from '../../stores/SettingStore'; import UIStore from '../../stores/UIStore'; const pointerDownStyles = ` @@ -15,14 +13,12 @@ const pointerDownStyles = ` `; interface TableHeadingProps extends WrappedComponentProps { - columns: FloodSettings['torrentListColumns']; - columnWidths: FloodSettings['torrentListColumnWidths']; - sortProp: FloodSettings['sortTorrents']; scrollOffset: number; onCellClick: (column: TorrentListColumn) => void; onWidthsChange: (column: TorrentListColumn, width: number) => void; } +@observer class TableHeading extends React.PureComponent { focusedCell: TorrentListColumn | null = null; focusedCellWidth: number | null = null; @@ -47,15 +43,15 @@ class TableHeading extends React.PureComponent { } getHeadingElements() { - const {intl, columns, columnWidths, sortProp, onCellClick} = this.props; + const {intl, onCellClick} = this.props; - return columns.reduce((accumulator: React.ReactNodeArray, {id, visible}) => { + return SettingStore.floodSettings.torrentListColumns.reduce((accumulator: React.ReactNodeArray, {id, visible}) => { if (!visible) { return accumulator; } let handle = null; - const width = columnWidths[id] || defaultFloodSettings.torrentListColumnWidths[id]; + const width = SettingStore.floodSettings.torrentListColumnWidths[id]; if (!this.isPointerDown) { handle = ( @@ -68,10 +64,10 @@ class TableHeading extends React.PureComponent { ); } - const isSortActive = id === sortProp.property; + const isSortActive = id === SettingStore.floodSettings.sortTorrents.property; const classes = classnames('table__cell table__heading', { 'table__heading--is-sorted': isSortActive, - [`table__heading--direction--${sortProp.direction}`]: isSortActive, + [`table__heading--direction--${SettingStore.floodSettings.sortTorrents.direction}`]: isSortActive, }); const label = ; diff --git a/client/src/javascript/components/torrent-list/TorrentList.tsx b/client/src/javascript/components/torrent-list/TorrentList.tsx index 5f6fed08..490e84ad 100644 --- a/client/src/javascript/components/torrent-list/TorrentList.tsx +++ b/client/src/javascript/components/torrent-list/TorrentList.tsx @@ -1,7 +1,9 @@ import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; import debounce from 'lodash/debounce'; import Dropzone from 'react-dropzone'; +import {observer} from 'mobx-react'; import {Scrollbars} from 'react-custom-scrollbars'; +import {observable, reaction, runInAction} from 'mobx'; import React from 'react'; import defaultFloodSettings from '@shared/constants/defaultFloodSettings'; @@ -11,12 +13,12 @@ import type {TorrentProperties} from '@shared/types/Torrent'; import {Button} from '../../ui'; import ClientStatusStore from '../../stores/ClientStatusStore'; -import connectStores from '../../util/connectStores'; import CustomScrollbars from '../general/CustomScrollbars'; import Files from '../icons/Files'; import GlobalContextMenuMountPoint from '../general/GlobalContextMenuMountPoint'; import ListViewport from '../general/ListViewport'; -import SettingsStore from '../../stores/SettingsStore'; +import SettingActions from '../../actions/SettingActions'; +import SettingStore from '../../stores/SettingStore'; import TableHeading from './TableHeading'; import TorrentActions from '../../actions/TorrentActions'; import TorrentFilterStore from '../../stores/TorrentFilterStore'; @@ -27,16 +29,15 @@ import UIActions from '../../actions/UIActions'; import type {TorrentListColumn} from '../../constants/TorrentListColumns'; -const getEmptyTorrentListNotification = () => { +const getEmptyTorrentListNotification = (): React.ReactNode => { let clearFilters = null; - if (TorrentFilterStore.isFilterActive()) { + if (TorrentFilterStore.isFilterActive) { clearFilters = (