diff --git a/client/src/javascript/components/modals/Modals.tsx b/client/src/javascript/components/modals/Modals.tsx index 7a1d62df..1425477e 100644 --- a/client/src/javascript/components/modals/Modals.tsx +++ b/client/src/javascript/components/modals/Modals.tsx @@ -6,6 +6,7 @@ import {useKeyPressEvent} from 'react-use'; import AddTorrentsModal from './add-torrents-modal/AddTorrentsModal'; import ConfirmModal from './confirm-modal/ConfirmModal'; import FeedsModal from './feeds-modal/FeedsModal'; +import GenerateMagnetModal from './generate-magnet-modal/GenerateMagnetModal'; import MoveTorrentsModal from './move-torrents-modal/MoveTorrentsModal'; import RemoveTorrentsModal from './remove-torrents-modal/RemoveTorrentsModal'; import SetTagsModal from './set-tags-modal/SetTagsModal'; @@ -25,6 +26,8 @@ const createModal = (id: Modal['id']): React.ReactNode => { return ; case 'feeds': return ; + case 'generate-magnet': + return ; case 'move-torrents': return ; case 'remove-torrents': diff --git a/client/src/javascript/components/modals/generate-magnet-modal/GenerateMagnetModal.tsx b/client/src/javascript/components/modals/generate-magnet-modal/GenerateMagnetModal.tsx new file mode 100644 index 00000000..3a8f5f84 --- /dev/null +++ b/client/src/javascript/components/modals/generate-magnet-modal/GenerateMagnetModal.tsx @@ -0,0 +1,145 @@ +import {FC, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import {TorrentTrackerType} from '@shared/types/TorrentTracker'; + +import Checkmark from '../../icons/Checkmark'; +import ClipboardIcon from '../../icons/ClipboardIcon'; +import {Form, FormElementAddon, FormError, FormRow, Textbox} from '../../../ui'; +import Modal from '../Modal'; +import TorrentActions from '../../../actions/TorrentActions'; +import TorrentStore from '../../../stores/TorrentStore'; + +const generateMagnet = (hash: string, trackers?: Array): string => { + let result = `magnet:?xt=urn:btih:${hash}`; + + if (trackers?.length) { + trackers.forEach((tracker) => { + result = `${result}&tr=${encodeURI(tracker)}`; + }); + } + + return result; +}; + +const GenerateMagnetModal: FC = () => { + const magnetTextboxRef = useRef(null); + const magnetTrackersTextboxRef = useRef(null); + const intl = useIntl(); + + const [isMagnetCopied, setIsMagnetCopied] = useState(false); + const [isMagnetTrackersCopied, setIsMagnetTrackersCopied] = useState(false); + const [trackerState, setTrackerState] = useState<{ + isLoadingTrackers: boolean; + magnetTrackersLink: string; + }>({ + isLoadingTrackers: true, + magnetTrackersLink: '', + }); + + useEffect(() => { + TorrentActions.fetchTorrentTrackers(TorrentStore.selectedTorrents[0]).then((trackers) => { + if (trackers != null) { + setTrackerState({ + isLoadingTrackers: false, + magnetTrackersLink: generateMagnet( + TorrentStore.selectedTorrents[0], + trackers.filter((tracker) => tracker.type !== TorrentTrackerType.DHT).map((tracker) => tracker.url), + ), + }); + } + }); + }, []); + + const magnetLink = generateMagnet(TorrentStore.selectedTorrents[0]); + + return ( + +
+ {TorrentStore.torrents[TorrentStore.selectedTorrents[0]]?.isPrivate ? ( + + {intl.formatMessage({id: 'torrents.generate.magnet.private.torrent'})} + + ) : null} + + + { + if (typeof navigator.clipboard?.writeText === 'function') { + navigator.clipboard.writeText(magnetLink).then(() => { + setIsMagnetCopied(true); + }); + } else if (magnetTextboxRef.current != null) { + magnetTextboxRef.current?.select(); + document.execCommand('copy'); + setIsMagnetCopied(true); + } + }}> + {isMagnetCopied ? : } + + + + + {trackerState.isLoadingTrackers ? ( + + ) : ( + + { + if (typeof navigator.clipboard?.writeText === 'function') { + navigator.clipboard.writeText(trackerState.magnetTrackersLink).then(() => { + setIsMagnetTrackersCopied(true); + }); + } else if (magnetTrackersTextboxRef.current != null) { + magnetTrackersTextboxRef.current?.select(); + document.execCommand('copy'); + setIsMagnetTrackersCopied(true); + } + }}> + {isMagnetTrackersCopied ? : } + + + )} + +
+ + } + actions={[ + { + clickHandler: null, + content: intl.formatMessage({ + id: 'button.close', + }), + triggerDismiss: true, + type: 'tertiary', + }, + ]} + /> + ); +}; + +export default GenerateMagnetModal; diff --git a/client/src/javascript/components/modals/settings-modal/lists/TorrentContextMenuActionsList.tsx b/client/src/javascript/components/modals/settings-modal/lists/TorrentContextMenuActionsList.tsx index b3544ae0..e76fff18 100644 --- a/client/src/javascript/components/modals/settings-modal/lists/TorrentContextMenuActionsList.tsx +++ b/client/src/javascript/components/modals/settings-modal/lists/TorrentContextMenuActionsList.tsx @@ -30,8 +30,13 @@ class TorrentContextMenuActionsList extends Component< constructor(props: TorrentContextMenuActionsListProps) { super(props); + const {torrentContextMenuActions} = SettingStore.floodSettings; + this.state = { - torrentContextMenuActions: SettingStore.floodSettings.torrentContextMenuActions, + torrentContextMenuActions: Object.keys(TorrentContextMenuActions).map((key) => ({ + id: key, + visible: torrentContextMenuActions.some((setting) => setting.id === key && setting.visible), + })) as FloodSettings['torrentContextMenuActions'], }; } @@ -92,16 +97,11 @@ class TorrentContextMenuActionsList extends Component< }; render() { - const torrentContextMenuActions = Object.keys(TorrentContextMenuActions).map((key) => ({ - id: key, - visible: this.state.torrentContextMenuActions.some((setting) => setting.id === key && setting.visible), - })); - return ( TorrentListColumns[column.id] != null) + .slice(); + + const newTorrentListColumnItems: ListItem[] = Object.keys(TorrentListColumns) + .filter((key) => torrentListColumns.every((column) => column.id !== key)) + .map((newColumn) => { + return { + id: newColumn, + visible: false, + }; + }); + this.state = { - torrentListColumns: SettingStore.floodSettings.torrentListColumns, + torrentListColumns: torrentListColumnItems.concat( + newTorrentListColumnItems, + ) as FloodSettings['torrentListColumns'], }; } @@ -125,24 +142,11 @@ class TorrentListColumnsList extends React.Component TorrentListColumns[column.id] != null) - .slice(); - - const newTorrentListColumnItems: ListItem[] = Object.keys(TorrentListColumns) - .filter((key) => this.state.torrentListColumns.every((column) => column.id !== key)) - .map((newColumn) => { - return { - id: newColumn, - visible: false, - }; - }); - return ( handleTorrentDownload(selectedTorrents[selectedTorrents.length - 1]); }, }, + { + type: 'action', + action: 'generateMagnet', + label: TorrentContextMenuActions.generateMagnet.id, + clickHandler: () => { + UIActions.displayModal({id: 'generate-magnet'}); + }, + }, { type: 'action', action: 'setPriority', diff --git a/client/src/javascript/constants/TorrentContextMenuActions.ts b/client/src/javascript/constants/TorrentContextMenuActions.ts index 8f69bddb..85e9fb20 100644 --- a/client/src/javascript/constants/TorrentContextMenuActions.ts +++ b/client/src/javascript/constants/TorrentContextMenuActions.ts @@ -26,6 +26,9 @@ const TorrentContextMenuActions = { torrentDownload: { id: 'torrents.list.context.download', }, + generateMagnet: { + id: 'torrents.list.context.generate.magnet', + }, setPriority: { id: 'torrents.list.context.priority', }, diff --git a/client/src/javascript/i18n/strings.compiled.json b/client/src/javascript/i18n/strings.compiled.json index 41ea324c..6c78a86d 100644 --- a/client/src/javascript/i18n/strings.compiled.json +++ b/client/src/javascript/i18n/strings.compiled.json @@ -389,6 +389,12 @@ "value": "Cancel" } ], + "button.close": [ + { + "type": 0, + "value": "Close" + } + ], "button.download": [ { "type": 0, @@ -2137,6 +2143,36 @@ "value": "Type" } ], + "torrents.generate.magnet.heading": [ + { + "type": 0, + "value": "Generate Magnet Link" + } + ], + "torrents.generate.magnet.loading.trackers": [ + { + "type": 0, + "value": "Loading trackers..." + } + ], + "torrents.generate.magnet.magnet": [ + { + "type": 0, + "value": "Magnet Link" + } + ], + "torrents.generate.magnet.magnet.with.trackers": [ + { + "type": 0, + "value": "Magnet Link with Trackers" + } + ], + "torrents.generate.magnet.private.torrent": [ + { + "type": 0, + "value": "This is a private torrent." + } + ], "torrents.list.cannot.connect": [ { "type": 0, @@ -2167,6 +2203,12 @@ "value": "Download" } ], + "torrents.list.context.generate.magnet": [ + { + "type": 0, + "value": "Generate Magnet Link" + } + ], "torrents.list.context.move": [ { "type": 0, diff --git a/client/src/javascript/i18n/strings.json b/client/src/javascript/i18n/strings.json index 42841180..f5ec5bb6 100644 --- a/client/src/javascript/i18n/strings.json +++ b/client/src/javascript/i18n/strings.json @@ -28,6 +28,7 @@ "auth.message.not.admin": "User is not Admin", "button.add": "Add", "button.cancel": "Cancel", + "button.close": "Close", "button.download": "Download", "button.no": "No", "button.ok": "OK", @@ -302,9 +303,15 @@ "torrents.details.trackers.no.data": "There is no tracker data for this torrent.", "torrents.details.trackers.type": "Type", "torrents.details.trackers": "Trackers", + "torrents.generate.magnet.heading": "Generate Magnet Link", + "torrents.generate.magnet.loading.trackers": "Loading trackers...", + "torrents.generate.magnet.private.torrent": "This is a private torrent.", + "torrents.generate.magnet.magnet": "Magnet Link", + "torrents.generate.magnet.magnet.with.trackers": "Magnet Link with Trackers", "torrents.list.clear.filters": "Clear Filters", "torrents.list.context.check.hash": "Check Hash", "torrents.list.context.details": "Torrent Details", + "torrents.list.context.generate.magnet": "Generate Magnet Link", "torrents.list.context.move": "Set Torrent Location", "torrents.list.context.pause": "Pause", "torrents.list.context.download": "Download", diff --git a/client/src/javascript/stores/UIStore.ts b/client/src/javascript/stores/UIStore.ts index 8a3307d3..1fb09ba7 100644 --- a/client/src/javascript/stores/UIStore.ts +++ b/client/src/javascript/stores/UIStore.ts @@ -57,7 +57,14 @@ export type ModalAction = CheckboxModalAction | ButtonModalAction; export type Modal = | { - id: 'feeds' | 'move-torrents' | 'remove-torrents' | 'set-taxonomy' | 'set-trackers' | 'settings'; + id: + | 'feeds' + | 'generate-magnet' + | 'move-torrents' + | 'remove-torrents' + | 'set-taxonomy' + | 'set-trackers' + | 'settings'; } | { id: 'add-torrents'; diff --git a/client/src/javascript/ui/components/Textbox.tsx b/client/src/javascript/ui/components/Textbox.tsx index 4e755e59..28acfd7c 100644 --- a/client/src/javascript/ui/components/Textbox.tsx +++ b/client/src/javascript/ui/components/Textbox.tsx @@ -8,7 +8,7 @@ import type {FormRowItemProps} from './FormRowItem'; type TextboxProps = Pick< React.InputHTMLAttributes, - 'children' | 'defaultValue' | 'placeholder' | 'onChange' | 'onClick' | 'autoComplete' + 'children' | 'disabled' | 'defaultValue' | 'placeholder' | 'readOnly' | 'onChange' | 'onClick' | 'autoComplete' > & { id: string; label?: React.ReactNode; @@ -33,6 +33,8 @@ const Textbox = forwardRef( placeholder, autoComplete, type, + disabled, + readOnly, onChange, onClick, }: TextboxProps, @@ -78,6 +80,8 @@ const Textbox = forwardRef( tabIndex={0} type={type} autoComplete={autoComplete} + disabled={disabled} + readOnly={readOnly} /> {childElements} diff --git a/shared/constants/defaultFloodSettings.ts b/shared/constants/defaultFloodSettings.ts index cf82d465..52252ba6 100644 --- a/shared/constants/defaultFloodSettings.ts +++ b/shared/constants/defaultFloodSettings.ts @@ -58,6 +58,7 @@ const defaultFloodSettings: Readonly = { {id: 'setTrackers', visible: false}, {id: 'torrentDetails', visible: true}, {id: 'torrentDownload', visible: true}, + {id: 'generateMagnet', visible: false}, {id: 'setPriority', visible: false}, ], torrentListViewSize: 'condensed',