Compare commits
9 commits
master
...
issue/7152
Author | SHA1 | Date | |
---|---|---|---|
|
542253c8a0 | ||
|
a6cac05a66 | ||
|
eecb5bac8b | ||
|
172d4d35eb | ||
|
5dfc247685 | ||
|
02e121ea81 | ||
|
c082905fa4 | ||
|
3ec129b037 | ||
|
ff2c9bf56c |
19 changed files with 664 additions and 29 deletions
0
custom/homepages/.gitkeep
Normal file → Executable file
0
custom/homepages/.gitkeep
Normal file → Executable file
|
@ -2178,6 +2178,16 @@
|
||||||
"Card Last 4": "Card Last 4",
|
"Card Last 4": "Card Last 4",
|
||||||
"Search blocked channel name": "Search blocked channel name",
|
"Search blocked channel name": "Search blocked channel name",
|
||||||
"Discuss": "Discuss",
|
"Discuss": "Discuss",
|
||||||
|
"Play Next (SHIFT+N)": "Play Next (SHIFT+N)",
|
||||||
|
"Play Previous (SHIFT+P)": "Play Previous (SHIFT+P)",
|
||||||
|
"Finance": "Finance",
|
||||||
|
"You followed @veritasium!": "You followed @veritasium!",
|
||||||
|
"Downloading": "Downloading",
|
||||||
|
"Do you cancel download this file?": "Do you cancel download this file?",
|
||||||
|
"%remainingMinutes% minutes %remainSecond% seconds remaining": "%remainingMinutes% minutes %remainSecond% seconds remaining",
|
||||||
|
"%remainSecond% seconds remaining": "%remainSecond% seconds remaining",
|
||||||
|
"%written% of %total%": "%written% of %total%",
|
||||||
|
"(%speed%/sec)": "(%speed%/sec)",
|
||||||
"lbry.tv has been retired. You have been magically transported to Odysee.com. %more%": "lbry.tv has been retired. You have been magically transported to Odysee.com. %more%",
|
"lbry.tv has been retired. You have been magically transported to Odysee.com. %more%": "lbry.tv has been retired. You have been magically transported to Odysee.com. %more%",
|
||||||
"Show more livestreams": "Show more livestreams",
|
"Show more livestreams": "Show more livestreams",
|
||||||
"Creator": "Creator",
|
"Creator": "Creator",
|
||||||
|
|
BIN
static/img/dark_loading.gif
Normal file
BIN
static/img/dark_loading.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 78 KiB |
BIN
static/img/white_loading.gif
Normal file
BIN
static/img/white_loading.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 79 KiB |
|
@ -191,9 +191,11 @@ function ClaimPreviewTile(props: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div className="placeholder__wrapper">
|
<div className="placeholder__wrapper">
|
||||||
<div className="placeholder claim-tile__title" />
|
<div className="placeholder claim-tile__title" />
|
||||||
<div className={classnames('claim-tile__info placeholder', {
|
<div
|
||||||
'contains_view_count': shouldShowViewCount,
|
className={classnames('claim-tile__info placeholder', {
|
||||||
})} />
|
contains_view_count: shouldShowViewCount,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
@ -253,9 +255,11 @@ function ClaimPreviewTile(props: Props) {
|
||||||
<ClaimMenuList uri={uri} collectionId={listId} channelUri={channelUri} />
|
<ClaimMenuList uri={uri} collectionId={listId} channelUri={channelUri} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={classnames('claim-tile__info', {
|
<div
|
||||||
'contains_view_count': shouldShowViewCount,
|
className={classnames('claim-tile__info', {
|
||||||
})}>
|
contains_view_count: shouldShowViewCount,
|
||||||
|
})}
|
||||||
|
>
|
||||||
{isChannel ? (
|
{isChannel ? (
|
||||||
<div className="claim-tile__about--channel">
|
<div className="claim-tile__about--channel">
|
||||||
<SubscribeButton uri={repostedChannelUri || uri} />
|
<SubscribeButton uri={repostedChannelUri || uri} />
|
||||||
|
|
|
@ -104,7 +104,11 @@ class DateTime extends React.Component<Props, State> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className="date_time" title={moment(date).format(`MMMM Do, YYYY ${clockFormat}`)}>{DateTime.getTimeAgoStr(date)}</span>;
|
return (
|
||||||
|
<span className="date_time" title={moment(date).format(`MMMM Do, YYYY ${clockFormat}`)}>
|
||||||
|
{DateTime.getTimeAgoStr(date)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
48
ui/component/downloadProgress/index.js
Normal file
48
ui/component/downloadProgress/index.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import DownloadProgress from './view';
|
||||||
|
import { doSetPlayingUri, doStopDownload, doContinueDownloading, doPurchaseUriWrapper } from 'redux/actions/content';
|
||||||
|
import { selectFileInfosByOutpoint, SETTINGS } from 'lbry-redux';
|
||||||
|
import { selectPrimaryUri, selectPlayingUri } from 'redux/selectors/content';
|
||||||
|
import { makeSelectClientSetting } from 'redux/selectors/settings';
|
||||||
|
|
||||||
|
const select = (state) => {
|
||||||
|
const byOutpoint = selectFileInfosByOutpoint(state);
|
||||||
|
const runningByOutpoint = [];
|
||||||
|
const primaryUri = selectPrimaryUri(state);
|
||||||
|
const playingUri = selectPlayingUri(state);
|
||||||
|
const uri = playingUri ? playingUri.uri : null;
|
||||||
|
let primaryOutpoint = null;
|
||||||
|
let playingOutpoint = null;
|
||||||
|
|
||||||
|
for (const key in byOutpoint) {
|
||||||
|
const item = byOutpoint[key];
|
||||||
|
|
||||||
|
if (item && primaryUri && primaryUri.includes(`/${item.claim_name}`)) primaryOutpoint = item.outpoint;
|
||||||
|
if (item && uri && uri.includes(`/${item.claim_name}`)) playingOutpoint = item.outpoint;
|
||||||
|
|
||||||
|
if (item && item.status === 'running') {
|
||||||
|
runningByOutpoint.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
byOutpoint: selectFileInfosByOutpoint(state),
|
||||||
|
primary: {
|
||||||
|
uri: primaryUri,
|
||||||
|
outpoint: primaryOutpoint,
|
||||||
|
},
|
||||||
|
playing: {
|
||||||
|
uri,
|
||||||
|
outpoint: playingOutpoint,
|
||||||
|
},
|
||||||
|
currentTheme: makeSelectClientSetting(SETTINGS.THEME)(state),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const perform = (dispatch) => ({
|
||||||
|
pause: () => dispatch(doSetPlayingUri({ uri: null })),
|
||||||
|
doContinueDownloading: (outpoint, force) => dispatch(doContinueDownloading(outpoint, force)),
|
||||||
|
stopDownload: (outpoint) => dispatch(doStopDownload(outpoint)),
|
||||||
|
download: (uri) => dispatch(doPurchaseUriWrapper(uri, false, true)),
|
||||||
|
});
|
||||||
|
export default connect(select, perform)(DownloadProgress);
|
286
ui/component/downloadProgress/view.jsx
Normal file
286
ui/component/downloadProgress/view.jsx
Normal file
|
@ -0,0 +1,286 @@
|
||||||
|
// @flow
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { shell } from 'electron';
|
||||||
|
import Button from 'component/button';
|
||||||
|
import * as ICONS from 'constants/icons';
|
||||||
|
import { buildURI } from 'lbry-redux';
|
||||||
|
import { formatBytes } from 'util/format-bytes';
|
||||||
|
import { areEqual, removeItem } from 'util/array';
|
||||||
|
import loadingIcon from '../../../static/img/white_loading.gif';
|
||||||
|
import darkLoadingIcon from '../../../static/img/dark_loading.gif';
|
||||||
|
import usePersistedState from 'effects/use-persisted-state';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
byOutpoint: any,
|
||||||
|
primary: any,
|
||||||
|
playing: any,
|
||||||
|
currentTheme: string,
|
||||||
|
stopDownload: (outpoint: string) => void,
|
||||||
|
doContinueDownloading: (outpoint: string, force: boolean) => void,
|
||||||
|
download: (uri: string) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
function DownloadProgress({ byOutpoint, primary, playing, currentTheme, stopDownload, doContinueDownloading }: Props) {
|
||||||
|
const [isShow, setIsShow] = usePersistedState('download-progress', true);
|
||||||
|
const [downloading, setDownloading] = usePersistedState('download-progress-downloading', []);
|
||||||
|
const [cancelHash] = useState({});
|
||||||
|
const [initDownloadingHash] = useState({});
|
||||||
|
const [prevPlaying, setPrevPlaying] = useState({});
|
||||||
|
const [prevPrimary, setPrevPrimary] = useState({});
|
||||||
|
|
||||||
|
const handleCancel = (hash, value) => {
|
||||||
|
cancelHash[hash] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopDownload = (outpoint) => {
|
||||||
|
const updated = [...downloading];
|
||||||
|
removeItem(updated, outpoint);
|
||||||
|
setDownloading(updated);
|
||||||
|
stopDownload(outpoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningByOutpoint = {};
|
||||||
|
const currentDownloading = [...downloading];
|
||||||
|
|
||||||
|
for (const key in byOutpoint) {
|
||||||
|
const item = byOutpoint[key];
|
||||||
|
if (item && item.status === 'running') runningByOutpoint[item.outpoint] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.keys(runningByOutpoint)
|
||||||
|
.filter((outpoint) => downloading.indexOf(outpoint) === -1)
|
||||||
|
.map((outpoint) => {
|
||||||
|
if (primary.outpoint !== outpoint && playing.outpoint !== outpoint) {
|
||||||
|
currentDownloading.push(outpoint);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
downloading
|
||||||
|
.filter((outpoint) => (byOutpoint[outpoint] && byOutpoint[outpoint].status !== 'running') || !byOutpoint[outpoint])
|
||||||
|
.map((outpoint) => {
|
||||||
|
removeItem(currentDownloading, outpoint);
|
||||||
|
});
|
||||||
|
if (!areEqual(downloading, currentDownloading)) setDownloading(currentDownloading);
|
||||||
|
|
||||||
|
if (currentDownloading.length === 0) return null;
|
||||||
|
|
||||||
|
if (playing.outpoint !== prevPlaying.outpoint) {
|
||||||
|
if (downloading.includes(prevPlaying.outpoint)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
doContinueDownloading(prevPlaying.outpoint, true);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
setPrevPlaying(playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primary.outpoint !== prevPrimary.outpoint) {
|
||||||
|
if (downloading.includes(prevPrimary.outpoint)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
doContinueDownloading(prevPrimary.outpoint, true);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
setPrevPrimary(primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDownloading.map((outpoint) => {
|
||||||
|
if (!initDownloadingHash[outpoint]) {
|
||||||
|
initDownloadingHash[outpoint] = true;
|
||||||
|
doContinueDownloading(outpoint, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isShow) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
iconSize={40}
|
||||||
|
icon={ICONS.DOWNLOAD}
|
||||||
|
className="download-progress__toggle-button"
|
||||||
|
onClick={() => setIsShow(true)}
|
||||||
|
>
|
||||||
|
<div className="download-progress__current-downloading">
|
||||||
|
<span className="notification__bubble">
|
||||||
|
<span className="notification__count">{currentDownloading.length}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="download-progress__header">
|
||||||
|
<Button className="download-progress__top-close-button" onClick={() => setIsShow(false)}>
|
||||||
|
<div />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{currentDownloading.map((outpoint, index) => {
|
||||||
|
const item = runningByOutpoint[outpoint];
|
||||||
|
let releaseTime = '';
|
||||||
|
let isPlaying = false;
|
||||||
|
if (item.metadata && item.metadata.release_time) {
|
||||||
|
releaseTime = new Date(parseInt(item.metadata.release_time) * 1000).toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
if (outpoint === primary.outpoint || outpoint === playing.outpoint) {
|
||||||
|
isPlaying = true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={item.outpoint}>
|
||||||
|
{index !== 0 && <hr className="download-progress__divider" />}
|
||||||
|
<DownloadProgressItem
|
||||||
|
fileName={item.suggested_file_name}
|
||||||
|
title={item.metadata.title}
|
||||||
|
releaseTime={releaseTime}
|
||||||
|
writtenBytes={item.written_bytes}
|
||||||
|
totalBytes={item.total_bytes}
|
||||||
|
addedOn={item.added_on}
|
||||||
|
directory={item.download_directory}
|
||||||
|
stopDownload={handleStopDownload}
|
||||||
|
outpoint={item.outpoint}
|
||||||
|
isCancel={cancelHash[item.outpoint]}
|
||||||
|
claimID={item.claim_id}
|
||||||
|
playing={isPlaying}
|
||||||
|
claimName={item.claim_name}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
currentTheme={currentTheme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadProgressItemProps = {
|
||||||
|
fileName: string,
|
||||||
|
writtenBytes: number,
|
||||||
|
totalBytes: number,
|
||||||
|
addedOn: number,
|
||||||
|
title: string,
|
||||||
|
releaseTime: string,
|
||||||
|
directory: string,
|
||||||
|
outpoint: string,
|
||||||
|
isCancel: boolean,
|
||||||
|
claimID: string,
|
||||||
|
claimName: string,
|
||||||
|
playing: boolean,
|
||||||
|
currentTheme: string,
|
||||||
|
stopDownload: (outpoint: string) => void,
|
||||||
|
handleCancel: (hash: string, value: boolean) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
function DownloadProgressItem({
|
||||||
|
fileName,
|
||||||
|
writtenBytes,
|
||||||
|
totalBytes,
|
||||||
|
addedOn,
|
||||||
|
title,
|
||||||
|
releaseTime,
|
||||||
|
directory,
|
||||||
|
outpoint,
|
||||||
|
isCancel,
|
||||||
|
claimID,
|
||||||
|
claimName,
|
||||||
|
playing,
|
||||||
|
currentTheme,
|
||||||
|
stopDownload,
|
||||||
|
handleCancel,
|
||||||
|
}: DownloadProgressItemProps) {
|
||||||
|
const processStopDownload = () => {
|
||||||
|
handleCancel(outpoint, false);
|
||||||
|
stopDownload(outpoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [percent, setPercent] = useState(0);
|
||||||
|
const [progressText, setProgressText] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updatePercent = ((writtenBytes / totalBytes) * 100).toFixed(0);
|
||||||
|
setPercent(updatePercent);
|
||||||
|
|
||||||
|
let updateText = '';
|
||||||
|
const downloadSpeed = Math.ceil(writtenBytes / (Date.now() / 1000 - addedOn));
|
||||||
|
const remainingSecond = Math.ceil((totalBytes - writtenBytes) / downloadSpeed);
|
||||||
|
const remainingMinutes = Math.floor(remainingSecond / 60);
|
||||||
|
|
||||||
|
if (remainingMinutes > 0) {
|
||||||
|
updateText += __('%remainingMinutes% minutes %remainSecond% seconds remaining', {
|
||||||
|
remainingMinutes: remainingMinutes,
|
||||||
|
remainSecond: remainingSecond - 60 * remainingMinutes,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateText += __('%remainSecond% seconds remaining', { remainSecond: remainingSecond - 60 * remainingMinutes });
|
||||||
|
}
|
||||||
|
updateText += ' -- ';
|
||||||
|
|
||||||
|
updateText += __('%written% of %total%', {
|
||||||
|
written: formatBytes(writtenBytes),
|
||||||
|
total: formatBytes(totalBytes),
|
||||||
|
});
|
||||||
|
updateText += ' ';
|
||||||
|
|
||||||
|
updateText += __('(%speed%/sec)', {
|
||||||
|
speed: formatBytes(downloadSpeed),
|
||||||
|
});
|
||||||
|
|
||||||
|
setProgressText(updateText);
|
||||||
|
}, [writtenBytes, totalBytes, addedOn]);
|
||||||
|
|
||||||
|
const openDownloadFolder = () => {
|
||||||
|
shell.openPath(directory);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="download-progress__state-container">
|
||||||
|
<div className="download-progress__state-bar">
|
||||||
|
<Button
|
||||||
|
label={title}
|
||||||
|
className="download-progress__state-filename"
|
||||||
|
navigate={buildURI({ claimName, claimID })}
|
||||||
|
/>
|
||||||
|
{playing ? (
|
||||||
|
currentTheme === 'light' ? (
|
||||||
|
<img src={loadingIcon} className="download-progress__playing-button" />
|
||||||
|
) : (
|
||||||
|
<img src={darkLoadingIcon} className="download-progress__playing-button" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="download-progress__close-button"
|
||||||
|
onClick={() => {
|
||||||
|
handleCancel(outpoint, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="download-progress__state-bar">
|
||||||
|
<a className="download-progress__state-filename-link" onClick={openDownloadFolder}>
|
||||||
|
{fileName}
|
||||||
|
</a>
|
||||||
|
<p className="download-progress__release-time">{releaseTime}</p>
|
||||||
|
</div>
|
||||||
|
<div className="download-progress__state-bar">
|
||||||
|
<div className="download-progress__bar-container">
|
||||||
|
<div className="download-progress__bar-content" style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="download-progress__count-time">{progressText}</p>
|
||||||
|
{isCancel && (
|
||||||
|
<div className="download-progress__cancel">
|
||||||
|
<p>{__('Do you cancel download this file?')}</p>
|
||||||
|
<div className="download-progress__cancel-confirm">
|
||||||
|
<Button label={__('Yes')} className="download-progress__cancel-ok" onClick={processStopDownload} />
|
||||||
|
<Button
|
||||||
|
label={__('No')}
|
||||||
|
className="download-progress__cancel-ok"
|
||||||
|
onClick={() => handleCancel(outpoint, false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadProgress;
|
|
@ -7,6 +7,7 @@ import SideNavigation from 'component/sideNavigation';
|
||||||
import SettingsSideNavigation from 'component/settingsSideNavigation';
|
import SettingsSideNavigation from 'component/settingsSideNavigation';
|
||||||
import Header from 'component/header';
|
import Header from 'component/header';
|
||||||
/* @if TARGET='app' */
|
/* @if TARGET='app' */
|
||||||
|
import DownloadProgress from '../downloadProgress';
|
||||||
import StatusBar from 'component/common/status-bar';
|
import StatusBar from 'component/common/status-bar';
|
||||||
/* @endif */
|
/* @endif */
|
||||||
import usePersistedState from 'effects/use-persisted-state';
|
import usePersistedState from 'effects/use-persisted-state';
|
||||||
|
@ -102,7 +103,7 @@ function Page(props: Props) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
// TODO: make sure setState callback for usePersistedState uses useCallback to it doesn't cause effect to re-run
|
// TODO: make sure setState callback for usePersistedState uses useCallback to it doesn't cause effect to re-run
|
||||||
}, [isOnFilePage, isMediumScreen]);
|
}, [isOnFilePage, isMediumScreen, setSidebarOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
@ -150,6 +151,9 @@ function Page(props: Props) {
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
)}
|
)}
|
||||||
{/* @endif */}
|
{/* @endif */}
|
||||||
|
{/* @if TARGET='app' */}
|
||||||
|
<DownloadProgress />
|
||||||
|
{/* @endif */}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,7 +178,15 @@ function VideoViewer(props: Props) {
|
||||||
|
|
||||||
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
|
fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => {
|
||||||
let playerPoweredBy = response.headers.get('x-powered-by') || '';
|
let playerPoweredBy = response.headers.get('x-powered-by') || '';
|
||||||
analytics.videoStartEvent(claimId, timeToStart, playerPoweredBy, userId, claim.canonical_url, this, bitrateAsBitsPerSecond);
|
analytics.videoStartEvent(
|
||||||
|
claimId,
|
||||||
|
timeToStart,
|
||||||
|
playerPoweredBy,
|
||||||
|
userId,
|
||||||
|
claim.canonical_url,
|
||||||
|
this,
|
||||||
|
bitrateAsBitsPerSecond
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
doAnalyticsView(uri, timeToStart).then(() => {
|
doAnalyticsView(uri, timeToStart).then(() => {
|
||||||
|
|
|
@ -18,27 +18,28 @@ import {
|
||||||
doToast,
|
doToast,
|
||||||
makeSelectUrlsForCollectionId,
|
makeSelectUrlsForCollectionId,
|
||||||
} from 'lbry-redux';
|
} from 'lbry-redux';
|
||||||
import { doPurchaseUri } from 'redux/actions/file';
|
import { doPurchaseUri, doDeleteFile } from 'redux/actions/file';
|
||||||
import { makeSelectCostInfoForUri, Lbryio } from 'lbryinc';
|
import { makeSelectCostInfoForUri, Lbryio } from 'lbryinc';
|
||||||
import { makeSelectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings';
|
import { makeSelectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings';
|
||||||
|
|
||||||
const DOWNLOAD_POLL_INTERVAL = 1000;
|
const DOWNLOAD_POLL_INTERVAL = 1000;
|
||||||
|
var timeOutHash = {};
|
||||||
|
|
||||||
export function doUpdateLoadStatus(uri: string, outpoint: string) {
|
export function doUpdateLoadStatus(uri: any, outpoint: string) {
|
||||||
// Updates the loading status for a uri as it's downloading
|
// Updates the loading status for a uri as it's downloading
|
||||||
// Calls file_list and checks the written_bytes value to see if the number has increased
|
// Calls file_list and checks the written_bytes value to see if the number has increased
|
||||||
// Not needed on web as users aren't actually downloading the file
|
// Not needed on web as users aren't actually downloading the file
|
||||||
// @if TARGET='app'
|
// @if TARGET='app'
|
||||||
return (dispatch: Dispatch, getState: GetState) => {
|
return (dispatch: Dispatch, getState: GetState) => {
|
||||||
const setNextStatusUpdate = () =>
|
const setNextStatusUpdate = () =>
|
||||||
setTimeout(() => {
|
(timeOutHash[outpoint] = setTimeout(() => {
|
||||||
// We need to check if outpoint still exists first because user are able to delete file (outpoint) while downloading.
|
// We need to check if outpoint still exists first because user are able to delete file (outpoint) while downloading.
|
||||||
// If a file is already deleted, no point to still try update load status
|
// If a file is already deleted, no point to still try update load status
|
||||||
const byOutpoint = selectFileInfosByOutpoint(getState());
|
const byOutpoint = selectFileInfosByOutpoint(getState());
|
||||||
if (byOutpoint[outpoint]) {
|
if (byOutpoint[outpoint]) {
|
||||||
dispatch(doUpdateLoadStatus(uri, outpoint));
|
dispatch(doUpdateLoadStatus(uri, outpoint));
|
||||||
}
|
}
|
||||||
}, DOWNLOAD_POLL_INTERVAL);
|
}, DOWNLOAD_POLL_INTERVAL));
|
||||||
|
|
||||||
Lbry.file_list({
|
Lbry.file_list({
|
||||||
outpoint,
|
outpoint,
|
||||||
|
@ -53,6 +54,7 @@ export function doUpdateLoadStatus(uri: string, outpoint: string) {
|
||||||
setNextStatusUpdate();
|
setNextStatusUpdate();
|
||||||
} else if (fileInfo.completed) {
|
} else if (fileInfo.completed) {
|
||||||
// TODO this isn't going to get called if they reload the client before
|
// TODO this isn't going to get called if they reload the client before
|
||||||
|
|
||||||
// the download finished
|
// the download finished
|
||||||
dispatch({
|
dispatch({
|
||||||
type: ACTIONS.DOWNLOADING_COMPLETED,
|
type: ACTIONS.DOWNLOADING_COMPLETED,
|
||||||
|
@ -98,6 +100,25 @@ export function doUpdateLoadStatus(uri: string, outpoint: string) {
|
||||||
// @endif
|
// @endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function doContinueDownloading(outpoint: string, force: boolean) {
|
||||||
|
return (dispatch: Dispatch) => {
|
||||||
|
if (!timeOutHash[outpoint] || force) {
|
||||||
|
dispatch(doUpdateLoadStatus(null, outpoint));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doStopDownload(outpoint: string) {
|
||||||
|
return (dispatch: Dispatch) => {
|
||||||
|
if (timeOutHash[outpoint]) {
|
||||||
|
clearInterval(timeOutHash[outpoint]);
|
||||||
|
timeOutHash[outpoint] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(doDeleteFile(outpoint, false, false, null));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function doSetPrimaryUri(uri: ?string) {
|
export function doSetPrimaryUri(uri: ?string) {
|
||||||
return (dispatch: Dispatch) => {
|
return (dispatch: Dispatch) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { selectClaimsByUri, selectIsFetchingClaimListMine, selectMyClaims } from 'lbry-redux';
|
import { selectClaimsByUri, selectIsFetchingClaimListMine, selectMyClaims } from 'lbry-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
export const selectState = state => state.fileInfo || {};
|
export const selectState = (state) => state.fileInfo || {};
|
||||||
|
|
||||||
export const selectFileInfosByOutpoint = createSelector(selectState, state => state.byOutpoint || {});
|
export const selectFileInfosByOutpoint = createSelector(selectState, (state) => state.byOutpoint || {});
|
||||||
|
|
||||||
export const selectIsFetchingFileList = createSelector(selectState, state => state.isFetchingFileList);
|
export const selectIsFetchingFileList = createSelector(selectState, (state) => state.isFetchingFileList);
|
||||||
|
|
||||||
export const selectIsFetchingFileListDownloadedOrPublished = createSelector(
|
export const selectIsFetchingFileListDownloadedOrPublished = createSelector(
|
||||||
selectIsFetchingFileList,
|
selectIsFetchingFileList,
|
||||||
|
@ -13,31 +13,31 @@ export const selectIsFetchingFileListDownloadedOrPublished = createSelector(
|
||||||
(isFetchingFileList, isFetchingClaimListMine) => isFetchingFileList || isFetchingClaimListMine
|
(isFetchingFileList, isFetchingClaimListMine) => isFetchingFileList || isFetchingClaimListMine
|
||||||
);
|
);
|
||||||
|
|
||||||
export const makeSelectFileInfoForUri = uri =>
|
export const makeSelectFileInfoForUri = (uri) =>
|
||||||
createSelector(selectClaimsByUri, selectFileInfosByOutpoint, (claims, byOutpoint) => {
|
createSelector(selectClaimsByUri, selectFileInfosByOutpoint, (claims, byOutpoint) => {
|
||||||
const claim = claims[uri];
|
const claim = claims[uri];
|
||||||
const outpoint = claim ? `${claim.txid}:${claim.nout}` : undefined;
|
const outpoint = claim ? `${claim.txid}:${claim.nout}` : undefined;
|
||||||
return outpoint ? byOutpoint[outpoint] : undefined;
|
return outpoint ? byOutpoint[outpoint] : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const selectDownloadingByOutpoint = createSelector(selectState, state => state.downloadingByOutpoint || {});
|
export const selectDownloadingByOutpoint = createSelector(selectState, (state) => state.downloadingByOutpoint || {});
|
||||||
|
|
||||||
export const makeSelectDownloadingForUri = uri =>
|
export const makeSelectDownloadingForUri = (uri) =>
|
||||||
createSelector(selectDownloadingByOutpoint, makeSelectFileInfoForUri(uri), (byOutpoint, fileInfo) => {
|
createSelector(selectDownloadingByOutpoint, makeSelectFileInfoForUri(uri), (byOutpoint, fileInfo) => {
|
||||||
if (!fileInfo) return false;
|
if (!fileInfo) return false;
|
||||||
return byOutpoint[fileInfo.outpoint];
|
return byOutpoint[fileInfo.outpoint];
|
||||||
});
|
});
|
||||||
|
|
||||||
export const selectUrisLoading = createSelector(selectState, state => state.urisLoading || {});
|
export const selectUrisLoading = createSelector(selectState, (state) => state.urisLoading || {});
|
||||||
|
|
||||||
export const makeSelectLoadingForUri = uri => createSelector(selectUrisLoading, byUri => byUri && byUri[uri]);
|
export const makeSelectLoadingForUri = (uri) => createSelector(selectUrisLoading, (byUri) => byUri && byUri[uri]);
|
||||||
|
|
||||||
export const selectFileInfosDownloaded = createSelector(
|
export const selectFileInfosDownloaded = createSelector(
|
||||||
selectFileInfosByOutpoint,
|
selectFileInfosByOutpoint,
|
||||||
selectMyClaims,
|
selectMyClaims,
|
||||||
(byOutpoint, myClaims) =>
|
(byOutpoint, myClaims) =>
|
||||||
Object.values(byOutpoint).filter(fileInfo => {
|
Object.values(byOutpoint).filter((fileInfo) => {
|
||||||
const myClaimIds = myClaims.map(claim => claim.claim_id);
|
const myClaimIds = myClaims.map((claim) => claim.claim_id);
|
||||||
|
|
||||||
return fileInfo && myClaimIds.indexOf(fileInfo.claim_id) === -1 && (fileInfo.completed || fileInfo.written_bytes);
|
return fileInfo && myClaimIds.indexOf(fileInfo.claim_id) === -1 && (fileInfo.completed || fileInfo.written_bytes);
|
||||||
})
|
})
|
||||||
|
@ -59,7 +59,7 @@ export const selectDownloadingFileInfos = createSelector(
|
||||||
const outpoints = Object.keys(downloadingByOutpoint);
|
const outpoints = Object.keys(downloadingByOutpoint);
|
||||||
const fileInfos = [];
|
const fileInfos = [];
|
||||||
|
|
||||||
outpoints.forEach(outpoint => {
|
outpoints.forEach((outpoint) => {
|
||||||
const fileInfo = fileInfosByOutpoint[outpoint];
|
const fileInfo = fileInfosByOutpoint[outpoint];
|
||||||
|
|
||||||
if (fileInfo) fileInfos.push(fileInfo);
|
if (fileInfo) fileInfos.push(fileInfo);
|
||||||
|
@ -69,10 +69,10 @@ export const selectDownloadingFileInfos = createSelector(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectTotalDownloadProgress = createSelector(selectDownloadingFileInfos, fileInfos => {
|
export const selectTotalDownloadProgress = createSelector(selectDownloadingFileInfos, (fileInfos) => {
|
||||||
const progress = [];
|
const progress = [];
|
||||||
|
|
||||||
fileInfos.forEach(fileInfo => {
|
fileInfos.forEach((fileInfo) => {
|
||||||
progress.push((fileInfo.written_bytes / fileInfo.total_bytes) * 100);
|
progress.push((fileInfo.written_bytes / fileInfo.total_bytes) * 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -82,4 +82,4 @@ export const selectTotalDownloadProgress = createSelector(selectDownloadingFileI
|
||||||
return -1;
|
return -1;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const selectFileInfoErrors = createSelector(selectState, state => state.errors || {});
|
export const selectFileInfoErrors = createSelector(selectState, (state) => state.errors || {});
|
||||||
|
|
|
@ -68,3 +68,4 @@
|
||||||
@import 'component/empty';
|
@import 'component/empty';
|
||||||
@import 'component/stripe-card';
|
@import 'component/stripe-card';
|
||||||
@import 'component/wallet-tip-send';
|
@import 'component/wallet-tip-send';
|
||||||
|
@import 'component/download-progress';
|
||||||
|
|
|
@ -720,6 +720,7 @@
|
||||||
margin: 0 0;
|
margin: 0 0;
|
||||||
padding: var(--spacing-xxs) var(--spacing-xxs);
|
padding: var(--spacing-xxs) var(--spacing-xxs);
|
||||||
height: unset;
|
height: unset;
|
||||||
|
background-color: var(--color-header-background);
|
||||||
|
|
||||||
// label (with 'Add' text) hidden by default
|
// label (with 'Add' text) hidden by default
|
||||||
.button__label {
|
.button__label {
|
||||||
|
|
224
ui/scss/component/_download-progress.scss
Normal file
224
ui/scss/component/_download-progress.scss
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
.download-progress__header {
|
||||||
|
padding: 15px;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-header-background); //var(--color-gray-9):dark-mode
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
// border: 1px solid var(--color-gray-3);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.download-progress__top-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
right: 15px;
|
||||||
|
font-size: 35px;
|
||||||
|
background-color: transparent;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
div {
|
||||||
|
height: 2px;
|
||||||
|
width: 13px;
|
||||||
|
background-color: var(--color-gray-4);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.download-progress__state-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.download-progress__state-filename {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-right: 10px;
|
||||||
|
span.button__label {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.download-progress__state-filename-link {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-right: 10px;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.download-progress__release-time {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.download-progress__state-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.download-progress__bar-container {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-gray-5);
|
||||||
|
height: 6px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
.download-progress__bar-content {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.download-progress__close-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.download-progress__playing-button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
width: 29.6px;
|
||||||
|
height: 29.6px;
|
||||||
|
}
|
||||||
|
.download-progress__count-time {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: -0.6px;
|
||||||
|
}
|
||||||
|
.download-progress__divider {
|
||||||
|
border-top: 1px solid var(--color-gray-6);
|
||||||
|
margin-left: -15px;
|
||||||
|
width: 110%;
|
||||||
|
}
|
||||||
|
.download-progress__cancel {
|
||||||
|
margin-top: 7px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.download-progress__cancel p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 285px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.download-progress__cancel b {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.download-progress__cancel-confirm {
|
||||||
|
width: 90px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.download-progress__cancel-ok {
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.download-progress__cancel-ok:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.download__container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 400px;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: 2px 2px 5px var(--color-gray-4);
|
||||||
|
background-color: var(--color-white);
|
||||||
|
transition: width 2s;
|
||||||
|
}
|
||||||
|
.download-progress__toggle-button {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
border: none;
|
||||||
|
background: var(--color-header-background);
|
||||||
|
color: var(--color-gray-6);
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: 0px 5px 4px var(--color-gray-4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.download_close_modal {
|
||||||
|
float: right;
|
||||||
|
margin-right: 10px;
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
.download-progress__current-downloading {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 25px;
|
||||||
|
right: 15px;
|
||||||
|
border: none;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
animation-name: downloadcount;
|
||||||
|
animation-duration: 1.3s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
|
||||||
|
.notification__bubble {
|
||||||
|
height: 1.5rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-editor-tag);
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5rem;
|
||||||
|
right: -0.5rem;
|
||||||
|
color: white;
|
||||||
|
font-size: var(--font-small);
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification__bubble--small {
|
||||||
|
font-size: var(--font-xsmall);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification__bubble--inline {
|
||||||
|
@extend .notification__bubble;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes downloadcount {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
|
@ -121,6 +121,11 @@
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu__link-disable {
|
||||||
|
@extend .menu__link;
|
||||||
|
color: var(--color-text-subtitle) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.menu__link--notification {
|
.menu__link--notification {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
18
ui/util/array.js
Normal file
18
ui/util/array.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
export function areEqual(first, second) {
|
||||||
|
if (first.length !== second.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < first.length; i++) {
|
||||||
|
if (!second.includes(first[i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeItem(array, item) {
|
||||||
|
const index = array.indexOf(item);
|
||||||
|
if (index > -1) {
|
||||||
|
array.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,14 +3,14 @@ const downloadSize = 1093957; // this must match with the image above
|
||||||
|
|
||||||
let startTime, endTime;
|
let startTime, endTime;
|
||||||
async function measureConnectionSpeed() {
|
async function measureConnectionSpeed() {
|
||||||
startTime = (new Date()).getTime();
|
startTime = new Date().getTime();
|
||||||
const cacheBuster = '?nnn=' + startTime;
|
const cacheBuster = '?nnn=' + startTime;
|
||||||
|
|
||||||
const download = new Image();
|
const download = new Image();
|
||||||
download.src = imageAddr + cacheBuster;
|
download.src = imageAddr + cacheBuster;
|
||||||
// this returns when the image is finished downloading
|
// this returns when the image is finished downloading
|
||||||
await download.decode();
|
await download.decode();
|
||||||
endTime = (new Date()).getTime();
|
endTime = new Date().getTime();
|
||||||
const duration = (endTime - startTime) / 1000;
|
const duration = (endTime - startTime) / 1000;
|
||||||
const bitsLoaded = downloadSize * 8;
|
const bitsLoaded = downloadSize * 8;
|
||||||
const speedBps = (bitsLoaded / duration).toFixed(2);
|
const speedBps = (bitsLoaded / duration).toFixed(2);
|
||||||
|
|
|
@ -69,3 +69,4 @@
|
||||||
@import '../../ui/scss/component/empty';
|
@import '../../ui/scss/component/empty';
|
||||||
@import '../../ui/scss/component/stripe-card';
|
@import '../../ui/scss/component/stripe-card';
|
||||||
@import '../../ui/scss/component/wallet-tip-send';
|
@import '../../ui/scss/component/wallet-tip-send';
|
||||||
|
@import '../../ui/scss/component/download-progress';
|
||||||
|
|
Loading…
Reference in a new issue