diff --git a/CHANGELOG.md b/CHANGELOG.md index 40af3e98d..dad9cdab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ Web UI version numbers should always match the corresponding version of LBRY App ## [Unreleased] ### Added * Save app state when closing to tray ([#968](https://github.com/lbryio/lbry-app/issues/968)) + * Added ability to export wallet transactions to JSON and CSV format ([#976](https://github.com/lbryio/lbry-app/pull/976)) * Add Rewards FAQ to LBRY app ([#1041](https://github.com/lbryio/lbry-app/pull/1041)) + * ### Changed * diff --git a/src/renderer/analytics.js b/src/renderer/analytics.js index 7d426e4f9..c0fd82f13 100644 --- a/src/renderer/analytics.js +++ b/src/renderer/analytics.js @@ -5,16 +5,16 @@ mixpanel.init('691723e855cabb9d27a7a79002216967'); type Analytics = { track: (string, ?Object) => void, - setUser: (Object) => void, - toggle: (boolean, ?boolean) => void -} + setUser: Object => void, + toggle: (boolean, ?boolean) => void, +}; let analyticsEnabled: boolean = false; const analytics: Analytics = { track: (name: string, payload: ?Object): void => { - if(analyticsEnabled) { - if(payload) { + if (analyticsEnabled) { + if (payload) { mixpanel.track(name, payload); } else { mixpanel.track(name); @@ -22,21 +22,21 @@ const analytics: Analytics = { } }, setUser: (user: Object): void => { - if(user.id) { + if (user.id) { mixpanel.identify(user.id); } - if(user.primary_email) { + if (user.primary_email) { mixpanel.people.set({ - "$email": user.primary_email + $email: user.primary_email, }); } }, toggle: (enabled: boolean, logDisabled: ?boolean): void => { - if(!enabled && logDisabled) { + if (!enabled && logDisabled) { mixpanel.track('DISABLED'); } analyticsEnabled = enabled; - } -} + }, +}; export default analytics; diff --git a/src/renderer/component/file-exporter.js b/src/renderer/component/file-exporter.js new file mode 100644 index 000000000..703d565ab --- /dev/null +++ b/src/renderer/component/file-exporter.js @@ -0,0 +1,67 @@ +import fs from 'fs'; +import path from 'path'; +import React from 'react'; +import PropTypes from 'prop-types'; +import Link from 'component/link'; +import parseData from 'util/parseData'; +import * as icons from 'constants/icons'; +const { remote } = require('electron'); + +class FileExporter extends React.PureComponent { + static propTypes = { + data: PropTypes.array, + title: PropTypes.string, + label: PropTypes.string, + defaultPath: PropTypes.string, + onFileCreated: PropTypes.func, + }; + + constructor(props) { + super(props); + } + + handleFileCreation(filename, data) { + const { onFileCreated } = this.props; + fs.writeFile(filename, data, err => { + if (err) throw err; + // Do something after creation + onFileCreated && onFileCreated(filename); + }); + } + + handleButtonClick() { + const { title, defaultPath, data } = this.props; + + const options = { + title, + defaultPath, + filters: [{ name: 'JSON', extensions: ['json'] }, { name: 'CSV', extensions: ['csv'] }], + }; + + remote.dialog.showSaveDialog(options, filename => { + // User hit cancel so do nothing: + if (!filename) return; + // Get extension and remove initial dot + const format = path.extname(filename).replace(/\./g, ''); + // Parse data to string with the chosen format + const parsed = parseData(data, format); + // Write file + parsed && this.handleFileCreation(filename, parsed); + }); + } + + render() { + const { title, label } = this.props; + return ( + this.handleButtonClick()} + /> + ); + } +} + +export default FileExporter; diff --git a/src/renderer/component/transactionList/view.jsx b/src/renderer/component/transactionList/view.jsx index 0d5de3597..df1be4813 100644 --- a/src/renderer/component/transactionList/view.jsx +++ b/src/renderer/component/transactionList/view.jsx @@ -2,6 +2,7 @@ import React from 'react'; import TransactionListItem from './internal/TransactionListItem'; import FormField from 'component/formField'; import Link from 'component/link'; +import FileExporter from 'component/file-exporter.js'; import * as icons from 'constants/icons'; import * as modals from 'constants/modal_types'; @@ -43,6 +44,13 @@ class TransactionList extends React.PureComponent { return (
+ {Boolean(transactionList.length) && ( + + )} {(transactionList.length || this.state.filter) && ( {__('Filter')}{' '} diff --git a/src/renderer/constants/icons.js b/src/renderer/constants/icons.js index 728a8f196..87b9942ce 100644 --- a/src/renderer/constants/icons.js +++ b/src/renderer/constants/icons.js @@ -3,3 +3,4 @@ export const LOCAL = 'folder'; export const FILE = 'file'; export const HISTORY = 'history'; export const HELP_CIRCLE = 'question-circle'; +export const DOWNLOAD = 'download'; diff --git a/src/renderer/constants/modal_types.js b/src/renderer/constants/modal_types.js index dd1fb744c..a0272b708 100644 --- a/src/renderer/constants/modal_types.js +++ b/src/renderer/constants/modal_types.js @@ -2,7 +2,7 @@ export const CONFIRM_FILE_REMOVE = 'confirmFileRemove'; export const INCOMPATIBLE_DAEMON = 'incompatibleDaemon'; export const FILE_TIMEOUT = 'file_timeout'; export const DOWNLOADING = 'downloading'; -export const AUTO_UPDATE_DOWNLOADED = "auto_update_downloaded"; +export const AUTO_UPDATE_DOWNLOADED = 'auto_update_downloaded'; export const AUTO_UPDATE_CONFIRM = 'auto_update_confirm'; export const ERROR = 'error'; export const INSUFFICIENT_CREDITS = 'insufficient_credits'; diff --git a/src/renderer/index.js b/src/renderer/index.js index 3812c3c91..822f0b153 100644 --- a/src/renderer/index.js +++ b/src/renderer/index.js @@ -8,7 +8,12 @@ import lbry from 'lbry'; import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import { doConditionalAuthNavigate, doDaemonReady, doShowSnackBar, doAutoUpdate } from 'redux/actions/app'; +import { + doConditionalAuthNavigate, + doDaemonReady, + doShowSnackBar, + doAutoUpdate, +} from 'redux/actions/app'; import { doUpdateIsNightAsync } from 'redux/actions/settings'; import { doNavigate } from 'redux/actions/navigation'; import { doDownloadLanguages } from 'redux/actions/settings'; @@ -20,7 +25,7 @@ import analytics from './analytics'; const { autoUpdater } = remote.require('electron-updater'); -autoUpdater.logger = remote.require("electron-log"); +autoUpdater.logger = remote.require('electron-log'); window.addEventListener('contextmenu', event => { contextMenu(remote.getCurrentWindow(), event.x, event.y, app.env === 'development'); @@ -97,19 +102,19 @@ document.addEventListener('click', event => { }); const init = () => { - autoUpdater.on("update-downloaded", () => { + autoUpdater.on('update-downloaded', () => { app.store.dispatch(doAutoUpdate()); }); - if (["win32", "darwin"].includes(process.platform)) { - autoUpdater.on("update-available", () => { - console.log("Update available"); + if (['win32', 'darwin'].includes(process.platform)) { + autoUpdater.on('update-available', () => { + console.log('Update available'); }); - autoUpdater.on("update-not-available", () => { - console.log("Update not available"); + autoUpdater.on('update-not-available', () => { + console.log('Update not available'); }); - autoUpdater.on("update-downloaded", () => { - console.log("Update downloaded"); + autoUpdater.on('update-downloaded', () => { + console.log('Update downloaded'); app.store.dispatch(doAutoUpdate()); }); } diff --git a/src/renderer/modal/modalAutoUpdateConfirm/index.js b/src/renderer/modal/modalAutoUpdateConfirm/index.js index a00df7e2f..7ce7ae132 100644 --- a/src/renderer/modal/modalAutoUpdateConfirm/index.js +++ b/src/renderer/modal/modalAutoUpdateConfirm/index.js @@ -1,7 +1,7 @@ -import React from "react"; -import { connect } from "react-redux"; -import { doCloseModal, doAutoUpdateDeclined } from "redux/actions/app"; -import ModalAutoUpdateConfirm from "./view"; +import React from 'react'; +import { connect } from 'react-redux'; +import { doCloseModal, doAutoUpdateDeclined } from 'redux/actions/app'; +import ModalAutoUpdateConfirm from './view'; const perform = dispatch => ({ closeModal: () => dispatch(doCloseModal()), diff --git a/src/renderer/modal/modalAutoUpdateConfirm/view.jsx b/src/renderer/modal/modalAutoUpdateConfirm/view.jsx index 2c8d8e3b2..f37d59a79 100644 --- a/src/renderer/modal/modalAutoUpdateConfirm/view.jsx +++ b/src/renderer/modal/modalAutoUpdateConfirm/view.jsx @@ -1,9 +1,9 @@ -import React from "react"; -import { Modal } from "modal/modal"; -import { Line } from "rc-progress"; -import Link from "component/link/index"; +import React from 'react'; +import { Modal } from 'modal/modal'; +import { Line } from 'rc-progress'; +import Link from 'component/link/index'; -const { ipcRenderer } = require("electron"); +const { ipcRenderer } = require('electron'); class ModalAutoUpdateConfirm extends React.PureComponent { render() { @@ -13,11 +13,11 @@ class ModalAutoUpdateConfirm extends React.PureComponent { { - ipcRenderer.send("autoUpdateAccepted"); + ipcRenderer.send('autoUpdateAccepted'); }} onAborted={() => { declineAutoUpdate(); @@ -25,12 +25,8 @@ class ModalAutoUpdateConfirm extends React.PureComponent { }} >
-

{__("LBRY Update Ready")}

-

- {__( - 'Your LBRY update is ready. Restart LBRY now to use it!' - )} -

+

{__('LBRY Update Ready')}

+

{__('Your LBRY update is ready. Restart LBRY now to use it!')}

{__('Want to know what has changed?')} See the{' '} . diff --git a/src/renderer/modal/modalAutoUpdateDownloaded/index.js b/src/renderer/modal/modalAutoUpdateDownloaded/index.js index 7283489aa..855d556df 100644 --- a/src/renderer/modal/modalAutoUpdateDownloaded/index.js +++ b/src/renderer/modal/modalAutoUpdateDownloaded/index.js @@ -1,7 +1,7 @@ -import React from "react"; -import { connect } from "react-redux"; -import { doCloseModal, doAutoUpdateDeclined } from "redux/actions/app"; -import ModalAutoUpdateDownloaded from "./view"; +import React from 'react'; +import { connect } from 'react-redux'; +import { doCloseModal, doAutoUpdateDeclined } from 'redux/actions/app'; +import ModalAutoUpdateDownloaded from './view'; const perform = dispatch => ({ closeModal: () => dispatch(doCloseModal()), diff --git a/src/renderer/modal/modalAutoUpdateDownloaded/view.jsx b/src/renderer/modal/modalAutoUpdateDownloaded/view.jsx index fda5ed519..c067c24ba 100644 --- a/src/renderer/modal/modalAutoUpdateDownloaded/view.jsx +++ b/src/renderer/modal/modalAutoUpdateDownloaded/view.jsx @@ -1,9 +1,9 @@ -import React from "react"; -import { Modal } from "modal/modal"; -import { Line } from "rc-progress"; -import Link from "component/link/index"; +import React from 'react'; +import { Modal } from 'modal/modal'; +import { Line } from 'rc-progress'; +import Link from 'component/link/index'; -const { ipcRenderer } = require("electron"); +const { ipcRenderer } = require('electron'); class ModalAutoUpdateDownloaded extends React.PureComponent { render() { @@ -13,20 +13,20 @@ class ModalAutoUpdateDownloaded extends React.PureComponent { { - ipcRenderer.send("autoUpdateAccepted"); + ipcRenderer.send('autoUpdateAccepted'); }} onAborted={() => { declineAutoUpdate(); - ipcRenderer.send("autoUpdateDeclined"); + ipcRenderer.send('autoUpdateDeclined'); closeModal(); }} >

-

{__("LBRY Leveled Up")}

+

{__('LBRY Leveled Up')}

{__( 'A new version of LBRY has been released, downloaded, and is ready for you to use pending a restart.' diff --git a/src/renderer/page/settings/view.jsx b/src/renderer/page/settings/view.jsx index 639d9195e..d2a22db03 100644 --- a/src/renderer/page/settings/view.jsx +++ b/src/renderer/page/settings/view.jsx @@ -334,7 +334,7 @@ class SettingsPage extends React.PureComponent { this.onAutomaticDarkModeChange(e.target.checked)} + onChange={e => this.onAutomaticDarkModeChange(e.target.checked)} checked={automaticDarkModeEnabled} label={__('Automatic dark mode (9pm to 8am)')} /> diff --git a/src/renderer/redux/actions/app.js b/src/renderer/redux/actions/app.js index 0d94fe4d4..08009c896 100644 --- a/src/renderer/redux/actions/app.js +++ b/src/renderer/redux/actions/app.js @@ -10,7 +10,7 @@ import { doAuthNavigate } from 'redux/actions/navigation'; import { doFetchDaemonSettings } from 'redux/actions/settings'; import { doAuthenticate } from 'redux/actions/user'; import { doBalanceSubscribe } from 'redux/actions/wallet'; -import { doPause } from "redux/actions/media"; +import { doPause } from 'redux/actions/media'; import { selectCurrentModal, @@ -84,7 +84,8 @@ export function doDownloadUpgradeRequested() { const autoUpdateDeclined = selectAutoUpdateDeclined(state); - if (['win32', 'darwin'].includes(process.platform)) { // electron-updater behavior + if (['win32', 'darwin'].includes(process.platform)) { + // electron-updater behavior if (autoUpdateDeclined) { // The user declined an update before, so show the "confirm" dialog dispatch({ @@ -99,7 +100,8 @@ export function doDownloadUpgradeRequested() { data: { modal: MODALS.AUTO_UPDATE_DOWNLOADED }, }); } - } else { // Old behavior for Linux + } else { + // Old behavior for Linux dispatch(doDownloadUpgrade()); } }; @@ -164,7 +166,7 @@ export function doAutoUpdateDeclined() { dispatch({ type: ACTIONS.AUTO_UPDATE_DECLINED, }); - } + }; } export function doCancelUpgrade() { @@ -197,7 +199,7 @@ export function doCheckUpgradeAvailable() { type: ACTIONS.CHECK_UPGRADE_START, }); - if (["win32", "darwin"].includes(process.platform)) { + if (['win32', 'darwin'].includes(process.platform)) { // On Windows and Mac, updates happen silently through // electron-updater. const autoUpdateDeclined = selectAutoUpdateDeclined(state); diff --git a/src/renderer/redux/actions/settings.js b/src/renderer/redux/actions/settings.js index 40365b7f8..da4ed52a0 100644 --- a/src/renderer/redux/actions/settings.js +++ b/src/renderer/redux/actions/settings.js @@ -74,7 +74,7 @@ export function doUpdateIsNight() { const startNightMoment = moment('21:00', 'HH:mm'); const endNightMoment = moment('8:00', 'HH:mm'); return !(momentNow.isAfter(endNightMoment) && momentNow.isBefore(startNightMoment)); - })() + })(), }, }; } diff --git a/src/renderer/redux/reducers/app.js b/src/renderer/redux/reducers/app.js index 0b9ccee07..fa13a325f 100644 --- a/src/renderer/redux/reducers/app.js +++ b/src/renderer/redux/reducers/app.js @@ -93,7 +93,7 @@ reducers[ACTIONS.AUTO_UPDATE_DECLINED] = state => { return Object.assign({}, state, { autoUpdateDeclined: true, }); -} +}; reducers[ACTIONS.UPGRADE_DOWNLOAD_COMPLETED] = (state, action) => Object.assign({}, state, { diff --git a/src/renderer/redux/selectors/app.js b/src/renderer/redux/selectors/app.js index 283e46f51..36f5bcb04 100644 --- a/src/renderer/redux/selectors/app.js +++ b/src/renderer/redux/selectors/app.js @@ -56,9 +56,15 @@ export const selectUpgradeDownloadPath = createSelector(selectState, state => st export const selectUpgradeDownloadItem = createSelector(selectState, state => state.downloadItem); -export const selectAutoUpdateDownloaded = createSelector(selectState, state => state.autoUpdateDownloaded); +export const selectAutoUpdateDownloaded = createSelector( + selectState, + state => state.autoUpdateDownloaded +); -export const selectAutoUpdateDeclined = createSelector(selectState, state => state.autoUpdateDeclined); +export const selectAutoUpdateDeclined = createSelector( + selectState, + state => state.autoUpdateDeclined +); export const selectModalsAllowed = createSelector(selectState, state => state.modalsAllowed); diff --git a/src/renderer/util/parseData.js b/src/renderer/util/parseData.js new file mode 100644 index 000000000..559809883 --- /dev/null +++ b/src/renderer/util/parseData.js @@ -0,0 +1,38 @@ +// Beautify JSON +const parseJson = data => JSON.stringify(data, null, '\t'); + +// No need for an external module: +// https://gist.github.com/btzr-io/55c3450ea3d709fc57540e762899fb85 +const parseCsv = data => { + // Get items for header + const getHeaders = temp => + Object.entries(temp) + .map(([key]) => key) + .join(','); + // Get rows content + const getData = list => + list + .map(item => { + const row = Object.entries(item) + .map(([key, value]) => value) + .join(','); + return row; + }) + .join('\n'); + // Return CSV string + return `${getHeaders(data[0])} \n ${getData(data)}`; +}; + +const parseData = (data, format) => { + // Check for validation + const valid = data && data[0] && format; + // Pick a format + const formats = { + csv: list => parseCsv(list), + json: list => parseJson(list), + }; + // Return parsed data: JSON || CSV + return valid && formats[format] ? formats[format](data) : undefined; +}; + +export default parseData;