diff --git a/client/src/javascript/actions/TorrentActions.ts b/client/src/javascript/actions/TorrentActions.ts index b81aff8c..973e63f9 100644 --- a/client/src/javascript/actions/TorrentActions.ts +++ b/client/src/javascript/actions/TorrentActions.ts @@ -250,14 +250,11 @@ const TorrentActions = { }) .then((json) => json.data) .then( - (data) => { + () => { AppDispatcher.dispatchServerAction({ type: 'CLIENT_SET_FILE_PRIORITY_SUCCESS', data: { - ...data, hash, - indices, - priority, }, }); }, diff --git a/client/src/javascript/components/general/Duration.tsx b/client/src/javascript/components/general/Duration.tsx index bfeae370..7a44c9c7 100644 --- a/client/src/javascript/components/general/Duration.tsx +++ b/client/src/javascript/components/general/Duration.tsx @@ -4,7 +4,7 @@ import React from 'react'; import type {Duration as DurationType} from '@shared/types/Torrent'; interface DurationProps { - suffix: React.ReactNode; + suffix?: React.ReactNode; value: 'Infinity' | DurationType; } diff --git a/client/src/javascript/components/general/filesystem/DirectoryFileList.js b/client/src/javascript/components/general/filesystem/DirectoryFileList.tsx similarity index 61% rename from client/src/javascript/components/general/filesystem/DirectoryFileList.js rename to client/src/javascript/components/general/filesystem/DirectoryFileList.tsx index 1c46114c..d53cb372 100644 --- a/client/src/javascript/components/general/filesystem/DirectoryFileList.js +++ b/client/src/javascript/components/general/filesystem/DirectoryFileList.tsx @@ -2,43 +2,57 @@ import classnames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; +import type { + TorrentContent, + TorrentContentSelection, + TorrentContentSelectionTree, +} from '@shared/constants/torrentFilePropsMap'; +import type {TorrentProperties} from '@shared/types/Torrent'; + import {Checkbox} from '../../../ui'; -import File from '../../icons/File'; +import FileIcon from '../../icons/File'; import PriorityMeter from './PriorityMeter'; import Size from '../Size'; import TorrentActions from '../../../actions/TorrentActions'; -const ICONS = {file: }; -const METHODS_TO_BIND = ['handlePriorityChange']; +interface DirectoryFilesProps { + depth: number; + hash: TorrentProperties['hash']; + fileList: Array; + selectedItems: TorrentContentSelectionTree['files']; + path: Array; + onPriorityChange: () => void; + onItemSelect: (selection: TorrentContentSelection) => void; +} -class DirectoryFiles extends React.Component { +const METHODS_TO_BIND = ['handlePriorityChange'] as const; + +class DirectoryFiles extends React.Component { static propTypes = { - isParentSelected: PropTypes.bool, path: PropTypes.array, selectedItems: PropTypes.object, }; static defaultProps = { - isParentSelected: false, path: [], selectedItems: {}, }; - constructor() { - super(); + constructor(props: DirectoryFilesProps) { + super(props); METHODS_TO_BIND.forEach((method) => { this[method] = this[method].bind(this); }); } - getCurrentPath(file) { + getCurrentPath(file: TorrentContent) { return [...this.props.path, file.filename]; } - getIcon(file, isSelected) { - const changeHandler = (value, event) => { - this.handleFileSelect(file, isSelected, event); + getIcon(file: TorrentContent, isSelected: boolean) { + const changeHandler = (): void => { + this.handleFileSelect(file, isSelected); }; return ( @@ -46,42 +60,42 @@ class DirectoryFiles extends React.Component {
- +
- {ICONS.file} +
); } - handleFileSelect(file, isSelected, event) { + handleFileSelect(file: TorrentContent, isSelected: boolean): void { this.props.onItemSelect({ - ...file, - depth: this.props.depth, - event, - id: file.index, - isParentSelected: this.props.isParentSelected, - isSelected, - path: this.getCurrentPath(file), type: 'file', + depth: this.props.depth, + path: this.getCurrentPath(file), + select: !isSelected, }); } - handlePriorityChange(fileIndex, priorityLevel) { + handlePriorityChange(fileIndex: React.ReactText, priorityLevel: number) { this.props.onPriorityChange(); - TorrentActions.setFilePriority(this.props.hash, [fileIndex], priorityLevel); + TorrentActions.setFilePriority(this.props.hash, [Number(fileIndex)], priorityLevel); } render() { - const branch = Object.assign([], this.props.fileList); + const branch = [...this.props.fileList]; branch.sort((a, b) => a.filename.localeCompare(b.filename)); const files = branch.map((file) => { - const isSelected = this.props.selectedItems[file.filename] && this.props.selectedItems[file.filename].isSelected; + const isSelected = + (this.props.selectedItems && + this.props.selectedItems[file.filename] && + this.props.selectedItems[file.filename].isSelected) || + false; const classes = classnames( 'directory-tree__node file', 'directory-tree__node--file directory-tree__node--selectable', @@ -109,7 +123,7 @@ class DirectoryFiles extends React.Component { id={file.index} maxLevel={2} onChange={this.handlePriorityChange} - type="file" + priorityType="file" /> diff --git a/client/src/javascript/components/general/filesystem/DirectoryTree.js b/client/src/javascript/components/general/filesystem/DirectoryTree.js deleted file mode 100644 index fb96ff7a..00000000 --- a/client/src/javascript/components/general/filesystem/DirectoryTree.js +++ /dev/null @@ -1,102 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; - -import DirectoryFileList from './DirectoryFileList'; -// TODO: Fix this circular dependency -// eslint-disable-next-line import/no-cycle -import DirectoryTreeNode from './DirectoryTreeNode'; - -const METHODS_TO_BIND = ['getDirectoryTreeDomNodes']; - -class DirectoryTree extends React.Component { - static propTypes = { - isParentSelected: PropTypes.bool, - path: PropTypes.array, - selectedItems: PropTypes.object, - }; - - static defaultProps = { - isParentSelected: false, - path: [], - selectedItems: {}, - }; - - constructor() { - super(); - - METHODS_TO_BIND.forEach((method) => { - this[method] = this[method].bind(this); - }); - } - - getDirectoryTreeDomNodes(tree = {}, depth = 0) { - const {files = []} = tree; - let {directories = {}} = tree; - const {hash} = this.props; - let fileList = null; - depth++; - - directories = Object.keys(directories) - .sort(this.sortDirectories) - .map((directoryName, index) => { - let subSelectedItems = {}; - - if (this.props.selectedItems.directories) { - subSelectedItems = this.props.selectedItems.directories[directoryName]; - } - - const subTree = directories[directoryName]; - const id = `${index}${depth}${directoryName}`; - const isSelected = subSelectedItems && subSelectedItems.isSelected; - - return ( - - ); - }); - - if (files.length) { - const subSelectedItems = this.props.selectedItems.files; - - fileList = ( - - ); - } - - return directories.concat([fileList]); - } - - sortDirectories(a, b) { - return a.localeCompare(b); - } - - render() { - return ( -
{this.getDirectoryTreeDomNodes(this.props.tree, this.props.depth)}
- ); - } -} - -export default DirectoryTree; diff --git a/client/src/javascript/components/general/filesystem/DirectoryTree.tsx b/client/src/javascript/components/general/filesystem/DirectoryTree.tsx new file mode 100644 index 00000000..0a535b07 --- /dev/null +++ b/client/src/javascript/components/general/filesystem/DirectoryTree.tsx @@ -0,0 +1,104 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import type {TorrentContentSelection, TorrentContentSelectionTree} from '@shared/constants/torrentFilePropsMap'; +import type {TorrentDetails, TorrentProperties} from '@shared/types/Torrent'; + +import DirectoryFileList from './DirectoryFileList'; +// TODO: Fix this circular dependency +// eslint-disable-next-line import/no-cycle +import DirectoryTreeNode from './DirectoryTreeNode'; + +interface DirectoryTreeProps { + depth?: number; + path: Array; + hash: TorrentProperties['hash']; + tree: TorrentDetails['fileTree']; + selectedItems: TorrentContentSelectionTree; + onPriorityChange: () => void; + onItemSelect: (selection: TorrentContentSelection) => void; +} + +const METHODS_TO_BIND = ['getDirectoryTreeDomNodes'] as const; + +class DirectoryTree extends React.Component { + static propTypes = { + path: PropTypes.array, + selectedItems: PropTypes.object, + }; + + static defaultProps = { + path: [], + selectedItems: {}, + }; + + constructor(props: DirectoryTreeProps) { + super(props); + + METHODS_TO_BIND.forEach((method) => { + this[method] = this[method].bind(this); + }); + } + + getDirectoryTreeDomNodes(tree: TorrentDetails['fileTree'], depth = 0) { + const {hash} = this.props; + const {files, directories} = tree; + const childDepth = depth + 1; + + const directoryNodes: Array = + directories != null + ? Object.keys(directories) + .sort((a, b) => a.localeCompare(b)) + .map( + (directoryName, index): React.ReactNode => { + const subSelectedItems = + this.props.selectedItems.directories && this.props.selectedItems.directories[directoryName]; + + const subTree = directories[directoryName]; + const id = `${index}${childDepth}${directoryName}`; + const isSelected = (subSelectedItems && subSelectedItems.isSelected) || false; + + return ( + + ); + }, + ) + : []; + + const fileList: React.ReactNode = + files != null && files.length > 0 ? ( + + ) : null; + + return directoryNodes.concat(fileList); + } + + render() { + return ( +
{this.getDirectoryTreeDomNodes(this.props.tree, this.props.depth)}
+ ); + } +} + +export default DirectoryTree; diff --git a/client/src/javascript/components/general/filesystem/DirectoryTreeNode.js b/client/src/javascript/components/general/filesystem/DirectoryTreeNode.tsx similarity index 75% rename from client/src/javascript/components/general/filesystem/DirectoryTreeNode.js rename to client/src/javascript/components/general/filesystem/DirectoryTreeNode.tsx index a34ec4d9..ab38c52b 100644 --- a/client/src/javascript/components/general/filesystem/DirectoryTreeNode.js +++ b/client/src/javascript/components/general/filesystem/DirectoryTreeNode.tsx @@ -2,6 +2,13 @@ import classnames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; +import type { + TorrentContentSelection, + TorrentContentSelectionTree, + TorrentContentTree, +} from '@shared/constants/torrentFilePropsMap'; +import type {TorrentProperties} from '@shared/types/Torrent'; + import {Checkbox} from '../../../ui'; import FolderClosedSolid from '../../icons/FolderClosedSolid'; import FolderOpenSolid from '../../icons/FolderOpenSolid'; @@ -9,30 +16,45 @@ import FolderOpenSolid from '../../icons/FolderOpenSolid'; // eslint-disable-next-line import/no-cycle import DirectoryTree from './DirectoryTree'; -const METHODS_TO_BIND = ['handleDirectoryClick', 'handleDirectorySelection']; +interface DirectoryTreeNodeProps { + id: string; + depth: number; + hash: TorrentProperties['hash']; + path: Array; + directoryName: string; + selectedItems: TorrentContentSelectionTree; + subTree: TorrentContentTree; + isSelected: boolean; + onPriorityChange: () => void; + onItemSelect: (selection: TorrentContentSelection) => void; +} -class DirectoryTreeNode extends React.Component { +interface DirectoryTreeNodeStates { + expanded: boolean; +} + +const METHODS_TO_BIND = ['handleDirectoryClick', 'handleDirectorySelection'] as const; + +class DirectoryTreeNode extends React.Component { static propTypes = { - isParentSelected: PropTypes.bool, path: PropTypes.array, selectedItems: PropTypes.object, }; static defaultProps = { - isParentSelected: false, path: [], selectedItems: {}, }; - constructor() { - super(); + constructor(props: DirectoryTreeNodeProps) { + super(props); this.state = { expanded: false, }; - METHODS_TO_BIND.forEach((method) => { - this[method] = this[method].bind(this); + METHODS_TO_BIND.forEach((methodName: T) => { + this[methodName] = this[methodName].bind(this); }); } @@ -78,7 +100,6 @@ class DirectoryTreeNode extends React.Component { tree={this.props.subTree} depth={this.props.depth} hash={this.props.hash} - isParentSelected={this.props.isSelected || this.props.isParentSelected} key={`${this.state.expanded}-${this.props.depth}`} onPriorityChange={this.props.onPriorityChange} onItemSelect={this.props.onItemSelect} @@ -100,15 +121,12 @@ class DirectoryTreeNode extends React.Component { }); } - handleDirectorySelection(event) { + handleDirectorySelection() { this.props.onItemSelect({ - depth: this.props.depth, - event, - id: this.props.id, - isParentSelected: this.props.isParentSelected, - isSelected: this.props.isSelected, - path: this.getCurrentPath(), type: 'directory', + depth: this.props.depth, + path: this.getCurrentPath(), + select: !this.props.isSelected, }); } diff --git a/client/src/javascript/components/general/filesystem/PriorityMeter.js b/client/src/javascript/components/general/filesystem/PriorityMeter.tsx similarity index 68% rename from client/src/javascript/components/general/filesystem/PriorityMeter.js rename to client/src/javascript/components/general/filesystem/PriorityMeter.tsx index 3e3e3c4a..b64295a1 100644 --- a/client/src/javascript/components/general/filesystem/PriorityMeter.js +++ b/client/src/javascript/components/general/filesystem/PriorityMeter.tsx @@ -1,13 +1,29 @@ -import {injectIntl} from 'react-intl'; +import {injectIntl, WrappedComponentProps} from 'react-intl'; import React from 'react'; import PriorityLevels from '../../../constants/PriorityLevels'; -const METHODS_TO_BIND = ['handleClick']; +interface PriorityMeterProps extends WrappedComponentProps { + id: string | number; + level: number; + maxLevel: number; + priorityType: keyof typeof PriorityLevels; + showLabel?: boolean; + onChange: (id: this['id'], level: this['level']) => void; + bindExternalChangeHandler?: (clickHandler: (() => void) | null) => void; +} -class PriorityMeter extends React.Component { - constructor() { - super(); +interface PriorityMeterStates { + optimisticData: { + level: number | null; + }; +} + +const METHODS_TO_BIND = ['handleClick'] as const; + +class PriorityMeter extends React.Component { + constructor(props: PriorityMeterProps) { + super(props); this.state = { optimisticData: { @@ -33,7 +49,8 @@ class PriorityMeter extends React.Component { } getPriorityLabel() { - switch (PriorityLevels[this.props.priorityType][this.getPriorityLevel()]) { + const priorityLevel = PriorityLevels[this.props.priorityType]; + switch (priorityLevel[this.getPriorityLevel() as keyof typeof priorityLevel]) { case 'DONT_DOWNLOAD': return this.props.intl.formatMessage({ id: 'priority.dont.download', @@ -66,8 +83,10 @@ class PriorityMeter extends React.Component { handleClick() { let level = this.getPriorityLevel(); - if (level++ >= this.props.maxLevel) { + if (level >= this.props.maxLevel) { level = 0; + } else { + level += 1; } this.setState({optimisticData: {level}}); diff --git a/client/src/javascript/components/general/form-elements/TextboxRepeater.tsx b/client/src/javascript/components/general/form-elements/TextboxRepeater.tsx index a767a63c..accaca70 100644 --- a/client/src/javascript/components/general/form-elements/TextboxRepeater.tsx +++ b/client/src/javascript/components/general/form-elements/TextboxRepeater.tsx @@ -4,17 +4,15 @@ import {FormElementAddon, FormRow, FormRowGroup, Textbox} from '../../../ui'; import AddMini from '../../icons/AddMini'; import RemoveMini from '../../icons/RemoveMini'; -export type Textboxes = Array<{id: number; value: string}>; - interface TextboxRepeaterProps { - defaultValues?: Textboxes; + defaultValues?: Array<{id: number; value: string}>; id: number | string; label?: string; placeholder?: string; } interface TextboxRepeaterStates { - textboxes: Textboxes; + textboxes: Array<{id: number; value: string}>; } export default class TextboxRepeater extends React.PureComponent { diff --git a/client/src/javascript/components/modals/Modals.tsx b/client/src/javascript/components/modals/Modals.tsx index 44fd475b..cbd492f1 100644 --- a/client/src/javascript/components/modals/Modals.tsx +++ b/client/src/javascript/components/modals/Modals.tsx @@ -22,12 +22,12 @@ interface ModalsProps { activeModal?: Modal | null; } -const createModal = (id: Modal['id'], options: Modal['options']): React.ReactNode => { - switch (id) { +const createModal = (activeModal: Modal): React.ReactNode => { + switch (activeModal.id) { case 'add-torrents': - return ; + return ; case 'confirm': - return ; + return ; case 'feeds': return ; case 'move-torrents': @@ -41,7 +41,7 @@ const createModal = (id: Modal['id'], options: Modal['options']): React.ReactNod case 'settings': return ; case 'torrent-details': - return ; + return ; default: return null; } @@ -84,7 +84,7 @@ class Modals extends React.Component {
- {createModal(this.props.activeModal.id, this.props.activeModal.options)} + {createModal(this.props.activeModal)}
); 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 135df750..49a4914b 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx @@ -9,9 +9,6 @@ import SettingsStore from '../../../stores/SettingsStore'; import TextboxRepeater from '../../general/form-elements/TextboxRepeater'; import TorrentActions from '../../../actions/TorrentActions'; import TorrentDestination from '../../general/filesystem/TorrentDestination'; -import UIStore from '../../../stores/UIStore'; - -import type {Textboxes} from '../../general/form-elements/TextboxRepeater'; type AddTorrentsByURLFormData = { [urls: string]: string; @@ -22,25 +19,26 @@ type AddTorrentsByURLFormData = { tags: string; }; +interface AddTorrentsByURLProps extends WrappedComponentProps { + initialURLs?: Array<{id: number; value: string}>; +} + interface AddTorrentsByURLStates { isAddingTorrents: boolean; tags: string; - urlTextboxes: Textboxes; + urlTextboxes: Array<{id: number; value: string}>; } -class AddTorrentsByURL extends React.Component { +class AddTorrentsByURL extends React.Component { formRef: Form | null = null; - constructor(props: WrappedComponentProps) { + constructor(props: AddTorrentsByURLProps) { super(props); - const activeModal = UIStore.getActiveModal(); - const initialUrls = activeModal ? activeModal.torrents : null; - this.state = { isAddingTorrents: false, tags: '', - urlTextboxes: (initialUrls as Textboxes) || [{id: 0, value: ''}], + urlTextboxes: this.props.initialURLs || [{id: 0, value: ''}], }; } 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 d9f09b72..17205f73 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsModal.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsModal.tsx @@ -5,7 +5,11 @@ import AddTorrentsByFile from './AddTorrentsByFile'; import AddTorrentsByURL from './AddTorrentsByURL'; import Modal from '../Modal'; -class AddTorrents extends React.Component { +export interface AddTorrentsModalProps extends WrappedComponentProps { + initialURLs?: Array<{id: number; value: string}>; +} + +class AddTorrentsModal extends React.Component { render() { const tabs = { 'by-url': { @@ -13,6 +17,7 @@ class AddTorrents extends React.Component { label: this.props.intl.formatMessage({ id: 'torrents.add.tab.url.title', }), + props: this.props, }, 'by-file': { content: AddTorrentsByFile, @@ -33,4 +38,4 @@ class AddTorrents extends React.Component { } } -export default injectIntl(AddTorrents); +export default injectIntl(AddTorrentsModal); diff --git a/client/src/javascript/components/modals/confirm-modal/ConfirmModal.tsx b/client/src/javascript/components/modals/confirm-modal/ConfirmModal.tsx index e1ecf8aa..4a52b6b0 100644 --- a/client/src/javascript/components/modals/confirm-modal/ConfirmModal.tsx +++ b/client/src/javascript/components/modals/confirm-modal/ConfirmModal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import Modal from '../Modal'; -interface ConfirmModalProps { +export interface ConfirmModalProps { options: { content: React.ReactNode; heading: React.ReactNode; diff --git a/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx b/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx index 7ea39cf4..20dc2c49 100644 --- a/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx +++ b/client/src/javascript/components/modals/feeds-modal/FeedsTab.tsx @@ -489,7 +489,7 @@ class FeedsTab extends React.Component { .filter((item, index) => formData[index]) .map((torrent, index) => ({id: index, value: torrent.link})); - UIActions.displayModal({id: 'add-torrents', torrents: downloadedTorrents}); + UIActions.displayModal({id: 'add-torrents', initialURLs: downloadedTorrents}); }; validateForm(): {errors?: FeedsTabStates['errors']; isValid: boolean} { diff --git a/client/src/javascript/components/modals/torrent-details-modal/NavigationList.js b/client/src/javascript/components/modals/torrent-details-modal/NavigationList.js deleted file mode 100644 index 6ef673df..00000000 --- a/client/src/javascript/components/modals/torrent-details-modal/NavigationList.js +++ /dev/null @@ -1,54 +0,0 @@ -import classnames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; - -class NavigationList extends React.Component { - static propTypes = { - itemClassName: PropTypes.string, - items: PropTypes.array, - listClassName: PropTypes.string, - onChange: PropTypes.func, - selectedClassName: PropTypes.string, - }; - - static defaultProps = { - itemClassName: 'navigation__item', - listClassName: 'navigation', - }; - - constructor() { - super(); - - this.state = { - selectedItem: null, - }; - } - - getNavigationItems(items) { - return items.map((item) => { - const classes = classnames(this.props.itemClassName, { - [this.props.selectedClassName]: item.slug === this.state.selectedItem, - }); - - return ( -
  • - {item.label} -
  • - ); - }); - } - - handleItemClick(item) { - this.setState({ - selectedItem: item.slug, - }); - - this.props.onChange(item); - } - - render() { - return
      {this.getNavigationItems(this.props.items)}
    ; - } -} - -export default NavigationList; diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.js b/client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.tsx similarity index 62% rename from client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.js rename to client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.tsx index 75c4b3dc..8a82a30d 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.js +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentDetailsModal.tsx @@ -1,6 +1,8 @@ -import {injectIntl} from 'react-intl'; +import {injectIntl, WrappedComponentProps} 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 EventTypes from '../../../constants/EventTypes'; @@ -11,11 +13,16 @@ import TorrentHeading from './TorrentHeading'; import TorrentPeers from './TorrentPeers'; import TorrentStore from '../../../stores/TorrentStore'; import TorrentTrackers from './TorrentTrackers'; -import UIStore from '../../../stores/UIStore'; -class TorrentDetailsModal extends React.Component { +export interface TorrentDetailsModalProps extends WrappedComponentProps { + options: {hash: TorrentProperties['hash']}; + torrent?: TorrentProperties; + torrentDetails?: TorrentDetails; +} + +class TorrentDetailsModal extends React.Component { componentDidMount() { - TorrentStore.fetchTorrentDetails(); + TorrentStore.fetchTorrentDetails(this.props.options.hash); } componentWillUnmount() { @@ -23,7 +30,10 @@ class TorrentDetailsModal extends React.Component { } getModalHeading() { - return ; + if (this.props.torrent != null) { + return ; + } + return null; } render() { @@ -84,27 +94,30 @@ class TorrentDetailsModal extends React.Component { } } -const ConnectedTorrentDetailsModal = connectStores(injectIntl(TorrentDetailsModal), () => { - return [ - { - store: TorrentStore, - event: EventTypes.CLIENT_TORRENT_DETAILS_CHANGE, - getValue: ({store}) => { - return { - torrentDetails: store.getTorrentDetails(UIStore.getTorrentDetailsHash()), - }; +const ConnectedTorrentDetailsModal = connectStores, Record>( + injectIntl(TorrentDetailsModal), + () => { + return [ + { + store: TorrentStore, + event: EventTypes.CLIENT_TORRENT_DETAILS_CHANGE, + getValue: ({props}) => { + return { + torrentDetails: TorrentStore.getTorrentDetails(props.options.hash), + }; + }, }, - }, - { - store: TorrentStore, - event: EventTypes.CLIENT_TORRENTS_REQUEST_SUCCESS, - getValue: ({store}) => { - return { - torrent: store.getTorrent(UIStore.getTorrentDetailsHash()), - }; + { + store: TorrentStore, + event: EventTypes.CLIENT_TORRENTS_REQUEST_SUCCESS, + getValue: ({props}) => { + return { + torrent: TorrentStore.getTorrent(props.options.hash), + }; + }, }, - }, - ]; -}); + ]; + }, +); export default ConnectedTorrentDetailsModal; diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentFiles.js b/client/src/javascript/components/modals/torrent-details-modal/TorrentFiles.tsx similarity index 55% rename from client/src/javascript/components/modals/torrent-details-modal/TorrentFiles.js rename to client/src/javascript/components/modals/torrent-details-modal/TorrentFiles.tsx index adf82577..d64bcb33 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentFiles.js +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentFiles.tsx @@ -1,36 +1,54 @@ import classnames from 'classnames'; import isEqual from 'lodash/isEqual'; -import {FormattedMessage, injectIntl} from 'react-intl'; +import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; import React from 'react'; +import type { + TorrentContentSelection, + TorrentContentSelectionTree, + TorrentContentTree, +} from '@shared/constants/torrentFilePropsMap'; +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 TorrentActions from '../../../actions/TorrentActions'; -const TORRENT_PROPS_TO_CHECK = ['bytesDone']; -const METHODS_TO_BIND = ['handleItemSelect', 'handlePriorityChange', 'handleSelectAllClick']; +interface TorrentFilesProps extends WrappedComponentProps { + fileTree: TorrentContentTree; + torrent: TorrentProperties; +} -class TorrentFiles extends React.Component { - constructor() { - super(); +interface TorrentFilesStates { + allSelected: boolean; + selectedItems: TorrentContentSelectionTree; + selectedFiles: Array; +} - this.hasSelectionChanged = false; - this.hasPriorityChanged = false; +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, - selectedItems: {}, + selectedItems: this.selectAll(this.props.fileTree, false), selectedFiles: [], }; - METHODS_TO_BIND.forEach((method) => { - this[method] = this[method].bind(this); + METHODS_TO_BIND.forEach((methodName: T) => { + this[methodName] = this[methodName].bind(this); }); } - shouldComponentUpdate(nextProps) { + shouldComponentUpdate(nextProps: this['props']) { if (this.hasSelectionChanged) { this.hasSelectionChanged = false; return true; @@ -62,32 +80,31 @@ class TorrentFiles extends React.Component { return true; } - getSelectedFiles(selectionTree, selectedFiles = []) { - if (selectionTree.files) { - selectedFiles = [ - ...selectedFiles, - ...Object.keys(selectionTree.files).reduce((previousValue, filename) => { - const file = selectionTree.files[filename]; + getSelectedFiles(selectionTree: TorrentContentSelectionTree) { + const indices: Array = []; - if (file.isSelected) { - previousValue.push(file.index); - } + if (selectionTree.files != null) { + const {files} = selectionTree; + Object.keys(files).forEach((fileName) => { + const file = files[fileName]; - return previousValue; - }, []), - ]; - } - - if (selectionTree.directories) { - Object.keys(selectionTree.directories).forEach((directory) => { - selectedFiles = [...selectedFiles, ...this.getSelectedFiles(selectionTree.directories[directory])]; + if (file.isSelected) { + indices.push(file.index); + } }); } - return selectedFiles; + if (selectionTree.directories != null) { + const {directories} = selectionTree; + Object.keys(directories).forEach((directoryName) => { + indices.push(...this.getSelectedFiles(directories[directoryName])); + }); + } + + return indices; } - handleDownloadButtonClick = (event) => { + handleDownloadButtonClick = (event: React.MouseEvent): void => { event.preventDefault(); const baseURI = ConfigStore.getBaseURI(); const link = document.createElement('a'); @@ -98,17 +115,20 @@ class TorrentFiles extends React.Component { link.click(); }; - handleFormChange = ({event}) => { - if (event.target.name === 'file-priority') { - this.handlePriorityChange(); - TorrentActions.setFilePriority(this.props.hash, this.state.selectedFiles, event.target.value); + 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, this.state.selectedFiles, Number(inputElement.value)); + } } }; - handleItemSelect(selectedItem) { + handleItemSelect(selectedItem: TorrentContentSelection) { this.hasSelectionChanged = true; this.setState((state) => { - const selectedItems = this.mergeSelection(selectedItem, state.selectedItems, 0, this.props.fileTree); + const selectedItems = this.mergeSelection(selectedItem, 0, state.selectedItems, this.props.fileTree); const selectedFiles = this.getSelectedFiles(selectedItems); return { @@ -127,7 +147,7 @@ class TorrentFiles extends React.Component { this.hasSelectionChanged = true; this.setState((state, props) => { - const selectedItems = this.selectAll(state.selectedItems, props.fileTree, state.allSelected); + const selectedItems = this.selectAll(props.fileTree, state.allSelected); const selectedFiles = this.getSelectedFiles(selectedItems); return { @@ -142,92 +162,103 @@ class TorrentFiles extends React.Component { return this.props.fileTree != null; } - mergeSelection(item, tree = {}, depth = 0, fileTree = {}) { - const {path} = item; - const pathSegment = path[depth]; - const selectionSubTree = item.type === 'file' ? 'files' : 'directories'; + mergeSelection( + item: TorrentContentSelection, + currentDepth: number, + tree: TorrentContentSelectionTree, + fileTree: TorrentContentTree = {}, + ): TorrentContentSelectionTree { + const {depth, path, select, type} = item; + const currentPath = path[currentDepth]; - if (!tree[selectionSubTree]) { - tree[selectionSubTree] = {}; + // Change happens + if (currentDepth === depth - 1) { + if (type === 'file' && tree.files != null && tree.files[currentPath] != null) { + const files = { + ...tree.files, + [currentPath]: { + ...tree.files[currentPath], + isSelected: select, + }, + }; + + return { + ...tree, + files, + isSelected: + Object.values(files).every(({isSelected}) => isSelected) && + (tree.directories != null ? Object.values(tree.directories).every(({isSelected}) => isSelected) : true), + }; + } + + if ( + type === 'directory' && + tree.directories != null && + fileTree.directories != null && + fileTree.directories[currentPath] != null + ) { + const directories = { + ...tree.directories, + [currentPath]: this.selectAll(fileTree.directories[currentPath], select), + }; + + return { + ...tree, + directories, + isSelected: + Object.values(directories).every(({isSelected}) => isSelected) && + (tree.files != null ? Object.values(tree.files).every(({isSelected}) => isSelected) : true), + }; + } + + return tree; } - if (!tree[selectionSubTree][pathSegment]) { - tree[selectionSubTree][pathSegment] = {}; - } - - // If we are not at the clicked depth, then recurse over the path segments. - if (depth++ < path.length - 1) { - if (!tree.directories) { - tree.directories = {[pathSegment]: {}}; - } else if (!tree.directories[pathSegment]) { - tree.directories[pathSegment] = {}; - } - - // Deselect all parent directories if the item in question is being - // de-selected. - if (item.isSelected) { - delete tree.isSelected; - } - - tree.directories[pathSegment] = this.mergeSelection( - item, - tree.directories[pathSegment], - depth, - fileTree.directories[pathSegment], - ); - } else if (item.isSelected) { - delete tree.isSelected; - delete tree[selectionSubTree][pathSegment]; - } else { - let value; - - // If a directory was checked, recursively check all its children. - if (item.type === 'directory') { - value = this.selectAll(tree[selectionSubTree][pathSegment], fileTree[selectionSubTree][pathSegment]); - } else { - value = {...item, isSelected: true}; - } - - tree[selectionSubTree][pathSegment] = value; + // Recursive call till we reach the target + if (tree.directories != null && fileTree.directories != null) { + const selectionSubTree = tree.directories; + const fileSubTree = fileTree.directories; + Object.keys(selectionSubTree).forEach((directory) => { + if (directory === currentPath) { + selectionSubTree[directory] = this.mergeSelection( + item, + currentDepth + 1, + selectionSubTree[directory], + fileSubTree[directory], + ); + } + }); + return { + ...tree, + directories: selectionSubTree, + isSelected: Object.values(selectionSubTree).every(({isSelected}) => isSelected), + }; } return tree; } - selectAll(selectionTree = {}, fileTree = {}, deselect = false) { - if (fileTree.files) { - fileTree.files.forEach((file) => { - if (!selectionTree.files) { - selectionTree.files = {}; - } + selectAll(fileTree: TorrentContentTree, isSelected = true): TorrentContentSelectionTree { + const {files, directories} = fileTree; + const selectionTree: TorrentContentSelectionTree = {}; - if (!deselect) { - selectionTree.files[file.filename] = {...file, isSelected: true}; - } else { - delete selectionTree.files[file.filename]; - } + if (files) { + const selectedFiles: Exclude = {}; + files.forEach((file) => { + selectedFiles[file.filename] = {...file, isSelected}; }); + selectionTree.files = selectedFiles; } - if (fileTree.directories) { - Object.keys(fileTree.directories).forEach((directory) => { - if (!selectionTree.directories) { - selectionTree.directories = {}; - } - - if (deselect && selectionTree.directories[directory]) { - delete selectionTree.directories[directory].isSelected; - } - - selectionTree.directories[directory] = this.selectAll( - selectionTree.directories[directory], - fileTree.directories[directory], - deselect, - ); + if (directories) { + const selectedDirectories: Exclude = {}; + Object.keys(directories).forEach((directory) => { + selectedDirectories[directory] = this.selectAll(directories[directory], isSelected); }); + selectionTree.directories = selectedDirectories; } - selectionTree.isSelected = !deselect; + selectionTree.isSelected = isSelected; return selectionTree; } diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.js b/client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.tsx similarity index 86% rename from client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.js rename to client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.tsx index fbec3ab9..8c14c0de 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.js +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentGeneralInfo.tsx @@ -1,17 +1,23 @@ -import {FormattedMessage, FormattedNumber, injectIntl} from 'react-intl'; +import {FormattedMessage, FormattedNumber, injectIntl, WrappedComponentProps} from 'react-intl'; import React from 'react'; +import type {TorrentProperties} from '@shared/types/Torrent'; + import Size from '../../general/Size'; -class TorrentGeneralInfo extends React.Component { - getTags(tags) { - return tags.map((tag) => ( - - {tag} - - )); - } +interface TorrentGeneralInfoProps extends WrappedComponentProps { + torrent: TorrentProperties; +} +const getTags = (tags: TorrentProperties['tags']) => { + return tags.map((tag) => ( + + {tag} + + )); +}; + +class TorrentGeneralInfo extends React.Component { render() { const {torrent} = this.props; @@ -21,8 +27,8 @@ class TorrentGeneralInfo extends React.Component { } let creation = null; - if (torrent.creationDate) { - creation = new Date(torrent.creationDate * 1000); + if (torrent.dateCreated) { + creation = new Date(torrent.dateCreated * 1000); } const VALUE_NOT_AVAILABLE = ( @@ -36,7 +42,7 @@ class TorrentGeneralInfo extends React.Component { - @@ -60,30 +66,16 @@ class TorrentGeneralInfo extends React.Component { - - - - - @@ -127,7 +119,7 @@ class TorrentGeneralInfo extends React.Component { - @@ -170,17 +162,17 @@ class TorrentGeneralInfo extends React.Component { - diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.js b/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.tsx similarity index 75% rename from client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.js rename to client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.tsx index 14fa6a21..1ec4d8ca 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.js +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.tsx @@ -1,7 +1,8 @@ import {FormattedMessage} from 'react-intl'; import classnames from 'classnames'; import React from 'react'; -import stringUtil from '@shared/util/stringUtil'; + +import type {TorrentProperties} from '@shared/types/Torrent'; import ClockIcon from '../../icons/ClockIcon'; import DownloadThickIcon from '../../icons/DownloadThickIcon'; @@ -18,11 +19,26 @@ import torrentStatusClasses from '../../../util/torrentStatusClasses'; import torrentStatusIcons from '../../../util/torrentStatusIcons'; import UploadThickIcon from '../../icons/UploadThickIcon'; -const METHODS_TO_BIND = ['getCurrentStatus', 'handleStart', 'handleStop']; +interface TorrentHeadingProps { + torrent: TorrentProperties; +} -export default class TorrentHeading extends React.Component { - constructor() { - super(); +interface TorrentHeadingStates { + optimisticData: {currentStatus: 'start' | 'stop' | null}; +} + +const getCurrentStatus = (statuses: TorrentProperties['status']) => { + if (statuses.includes('stopped')) { + return 'stop'; + } + 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}, @@ -33,20 +49,13 @@ export default class TorrentHeading extends React.Component { }); } - getCurrentStatus(torrentStatus) { - if (torrentStatus.includes('stopped')) { - return 'stop'; - } - return 'start'; - } - - getTorrentActions(torrent) { - const currentStatus = this.state.optimisticData.currentStatus || this.getCurrentStatus(torrent.status); + getTorrentActions(torrent: TorrentProperties) { + const currentStatus = this.state.optimisticData.currentStatus || getCurrentStatus(torrent.status); const statusIcons = { start: , stop: , - }; - const torrentActions = ['start', 'stop']; + } as const; + const torrentActions = ['start', 'stop'] as const; const torrentActionElements = [
  • { + TorrentActions.setPriority(hash as string, level); + }} />
  • , ]; - torrentActions.forEach((torrentAction, index) => { - const capitalizedAction = stringUtil.capitalize(torrentAction); + 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( - // TODO: Find a better key - // eslint-disable-next-line react/no-array-index-key -
  • +
  • {statusIcons[torrentAction]}
  • , @@ -78,10 +98,6 @@ export default class TorrentHeading extends React.Component { return torrentActionElements; } - handlePriorityChange(hash, level) { - TorrentActions.setPriority(hash, level); - } - handleStart() { this.setState({optimisticData: {currentStatus: 'start'}}); TorrentActions.startTorrents({ diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.js b/client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.tsx similarity index 75% rename from client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.js rename to client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.tsx index fe1b5ba3..209af369 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.js +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentMediainfo.tsx @@ -1,7 +1,9 @@ import Clipboard from 'clipboard'; -import {defineMessages, FormattedMessage, injectIntl} from 'react-intl'; +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'; @@ -10,6 +12,17 @@ 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 extends Record { + copiedToClipboard: boolean; + isFetchingMediainfo: boolean; + fetchMediainfoError: {data: {error: unknown}} | null; +} + const MESSAGES = defineMessages({ copy: { id: 'general.clipboard.copy', @@ -28,12 +41,12 @@ const MESSAGES = defineMessages({ }, }); -class TorrentMediainfo extends React.Component { - clipboard = null; +class TorrentMediainfo extends React.Component { + clipboard: Clipboard | null = null; + copyButtonRef: HTMLButtonElement | null = null; + timeoutId: NodeJS.Timeout | null = null; - timeoutId = null; - - constructor(props) { + constructor(props: TorrentMediainfoProps) { super(props); this.state = { copiedToClipboard: false, @@ -144,18 +157,21 @@ class TorrentMediainfo extends React.Component { } } -const ConnectedTorrentMediainfo = connectStores(injectIntl(TorrentMediainfo), () => { - return [ - { - store: TorrentStore, - event: EventTypes.CLIENT_FETCH_TORRENT_MEDIAINFO_SUCCESS, - getValue: ({store, props}) => { - return { - mediainfo: store.getMediainfo(props.hash), - }; +const ConnectedTorrentMediainfo = connectStores, TorrentMediainfoStates>( + injectIntl(TorrentMediainfo), + () => { + return [ + { + store: TorrentStore, + event: EventTypes.CLIENT_FETCH_TORRENT_MEDIAINFO_SUCCESS, + getValue: ({props}) => { + return { + mediainfo: TorrentStore.getMediainfo(props.hash), + }; + }, }, - }, - ]; -}); + ]; + }, +); export default ConnectedTorrentMediainfo; 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 c57db42b..e525d41e 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx @@ -1,7 +1,7 @@ import {FormattedMessage} from 'react-intl'; import React from 'react'; -import type {TorrentPeer} from '@shared/types/Torrent'; +import type {TorrentPeer} from '@shared/constants/torrentPeerPropsMap'; import Badge from '../../general/Badge'; import Size from '../../general/Size'; diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.js b/client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.tsx similarity index 85% rename from client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.js rename to client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.tsx index bbccfe6d..4efcfeb6 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.js +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentTrackers.tsx @@ -1,9 +1,15 @@ import {FormattedMessage} from 'react-intl'; import React from 'react'; +import type {TorrentTracker} from '@shared/constants/torrentTrackerPropsMap'; + import Badge from '../../general/Badge'; -export default class TorrentTrackrs extends React.Component { +interface TorrentTrackersProps { + trackers: Array; +} + +export default class TorrentTrackers extends React.Component { render() { const trackers = this.props.trackers || []; diff --git a/client/src/javascript/components/torrent-list/TorrentDetail.js b/client/src/javascript/components/torrent-list/TorrentDetail.js index 82d0243b..cb4542b9 100644 --- a/client/src/javascript/components/torrent-list/TorrentDetail.js +++ b/client/src/javascript/components/torrent-list/TorrentDetail.js @@ -67,7 +67,6 @@ const transformers = { dateCreated: dateRenderer, downRate: speedRenderer, downTotal: sizeRenderer, - ignoreScheduler: booleanRenderer, isPrivate: booleanRenderer, percentComplete: (percent, size) => ( diff --git a/client/src/javascript/constants/ServerActions.ts b/client/src/javascript/constants/ServerActions.ts index dcfce99d..e5d327ce 100644 --- a/client/src/javascript/constants/ServerActions.ts +++ b/client/src/javascript/constants/ServerActions.ts @@ -41,7 +41,6 @@ const successTypes = [ 'AUTH_LOGOUT_SUCCESS', 'CLIENT_CHECK_HASH_SUCCESS', 'CLIENT_CONNECTION_TEST_SUCCESS', - 'CLIENT_SET_FILE_PRIORITY_SUCCESS', 'CLIENT_SET_TORRENT_PRIORITY_SUCCESS', 'CLIENT_SET_TAXONOMY_SUCCESS', 'CLIENT_SET_THROTTLE_SUCCESS', @@ -121,6 +120,11 @@ interface ClientFetchTorrentMediainfoSuccessAction { data: {hash: string; output: string}; } +interface ClientSetFilePrioritySuccessAction { + type: 'CLIENT_SET_FILE_PRIORITY_SUCCESS'; + data: {hash: string}; +} + interface ClientSettingsFetchRequestSuccessAction { type: 'CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS'; data: ClientSettings; @@ -134,6 +138,7 @@ export interface ClientSettingsSaveSuccessAction { type ClientAction = | ClientCheckHashErrorAction | ClientFetchTorrentMediainfoSuccessAction + | ClientSetFilePrioritySuccessAction | ClientSettingsFetchRequestSuccessAction | ClientSettingsSaveSuccessAction; diff --git a/client/src/javascript/constants/TorrentProperties.ts b/client/src/javascript/constants/TorrentProperties.ts index b72b7e66..8c7c0b0d 100644 --- a/client/src/javascript/constants/TorrentProperties.ts +++ b/client/src/javascript/constants/TorrentProperties.ts @@ -45,9 +45,6 @@ const torrentProperties = { basePath: { id: 'torrents.properties.base.path', }, - ignoreScheduler: { - id: 'torrents.properties.ignore.schedule', - }, comment: { id: 'torrents.properties.comment', }, diff --git a/client/src/javascript/stores/TorrentStore.ts b/client/src/javascript/stores/TorrentStore.ts index 45534026..06ba6b36 100644 --- a/client/src/javascript/stores/TorrentStore.ts +++ b/client/src/javascript/stores/TorrentStore.ts @@ -11,7 +11,6 @@ import SettingsStore from './SettingsStore'; import sortTorrents from '../util/sortTorrents'; import TorrentActions from '../actions/TorrentActions'; import TorrentFilterStore from './TorrentFilterStore'; -import UIStore from './UIStore'; import type {FloodSettings} from './SettingsStore'; @@ -26,18 +25,14 @@ class TorrentStoreClass extends BaseStore { sortTorrentsBy: FloodSettings['sortTorrents'] = {direction: 'desc', property: 'dateAdded'}; torrents: Torrents = {}; - fetchTorrentDetails(options: {forceUpdate?: boolean} = {}) { - if (!this.isRequestPending('fetch-torrent-details') || options.forceUpdate) { + fetchTorrentDetails(hash: TorrentProperties['hash'], forceUpdate?: boolean) { + if (!this.isRequestPending('fetch-torrent-details') || forceUpdate) { this.beginRequest('fetch-torrent-details'); - - const hash = UIStore.getTorrentDetailsHash(); - if (hash != null) { - TorrentActions.fetchTorrentDetails(hash); - } + TorrentActions.fetchTorrentDetails(hash); } if (this.pollTorrentDetailsIntervalID === null) { - this.startPollingTorrentDetails(); + this.startPollingTorrentDetails(hash); } } @@ -209,9 +204,9 @@ class TorrentStoreClass extends BaseStore { this.emit('UI_TORRENT_SELECTION_CHANGE'); } - handleSetFilePrioritySuccess() { + handleSetFilePrioritySuccess({hash}: {hash: string}) { this.emit('CLIENT_SET_FILE_PRIORITY_SUCCESS'); - this.fetchTorrentDetails({forceUpdate: true}); + this.fetchTorrentDetails(hash, true); } handleTorrentListDiffChange(torrentListDiff: TorrentListDiff) { @@ -269,8 +264,8 @@ class TorrentStoreClass extends BaseStore { this.sortedTorrents = sortTorrents(Object.values(this.torrents), this.getTorrentsSort()); } - startPollingTorrentDetails() { - this.pollTorrentDetailsIntervalID = setInterval(this.fetchTorrentDetails.bind(this), pollInterval); + startPollingTorrentDetails(hash: TorrentProperties['hash']) { + this.pollTorrentDetailsIntervalID = setInterval(this.fetchTorrentDetails.bind(this, hash), pollInterval); } stopPollingTorrentDetails() { @@ -330,7 +325,7 @@ TorrentStore.dispatcherID = AppDispatcher.register((payload) => { TorrentStoreClass.handleRemoveTorrentsError(action.error); break; case 'CLIENT_SET_FILE_PRIORITY_SUCCESS': - TorrentStore.handleSetFilePrioritySuccess(); + TorrentStore.handleSetFilePrioritySuccess(action.data); break; case 'CLIENT_SET_TRACKER_SUCCESS': // TODO: popup set tracker success message here diff --git a/client/src/javascript/stores/UIStore.ts b/client/src/javascript/stores/UIStore.ts index be3073e6..701de1ae 100644 --- a/client/src/javascript/stores/UIStore.ts +++ b/client/src/javascript/stores/UIStore.ts @@ -1,6 +1,9 @@ import AppDispatcher from '../dispatcher/AppDispatcher'; import BaseStore from './BaseStore'; +import type {ConfirmModalProps} from '../components/modals/confirm-modal/ConfirmModal'; +import type {TorrentDetailsModalProps} from '../components/modals/torrent-details-modal/TorrentDetailsModal'; + export interface ContextMenuItem { type?: 'separator'; action: string; @@ -28,20 +31,22 @@ export interface Dependency { export type Dependencies = Record; -export interface Modal { - id: - | 'add-torrents' - | 'confirm' - | 'feeds' - | 'move-torrents' - | 'remove-torrents' - | 'set-taxonomy' - | 'set-tracker' - | 'settings' - | 'torrent-details'; - torrents?: unknown; - options?: unknown; -} +export type Modal = + | { + id: 'feeds' | 'move-torrents' | 'remove-torrents' | 'set-taxonomy' | 'set-tracker' | 'settings'; + } + | { + id: 'add-torrents'; + initialURLs?: Array<{id: number; value: string}>; + } + | { + id: 'confirm'; + options: ConfirmModalProps['options']; + } + | { + id: 'torrent-details'; + options: TorrentDetailsModalProps['options']; + }; class UIStoreClass extends BaseStore { activeContextMenu: ContextMenu | null = null; @@ -50,7 +55,6 @@ class UIStoreClass extends BaseStore { dependencies: Dependencies = {}; globalStyles: Array = []; haveUIDependenciesResolved = false; - torrentDetailsHash: string | null = null; styleElement: HTMLStyleElement & {styleSheet?: {cssText: string}} = this.createStyleElement(); addGlobalStyle(cssString: string) { @@ -117,21 +121,12 @@ class UIStoreClass extends BaseStore { return this.dependencies; } - getTorrentDetailsHash() { - return this.torrentDetailsHash; - } - handleSetTaxonomySuccess() { if (this.activeModal != null && this.activeModal.id === 'set-taxonomy') { this.dismissModal(); } } - handleTorrentClick({hash}: {hash: string}) { - this.torrentDetailsHash = hash; - this.emit('UI_TORRENT_DETAILS_HASH_CHANGE'); - } - hasSatisfiedDependencies() { return Object.keys(this.dependencies).length === 0; } @@ -197,9 +192,6 @@ UIStore.dispatcherID = AppDispatcher.register((payload) => { const {action} = payload; switch (action.type) { - case 'UI_CLICK_TORRENT': - UIStore.handleTorrentClick(action.data); - break; case 'UI_DISPLAY_DROPDOWN_MENU': UIStore.setActiveDropdownMenu(action.data); break; diff --git a/client/src/javascript/ui/components/Select.tsx b/client/src/javascript/ui/components/Select.tsx index 80e0d06d..be80af28 100644 --- a/client/src/javascript/ui/components/Select.tsx +++ b/client/src/javascript/ui/components/Select.tsx @@ -96,10 +96,10 @@ export default class Select extends Component { if (childArray != null) { const item = childArray.find((child) => { return (child as SelectItem).props.id != null; - }); + }) as SelectItem; - if (item != null) { - return (item as SelectItem).props.id; + if (item != null && item.props.id != null) { + return item.props.id; } } diff --git a/client/src/javascript/ui/components/SelectItem.tsx b/client/src/javascript/ui/components/SelectItem.tsx index e7d20db3..0bb2a49b 100644 --- a/client/src/javascript/ui/components/SelectItem.tsx +++ b/client/src/javascript/ui/components/SelectItem.tsx @@ -5,7 +5,7 @@ import Checkmark from '../icons/Checkmark'; import ContextMenuItem from './ContextMenuItem'; interface SelectItemProps { - id: string | number; + id?: string | number; isSelected?: boolean; isTrigger?: boolean; placeholder?: boolean; diff --git a/client/src/javascript/util/torrentStatusIcons.tsx b/client/src/javascript/util/torrentStatusIcons.tsx index c9a51d76..48fda140 100644 --- a/client/src/javascript/util/torrentStatusIcons.tsx +++ b/client/src/javascript/util/torrentStatusIcons.tsx @@ -16,9 +16,9 @@ const STATUS_ICON_MAP: Partial> = { } as const; function torrentStatusIcons(statuses: Array) { - let resultIcon: React.ReactNode = null; + let resultIcon: JSX.Element = ; Object.entries(STATUS_ICON_MAP).some(([status, icon]) => { - if (statuses.includes(status as TorrentStatus)) { + if (statuses.includes(status as TorrentStatus) && icon != null) { resultIcon = icon; return true; } diff --git a/package-lock.json b/package-lock.json index 54552b9f..393ce43b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1469,6 +1469,12 @@ "integrity": "sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ==", "dev": true }, + "@types/clipboard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/clipboard/-/clipboard-2.0.1.tgz", + "integrity": "sha512-gJJX9Jjdt3bIAePQRRjYWG20dIhAgEqonguyHxXuqALxsoDsDLimihqrSg8fXgVTJ4KZCzkfglKtwsh/8dLfbA==", + "dev": true + }, "@types/color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", diff --git a/package.json b/package.json index 1a10a548..49d3a290 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/bencode": "^2.0.0", "@types/body-parser": "^1.19.0", "@types/classnames": "^2.2.10", + "@types/clipboard": "^2.0.1", "@types/compression": "^1.7.0", "@types/cookie-parser": "^1.4.2", "@types/d3": "^5.7.2", diff --git a/server/constants/torrentListPropMap.ts b/server/constants/torrentListPropMap.ts index cf60faf9..41418deb 100644 --- a/server/constants/torrentListPropMap.ts +++ b/server/constants/torrentListPropMap.ts @@ -3,19 +3,6 @@ import regEx from '../../shared/util/regEx'; const torrentListPropMap = new Map(); const booleanTransformer = (value: string) => value === '1'; -const dateTransformer = (dirtyDate: string) => { - if (!dirtyDate) { - return ''; - } - - const date = dirtyDate.trim(); - - if (date === '0') { - return ''; - } - - return date; -}; const defaultTransformer = (value: unknown) => value; torrentListPropMap.set('hash', { @@ -65,7 +52,7 @@ torrentListPropMap.set('isOpen', { torrentListPropMap.set('priority', { methodCall: 'd.priority=', - transformValue: defaultTransformer, + transformValue: Number, }); torrentListPropMap.set('upRate', { @@ -130,12 +117,12 @@ torrentListPropMap.set('seedingTime', { torrentListPropMap.set('dateAdded', { methodCall: 'd.custom=addtime', - transformValue: dateTransformer, + transformValue: Number, }); torrentListPropMap.set('dateCreated', { methodCall: 'd.creation_date=', - transformValue: dateTransformer, + transformValue: Number, }); torrentListPropMap.set('throttleName', { @@ -180,11 +167,6 @@ torrentListPropMap.set('comment', { }, }); -torrentListPropMap.set('ignoreScheduler', { - methodCall: 'd.custom=sch_ignore', - transformValue: booleanTransformer, -}); - torrentListPropMap.set('trackerURIs', { methodCall: 'cat="$t.multicall=d.hash=,t.is_enabled=,t.url=,cat={|||}"', transformValue: (value: string) => { diff --git a/shared/constants/torrentFilePropsMap.ts b/shared/constants/torrentFilePropsMap.ts index 7ef76e02..e43e612c 100644 --- a/shared/constants/torrentFilePropsMap.ts +++ b/shared/constants/torrentFilePropsMap.ts @@ -3,4 +3,37 @@ const torrentFilePropsMap = { methods: ['f.path=', 'f.path_components=', 'f.priority=', 'f.size_bytes=', 'f.size_chunks=', 'f.completed_chunks='], } as const; +export interface TorrentContent { + index: number; + path: string; + filename: string; + percentComplete: number; + priority: number; + sizeBytes: number; +} + +export interface TorrentContentTree { + files?: Array; + directories?: { + [directoryName: string]: TorrentContentTree; + }; +} + +export interface TorrentContentSelection { + type: 'file' | 'directory'; + depth: number; + path: Array; + select: boolean; +} + +export interface TorrentContentSelectionTree { + isSelected?: boolean; + files?: { + [fileName: string]: TorrentContent & {isSelected: boolean}; + }; + directories?: { + [directoryName: string]: TorrentContentSelectionTree; + }; +} + export default torrentFilePropsMap; diff --git a/shared/constants/torrentPeerPropsMap.ts b/shared/constants/torrentPeerPropsMap.ts index ae4facd8..2503e84c 100644 --- a/shared/constants/torrentPeerPropsMap.ts +++ b/shared/constants/torrentPeerPropsMap.ts @@ -29,4 +29,21 @@ const torrentPeerPropsMap = { ], } as const; +export interface TorrentPeer { + index: number; + country: string; + address: string; + completedPercent: number; + clientVersion: string; + downloadRate: number; + downloadTotal: number; + uploadRate: number; + uploadTotal: number; + id: string; + peerRate: number; + peerTotal: number; + isEncrypted: boolean; + isIncoming: boolean; +} + export default torrentPeerPropsMap; diff --git a/shared/constants/torrentTrackerPropsMap.ts b/shared/constants/torrentTrackerPropsMap.ts index 83754641..d74e6760 100644 --- a/shared/constants/torrentTrackerPropsMap.ts +++ b/shared/constants/torrentTrackerPropsMap.ts @@ -3,4 +3,14 @@ const torrentTrackerPropsMap = { methods: ['t.group=', 't.url=', 't.id=', 't.min_interval=', 't.normal_interval=', 't.type='], } as const; +export interface TorrentTracker { + index: number; + id: string; + url: string; + type: number; + group: number; + minInterval: number; + normalInterval: number; +} + export default torrentTrackerPropsMap; diff --git a/shared/types/Torrent.ts b/shared/types/Torrent.ts index b966e455..ce6be120 100644 --- a/shared/types/Torrent.ts +++ b/shared/types/Torrent.ts @@ -1,4 +1,7 @@ -import {TorrentStatus} from '../constants/torrentStatusMap'; +import type {TorrentContentTree} from '../constants/torrentFilePropsMap'; +import type {TorrentPeer} from '../constants/torrentPeerPropsMap'; +import type {TorrentStatus} from '../constants/torrentStatusMap'; +import type {TorrentTracker} from '../constants/torrentTrackerPropsMap'; export interface Duration { years?: number; @@ -11,47 +14,9 @@ export interface Duration { } export interface TorrentDetails { - fileTree: { - files: Array<{ - index: number; - filename: string; - path: string; - percentComplete: number; - priority: number; - sizeBytes: number; - }>; - peers: Array; - trackers: Array; - }; -} - -// TODO: Unite with torrentPeerPropsMap when it is TS. -export interface TorrentPeer { - index: number; - country: string; - address: string; - completedPercent: number; - clientVersion: string; - downloadRate: number; - downloadTotal: number; - uploadRate: number; - uploadTotal: number; - id: string; - peerRate: number; - peerTotal: number; - isEncrypted: boolean; - isIncoming: boolean; -} - -// TODO: Unite with torrentTrackerPropsMap when it is TS. -export interface TorrentTracker { - index: number; - id: string; - url: string; - type: number; - group: number; - minInterval: number; - normalInterval: number; + peers: Array; + trackers: Array; + fileTree: TorrentContentTree; } // TODO: Rampant over-fetching of torrent properties. Need to remove unused items. @@ -62,15 +27,14 @@ export interface TorrentProperties { basePath: string; bytesDone: number; comment: string; - dateAdded: string; - dateCreated: string; + dateAdded: number; + dateCreated: number; details: TorrentDetails; directory: string; downRate: number; downTotal: number; eta: 'Infinity' | Duration; hash: string; - ignoreScheduler: boolean; isActive: boolean; isComplete: boolean; isHashing: string; @@ -83,7 +47,7 @@ export interface TorrentProperties { peersConnected: number; peersTotal: number; percentComplete: number; - priority: string; + priority: number; ratio: number; seedingTime: string; seedsConnected: number; diff --git a/shared/util/stringUtil.ts b/shared/util/stringUtil.ts index 941c4dc3..c1b84289 100644 --- a/shared/util/stringUtil.ts +++ b/shared/util/stringUtil.ts @@ -1,4 +1,3 @@ export default { - capitalize: (string: string): string => string.charAt(0).toUpperCase() + string.slice(1), withoutTrailingSlash: (input: string): string => input.replace(/\/{1,}$/, ''), };
    +
    {torrent.basePath}
    - - - {torrent.ignoreScheduler === '1' - ? this.props.intl.formatMessage({ - id: 'torrents.details.general.scheduler.ignored', - }) - : this.props.intl.formatMessage({ - id: 'torrents.details.general.scheduler.obeyed', - })} -
    - {torrent.tags.length ? this.getTags(torrent.tags) : VALUE_NOT_AVAILABLE} + {torrent.tags.length ? getTags(torrent.tags) : VALUE_NOT_AVAILABLE}
    +
    +
    - {torrent.isPrivate === '0' + {torrent.isPrivate ? this.props.intl.formatMessage({ - id: 'torrents.details.general.type.public', + id: 'torrents.details.general.type.private', }) : this.props.intl.formatMessage({ - id: 'torrents.details.general.type.private', + id: 'torrents.details.general.type.public', })}
    +