diff --git a/.flowconfig b/.flowconfig index 2c6b8839f..0ae16f24e 100644 --- a/.flowconfig +++ b/.flowconfig @@ -43,6 +43,8 @@ module.name_mapper='^web\/page\(.*\)$' -> '/web/page\1' module.name_mapper='^homepage\(.*\)$' -> '/ui/util/homepage\1' module.name_mapper='^scss\/component\(.*\)$' -> '/ui/scss/component/\1' +esproposal.optional_chaining=enable + ; Extensions module.file_ext=.js module.file_ext=.jsx @@ -51,4 +53,5 @@ module.file_ext=.css module.file_ext=.scss + [strict] diff --git a/flow-typed/File.js b/flow-typed/File.js new file mode 100644 index 000000000..44dc4f398 --- /dev/null +++ b/flow-typed/File.js @@ -0,0 +1,78 @@ +// @flow + +declare type FileListItem = { + metadata: StreamMetadata, + added_on: number, + blobs_completed: number, + blobs_in_stream: number, + blobs_remaining: number, + channel_claim_id: string, + channel_name: string, + claim_id: string, + claim_name: string, + completed: false, + content_fee?: { txid: string }, + purchase_receipt?: { txid: string, amount: string }, + download_directory: string, + download_path: string, + file_name: string, + key: string, + mime_type: string, + nout: number, + outpoint: string, + points_paid: number, + protobuf: string, + reflector_progress: number, + sd_hash: string, + status: string, + stopped: false, + stream_hash: string, + stream_name: string, + streaming_url: string, + suggested_file_name: string, + total_bytes: number, + total_bytes_lower_bound: number, + is_fully_reflected: boolean, + // TODO: sdk plans to change `tx` + // It isn't currently used by the apps + tx: {}, + txid: string, + uploading_to_reflector: boolean, + written_bytes: number, +}; + +declare type FileState = { + failedPurchaseUris: Array, + purchasedUris: Array, +}; + +declare type PurchaseUriCompleted = { + type: ACTIONS.PURCHASE_URI_COMPLETED, + data: { + uri: string, + streamingUrl: string, + }, +}; + +declare type PurchaseUriFailed = { + type: ACTIONS.PURCHASE_URI_FAILED, + data: { + uri: string, + error: any, + }, +}; + +declare type PurchaseUriStarted = { + type: ACTIONS.PURCHASE_URI_STARTED, + data: { + uri: string, + streamingUrl: string, + }, +}; + +declare type DeletePurchasedUri = { + type: ACTIONS.CLEAR_PURCHASED_URI_SUCCESS, + data: { + uri: string, + }, +}; diff --git a/package.json b/package.json index f1545afa3..6826b3296 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@babel/plugin-proposal-class-properties": "^7.0.0", "@babel/plugin-proposal-decorators": "^7.3.0", "@babel/plugin-proposal-object-rest-spread": "^7.6.2", + "@babel/plugin-proposal-optional-chaining": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.2.0", "@babel/plugin-transform-flow-strip-types": "^7.2.3", "@babel/plugin-transform-runtime": "^7.4.3", diff --git a/static/app-strings.json b/static/app-strings.json index 3584411fa..fb8abb180 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -1638,6 +1638,7 @@ "This link leads to an external website.": "This link leads to an external website.", "No Content Found": "No Content Found", "No Lists Found": "No Lists Found", + "No matching playlists": "No matching playlists", "You have no lists! Create one from any playable content.": "You have no lists! Create one from any playable content.", "Pick": "Pick", "You have unpublished lists! %pick% one and publish it!": "You have unpublished lists! %pick% one and publish it!", @@ -2058,6 +2059,8 @@ "MyAwesomeList": "MyAwesomeList", "My Awesome List": "My Awesome List", "This list has no items.": "This list has no items.", + "1 item": "1 item", + "%collectionCount% items": "%collectionCount% items", "Select File": "Select File", "File Selected": "File Selected", "Url": "Url", @@ -2190,5 +2193,6 @@ "Creator": "Creator", "From comments": "From comments", "From search": "From search", + "Manage tags": "Manage tags", "--end--": "--end--" } diff --git a/ui/analytics.js b/ui/analytics.js index 495535dcc..feae9a64a 100644 --- a/ui/analytics.js +++ b/ui/analytics.js @@ -10,6 +10,15 @@ import ElectronCookies from '@exponent/electron-cookies'; import { generateInitialUrl } from 'util/url'; // @endif import { MATOMO_ID, MATOMO_URL } from 'config'; +// import getConnectionSpeed from 'util/detect-user-bandwidth'; + +// let userDownloadBandwidthInBitsPerSecond; +// async function getUserBandwidth() { +// userDownloadBandwidthInBitsPerSecond = await getConnectionSpeed(); +// } + +// get user bandwidth every minute, starting after an initial one minute wait +// setInterval(getUserBandwidth, 1000 * 60); const isProduction = process.env.NODE_ENV === 'production'; const devInternalApis = process.env.LBRY_API_URL && process.env.LBRY_API_URL.includes('dev'); @@ -40,7 +49,7 @@ type Analytics = { tagFollowEvent: (string, boolean, ?string) => void, playerLoadedEvent: (?boolean) => void, playerStartedEvent: (?boolean) => void, - videoStartEvent: (string, number, string, number, string, any) => void, + videoStartEvent: (string, number, string, number, string, any, number) => void, videoIsPlaying: (boolean, any) => void, videoBufferEvent: ( StreamClaim, @@ -111,7 +120,7 @@ function getDeviceType() { // variables initialized for watchman let amountOfBufferEvents = 0; let amountOfBufferTimeInMS = 0; -let videoType, userId, claimUrl, playerPoweredBy, videoPlayer; +let videoType, userId, claimUrl, playerPoweredBy, videoPlayer, bitrateAsBitsPerSecond; let lastSentTime; // calculate data for backend, send them, and reset buffer data for next interval @@ -130,6 +139,9 @@ async function sendAndResetWatchmanData() { let protocol; if (videoType === 'application/x-mpegURL') { protocol = 'hls'; + // get bandwidth if it exists from the texttrack (so it's accurate if user changes quality) + // $FlowFixMe + bitrateAsBitsPerSecond = videoPlayer.textTracks?.().tracks_[0]?.activeCues[0]?.value?.bandwidth; } else { protocol = 'stb'; } @@ -152,6 +164,9 @@ async function sendAndResetWatchmanData() { user_id: userId.toString(), position: Math.round(positionInVideo), rel_position: Math.round((positionInVideo / (totalDurationInSeconds * 1000)) * 100), + bitrate: bitrateAsBitsPerSecond, + bandwidth: undefined, + // ...(userDownloadBandwidthInBitsPerSecond && {bandwidth: userDownloadBandwidthInBitsPerSecond}), // add bandwidth if populated }; // post to watchman @@ -202,7 +217,7 @@ async function sendWatchmanData(body) { } const analytics: Analytics = { - // receive buffer events from tracking plugin and jklj + // receive buffer events from tracking plugin and save buffer amounts and times for backend call videoBufferEvent: async (claim, data) => { amountOfBufferEvents = amountOfBufferEvents + 1; amountOfBufferTimeInMS = amountOfBufferTimeInMS + data.bufferDuration; @@ -240,7 +255,7 @@ const analytics: Analytics = { startWatchmanIntervalIfNotRunning(); } }, - videoStartEvent: (claimId, duration, poweredBy, passedUserId, canonicalUrl, passedPlayer) => { + videoStartEvent: (claimId, duration, poweredBy, passedUserId, canonicalUrl, passedPlayer, videoBitrate) => { // populate values for watchman when video starts userId = passedUserId; claimUrl = canonicalUrl; @@ -248,6 +263,7 @@ const analytics: Analytics = { videoType = passedPlayer.currentSource().type; videoPlayer = passedPlayer; + bitrateAsBitsPerSecond = videoBitrate; sendPromMetric('time_to_start', duration); sendMatomoEvent('Media', 'TimeToStart', claimId, duration); diff --git a/ui/component/claimPreview/index.js b/ui/component/claimPreview/index.js index 832004474..2dc96541d 100644 --- a/ui/component/claimPreview/index.js +++ b/ui/component/claimPreview/index.js @@ -6,7 +6,6 @@ import { makeSelectClaimIsMine, makeSelectClaimIsPending, makeSelectClaimIsNsfw, - doFileGet, makeSelectReflectingClaimForUri, makeSelectClaimWasPurchased, makeSelectStreamingUrlForUri, @@ -25,7 +24,7 @@ import { selectShowMatureContent } from 'redux/selectors/settings'; import { makeSelectHasVisitedUri } from 'redux/selectors/content'; import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions'; import { selectModerationBlockList } from 'redux/selectors/comments'; - +import { doFileGet } from 'redux/actions/file'; import ClaimPreview from './view'; import formatMediaDuration from 'util/formatMediaDuration'; diff --git a/ui/component/claimPreviewTile/index.js b/ui/component/claimPreviewTile/index.js index 40b3355c0..59d506718 100644 --- a/ui/component/claimPreviewTile/index.js +++ b/ui/component/claimPreviewTile/index.js @@ -5,7 +5,6 @@ import { makeSelectIsUriResolving, makeSelectThumbnailForUri, makeSelectTitleForUri, - doFileGet, makeSelectChannelForClaimUri, makeSelectClaimIsNsfw, makeSelectClaimIsStreamPlaceholder, @@ -14,6 +13,7 @@ import { import { selectMutedChannels } from 'redux/selectors/blocked'; import { makeSelectViewCountForUri, selectBlackListedOutpoints, selectFilteredOutpoints } from 'lbryinc'; import { makeSelectIsActiveLivestream } from 'redux/selectors/livestream'; +import { doFileGet } from 'redux/actions/file'; import { selectShowMatureContent } from 'redux/selectors/settings'; import ClaimPreviewTile from './view'; import formatMediaDuration from 'util/formatMediaDuration'; diff --git a/ui/component/collectionContentSidebar/view.jsx b/ui/component/collectionContentSidebar/view.jsx index 0e3573442..786c233c2 100644 --- a/ui/component/collectionContentSidebar/view.jsx +++ b/ui/component/collectionContentSidebar/view.jsx @@ -65,7 +65,7 @@ export default function CollectionContent(props: Props) { titleActions={
{/* TODO: BUTTON TO SAVE COLLECTION - Probably save/copy modal */} -
} body={ diff --git a/ui/component/collectionsListMine/view.jsx b/ui/component/collectionsListMine/view.jsx index c57273b38..3cbffa9b1 100644 --- a/ui/component/collectionsListMine/view.jsx +++ b/ui/component/collectionsListMine/view.jsx @@ -175,7 +175,7 @@ export default function CollectionsListMine(props: Props) { {filteredCollections && filteredCollections.length > 0 && filteredCollections.map((key) => )} - {!filteredCollections.length &&
{__('No matching collections')}
} + {!filteredCollections.length &&
{__('No matching playlists')}
} )} diff --git a/ui/component/sideNavigation/index.js b/ui/component/sideNavigation/index.js index 6eda01389..40a13ccc8 100644 --- a/ui/component/sideNavigation/index.js +++ b/ui/component/sideNavigation/index.js @@ -1,10 +1,12 @@ import { connect } from 'react-redux'; import { selectSubscriptions } from 'redux/selectors/subscriptions'; -import { selectPurchaseUriSuccess, doClearPurchasedUriSuccess } from 'lbry-redux'; +import { selectPurchaseUriSuccess } from 'lbry-redux'; import { selectFollowedTags } from 'redux/selectors/tags'; import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user'; import { selectHomepageData, selectLanguage } from 'redux/selectors/settings'; import { doSignOut } from 'redux/actions/app'; +import { doClearPurchasedUriSuccess } from 'redux/actions/file'; + import { selectUnseenNotificationCount } from 'redux/selectors/notifications'; import SideNavigation from './view'; diff --git a/ui/component/viewers/videoViewer/view.jsx b/ui/component/viewers/videoViewer/view.jsx index efe534dd1..5619065f9 100644 --- a/ui/component/viewers/videoViewer/view.jsx +++ b/ui/component/viewers/videoViewer/view.jsx @@ -168,9 +168,17 @@ function VideoViewer(props: Props) { } analytics.playerStartedEvent(embedded); + // convert bytes to bits, and then divide by seconds + const contentInBits = Number(claim.value.source.size) * 8; + const durationInSeconds = claim.value.video && claim.value.video.duration; + let bitrateAsBitsPerSecond; + if (durationInSeconds) { + bitrateAsBitsPerSecond = Math.round(contentInBits / durationInSeconds); + } + fetch(source, { method: 'HEAD', cache: 'no-store' }).then((response) => { let playerPoweredBy = response.headers.get('x-powered-by') || ''; - analytics.videoStartEvent(claimId, timeToStart, playerPoweredBy, userId, claim.canonical_url, this); + analytics.videoStartEvent(claimId, timeToStart, playerPoweredBy, userId, claim.canonical_url, this, bitrateAsBitsPerSecond); }); doAnalyticsView(uri, timeToStart).then(() => { diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index 7345e773f..4460028ed 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -120,6 +120,13 @@ export const PLAY_VIDEO_STARTED = 'PLAY_VIDEO_STARTED'; export const FETCH_AVAILABILITY_STARTED = 'FETCH_AVAILABILITY_STARTED'; export const FETCH_AVAILABILITY_COMPLETED = 'FETCH_AVAILABILITY_COMPLETED'; export const FILE_DELETE = 'FILE_DELETE'; +export const FETCH_FILE_INFO_FAILED = 'FETCH_FILE_INFO_FAILED'; +export const DOWNLOADING_CANCELED = 'DOWNLOADING_CANCELED'; +export const SET_FILE_LIST_SORT = 'SET_FILE_LIST_SORT'; +export const PURCHASE_URI_STARTED = 'PURCHASE_URI_STARTED'; +export const PURCHASE_URI_COMPLETED = 'PURCHASE_URI_COMPLETED'; +export const PURCHASE_URI_FAILED = 'PURCHASE_URI_FAILED'; +export const CLEAR_PURCHASED_URI_SUCCESS = 'CLEAR_PURCHASED_URI_SUCCESS'; // Search export const SEARCH_START = 'SEARCH_START'; diff --git a/ui/page/collection/view.jsx b/ui/page/collection/view.jsx index 282233663..4befdc9d6 100644 --- a/ui/page/collection/view.jsx +++ b/ui/page/collection/view.jsx @@ -102,7 +102,9 @@ export default function CollectionPage(props: Props) { const subTitle = (
- {collectionCount} items + + {collectionCount === 1 ? __('1 item') : __('%collectionCount% items', { collectionCount })} + {uri && }
); diff --git a/ui/page/settingsNotifications/view.jsx b/ui/page/settingsNotifications/view.jsx index 6d5053034..f086a9682 100644 --- a/ui/page/settingsNotifications/view.jsx +++ b/ui/page/settingsNotifications/view.jsx @@ -32,28 +32,30 @@ export default function NotificationSettingsPage(props: Props) { const lbryIoParams = verificationToken ? { auth_token: verificationToken } : undefined; React.useEffect(() => { - Lbryio.call('tag', 'list', lbryIoParams) - .then(setTags) - .catch((e) => { - setError(true); - }); + if (isAuthenticated) { + Lbryio.call('tag', 'list', lbryIoParams) + .then(setTags) + .catch((e) => { + setError(true); + }); - Lbryio.call('user_email', 'status', lbryIoParams) - .then((res) => { - const enabledEmails = - res.emails && - Object.keys(res.emails).reduce((acc, email) => { - const isEnabled = res.emails[email]; - return [...acc, { email, isEnabled }]; - }, []); + Lbryio.call('user_email', 'status', lbryIoParams) + .then((res) => { + const enabledEmails = + res.emails && + Object.keys(res.emails).reduce((acc, email) => { + const isEnabled = res.emails[email]; + return [...acc, { email, isEnabled }]; + }, []); - setTagMap(res.tags); - setEnabledEmails(enabledEmails); - }) - .catch((e) => { - setError(true); - }); - }, []); + setTagMap(res.tags); + setEnabledEmails(enabledEmails); + }) + .catch((e) => { + setError(true); + }); + } + }, [isAuthenticated]); function handleChangeTag(name, newIsEnabled) { const tagParams = newIsEnabled ? { add: name } : { remove: name }; diff --git a/ui/redux/actions/app.js b/ui/redux/actions/app.js index 67a00c4a0..cb0b81332 100644 --- a/ui/redux/actions/app.js +++ b/ui/redux/actions/app.js @@ -505,6 +505,7 @@ export function doAnalyticsBuffer(uri, bufferData) { const fileSizeInBits = fileSize * 8; const bitRate = parseInt(fileSizeInBits / fileDurationInSeconds); const userId = user && user.id.toString(); + // if there's a logged in user, send buffer event data to watchman if (userId) { analytics.videoBufferEvent(claim, { timeAtBuffer, diff --git a/ui/redux/actions/content.js b/ui/redux/actions/content.js index f22730e86..f19109360 100644 --- a/ui/redux/actions/content.js +++ b/ui/redux/actions/content.js @@ -11,7 +11,6 @@ import { SETTINGS, makeSelectFileInfoForUri, selectFileInfosByOutpoint, - doPurchaseUri, makeSelectUriIsStreamable, selectDownloadingByOutpoint, makeSelectClaimForUri, @@ -20,6 +19,7 @@ import { doToast, makeSelectUrlsForCollectionId, } from 'lbry-redux'; +import { doPurchaseUri } from 'redux/actions/file'; import { makeSelectCostInfoForUri, Lbryio } from 'lbryinc'; import { makeSelectClientSetting, selectosNotificationsEnabled, selectDaemonSettings } from 'redux/selectors/settings'; @@ -137,8 +137,8 @@ export function doSetPlayingUri({ uri: ?string, source?: string, commentId?: string, - pathname: string, - collectionId: string, + pathname?: string, + collectionId?: string, }) { return (dispatch: Dispatch) => { dispatch({ @@ -160,7 +160,7 @@ export function doPurchaseUriWrapper(uri: string, cost: number, saveFile: boolea } } - dispatch(doPurchaseUri(uri, { costInfo: cost }, saveFile, onSuccess)); + dispatch(doPurchaseUri(uri, { cost }, saveFile, onSuccess)); }; } diff --git a/ui/redux/actions/file.js b/ui/redux/actions/file.js index 447fd4f20..73adf0bd4 100644 --- a/ui/redux/actions/file.js +++ b/ui/redux/actions/file.js @@ -1,3 +1,4 @@ +// @flow import * as ACTIONS from 'constants/action_types'; // @if TARGET='app' import { shell } from 'electron'; @@ -7,22 +8,28 @@ import { batchActions, doAbandonClaim, makeSelectFileInfoForUri, + selectDownloadingByOutpoint, + makeSelectStreamingUrlForUri, makeSelectClaimForUri, + selectBalance, ABANDON_STATES, } from 'lbry-redux'; import { doHideModal } from 'redux/actions/app'; import { goBack } from 'connected-react-router'; import { doSetPlayingUri } from 'redux/actions/content'; import { selectPlayingUri } from 'redux/selectors/content'; +import { doToast } from 'redux/actions/notifications'; +type Dispatch = (action: any) => any; +type GetState = () => { file: FileState }; -export function doOpenFileInFolder(path) { +export function doOpenFileInFolder(path: string) { return () => { shell.showItemInFolder(path); }; } -export function doOpenFileInShell(path) { - return (dispatch) => { +export function doOpenFileInShell(path: string) { + return (dispatch: Dispatch) => { const success = shell.openPath(path); if (!success) { dispatch(doOpenFileInFolder(path)); @@ -30,8 +37,8 @@ export function doOpenFileInShell(path) { }; } -export function doDeleteFile(outpoint, deleteFromComputer, abandonClaim, cb) { - return (dispatch) => { +export function doDeleteFile(outpoint: string, deleteFromComputer?: boolean, abandonClaim?: boolean, cb: any) { + return (dispatch: Dispatch) => { if (abandonClaim) { const [txid, nout] = outpoint.split(':'); dispatch(doAbandonClaim(txid, Number(nout), cb)); @@ -53,8 +60,13 @@ export function doDeleteFile(outpoint, deleteFromComputer, abandonClaim, cb) { }; } -export function doDeleteFileAndMaybeGoBack(uri, deleteFromComputer, abandonClaim, doGoBack) { - return (dispatch, getState) => { +export function doDeleteFileAndMaybeGoBack( + uri: string, + deleteFromComputer?: boolean, + abandonClaim?: boolean, + doGoBack: (any) => void +) { + return (dispatch: Dispatch, getState: GetState) => { const state = getState(); const playingUri = selectPlayingUri(state); const { outpoint } = makeSelectFileInfoForUri(uri)(state) || ''; @@ -88,3 +100,121 @@ export function doDeleteFileAndMaybeGoBack(uri, deleteFromComputer, abandonClaim dispatch(batchActions(...actions)); }; } + +export function doFileGet(uri: string, saveFile: boolean = true, onSuccess?: (GetResponse) => any) { + return (dispatch: Dispatch, getState: () => any) => { + const state = getState(); + const { nout, txid } = makeSelectClaimForUri(uri)(state); + const outpoint = `${txid}:${nout}`; + + dispatch({ + type: ACTIONS.FETCH_FILE_INFO_STARTED, + data: { + outpoint, + }, + }); + + // set save_file argument to True to save the file (old behaviour) + Lbry.get({ uri, save_file: saveFile }) + .then((streamInfo: GetResponse) => { + const timeout = streamInfo === null || typeof streamInfo !== 'object' || streamInfo.error === 'Timeout'; + if (timeout) { + dispatch({ + type: ACTIONS.FETCH_FILE_INFO_FAILED, + data: { outpoint }, + }); + + dispatch(doToast({ message: `File timeout for uri ${uri}`, isError: true })); + } else { + if (streamInfo.purchase_receipt || streamInfo.content_fee) { + dispatch({ + type: ACTIONS.PURCHASE_URI_COMPLETED, + data: { uri, purchaseReceipt: streamInfo.purchase_receipt || streamInfo.content_fee }, + }); + } + dispatch({ + type: ACTIONS.FETCH_FILE_INFO_COMPLETED, + data: { + fileInfo: streamInfo, + outpoint: outpoint, + }, + }); + + if (onSuccess) { + onSuccess(streamInfo); + } + } + }) + .catch((error) => { + dispatch({ + type: ACTIONS.PURCHASE_URI_FAILED, + data: { uri, error }, + }); + + dispatch({ + type: ACTIONS.FETCH_FILE_INFO_FAILED, + data: { outpoint }, + }); + + dispatch( + doToast({ + message: `Failed to view ${uri}, please try again. If this problem persists, visit https://lbry.com/faq/support for support.`, + isError: true, + }) + ); + }); + }; +} + +export function doPurchaseUri( + uri: string, + costInfo: { cost: number }, + saveFile: boolean = true, + onSuccess?: (GetResponse) => any +) { + return (dispatch: Dispatch, getState: GetState) => { + dispatch({ + type: ACTIONS.PURCHASE_URI_STARTED, + data: { uri }, + }); + + const state = getState(); + const balance = selectBalance(state); + const fileInfo = makeSelectFileInfoForUri(uri)(state); + const downloadingByOutpoint = selectDownloadingByOutpoint(state); + const alreadyDownloading = fileInfo && !!downloadingByOutpoint[fileInfo.outpoint]; + const alreadyStreaming = makeSelectStreamingUrlForUri(uri)(state); + + if (!saveFile && (alreadyDownloading || alreadyStreaming)) { + dispatch({ + type: ACTIONS.PURCHASE_URI_FAILED, + data: { uri, error: `Already fetching uri: ${uri}` }, + }); + + if (onSuccess) { + onSuccess(fileInfo); + } + + return; + } + + const { cost } = costInfo; + if (parseFloat(cost) > balance) { + dispatch({ + type: ACTIONS.PURCHASE_URI_FAILED, + data: { uri, error: 'Insufficient credits' }, + }); + + Promise.resolve(); + return; + } + + dispatch(doFileGet(uri, saveFile, onSuccess)); + }; +} + +export function doClearPurchasedUriSuccess() { + return { + type: ACTIONS.CLEAR_PURCHASED_URI_SUCCESS, + }; +} diff --git a/ui/util/detect-user-bandwidth.js b/ui/util/detect-user-bandwidth.js new file mode 100644 index 000000000..fd3f0d187 --- /dev/null +++ b/ui/util/detect-user-bandwidth.js @@ -0,0 +1,20 @@ +const imageAddr = 'https://upload.wikimedia.org/wikipedia/commons/b/b9/Pizigani_1367_Chart_1MB.jpg'; +const downloadSize = 1093957; // this must match with the image above + +let startTime, endTime; +async function measureConnectionSpeed() { + startTime = (new Date()).getTime(); + const cacheBuster = '?nnn=' + startTime; + + const download = new Image(); + download.src = imageAddr + cacheBuster; + // this returns when the image is finished downloading + await download.decode(); + endTime = (new Date()).getTime(); + const duration = (endTime - startTime) / 1000; + const bitsLoaded = downloadSize * 8; + const speedBps = (bitsLoaded / duration).toFixed(2); + return Math.round(Number(speedBps)); +} + +module.exports = measureConnectionSpeed; diff --git a/web/webpack.config.js b/web/webpack.config.js index f483c6217..120747802 100644 --- a/web/webpack.config.js +++ b/web/webpack.config.js @@ -149,7 +149,7 @@ const webConfig = { test: /\.jsx?$/, options: { presets: ['@babel/env', '@babel/react', '@babel/flow'], - plugins: ['@babel/plugin-proposal-object-rest-spread', '@babel/plugin-proposal-class-properties'], + plugins: ['@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-object-rest-spread', '@babel/plugin-proposal-class-properties'], }, }, { diff --git a/yarn.lock b/yarn.lock index 96aee291f..22e0a95f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -329,6 +329,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== +"@babel/helper-plugin-utils@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9" + integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ== + "@babel/helper-remap-async-to-generator@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.12.1.tgz#8c4dbbf916314f6047dc05e6a2217074238347fd" @@ -390,6 +395,13 @@ dependencies: "@babel/types" "^7.12.1" +"@babel/helper-skip-transparent-expression-wrappers@^7.14.5": + version "7.15.4" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.15.4.tgz#707dbdba1f4ad0fa34f9114fc8197aec7d5da2eb" + integrity sha512-BMRLsdh+D1/aap19TycS4eD1qELGrCBJwzaY9IE8LrpJtJb+H7rQkPIdsfgnMtLBA6DJls7X9z93Z4U8h7xw0A== + dependencies: + "@babel/types" "^7.15.4" + "@babel/helper-split-export-declaration@^7.10.1": version "7.10.1" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz#c6f4be1cbc15e3a868e4c64a17d5d31d754da35f" @@ -426,6 +438,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== +"@babel/helper-validator-identifier@^7.14.9": + version "7.15.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz#220df993bfe904a4a6b02ab4f3385a5ebf6e2389" + integrity sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w== + "@babel/helper-validator-option@^7.12.1", "@babel/helper-validator-option@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.11.tgz#d66cb8b7a3e7fe4c6962b32020a131ecf0847f4f" @@ -590,6 +607,15 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1" "@babel/plugin-syntax-optional-chaining" "^7.8.0" +"@babel/plugin-proposal-optional-chaining@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.14.5.tgz#fa83651e60a360e3f13797eef00b8d519695b603" + integrity sha512-ycz+VOzo2UbWNI1rQXxIuMOzrDdHGrI23fRiz/Si2R4kv2XZQ1BK8ccdHwehMKBlcH/joGW/tzrUmo67gbJHlQ== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-proposal-private-methods@^7.12.1": version "7.12.1" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.12.1.tgz#86814f6e7a21374c980c10d38b4493e703f4a389" @@ -694,7 +720,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-optional-chaining@^7.8.0": +"@babel/plugin-syntax-optional-chaining@^7.8.0", "@babel/plugin-syntax-optional-chaining@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== @@ -1230,6 +1256,14 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" +"@babel/types@^7.15.4": + version "7.15.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.6.tgz#99abdc48218b2881c058dd0a7ab05b99c9be758f" + integrity sha512-BPU+7QhqNjmWyDO0/vitH/CuhpV8ZmK1wpKva8nuyNF5MJfuRNWMc+hc14+u9xT93kvykMdncrJT19h74uB1Ig== + dependencies: + "@babel/helper-validator-identifier" "^7.14.9" + to-fast-properties "^2.0.0" + "@datapunt/matomo-tracker-js@^0.1.4": version "0.1.4" resolved "https://registry.yarnpkg.com/@datapunt/matomo-tracker-js/-/matomo-tracker-js-0.1.4.tgz#1226f0964d2c062bf9392e9c2fd89838262b10df" @@ -16391,9 +16425,9 @@ url-parse-lax@^3.0.0: prepend-http "^2.0.0" url-parse@^1.1.1, url-parse@^1.1.8, url-parse@^1.4.3: - version "1.5.1" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.1.tgz#d5fa9890af8a5e1f274a2c98376510f6425f6e3b" - integrity sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q== + version "1.5.3" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862" + integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ== dependencies: querystringify "^2.1.1" requires-port "^1.0.0"