From 7a5a3f68d2127adafcac8e241b54f0dd05f8e123 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Tue, 29 Nov 2022 18:02:59 +0000 Subject: [PATCH 01/33] feat: move station rpc into hook --- renderer/src/hooks/StationActivity.tsx | 54 ++++++++++++++++++++++++++ renderer/src/pages/Dashboard.tsx | 30 ++------------ 2 files changed, 58 insertions(+), 26 deletions(-) create mode 100644 renderer/src/hooks/StationActivity.tsx diff --git a/renderer/src/hooks/StationActivity.tsx b/renderer/src/hooks/StationActivity.tsx new file mode 100644 index 000000000..f482248b6 --- /dev/null +++ b/renderer/src/hooks/StationActivity.tsx @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react' +import { getTotalJobsCompleted, getAllActivities, getTotalEarnings } from '../lib/station-config' +import { ActivityEventMessage } from '../typings' + +interface StationActivity { + totalJobs: number, + totalEarnings: number, + activities: ActivityEventMessage[] | [] +} +const useStationActivity = (): StationActivity => { + const [totalJobs, setTotalJobs] = useState(0) + const [activities, setActivities] = useState([]) + const [totalEarnings, setTotalEarnigs] = useState(0) + + useEffect(() => { + const loadStoredInfo = async () => setTotalJobs(await getTotalJobsCompleted()) + loadStoredInfo() + }, []) + + useEffect(() => { + const loadStoredInfo = async () => setActivities(await getAllActivities()) + loadStoredInfo() + }, []) + + useEffect(() => { + const loadStoredInfo = async () => setTotalEarnigs(await getTotalEarnings()) + loadStoredInfo() + }, []) + + useEffect(() => { + const unsubscribeOnJobProcessed = window.electron.stationEvents.onJobProcessed(setTotalJobs) + return () => { + unsubscribeOnJobProcessed() + } + }, []) + + useEffect(() => { + const unsubscribeOnActivityLogged = window.electron.stationEvents.onActivityLogged(setActivities) + return () => { + unsubscribeOnActivityLogged() + } + }, []) + + useEffect(() => { + const unsubscribeOnEarningsChanged = window.electron.stationEvents.onEarningsChanged(setTotalEarnigs) + return () => { + unsubscribeOnEarningsChanged() + } + }, []) + + return { totalJobs, totalEarnings, activities } +} + +export default useStationActivity diff --git a/renderer/src/pages/Dashboard.tsx b/renderer/src/pages/Dashboard.tsx index 266f58f69..2e5f97901 100644 --- a/renderer/src/pages/Dashboard.tsx +++ b/renderer/src/pages/Dashboard.tsx @@ -1,24 +1,17 @@ import { useEffect, useState } from 'react' -import { - getAllActivities, stopSaturnNode, - setFilAddress, getFilAddress, - getTotalEarnings, getTotalJobsCompleted -} from '../lib/station-config' -import { ActivityEventMessage } from '../typings' +import { stopSaturnNode, setFilAddress, getFilAddress } from '../lib/station-config' import ActivityLog from '../components/ActivityLog' import HeaderBackgroundImage from '../assets/img/header.png' import WalletIcon from '../assets/img/wallet.svg' import { useNavigate } from 'react-router-dom' import { confirmChangeWalletAddress } from '../lib/dialogs' import UpdateBanner from '../components/UpdateBanner' +import useStationActivity from '../hooks/StationActivity' const Dashboard = (): JSX.Element => { const navigate = useNavigate() - const [address, setAddress] = useState() - const [totalJobs, setTotalJobs] = useState(0) - const [totalEarnings, setTotalEarnigs] = useState(0) - const [activities, setActivities] = useState([]) + const { totalJobs, totalEarnings, activities } = useStationActivity() const shortAddress = (str: string) => str ? str.substring(0, 4) + '...' + str.substring(str.length - 4, str.length) : '' @@ -32,24 +25,9 @@ const Dashboard = (): JSX.Element => { useEffect(() => { const loadStoredInfo = async () => { - Promise.all([ - (async () => { setAddress(await getFilAddress()) })(), - (async () => { setActivities(await getAllActivities()) })(), - (async () => { setTotalEarnigs(await getTotalEarnings()) })(), - (async () => { setTotalJobs(await getTotalJobsCompleted()) })() - ]) + setAddress(await getFilAddress()) } loadStoredInfo() - - const unsubscribeOnActivityLogged = window.electron.stationEvents.onActivityLogged(setActivities) - const unsubscribeOnEarningsChanged = window.electron.stationEvents.onEarningsChanged(setTotalEarnigs) - const unsubscribeOnJobProcessed = window.electron.stationEvents.onJobProcessed(setTotalJobs) - - return () => { - unsubscribeOnActivityLogged() - unsubscribeOnEarningsChanged() - unsubscribeOnJobProcessed() - } }, []) return ( From 13d9046b4ecd4789f3418a481aa3eae881ab3d95 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Tue, 29 Nov 2022 19:16:09 +0000 Subject: [PATCH 02/33] feat: backend interface and wire up for wallet --- main/index.js | 5 +- main/ipc.js | 11 +++- main/preload.js | 28 ++++++-- main/saturn-node.js | 5 +- main/station-config.js | 62 +++++++++++++++--- main/typings.d.ts | 13 +++- renderer/src/hooks/StationWallet.tsx | 98 ++++++++++++++++++++++++++++ renderer/src/lib/station-config.tsx | 42 ++++++++---- renderer/src/pages/WalletConfig.tsx | 2 +- renderer/src/typings.d.ts | 40 ++++++++---- 10 files changed, 258 insertions(+), 48 deletions(-) create mode 100644 renderer/src/hooks/StationWallet.tsx diff --git a/main/index.js b/main/index.js index 393594e61..143d214ab 100644 --- a/main/index.js +++ b/main/index.js @@ -1,6 +1,6 @@ 'use strict' -const { app, dialog } = require('electron') +const { app, dialog, shell } = require('electron') const log = require('electron-log') const path = require('node:path') @@ -108,7 +108,8 @@ const ctx = { confirmChangeWalletAddress: () => { throw new Error('never get here') }, restartToUpdate: () => { throw new Error('never get here') }, openReleaseNotes: () => { throw new Error('never get here') }, - getUpdaterStatus: () => { throw new Error('never get here') } + getUpdaterStatus: () => { throw new Error('never get here') }, + browseTransactionTracker: (/** @type {string} */ transactionHash) => { shell.openExternal(`https://explorer.glif.io/tx/${transactionHash}`) } } app.on('before-quit', () => { diff --git a/main/ipc.js b/main/ipc.js index 8f5aa601e..2fe4599cc 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -24,12 +24,16 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('saturn:getLog', saturnNode.getLog) ipcMain.handle('saturn:getWebUrl', saturnNode.getWebUrl) ipcMain.handle('saturn:getFilAddress', saturnNode.getFilAddress) - ipcMain.handle('saturn:setFilAddress', (_event, address) => saturnNode.setFilAddress(address)) // Station-wide config - ipcMain.handle('station:getFilAddress', saturnNode.getFilAddress) - ipcMain.handle('station:setFilAddress', (_event, address) => saturnNode.setFilAddress(address)) ipcMain.handle('station:getOnboardingCompleted', stationConfig.getOnboardingCompleted) ipcMain.handle('station:setOnboardingCompleted', (_event) => stationConfig.setOnboardingCompleted()) + // Wallet-wide config + ipcMain.handle('station:getStationWalletAddress', stationConfig.getStationWalletAddress) + ipcMain.handle('station:getDestinationWalletAddress', stationConfig.getDestinationWalletAddress) + ipcMain.handle('station:setDestinationWalletAddress', (_event, address) => stationConfig.setDestinationWalletAddress(address)) + ipcMain.handle('station:getStationWalletBalance', stationConfig.getStationWalletBalance) + ipcMain.handle('station:getStationWalletTransactionsHistory', stationConfig.getStationWalletTransactionsHistory) + ipcMain.handle('station:trasnferAllFundsToDestinationWallet', (_event, _args) => stationConfig.trasnferAllFundsToDestinationWallet()) ipcMain.handle('station:getAllActivities', (_event, _args) => ctx.getAllActivities()) ipcMain.handle('station:getTotalJobsCompleted', (_event, _args) => ctx.getTotalJobsCompleted()) @@ -39,6 +43,7 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('station:restartToUpdate', (_event, _args) => ctx.restartToUpdate()) ipcMain.handle('station:openReleaseNotes', (_event) => ctx.openReleaseNotes()) ipcMain.handle('station:getUpdaterStatus', (_events, _args) => ctx.getUpdaterStatus()) + ipcMain.handle('station:browseTransactionTracker', (_events, transactoinHash) => ctx.browseTransactionTracker(transactoinHash)) } module.exports = { diff --git a/main/preload.js b/main/preload.js index 2673763a6..1edcf657e 100644 --- a/main/preload.js +++ b/main/preload.js @@ -2,6 +2,7 @@ /** @typedef {import('electron').IpcRendererEvent} IpcRendererEvent */ /** @typedef {import('./typings').Activity} Activity */ +/** @typedef {import('./typings').FILTransaction} TransactionMessage */ const { contextBridge, ipcRenderer } = require('electron') @@ -22,14 +23,19 @@ contextBridge.exposeInMainWorld('electron', { isReady: () => ipcRenderer.invoke('saturn:isReady'), getLog: () => ipcRenderer.invoke('saturn:getLog'), getWebUrl: () => ipcRenderer.invoke('saturn:getWebUrl'), - getFilAddress: () => ipcRenderer.invoke('saturn:getFilAddress'), - setFilAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('saturn:setFilAddress', address) + getFilAddress: () => ipcRenderer.invoke('saturn:getFilAddress'), // soon to be removed + setFilAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('saturn:setFilAddress', address) // soon to be removed }, stationConfig: { - getFilAddress: () => ipcRenderer.invoke('station:getFilAddress'), - setFilAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('station:setFilAddress', address), getOnboardingCompleted: () => ipcRenderer.invoke('station:getOnboardingCompleted'), - setOnboardingCompleted: () => ipcRenderer.invoke('station:setOnboardingCompleted') + setOnboardingCompleted: () => ipcRenderer.invoke('station:setOnboardingCompleted'), + getStationWalletAddress: () => ipcRenderer.invoke('station:getStationWalletAddress'), + getDestinationWalletAddress: () => ipcRenderer.invoke('station:getDestinationWalletAddress'), + setDestinationWalletAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('station:setDestinationWalletAddress', address), + getStationWalletBalance: () => ipcRenderer.invoke('station:getStationWalletBalance'), + getStationWalletTransactionsHistory: () => ipcRenderer.invoke('station:getStationWalletTransactionsHistory'), + trasnferAllFundsToDestinationWallet: () => ipcRenderer.invoke('station:trasnferAllFundsToDestinationWallet'), + browseTransactionTracker: (/** @type {string } */ transactoinHash) => ipcRenderer.invoke('station:browseTransactionTracker', transactoinHash) }, stationEvents: { onActivityLogged: (/** @type {(value: Activity) => void} */ callback) => { @@ -54,6 +60,18 @@ contextBridge.exposeInMainWorld('electron', { const listener = () => callback() ipcRenderer.on('station:update-available', listener) return () => ipcRenderer.removeListener('station:update-available', listener) + }, + onBalanceUpdate: (/** @type {(value: number) => void} */ callback) => { + /** @type {(event: IpcRendererEvent, ...args: any[]) => void} */ + const listener = (_event, balance) => callback(balance) + ipcRenderer.on('station:wallet-balance-update', listener) + return () => ipcRenderer.removeListener('station:wallet-balance-update', listener) + }, + onTransactionUpdate: (/** @type {(value: TransactionMessage) => void} */ callback) => { + /** @type {(event: IpcRendererEvent, ...args: any[]) => void} */ + const listener = (_event, transactions) => callback(transactions) + ipcRenderer.on('station:transaction-update', listener) + return () => ipcRenderer.removeListener('station:transaction-update', listener) } }, dialogs: { diff --git a/main/saturn-node.js b/main/saturn-node.js index 468aa64d6..6ebc5de22 100644 --- a/main/saturn-node.js +++ b/main/saturn-node.js @@ -8,7 +8,7 @@ const { fetch } = require('undici') const fs = require('node:fs/promises') const path = require('path') const { setTimeout } = require('timers/promises') -const { getFilAddress, setFilAddress } = require('./station-config') +const { getFilAddress } = require('./station-config') const Sentry = require('@sentry/node') /** @typedef {import('./typings').Context} Context */ @@ -276,6 +276,5 @@ module.exports = { isReady, getLog, getWebUrl, - getFilAddress, - setFilAddress + getFilAddress } diff --git a/main/station-config.js b/main/station-config.js index 97499cef1..c400ea837 100644 --- a/main/station-config.js +++ b/main/station-config.js @@ -7,9 +7,12 @@ const ConfigKeys = { OnboardingCompleted: 'station.OnboardingCompleted', TrayOperationExplained: 'station.TrayOperationExplained', StationID: 'station.StationID', - FilAddress: 'station.FilAddress' + FilAddress: 'station.FilAddress', + DestinationFilAddress: 'station.FilAddress' // todo - replace by 'station.DestinationFilAddress' } +/** @typedef {import('./typings').FILTransaction} TransactionMessage */ + // Use this to test migrations // https://github.com/sindresorhus/electron-store/issues/205 // require('electron').app.setVersion('9999.9.9') @@ -35,6 +38,7 @@ console.log('Loading Station configuration from', configStore.path) let OnboardingCompleted = /** @type {boolean} */ (configStore.get(ConfigKeys.OnboardingCompleted, false)) let TrayOperationExplained = /** @type {boolean} */ (configStore.get(ConfigKeys.TrayOperationExplained, false)) let FilAddress = /** @type {string | undefined} */ (configStore.get(ConfigKeys.FilAddress)) +let DestinationFilAddress = /** @type {string | undefined} */ (configStore.get(ConfigKeys.DestinationFilAddress)) const StationID = /** @type {string} */ (configStore.get(ConfigKeys.StationID, randomUUID())) /** @@ -75,18 +79,53 @@ function getFilAddress () { } /** - * @param {string | undefined} address + * @returns {string} */ -function setFilAddress (address) { - FilAddress = address - configStore.set(ConfigKeys.FilAddress, address) +function getStationID() { + return StationID } /** * @returns {string} */ -function getStationID () { - return StationID +function getStationWalletAddress() { + return FilAddress || '' // needs refactor +} + +/** + * @returns {string | undefined} + */ +function getDestinationWalletAddress() { + return DestinationFilAddress +} + +/** + * @param {string | undefined} address + */ +function setDestinationWalletAddress(address) { + DestinationFilAddress = address + configStore.set(ConfigKeys.DestinationFilAddress, DestinationFilAddress) +} + +/** + * @returns {number} + */ +function getStationWalletBalance() { + return 0 // todo - backend logic +} + +/** + * @returns { TransactionMessage[] } + */ +function getStationWalletTransactionsHistory() { + return [] // todo - backend logic +} + +/** + * @returns void + */ +function trasnferAllFundsToDestinationWallet() { + return {} // todo - backend logic } module.exports = { @@ -95,6 +134,11 @@ module.exports = { getTrayOperationExplained, setTrayOperationExplained, getFilAddress, - setFilAddress, - getStationID + getStationID, + getStationWalletAddress, + getDestinationWalletAddress, + setDestinationWalletAddress, + getStationWalletBalance, + getStationWalletTransactionsHistory, + trasnferAllFundsToDestinationWallet } diff --git a/main/typings.d.ts b/main/typings.d.ts index 918e514e7..fb589bdb8 100644 --- a/main/typings.d.ts +++ b/main/typings.d.ts @@ -1,5 +1,6 @@ export type ActivitySource = 'Station' | 'Saturn'; export type ActivityType = 'info' | 'error'; +export type TransactionStatus = 'sent' | 'processing' | 'failed' export interface Activity { id: string; @@ -9,6 +10,15 @@ export interface Activity { message: string; } +export type FILTransaction = { + hash: string + timestamp: number + status: TransactionStatus + outgoing: boolean + amount: string + address: string +} + export type RecordActivityArgs = Omit; export type ModuleJobStatsMap = Record; @@ -28,5 +38,6 @@ export interface Context { openReleaseNotes: () => void, restartToUpdate: () => void, - getUpdaterStatus: () => {updateAvailable: boolean} + getUpdaterStatus: () => {updateAvailable: boolean}, + browseTransactionTracker: (transactionHash: string) => void } diff --git a/renderer/src/hooks/StationWallet.tsx b/renderer/src/hooks/StationWallet.tsx new file mode 100644 index 000000000..3d0582ef1 --- /dev/null +++ b/renderer/src/hooks/StationWallet.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect } from 'react' +import { + getDestinationWalletAddress, + setDestinationWalletAddress, + getStationWalletAddress, + getStationWalletBalance, + getStationWalletTransactionsHistory +} from '../lib/station-config' +import { FILTransaction } from '../typings' + +interface Wallet { + stationAddress: string, + destinationFilAddress: string | undefined, + walletBalance: number, + walletTransactions: FILTransaction[] | [], + editDestinationAddress: (address: string|undefined) => void, + currentTransaction: FILTransaction | undefined, + dismissCurrentTransaction: () => void +} + +const useWallet = (): Wallet => { + const [stationAddress, setStationAddress] = useState('') + const [destinationFilAddress, setDestinationFilAddress] = useState() + const [walletBalance, setWalletBalance] = useState(0) + const [walletTransactions, setWalletTransactions] = useState([]) + const [currentTransaction, setCurrentTransaction] = useState() + + const editDestinationAddress = async (address: string | undefined) => { + await setDestinationWalletAddress(address) + setDestinationFilAddress(address) + } + + const dismissCurrentTransaction = () => { + if (currentTransaction && currentTransaction.status !== 'processing') { + setWalletTransactions([currentTransaction, ...walletTransactions]) + setCurrentTransaction(undefined) + } + } + + useEffect(() => { + const loadStoredInfo = async () => { + setDestinationFilAddress(await getDestinationWalletAddress()) + } + loadStoredInfo() + }, [destinationFilAddress]) + + useEffect(() => { + const loadStoredInfo = async () => { + setStationAddress(await getStationWalletAddress()) + } + loadStoredInfo() + }, [stationAddress]) + + useEffect(() => { + const loadStoredInfo = async () => { + setWalletBalance(await getStationWalletBalance()) + } + loadStoredInfo() + }, []) + + useEffect(() => { + const loadStoredInfo = async () => { + setWalletTransactions(await getStationWalletTransactionsHistory()) + } + loadStoredInfo() + }, []) + + useEffect(() => { + const updateWalletTransactionsArray = (transactions: FILTransaction[]) => { + const newCurrentTransaction = transactions[0] + if (newCurrentTransaction.status === 'processing' || (currentTransaction && +currentTransaction.timestamp === +newCurrentTransaction.timestamp)) { + setCurrentTransaction(newCurrentTransaction) + if (newCurrentTransaction.status !== 'processing') { setTimeout(() => { setWalletTransactions(transactions); setCurrentTransaction(undefined) }, 6000) } + + const transactionsExceptLatest = transactions.filter((t) => { return t !== newCurrentTransaction }) + setWalletTransactions(transactionsExceptLatest) + } else { + setWalletTransactions(transactions) + } + } + + const unsubscribeOnTransactionUpdate = window.electron.stationEvents.onTransactionUpdate(updateWalletTransactionsArray) + return () => { + unsubscribeOnTransactionUpdate() + } + }, [currentTransaction]) + + useEffect(() => { + const unsubscribeOnBalanceUpdate = window.electron.stationEvents.onBalanceUpdate(setWalletBalance) + return () => { + unsubscribeOnBalanceUpdate() + } + }, [walletBalance]) + + return { stationAddress, destinationFilAddress, walletBalance, walletTransactions, editDestinationAddress, currentTransaction, dismissCurrentTransaction } +} + +export default useWallet diff --git a/renderer/src/lib/station-config.tsx b/renderer/src/lib/station-config.tsx index f4bedc7c6..ab5f61d45 100644 --- a/renderer/src/lib/station-config.tsx +++ b/renderer/src/lib/station-config.tsx @@ -1,4 +1,4 @@ -import { ActivityEventMessage } from '../typings' +import { ActivityEventMessage, FILTransaction } from '../typings' export async function getOnboardingCompleted (): Promise { return await window.electron.stationConfig.getOnboardingCompleted() @@ -8,18 +8,6 @@ export async function setOnboardingCompleted (): Promise { return await window.electron.stationConfig.setOnboardingCompleted() } -export async function getFilAddress (): Promise { - return await window.electron.stationConfig.getFilAddress() -} - -export async function setFilAddress (address: string | undefined): Promise { - return await window.electron.stationConfig.setFilAddress(address) -} - -export async function setStationFilAddress (address: string | undefined): Promise { - return await window.electron.stationConfig.setFilAddress(address) -} - export async function isSaturnNodeRunning (): Promise { return await window.electron.saturnNode.isRunning() } @@ -59,3 +47,31 @@ export async function restartToUpdate (): Promise { export function openReleaseNotes (): void { return window.electron.openReleaseNotes() } + +export async function getDestinationWalletAddress (): Promise { + return await window.electron.stationConfig.getDestinationWalletAddress() +} + +export async function setDestinationWalletAddress (address: string | undefined): Promise { + return await window.electron.stationConfig.setDestinationWalletAddress(address) +} + +export async function getStationWalletAddress (): Promise { + return await window.electron.stationConfig.getStationWalletAddress() +} + +export async function getStationWalletBalance (): Promise { + return await window.electron.stationConfig.getStationWalletBalance() +} + +export async function getStationWalletTransactionsHistory (): Promise { + return await window.electron.stationConfig.getStationWalletTransactionsHistory() +} + +export async function trasnferAllFundsToDestinationWallet (): Promise { + return await window.electron.stationConfig.trasnferAllFundsToDestinationWallet() +} + +export function brownseTransactionTracker (transactionHash: string): void { + return window.electron.stationConfig.browseTransactionTracker(transactionHash) +} diff --git a/renderer/src/pages/WalletConfig.tsx b/renderer/src/pages/WalletConfig.tsx index 88fa32a65..4565bf020 100644 --- a/renderer/src/pages/WalletConfig.tsx +++ b/renderer/src/pages/WalletConfig.tsx @@ -2,7 +2,7 @@ import { useCallback } from 'react' import FilAddressForm from '../components/FilAddressForm' import BackgroundGraph from './../assets/img/graph.svg' import { useNavigate } from 'react-router-dom' -import { startSaturnNode, setFilAddress as saveFilAddress } from '../lib/station-config' +import { startSaturnNode, setDestinationWalletAddress as saveFilAddress } from '../lib/station-config' import UpdateBanner from '../components/UpdateBanner' const WalletConfig = (): JSX.Element => { diff --git a/renderer/src/typings.d.ts b/renderer/src/typings.d.ts index ae66e113d..f82ca5be9 100644 --- a/renderer/src/typings.d.ts +++ b/renderer/src/typings.d.ts @@ -26,16 +26,23 @@ export declare global { setFilAddress: (address: string | undefined) => Promise }, stationConfig: { - getFilAddress: () => Promise, - setFilAddress: (address: string | undefined) => Promise, getOnboardingCompleted: () => Promise, setOnboardingCompleted: () => Promise + getStationWalletAddress: () => Promise, + getDestinationWalletAddress: () => Promise, + setDestinationWalletAddress: (address: string | undefined) => Promise, + getStationWalletBalance: () => Promise, + getStationWalletTransactionsHistory: () => Promise, + trasnferAllFundsToDestinationWallet: () => Promise, + browseTransactionTracker: (transactionHash: string) => void }, stationEvents: { - onActivityLogged: (callback) => () => void - onJobProcessed: (callback) => () => void - onEarningsChanged: (callback) => () => void - onUpdateAvailable: (callback: () => void) => () => void + onActivityLogged: (callback) => () => void, + onJobProcessed: (callback) => () => void, + onEarningsChanged: (callback) => () => void, + onUpdateAvailable: (callback: () => void) => () => void, + onTransactionUpdate (callback: (allTransactions: TransactionMessage[]) => void), + onBalanceUpdate (callback: (balance: number) => void) }, dialogs: { confirmChangeWalletAddress: () => Promise @@ -45,9 +52,20 @@ export declare global { } export type ActivityEventMessage = { - id: string; - timestamp: number; - type: string; - source: string; - message: string; + id: string + timestamp: number + type: string + source: string + message: string +} + +export type FILTransactionStatus = 'sent' | 'processing' | 'failed' + +export type FILTransaction = { + hash: string + timestamp: number + status: TransactionStatus + outgoing: boolean + amount: string + address: string } From 848815db92b1f5fb40d1f8339fac5386ab28f8af Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Tue, 29 Nov 2022 20:01:54 +0000 Subject: [PATCH 03/33] wip: ensure compatibility --- main/ipc.js | 1 + main/saturn-node.js | 5 +++-- main/station-config.js | 9 +++++++++ renderer/src/components/Saturn.tsx | 4 ++-- renderer/src/lib/station-config.tsx | 3 ++- renderer/src/pages/Dashboard.tsx | 6 +++--- renderer/src/pages/Onboarding.tsx | 4 ++-- renderer/src/test/dashboard.test.tsx | 8 ++++---- renderer/src/test/onboarding.test.tsx | 6 +++--- renderer/src/test/walletconfig.test.tsx | 4 ++-- 10 files changed, 31 insertions(+), 19 deletions(-) diff --git a/main/ipc.js b/main/ipc.js index 2fe4599cc..394892827 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -24,6 +24,7 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('saturn:getLog', saturnNode.getLog) ipcMain.handle('saturn:getWebUrl', saturnNode.getWebUrl) ipcMain.handle('saturn:getFilAddress', saturnNode.getFilAddress) + ipcMain.handle('saturn:setFilAddress', (_event, address) => saturnNode.setFilAddress(address)) // Station-wide config ipcMain.handle('station:getOnboardingCompleted', stationConfig.getOnboardingCompleted) ipcMain.handle('station:setOnboardingCompleted', (_event) => stationConfig.setOnboardingCompleted()) diff --git a/main/saturn-node.js b/main/saturn-node.js index 6ebc5de22..468aa64d6 100644 --- a/main/saturn-node.js +++ b/main/saturn-node.js @@ -8,7 +8,7 @@ const { fetch } = require('undici') const fs = require('node:fs/promises') const path = require('path') const { setTimeout } = require('timers/promises') -const { getFilAddress } = require('./station-config') +const { getFilAddress, setFilAddress } = require('./station-config') const Sentry = require('@sentry/node') /** @typedef {import('./typings').Context} Context */ @@ -276,5 +276,6 @@ module.exports = { isReady, getLog, getWebUrl, - getFilAddress + getFilAddress, + setFilAddress } diff --git a/main/station-config.js b/main/station-config.js index c400ea837..13f8ff235 100644 --- a/main/station-config.js +++ b/main/station-config.js @@ -78,6 +78,14 @@ function getFilAddress () { return FilAddress } +/** + * @param {string | undefined} address + */ +function setFilAddress (address) { + FilAddress = address + configStore.set(ConfigKeys.FilAddress, address) +} + /** * @returns {string} */ @@ -134,6 +142,7 @@ module.exports = { getTrayOperationExplained, setTrayOperationExplained, getFilAddress, + setFilAddress, getStationID, getStationWalletAddress, getDestinationWalletAddress, diff --git a/renderer/src/components/Saturn.tsx b/renderer/src/components/Saturn.tsx index c32c7843a..9cef309a9 100644 --- a/renderer/src/components/Saturn.tsx +++ b/renderer/src/components/Saturn.tsx @@ -1,10 +1,10 @@ import { useEffect } from 'react' -import { getFilAddress, startSaturnNode } from '../lib/station-config' +import { getDestinationWalletAddress, startSaturnNode } from '../lib/station-config' const Saturn = () => { useEffect(() => { (async () => { - if (await getFilAddress()) { + if (await getDestinationWalletAddress()) { startSaturnNode() } })() diff --git a/renderer/src/lib/station-config.tsx b/renderer/src/lib/station-config.tsx index ab5f61d45..807d15f53 100644 --- a/renderer/src/lib/station-config.tsx +++ b/renderer/src/lib/station-config.tsx @@ -53,7 +53,8 @@ export async function getDestinationWalletAddress (): Promise { - return await window.electron.stationConfig.setDestinationWalletAddress(address) + return await window.electron.saturnNode.setFilAddress(address) + //return await window.electron.stationConfig.setDestinationWalletAddress(address) } export async function getStationWalletAddress (): Promise { diff --git a/renderer/src/pages/Dashboard.tsx b/renderer/src/pages/Dashboard.tsx index 2e5f97901..8ef352fb6 100644 --- a/renderer/src/pages/Dashboard.tsx +++ b/renderer/src/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { stopSaturnNode, setFilAddress, getFilAddress } from '../lib/station-config' +import { stopSaturnNode, setDestinationWalletAddress, getDestinationWalletAddress } from '../lib/station-config' import ActivityLog from '../components/ActivityLog' import HeaderBackgroundImage from '../assets/img/header.png' import WalletIcon from '../assets/img/wallet.svg' @@ -18,14 +18,14 @@ const Dashboard = (): JSX.Element => { const disconnect = async () => { if (!(await confirmChangeWalletAddress())) return await stopSaturnNode() - await setFilAddress('') + await setDestinationWalletAddress('') setAddress(undefined) navigate('/wallet', { replace: true }) } useEffect(() => { const loadStoredInfo = async () => { - setAddress(await getFilAddress()) + setAddress(await getDestinationWalletAddress()) } loadStoredInfo() }, []) diff --git a/renderer/src/pages/Onboarding.tsx b/renderer/src/pages/Onboarding.tsx index cc94ace58..85ee39204 100644 --- a/renderer/src/pages/Onboarding.tsx +++ b/renderer/src/pages/Onboarding.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' -import { getFilAddress, getOnboardingCompleted, setOnboardingCompleted } from '../lib/station-config' +import { getDestinationWalletAddress, getOnboardingCompleted, setOnboardingCompleted } from '../lib/station-config' import Onboarding from '../components/Onboarding' import { ReactComponent as StationLogoLight } from '../assets/img/station-logo-light.svg' @@ -24,7 +24,7 @@ const OnboardingPage = (): JSX.Element => { useEffect(() => { (async () => { await sleep(2000) - if (await getFilAddress()) { + if (await getDestinationWalletAddress()) { return navigate('/dashboard', { replace: true }) } setIsOnboardingCompleted(await getOnboardingCompleted()) diff --git a/renderer/src/test/dashboard.test.tsx b/renderer/src/test/dashboard.test.tsx index f8d04c9b0..4b9e72316 100644 --- a/renderer/src/test/dashboard.test.tsx +++ b/renderer/src/test/dashboard.test.tsx @@ -19,8 +19,8 @@ describe('Dashboard page', () => { vi.restoreAllMocks() vi.mock('../lib/station-config', () => { return { - getFilAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), - setFilAddress: (address: string | undefined) => Promise.resolve(undefined), + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), + setDestinationWalletAddress: (address: string | undefined) => Promise.resolve(undefined), getTotalJobsCompleted: () => Promise.resolve(0), getTotalEarnings: () => Promise.resolve(0), startSaturnNode: () => Promise.resolve(undefined), @@ -121,8 +121,8 @@ describe('Dashboard page', () => { vi.restoreAllMocks() vi.mock('../lib/station-config', () => { return { - getFilAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), - setFilAddress: (address: string | undefined) => Promise.resolve(undefined), + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), + setDestinationWalletAddress: (address: string | undefined) => Promise.resolve(undefined), getTotalJobsCompleted: () => Promise.resolve(100), getTotalEarnings: () => Promise.resolve(100), startSaturnNode: () => Promise.resolve(undefined), diff --git a/renderer/src/test/onboarding.test.tsx b/renderer/src/test/onboarding.test.tsx index 5c270d8e4..3feac6784 100644 --- a/renderer/src/test/onboarding.test.tsx +++ b/renderer/src/test/onboarding.test.tsx @@ -15,7 +15,7 @@ describe('Welcome page test', () => { return { setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(true), - getFilAddress: () => Promise.resolve(undefined) + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), } }) @@ -46,7 +46,7 @@ describe('Welcome page test', () => { return { setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(false), - getFilAddress: () => Promise.resolve(undefined) + getDestinationWalletAddress: () => Promise.resolve(undefined), } }) @@ -100,7 +100,7 @@ describe('Welcome page test', () => { vi.clearAllMocks() vi.mock('../lib/station-config', () => { return { - getFilAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(true) } diff --git a/renderer/src/test/walletconfig.test.tsx b/renderer/src/test/walletconfig.test.tsx index a86d2160f..c9930209d 100644 --- a/renderer/src/test/walletconfig.test.tsx +++ b/renderer/src/test/walletconfig.test.tsx @@ -13,8 +13,8 @@ describe('WalletConfig page test', () => { vi.restoreAllMocks() vi.mock('../lib/station-config', () => { return { - getFilAddress: () => Promise.resolve(undefined), - setFilAddress: (address: string | undefined) => Promise.resolve(undefined), + getDestinationWalletAddress: () => Promise.resolve(undefined), + setDestinationWalletAddress: (address: string | undefined) => Promise.resolve(undefined), startSaturnNode: () => Promise.resolve(undefined) } }) From 2a08e231d6970334986225ee7159f8b35c2ec017 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Tue, 29 Nov 2022 21:29:07 +0000 Subject: [PATCH 04/33] eslint --- renderer/src/test/onboarding.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/renderer/src/test/onboarding.test.tsx b/renderer/src/test/onboarding.test.tsx index 3feac6784..f17377791 100644 --- a/renderer/src/test/onboarding.test.tsx +++ b/renderer/src/test/onboarding.test.tsx @@ -15,7 +15,7 @@ describe('Welcome page test', () => { return { setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(true), - getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa') } }) @@ -46,7 +46,7 @@ describe('Welcome page test', () => { return { setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(false), - getDestinationWalletAddress: () => Promise.resolve(undefined), + getDestinationWalletAddress: () => Promise.resolve(undefined) } }) From 6c7787b577b7edebba8f63a8be222296a5989a9d Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:37:21 +0000 Subject: [PATCH 05/33] Update main/ipc.js Co-authored-by: Julian Gruber --- main/ipc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/ipc.js b/main/ipc.js index 394892827..2a6921c8c 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -44,7 +44,7 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('station:restartToUpdate', (_event, _args) => ctx.restartToUpdate()) ipcMain.handle('station:openReleaseNotes', (_event) => ctx.openReleaseNotes()) ipcMain.handle('station:getUpdaterStatus', (_events, _args) => ctx.getUpdaterStatus()) - ipcMain.handle('station:browseTransactionTracker', (_events, transactoinHash) => ctx.browseTransactionTracker(transactoinHash)) + ipcMain.handle('station:browseTransactionTracker', (_events, transactionHash) => ctx.browseTransactionTracker(transactionHash)) } module.exports = { From 384fdcef6e66446cd405d7a198d36d1eea7cbe16 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:37:43 +0000 Subject: [PATCH 06/33] Update main/ipc.js Co-authored-by: Julian Gruber --- main/ipc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/ipc.js b/main/ipc.js index 2a6921c8c..1642929d3 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -34,7 +34,7 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('station:setDestinationWalletAddress', (_event, address) => stationConfig.setDestinationWalletAddress(address)) ipcMain.handle('station:getStationWalletBalance', stationConfig.getStationWalletBalance) ipcMain.handle('station:getStationWalletTransactionsHistory', stationConfig.getStationWalletTransactionsHistory) - ipcMain.handle('station:trasnferAllFundsToDestinationWallet', (_event, _args) => stationConfig.trasnferAllFundsToDestinationWallet()) + ipcMain.handle('station:transferAllFundsToDestinationWallet', (_event, _args) => stationConfig.transferAllFundsToDestinationWallet()) ipcMain.handle('station:getAllActivities', (_event, _args) => ctx.getAllActivities()) ipcMain.handle('station:getTotalJobsCompleted', (_event, _args) => ctx.getTotalJobsCompleted()) From 2684c878ad419e6ef4a654697c40450ee6ca8fdd Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:37:51 +0000 Subject: [PATCH 07/33] Update main/preload.js Co-authored-by: Julian Gruber --- main/preload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/preload.js b/main/preload.js index 1edcf657e..10fbd2463 100644 --- a/main/preload.js +++ b/main/preload.js @@ -34,7 +34,7 @@ contextBridge.exposeInMainWorld('electron', { setDestinationWalletAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('station:setDestinationWalletAddress', address), getStationWalletBalance: () => ipcRenderer.invoke('station:getStationWalletBalance'), getStationWalletTransactionsHistory: () => ipcRenderer.invoke('station:getStationWalletTransactionsHistory'), - trasnferAllFundsToDestinationWallet: () => ipcRenderer.invoke('station:trasnferAllFundsToDestinationWallet'), + transferAllFundsToDestinationWallet: () => ipcRenderer.invoke('station:transferAllFundsToDestinationWallet'), browseTransactionTracker: (/** @type {string } */ transactoinHash) => ipcRenderer.invoke('station:browseTransactionTracker', transactoinHash) }, stationEvents: { From 3d17c2358ae0aa10769771e0243d8138e081510a Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:42:20 +0000 Subject: [PATCH 08/33] Update main/preload.js Co-authored-by: Julian Gruber --- main/preload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/preload.js b/main/preload.js index 10fbd2463..d4c604a1a 100644 --- a/main/preload.js +++ b/main/preload.js @@ -35,7 +35,7 @@ contextBridge.exposeInMainWorld('electron', { getStationWalletBalance: () => ipcRenderer.invoke('station:getStationWalletBalance'), getStationWalletTransactionsHistory: () => ipcRenderer.invoke('station:getStationWalletTransactionsHistory'), transferAllFundsToDestinationWallet: () => ipcRenderer.invoke('station:transferAllFundsToDestinationWallet'), - browseTransactionTracker: (/** @type {string } */ transactoinHash) => ipcRenderer.invoke('station:browseTransactionTracker', transactoinHash) + browseTransactionTracker: (/** @type {string } */ transactionHash) => ipcRenderer.invoke('station:browseTransactionTracker', transactionHash) }, stationEvents: { onActivityLogged: (/** @type {(value: Activity) => void} */ callback) => { From e6ae80c3cc339e27b3ca7252514ea3a7a646fdfa Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:44:55 +0000 Subject: [PATCH 09/33] Update main/station-config.js Co-authored-by: Julian Gruber --- main/station-config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/station-config.js b/main/station-config.js index 13f8ff235..1c913baf0 100644 --- a/main/station-config.js +++ b/main/station-config.js @@ -132,7 +132,7 @@ function getStationWalletTransactionsHistory() { /** * @returns void */ -function trasnferAllFundsToDestinationWallet() { +function transferAllFundsToDestinationWallet() { return {} // todo - backend logic } From 9694821a8edf9fa22a8991bac98c4540be653d01 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:45:39 +0000 Subject: [PATCH 10/33] Update renderer/src/lib/station-config.tsx Co-authored-by: Julian Gruber --- renderer/src/lib/station-config.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/renderer/src/lib/station-config.tsx b/renderer/src/lib/station-config.tsx index 807d15f53..1372d278c 100644 --- a/renderer/src/lib/station-config.tsx +++ b/renderer/src/lib/station-config.tsx @@ -69,8 +69,8 @@ export async function getStationWalletTransactionsHistory (): Promise { - return await window.electron.stationConfig.trasnferAllFundsToDestinationWallet() +export async function transferAllFundsToDestinationWallet (): Promise { + return await window.electron.stationConfig.transferAllFundsToDestinationWallet() } export function brownseTransactionTracker (transactionHash: string): void { From ed758552f1f2c2dc85795c50f09e0823552c0c2a Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Tue, 29 Nov 2022 19:16:09 +0000 Subject: [PATCH 11/33] feat: backend interface and wire up for wallet --- main/index.js | 5 +- main/ipc.js | 11 +++- main/preload.js | 28 ++++++-- main/saturn-node.js | 5 +- main/station-config.js | 62 +++++++++++++++--- main/typings.d.ts | 13 +++- renderer/src/hooks/StationWallet.tsx | 98 ++++++++++++++++++++++++++++ renderer/src/lib/station-config.tsx | 42 ++++++++---- renderer/src/pages/WalletConfig.tsx | 2 +- renderer/src/typings.d.ts | 40 ++++++++---- 10 files changed, 258 insertions(+), 48 deletions(-) create mode 100644 renderer/src/hooks/StationWallet.tsx diff --git a/main/index.js b/main/index.js index 393594e61..143d214ab 100644 --- a/main/index.js +++ b/main/index.js @@ -1,6 +1,6 @@ 'use strict' -const { app, dialog } = require('electron') +const { app, dialog, shell } = require('electron') const log = require('electron-log') const path = require('node:path') @@ -108,7 +108,8 @@ const ctx = { confirmChangeWalletAddress: () => { throw new Error('never get here') }, restartToUpdate: () => { throw new Error('never get here') }, openReleaseNotes: () => { throw new Error('never get here') }, - getUpdaterStatus: () => { throw new Error('never get here') } + getUpdaterStatus: () => { throw new Error('never get here') }, + browseTransactionTracker: (/** @type {string} */ transactionHash) => { shell.openExternal(`https://explorer.glif.io/tx/${transactionHash}`) } } app.on('before-quit', () => { diff --git a/main/ipc.js b/main/ipc.js index 8f5aa601e..2fe4599cc 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -24,12 +24,16 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('saturn:getLog', saturnNode.getLog) ipcMain.handle('saturn:getWebUrl', saturnNode.getWebUrl) ipcMain.handle('saturn:getFilAddress', saturnNode.getFilAddress) - ipcMain.handle('saturn:setFilAddress', (_event, address) => saturnNode.setFilAddress(address)) // Station-wide config - ipcMain.handle('station:getFilAddress', saturnNode.getFilAddress) - ipcMain.handle('station:setFilAddress', (_event, address) => saturnNode.setFilAddress(address)) ipcMain.handle('station:getOnboardingCompleted', stationConfig.getOnboardingCompleted) ipcMain.handle('station:setOnboardingCompleted', (_event) => stationConfig.setOnboardingCompleted()) + // Wallet-wide config + ipcMain.handle('station:getStationWalletAddress', stationConfig.getStationWalletAddress) + ipcMain.handle('station:getDestinationWalletAddress', stationConfig.getDestinationWalletAddress) + ipcMain.handle('station:setDestinationWalletAddress', (_event, address) => stationConfig.setDestinationWalletAddress(address)) + ipcMain.handle('station:getStationWalletBalance', stationConfig.getStationWalletBalance) + ipcMain.handle('station:getStationWalletTransactionsHistory', stationConfig.getStationWalletTransactionsHistory) + ipcMain.handle('station:trasnferAllFundsToDestinationWallet', (_event, _args) => stationConfig.trasnferAllFundsToDestinationWallet()) ipcMain.handle('station:getAllActivities', (_event, _args) => ctx.getAllActivities()) ipcMain.handle('station:getTotalJobsCompleted', (_event, _args) => ctx.getTotalJobsCompleted()) @@ -39,6 +43,7 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('station:restartToUpdate', (_event, _args) => ctx.restartToUpdate()) ipcMain.handle('station:openReleaseNotes', (_event) => ctx.openReleaseNotes()) ipcMain.handle('station:getUpdaterStatus', (_events, _args) => ctx.getUpdaterStatus()) + ipcMain.handle('station:browseTransactionTracker', (_events, transactoinHash) => ctx.browseTransactionTracker(transactoinHash)) } module.exports = { diff --git a/main/preload.js b/main/preload.js index 2673763a6..1edcf657e 100644 --- a/main/preload.js +++ b/main/preload.js @@ -2,6 +2,7 @@ /** @typedef {import('electron').IpcRendererEvent} IpcRendererEvent */ /** @typedef {import('./typings').Activity} Activity */ +/** @typedef {import('./typings').FILTransaction} TransactionMessage */ const { contextBridge, ipcRenderer } = require('electron') @@ -22,14 +23,19 @@ contextBridge.exposeInMainWorld('electron', { isReady: () => ipcRenderer.invoke('saturn:isReady'), getLog: () => ipcRenderer.invoke('saturn:getLog'), getWebUrl: () => ipcRenderer.invoke('saturn:getWebUrl'), - getFilAddress: () => ipcRenderer.invoke('saturn:getFilAddress'), - setFilAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('saturn:setFilAddress', address) + getFilAddress: () => ipcRenderer.invoke('saturn:getFilAddress'), // soon to be removed + setFilAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('saturn:setFilAddress', address) // soon to be removed }, stationConfig: { - getFilAddress: () => ipcRenderer.invoke('station:getFilAddress'), - setFilAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('station:setFilAddress', address), getOnboardingCompleted: () => ipcRenderer.invoke('station:getOnboardingCompleted'), - setOnboardingCompleted: () => ipcRenderer.invoke('station:setOnboardingCompleted') + setOnboardingCompleted: () => ipcRenderer.invoke('station:setOnboardingCompleted'), + getStationWalletAddress: () => ipcRenderer.invoke('station:getStationWalletAddress'), + getDestinationWalletAddress: () => ipcRenderer.invoke('station:getDestinationWalletAddress'), + setDestinationWalletAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('station:setDestinationWalletAddress', address), + getStationWalletBalance: () => ipcRenderer.invoke('station:getStationWalletBalance'), + getStationWalletTransactionsHistory: () => ipcRenderer.invoke('station:getStationWalletTransactionsHistory'), + trasnferAllFundsToDestinationWallet: () => ipcRenderer.invoke('station:trasnferAllFundsToDestinationWallet'), + browseTransactionTracker: (/** @type {string } */ transactoinHash) => ipcRenderer.invoke('station:browseTransactionTracker', transactoinHash) }, stationEvents: { onActivityLogged: (/** @type {(value: Activity) => void} */ callback) => { @@ -54,6 +60,18 @@ contextBridge.exposeInMainWorld('electron', { const listener = () => callback() ipcRenderer.on('station:update-available', listener) return () => ipcRenderer.removeListener('station:update-available', listener) + }, + onBalanceUpdate: (/** @type {(value: number) => void} */ callback) => { + /** @type {(event: IpcRendererEvent, ...args: any[]) => void} */ + const listener = (_event, balance) => callback(balance) + ipcRenderer.on('station:wallet-balance-update', listener) + return () => ipcRenderer.removeListener('station:wallet-balance-update', listener) + }, + onTransactionUpdate: (/** @type {(value: TransactionMessage) => void} */ callback) => { + /** @type {(event: IpcRendererEvent, ...args: any[]) => void} */ + const listener = (_event, transactions) => callback(transactions) + ipcRenderer.on('station:transaction-update', listener) + return () => ipcRenderer.removeListener('station:transaction-update', listener) } }, dialogs: { diff --git a/main/saturn-node.js b/main/saturn-node.js index 6004a12c9..b09d3e190 100644 --- a/main/saturn-node.js +++ b/main/saturn-node.js @@ -8,7 +8,7 @@ const { fetch } = require('undici') const fs = require('node:fs/promises') const path = require('path') const { setTimeout } = require('timers/promises') -const { getFilAddress, setFilAddress } = require('./station-config') +const { getFilAddress } = require('./station-config') const Sentry = require('@sentry/node') /** @typedef {import('./typings').Context} Context */ @@ -283,6 +283,5 @@ module.exports = { isReady, getLog, getWebUrl, - getFilAddress, - setFilAddress + getFilAddress } diff --git a/main/station-config.js b/main/station-config.js index 97499cef1..c400ea837 100644 --- a/main/station-config.js +++ b/main/station-config.js @@ -7,9 +7,12 @@ const ConfigKeys = { OnboardingCompleted: 'station.OnboardingCompleted', TrayOperationExplained: 'station.TrayOperationExplained', StationID: 'station.StationID', - FilAddress: 'station.FilAddress' + FilAddress: 'station.FilAddress', + DestinationFilAddress: 'station.FilAddress' // todo - replace by 'station.DestinationFilAddress' } +/** @typedef {import('./typings').FILTransaction} TransactionMessage */ + // Use this to test migrations // https://github.com/sindresorhus/electron-store/issues/205 // require('electron').app.setVersion('9999.9.9') @@ -35,6 +38,7 @@ console.log('Loading Station configuration from', configStore.path) let OnboardingCompleted = /** @type {boolean} */ (configStore.get(ConfigKeys.OnboardingCompleted, false)) let TrayOperationExplained = /** @type {boolean} */ (configStore.get(ConfigKeys.TrayOperationExplained, false)) let FilAddress = /** @type {string | undefined} */ (configStore.get(ConfigKeys.FilAddress)) +let DestinationFilAddress = /** @type {string | undefined} */ (configStore.get(ConfigKeys.DestinationFilAddress)) const StationID = /** @type {string} */ (configStore.get(ConfigKeys.StationID, randomUUID())) /** @@ -75,18 +79,53 @@ function getFilAddress () { } /** - * @param {string | undefined} address + * @returns {string} */ -function setFilAddress (address) { - FilAddress = address - configStore.set(ConfigKeys.FilAddress, address) +function getStationID() { + return StationID } /** * @returns {string} */ -function getStationID () { - return StationID +function getStationWalletAddress() { + return FilAddress || '' // needs refactor +} + +/** + * @returns {string | undefined} + */ +function getDestinationWalletAddress() { + return DestinationFilAddress +} + +/** + * @param {string | undefined} address + */ +function setDestinationWalletAddress(address) { + DestinationFilAddress = address + configStore.set(ConfigKeys.DestinationFilAddress, DestinationFilAddress) +} + +/** + * @returns {number} + */ +function getStationWalletBalance() { + return 0 // todo - backend logic +} + +/** + * @returns { TransactionMessage[] } + */ +function getStationWalletTransactionsHistory() { + return [] // todo - backend logic +} + +/** + * @returns void + */ +function trasnferAllFundsToDestinationWallet() { + return {} // todo - backend logic } module.exports = { @@ -95,6 +134,11 @@ module.exports = { getTrayOperationExplained, setTrayOperationExplained, getFilAddress, - setFilAddress, - getStationID + getStationID, + getStationWalletAddress, + getDestinationWalletAddress, + setDestinationWalletAddress, + getStationWalletBalance, + getStationWalletTransactionsHistory, + trasnferAllFundsToDestinationWallet } diff --git a/main/typings.d.ts b/main/typings.d.ts index 918e514e7..fb589bdb8 100644 --- a/main/typings.d.ts +++ b/main/typings.d.ts @@ -1,5 +1,6 @@ export type ActivitySource = 'Station' | 'Saturn'; export type ActivityType = 'info' | 'error'; +export type TransactionStatus = 'sent' | 'processing' | 'failed' export interface Activity { id: string; @@ -9,6 +10,15 @@ export interface Activity { message: string; } +export type FILTransaction = { + hash: string + timestamp: number + status: TransactionStatus + outgoing: boolean + amount: string + address: string +} + export type RecordActivityArgs = Omit; export type ModuleJobStatsMap = Record; @@ -28,5 +38,6 @@ export interface Context { openReleaseNotes: () => void, restartToUpdate: () => void, - getUpdaterStatus: () => {updateAvailable: boolean} + getUpdaterStatus: () => {updateAvailable: boolean}, + browseTransactionTracker: (transactionHash: string) => void } diff --git a/renderer/src/hooks/StationWallet.tsx b/renderer/src/hooks/StationWallet.tsx new file mode 100644 index 000000000..3d0582ef1 --- /dev/null +++ b/renderer/src/hooks/StationWallet.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect } from 'react' +import { + getDestinationWalletAddress, + setDestinationWalletAddress, + getStationWalletAddress, + getStationWalletBalance, + getStationWalletTransactionsHistory +} from '../lib/station-config' +import { FILTransaction } from '../typings' + +interface Wallet { + stationAddress: string, + destinationFilAddress: string | undefined, + walletBalance: number, + walletTransactions: FILTransaction[] | [], + editDestinationAddress: (address: string|undefined) => void, + currentTransaction: FILTransaction | undefined, + dismissCurrentTransaction: () => void +} + +const useWallet = (): Wallet => { + const [stationAddress, setStationAddress] = useState('') + const [destinationFilAddress, setDestinationFilAddress] = useState() + const [walletBalance, setWalletBalance] = useState(0) + const [walletTransactions, setWalletTransactions] = useState([]) + const [currentTransaction, setCurrentTransaction] = useState() + + const editDestinationAddress = async (address: string | undefined) => { + await setDestinationWalletAddress(address) + setDestinationFilAddress(address) + } + + const dismissCurrentTransaction = () => { + if (currentTransaction && currentTransaction.status !== 'processing') { + setWalletTransactions([currentTransaction, ...walletTransactions]) + setCurrentTransaction(undefined) + } + } + + useEffect(() => { + const loadStoredInfo = async () => { + setDestinationFilAddress(await getDestinationWalletAddress()) + } + loadStoredInfo() + }, [destinationFilAddress]) + + useEffect(() => { + const loadStoredInfo = async () => { + setStationAddress(await getStationWalletAddress()) + } + loadStoredInfo() + }, [stationAddress]) + + useEffect(() => { + const loadStoredInfo = async () => { + setWalletBalance(await getStationWalletBalance()) + } + loadStoredInfo() + }, []) + + useEffect(() => { + const loadStoredInfo = async () => { + setWalletTransactions(await getStationWalletTransactionsHistory()) + } + loadStoredInfo() + }, []) + + useEffect(() => { + const updateWalletTransactionsArray = (transactions: FILTransaction[]) => { + const newCurrentTransaction = transactions[0] + if (newCurrentTransaction.status === 'processing' || (currentTransaction && +currentTransaction.timestamp === +newCurrentTransaction.timestamp)) { + setCurrentTransaction(newCurrentTransaction) + if (newCurrentTransaction.status !== 'processing') { setTimeout(() => { setWalletTransactions(transactions); setCurrentTransaction(undefined) }, 6000) } + + const transactionsExceptLatest = transactions.filter((t) => { return t !== newCurrentTransaction }) + setWalletTransactions(transactionsExceptLatest) + } else { + setWalletTransactions(transactions) + } + } + + const unsubscribeOnTransactionUpdate = window.electron.stationEvents.onTransactionUpdate(updateWalletTransactionsArray) + return () => { + unsubscribeOnTransactionUpdate() + } + }, [currentTransaction]) + + useEffect(() => { + const unsubscribeOnBalanceUpdate = window.electron.stationEvents.onBalanceUpdate(setWalletBalance) + return () => { + unsubscribeOnBalanceUpdate() + } + }, [walletBalance]) + + return { stationAddress, destinationFilAddress, walletBalance, walletTransactions, editDestinationAddress, currentTransaction, dismissCurrentTransaction } +} + +export default useWallet diff --git a/renderer/src/lib/station-config.tsx b/renderer/src/lib/station-config.tsx index f4bedc7c6..ab5f61d45 100644 --- a/renderer/src/lib/station-config.tsx +++ b/renderer/src/lib/station-config.tsx @@ -1,4 +1,4 @@ -import { ActivityEventMessage } from '../typings' +import { ActivityEventMessage, FILTransaction } from '../typings' export async function getOnboardingCompleted (): Promise { return await window.electron.stationConfig.getOnboardingCompleted() @@ -8,18 +8,6 @@ export async function setOnboardingCompleted (): Promise { return await window.electron.stationConfig.setOnboardingCompleted() } -export async function getFilAddress (): Promise { - return await window.electron.stationConfig.getFilAddress() -} - -export async function setFilAddress (address: string | undefined): Promise { - return await window.electron.stationConfig.setFilAddress(address) -} - -export async function setStationFilAddress (address: string | undefined): Promise { - return await window.electron.stationConfig.setFilAddress(address) -} - export async function isSaturnNodeRunning (): Promise { return await window.electron.saturnNode.isRunning() } @@ -59,3 +47,31 @@ export async function restartToUpdate (): Promise { export function openReleaseNotes (): void { return window.electron.openReleaseNotes() } + +export async function getDestinationWalletAddress (): Promise { + return await window.electron.stationConfig.getDestinationWalletAddress() +} + +export async function setDestinationWalletAddress (address: string | undefined): Promise { + return await window.electron.stationConfig.setDestinationWalletAddress(address) +} + +export async function getStationWalletAddress (): Promise { + return await window.electron.stationConfig.getStationWalletAddress() +} + +export async function getStationWalletBalance (): Promise { + return await window.electron.stationConfig.getStationWalletBalance() +} + +export async function getStationWalletTransactionsHistory (): Promise { + return await window.electron.stationConfig.getStationWalletTransactionsHistory() +} + +export async function trasnferAllFundsToDestinationWallet (): Promise { + return await window.electron.stationConfig.trasnferAllFundsToDestinationWallet() +} + +export function brownseTransactionTracker (transactionHash: string): void { + return window.electron.stationConfig.browseTransactionTracker(transactionHash) +} diff --git a/renderer/src/pages/WalletConfig.tsx b/renderer/src/pages/WalletConfig.tsx index 88fa32a65..4565bf020 100644 --- a/renderer/src/pages/WalletConfig.tsx +++ b/renderer/src/pages/WalletConfig.tsx @@ -2,7 +2,7 @@ import { useCallback } from 'react' import FilAddressForm from '../components/FilAddressForm' import BackgroundGraph from './../assets/img/graph.svg' import { useNavigate } from 'react-router-dom' -import { startSaturnNode, setFilAddress as saveFilAddress } from '../lib/station-config' +import { startSaturnNode, setDestinationWalletAddress as saveFilAddress } from '../lib/station-config' import UpdateBanner from '../components/UpdateBanner' const WalletConfig = (): JSX.Element => { diff --git a/renderer/src/typings.d.ts b/renderer/src/typings.d.ts index ae66e113d..f82ca5be9 100644 --- a/renderer/src/typings.d.ts +++ b/renderer/src/typings.d.ts @@ -26,16 +26,23 @@ export declare global { setFilAddress: (address: string | undefined) => Promise }, stationConfig: { - getFilAddress: () => Promise, - setFilAddress: (address: string | undefined) => Promise, getOnboardingCompleted: () => Promise, setOnboardingCompleted: () => Promise + getStationWalletAddress: () => Promise, + getDestinationWalletAddress: () => Promise, + setDestinationWalletAddress: (address: string | undefined) => Promise, + getStationWalletBalance: () => Promise, + getStationWalletTransactionsHistory: () => Promise, + trasnferAllFundsToDestinationWallet: () => Promise, + browseTransactionTracker: (transactionHash: string) => void }, stationEvents: { - onActivityLogged: (callback) => () => void - onJobProcessed: (callback) => () => void - onEarningsChanged: (callback) => () => void - onUpdateAvailable: (callback: () => void) => () => void + onActivityLogged: (callback) => () => void, + onJobProcessed: (callback) => () => void, + onEarningsChanged: (callback) => () => void, + onUpdateAvailable: (callback: () => void) => () => void, + onTransactionUpdate (callback: (allTransactions: TransactionMessage[]) => void), + onBalanceUpdate (callback: (balance: number) => void) }, dialogs: { confirmChangeWalletAddress: () => Promise @@ -45,9 +52,20 @@ export declare global { } export type ActivityEventMessage = { - id: string; - timestamp: number; - type: string; - source: string; - message: string; + id: string + timestamp: number + type: string + source: string + message: string +} + +export type FILTransactionStatus = 'sent' | 'processing' | 'failed' + +export type FILTransaction = { + hash: string + timestamp: number + status: TransactionStatus + outgoing: boolean + amount: string + address: string } From 735a5d0fccd77103566d6b6875a043151543e5b6 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Tue, 29 Nov 2022 20:01:54 +0000 Subject: [PATCH 12/33] wip: ensure compatibility --- main/ipc.js | 1 + main/saturn-node.js | 5 +++-- main/station-config.js | 9 +++++++++ renderer/src/components/Saturn.tsx | 4 ++-- renderer/src/lib/station-config.tsx | 3 ++- renderer/src/pages/Dashboard.tsx | 6 +++--- renderer/src/pages/Onboarding.tsx | 4 ++-- renderer/src/test/dashboard.test.tsx | 8 ++++---- renderer/src/test/onboarding.test.tsx | 6 +++--- renderer/src/test/walletconfig.test.tsx | 4 ++-- 10 files changed, 31 insertions(+), 19 deletions(-) diff --git a/main/ipc.js b/main/ipc.js index 2fe4599cc..394892827 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -24,6 +24,7 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('saturn:getLog', saturnNode.getLog) ipcMain.handle('saturn:getWebUrl', saturnNode.getWebUrl) ipcMain.handle('saturn:getFilAddress', saturnNode.getFilAddress) + ipcMain.handle('saturn:setFilAddress', (_event, address) => saturnNode.setFilAddress(address)) // Station-wide config ipcMain.handle('station:getOnboardingCompleted', stationConfig.getOnboardingCompleted) ipcMain.handle('station:setOnboardingCompleted', (_event) => stationConfig.setOnboardingCompleted()) diff --git a/main/saturn-node.js b/main/saturn-node.js index b09d3e190..6004a12c9 100644 --- a/main/saturn-node.js +++ b/main/saturn-node.js @@ -8,7 +8,7 @@ const { fetch } = require('undici') const fs = require('node:fs/promises') const path = require('path') const { setTimeout } = require('timers/promises') -const { getFilAddress } = require('./station-config') +const { getFilAddress, setFilAddress } = require('./station-config') const Sentry = require('@sentry/node') /** @typedef {import('./typings').Context} Context */ @@ -283,5 +283,6 @@ module.exports = { isReady, getLog, getWebUrl, - getFilAddress + getFilAddress, + setFilAddress } diff --git a/main/station-config.js b/main/station-config.js index c400ea837..13f8ff235 100644 --- a/main/station-config.js +++ b/main/station-config.js @@ -78,6 +78,14 @@ function getFilAddress () { return FilAddress } +/** + * @param {string | undefined} address + */ +function setFilAddress (address) { + FilAddress = address + configStore.set(ConfigKeys.FilAddress, address) +} + /** * @returns {string} */ @@ -134,6 +142,7 @@ module.exports = { getTrayOperationExplained, setTrayOperationExplained, getFilAddress, + setFilAddress, getStationID, getStationWalletAddress, getDestinationWalletAddress, diff --git a/renderer/src/components/Saturn.tsx b/renderer/src/components/Saturn.tsx index c32c7843a..9cef309a9 100644 --- a/renderer/src/components/Saturn.tsx +++ b/renderer/src/components/Saturn.tsx @@ -1,10 +1,10 @@ import { useEffect } from 'react' -import { getFilAddress, startSaturnNode } from '../lib/station-config' +import { getDestinationWalletAddress, startSaturnNode } from '../lib/station-config' const Saturn = () => { useEffect(() => { (async () => { - if (await getFilAddress()) { + if (await getDestinationWalletAddress()) { startSaturnNode() } })() diff --git a/renderer/src/lib/station-config.tsx b/renderer/src/lib/station-config.tsx index ab5f61d45..807d15f53 100644 --- a/renderer/src/lib/station-config.tsx +++ b/renderer/src/lib/station-config.tsx @@ -53,7 +53,8 @@ export async function getDestinationWalletAddress (): Promise { - return await window.electron.stationConfig.setDestinationWalletAddress(address) + return await window.electron.saturnNode.setFilAddress(address) + //return await window.electron.stationConfig.setDestinationWalletAddress(address) } export async function getStationWalletAddress (): Promise { diff --git a/renderer/src/pages/Dashboard.tsx b/renderer/src/pages/Dashboard.tsx index 2e5f97901..8ef352fb6 100644 --- a/renderer/src/pages/Dashboard.tsx +++ b/renderer/src/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { stopSaturnNode, setFilAddress, getFilAddress } from '../lib/station-config' +import { stopSaturnNode, setDestinationWalletAddress, getDestinationWalletAddress } from '../lib/station-config' import ActivityLog from '../components/ActivityLog' import HeaderBackgroundImage from '../assets/img/header.png' import WalletIcon from '../assets/img/wallet.svg' @@ -18,14 +18,14 @@ const Dashboard = (): JSX.Element => { const disconnect = async () => { if (!(await confirmChangeWalletAddress())) return await stopSaturnNode() - await setFilAddress('') + await setDestinationWalletAddress('') setAddress(undefined) navigate('/wallet', { replace: true }) } useEffect(() => { const loadStoredInfo = async () => { - setAddress(await getFilAddress()) + setAddress(await getDestinationWalletAddress()) } loadStoredInfo() }, []) diff --git a/renderer/src/pages/Onboarding.tsx b/renderer/src/pages/Onboarding.tsx index cc94ace58..85ee39204 100644 --- a/renderer/src/pages/Onboarding.tsx +++ b/renderer/src/pages/Onboarding.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' -import { getFilAddress, getOnboardingCompleted, setOnboardingCompleted } from '../lib/station-config' +import { getDestinationWalletAddress, getOnboardingCompleted, setOnboardingCompleted } from '../lib/station-config' import Onboarding from '../components/Onboarding' import { ReactComponent as StationLogoLight } from '../assets/img/station-logo-light.svg' @@ -24,7 +24,7 @@ const OnboardingPage = (): JSX.Element => { useEffect(() => { (async () => { await sleep(2000) - if (await getFilAddress()) { + if (await getDestinationWalletAddress()) { return navigate('/dashboard', { replace: true }) } setIsOnboardingCompleted(await getOnboardingCompleted()) diff --git a/renderer/src/test/dashboard.test.tsx b/renderer/src/test/dashboard.test.tsx index f8d04c9b0..4b9e72316 100644 --- a/renderer/src/test/dashboard.test.tsx +++ b/renderer/src/test/dashboard.test.tsx @@ -19,8 +19,8 @@ describe('Dashboard page', () => { vi.restoreAllMocks() vi.mock('../lib/station-config', () => { return { - getFilAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), - setFilAddress: (address: string | undefined) => Promise.resolve(undefined), + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), + setDestinationWalletAddress: (address: string | undefined) => Promise.resolve(undefined), getTotalJobsCompleted: () => Promise.resolve(0), getTotalEarnings: () => Promise.resolve(0), startSaturnNode: () => Promise.resolve(undefined), @@ -121,8 +121,8 @@ describe('Dashboard page', () => { vi.restoreAllMocks() vi.mock('../lib/station-config', () => { return { - getFilAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), - setFilAddress: (address: string | undefined) => Promise.resolve(undefined), + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), + setDestinationWalletAddress: (address: string | undefined) => Promise.resolve(undefined), getTotalJobsCompleted: () => Promise.resolve(100), getTotalEarnings: () => Promise.resolve(100), startSaturnNode: () => Promise.resolve(undefined), diff --git a/renderer/src/test/onboarding.test.tsx b/renderer/src/test/onboarding.test.tsx index 5c270d8e4..3feac6784 100644 --- a/renderer/src/test/onboarding.test.tsx +++ b/renderer/src/test/onboarding.test.tsx @@ -15,7 +15,7 @@ describe('Welcome page test', () => { return { setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(true), - getFilAddress: () => Promise.resolve(undefined) + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), } }) @@ -46,7 +46,7 @@ describe('Welcome page test', () => { return { setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(false), - getFilAddress: () => Promise.resolve(undefined) + getDestinationWalletAddress: () => Promise.resolve(undefined), } }) @@ -100,7 +100,7 @@ describe('Welcome page test', () => { vi.clearAllMocks() vi.mock('../lib/station-config', () => { return { - getFilAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(true) } diff --git a/renderer/src/test/walletconfig.test.tsx b/renderer/src/test/walletconfig.test.tsx index a86d2160f..c9930209d 100644 --- a/renderer/src/test/walletconfig.test.tsx +++ b/renderer/src/test/walletconfig.test.tsx @@ -13,8 +13,8 @@ describe('WalletConfig page test', () => { vi.restoreAllMocks() vi.mock('../lib/station-config', () => { return { - getFilAddress: () => Promise.resolve(undefined), - setFilAddress: (address: string | undefined) => Promise.resolve(undefined), + getDestinationWalletAddress: () => Promise.resolve(undefined), + setDestinationWalletAddress: (address: string | undefined) => Promise.resolve(undefined), startSaturnNode: () => Promise.resolve(undefined) } }) From 762e1b4d1ba4c6dbb84c1cf04506923a174cddda Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Tue, 29 Nov 2022 21:29:07 +0000 Subject: [PATCH 13/33] eslint --- renderer/src/test/onboarding.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/renderer/src/test/onboarding.test.tsx b/renderer/src/test/onboarding.test.tsx index 3feac6784..f17377791 100644 --- a/renderer/src/test/onboarding.test.tsx +++ b/renderer/src/test/onboarding.test.tsx @@ -15,7 +15,7 @@ describe('Welcome page test', () => { return { setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(true), - getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa'), + getDestinationWalletAddress: () => Promise.resolve('f16m5slrkc6zumruuhdzn557a5sdkbkiellron4qa') } }) @@ -46,7 +46,7 @@ describe('Welcome page test', () => { return { setOnboardingCompleted: () => Promise.resolve(undefined), getOnboardingCompleted: (status: boolean) => Promise.resolve(false), - getDestinationWalletAddress: () => Promise.resolve(undefined), + getDestinationWalletAddress: () => Promise.resolve(undefined) } }) From 8f848a30440050e69fd4ff1122848556fa618ab0 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:37:21 +0000 Subject: [PATCH 14/33] Update main/ipc.js Co-authored-by: Julian Gruber --- main/ipc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/ipc.js b/main/ipc.js index 394892827..2a6921c8c 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -44,7 +44,7 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('station:restartToUpdate', (_event, _args) => ctx.restartToUpdate()) ipcMain.handle('station:openReleaseNotes', (_event) => ctx.openReleaseNotes()) ipcMain.handle('station:getUpdaterStatus', (_events, _args) => ctx.getUpdaterStatus()) - ipcMain.handle('station:browseTransactionTracker', (_events, transactoinHash) => ctx.browseTransactionTracker(transactoinHash)) + ipcMain.handle('station:browseTransactionTracker', (_events, transactionHash) => ctx.browseTransactionTracker(transactionHash)) } module.exports = { From d26055ccdd7e92a0376adeab99e8fc92acb79c21 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:37:43 +0000 Subject: [PATCH 15/33] Update main/ipc.js Co-authored-by: Julian Gruber --- main/ipc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/ipc.js b/main/ipc.js index 2a6921c8c..1642929d3 100644 --- a/main/ipc.js +++ b/main/ipc.js @@ -34,7 +34,7 @@ function setupIpcMain (/** @type {Context} */ ctx) { ipcMain.handle('station:setDestinationWalletAddress', (_event, address) => stationConfig.setDestinationWalletAddress(address)) ipcMain.handle('station:getStationWalletBalance', stationConfig.getStationWalletBalance) ipcMain.handle('station:getStationWalletTransactionsHistory', stationConfig.getStationWalletTransactionsHistory) - ipcMain.handle('station:trasnferAllFundsToDestinationWallet', (_event, _args) => stationConfig.trasnferAllFundsToDestinationWallet()) + ipcMain.handle('station:transferAllFundsToDestinationWallet', (_event, _args) => stationConfig.transferAllFundsToDestinationWallet()) ipcMain.handle('station:getAllActivities', (_event, _args) => ctx.getAllActivities()) ipcMain.handle('station:getTotalJobsCompleted', (_event, _args) => ctx.getTotalJobsCompleted()) From f60c57e00f30db826ebeee129a1ffd474981a89a Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:37:51 +0000 Subject: [PATCH 16/33] Update main/preload.js Co-authored-by: Julian Gruber --- main/preload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/preload.js b/main/preload.js index 1edcf657e..10fbd2463 100644 --- a/main/preload.js +++ b/main/preload.js @@ -34,7 +34,7 @@ contextBridge.exposeInMainWorld('electron', { setDestinationWalletAddress: (/** @type {string | undefined} */ address) => ipcRenderer.invoke('station:setDestinationWalletAddress', address), getStationWalletBalance: () => ipcRenderer.invoke('station:getStationWalletBalance'), getStationWalletTransactionsHistory: () => ipcRenderer.invoke('station:getStationWalletTransactionsHistory'), - trasnferAllFundsToDestinationWallet: () => ipcRenderer.invoke('station:trasnferAllFundsToDestinationWallet'), + transferAllFundsToDestinationWallet: () => ipcRenderer.invoke('station:transferAllFundsToDestinationWallet'), browseTransactionTracker: (/** @type {string } */ transactoinHash) => ipcRenderer.invoke('station:browseTransactionTracker', transactoinHash) }, stationEvents: { From 7a6a5af343644ef9112a892838a0c3af07de0366 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:42:20 +0000 Subject: [PATCH 17/33] Update main/preload.js Co-authored-by: Julian Gruber --- main/preload.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/preload.js b/main/preload.js index 10fbd2463..d4c604a1a 100644 --- a/main/preload.js +++ b/main/preload.js @@ -35,7 +35,7 @@ contextBridge.exposeInMainWorld('electron', { getStationWalletBalance: () => ipcRenderer.invoke('station:getStationWalletBalance'), getStationWalletTransactionsHistory: () => ipcRenderer.invoke('station:getStationWalletTransactionsHistory'), transferAllFundsToDestinationWallet: () => ipcRenderer.invoke('station:transferAllFundsToDestinationWallet'), - browseTransactionTracker: (/** @type {string } */ transactoinHash) => ipcRenderer.invoke('station:browseTransactionTracker', transactoinHash) + browseTransactionTracker: (/** @type {string } */ transactionHash) => ipcRenderer.invoke('station:browseTransactionTracker', transactionHash) }, stationEvents: { onActivityLogged: (/** @type {(value: Activity) => void} */ callback) => { From 9aa20a16a8a04bf948cefd35a688fc7bae70ae5e Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:44:55 +0000 Subject: [PATCH 18/33] Update main/station-config.js Co-authored-by: Julian Gruber --- main/station-config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/station-config.js b/main/station-config.js index 13f8ff235..1c913baf0 100644 --- a/main/station-config.js +++ b/main/station-config.js @@ -132,7 +132,7 @@ function getStationWalletTransactionsHistory() { /** * @returns void */ -function trasnferAllFundsToDestinationWallet() { +function transferAllFundsToDestinationWallet() { return {} // todo - backend logic } From 56f9e79aa9ab3089c9a7af8ab48bd8f0945172d6 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:45:39 +0000 Subject: [PATCH 19/33] Update renderer/src/lib/station-config.tsx Co-authored-by: Julian Gruber --- renderer/src/lib/station-config.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/renderer/src/lib/station-config.tsx b/renderer/src/lib/station-config.tsx index 807d15f53..1372d278c 100644 --- a/renderer/src/lib/station-config.tsx +++ b/renderer/src/lib/station-config.tsx @@ -69,8 +69,8 @@ export async function getStationWalletTransactionsHistory (): Promise { - return await window.electron.stationConfig.trasnferAllFundsToDestinationWallet() +export async function transferAllFundsToDestinationWallet (): Promise { + return await window.electron.stationConfig.transferAllFundsToDestinationWallet() } export function brownseTransactionTracker (transactionHash: string): void { From 35e7d26b2f9e434ed6fc37045c6170cad84f455f Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:51:19 +0000 Subject: [PATCH 20/33] fixup: remove address check on onboarding page --- renderer/src/pages/Onboarding.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/renderer/src/pages/Onboarding.tsx b/renderer/src/pages/Onboarding.tsx index 85ee39204..9c40fcef9 100644 --- a/renderer/src/pages/Onboarding.tsx +++ b/renderer/src/pages/Onboarding.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { useNavigate } from 'react-router-dom' -import { getDestinationWalletAddress, getOnboardingCompleted, setOnboardingCompleted } from '../lib/station-config' +import { getOnboardingCompleted, setOnboardingCompleted } from '../lib/station-config' import Onboarding from '../components/Onboarding' import { ReactComponent as StationLogoLight } from '../assets/img/station-logo-light.svg' @@ -24,9 +24,6 @@ const OnboardingPage = (): JSX.Element => { useEffect(() => { (async () => { await sleep(2000) - if (await getDestinationWalletAddress()) { - return navigate('/dashboard', { replace: true }) - } setIsOnboardingCompleted(await getOnboardingCompleted()) setIsLoading(false) })() From 034e18ffbf401148a787b69de44d570f67fd13e0 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:54:28 +0000 Subject: [PATCH 21/33] refactor: staturn fe component startup --- renderer/src/components/Saturn.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/renderer/src/components/Saturn.tsx b/renderer/src/components/Saturn.tsx index 9cef309a9..b0fd17fce 100644 --- a/renderer/src/components/Saturn.tsx +++ b/renderer/src/components/Saturn.tsx @@ -1,13 +1,9 @@ import { useEffect } from 'react' -import { getDestinationWalletAddress, startSaturnNode } from '../lib/station-config' +import { startSaturnNode } from '../lib/station-config' const Saturn = () => { useEffect(() => { - (async () => { - if (await getDestinationWalletAddress()) { - startSaturnNode() - } - })() + startSaturnNode() }, []) return null From dd789ef58c6291fd40f4f5774bda8c85d15290cd Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:56:24 +0000 Subject: [PATCH 22/33] fixup: lint --- main/station-config.js | 18 +++++++++--------- renderer/src/lib/station-config.tsx | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/main/station-config.js b/main/station-config.js index 1c913baf0..5c023d1d1 100644 --- a/main/station-config.js +++ b/main/station-config.js @@ -89,28 +89,28 @@ function setFilAddress (address) { /** * @returns {string} */ -function getStationID() { +function getStationID () { return StationID } /** * @returns {string} */ -function getStationWalletAddress() { - return FilAddress || '' // needs refactor +function getStationWalletAddress () { + return FilAddress || '' } /** * @returns {string | undefined} */ -function getDestinationWalletAddress() { +function getDestinationWalletAddress () { return DestinationFilAddress } /** * @param {string | undefined} address */ -function setDestinationWalletAddress(address) { +function setDestinationWalletAddress (address) { DestinationFilAddress = address configStore.set(ConfigKeys.DestinationFilAddress, DestinationFilAddress) } @@ -118,21 +118,21 @@ function setDestinationWalletAddress(address) { /** * @returns {number} */ -function getStationWalletBalance() { +function getStationWalletBalance () { return 0 // todo - backend logic } /** * @returns { TransactionMessage[] } */ -function getStationWalletTransactionsHistory() { +function getStationWalletTransactionsHistory () { return [] // todo - backend logic } /** * @returns void */ -function transferAllFundsToDestinationWallet() { +function transferAllFundsToDestinationWallet () { return {} // todo - backend logic } @@ -149,5 +149,5 @@ module.exports = { setDestinationWalletAddress, getStationWalletBalance, getStationWalletTransactionsHistory, - trasnferAllFundsToDestinationWallet + transferAllFundsToDestinationWallet } diff --git a/renderer/src/lib/station-config.tsx b/renderer/src/lib/station-config.tsx index 1372d278c..04db50e65 100644 --- a/renderer/src/lib/station-config.tsx +++ b/renderer/src/lib/station-config.tsx @@ -54,7 +54,7 @@ export async function getDestinationWalletAddress (): Promise { return await window.electron.saturnNode.setFilAddress(address) - //return await window.electron.stationConfig.setDestinationWalletAddress(address) + // return await window.electron.stationConfig.setDestinationWalletAddress(address) } export async function getStationWalletAddress (): Promise { From 073ec79608ed300ce76bf77b69a6d37223a5195a Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Fri, 16 Dec 2022 13:58:59 +0000 Subject: [PATCH 23/33] Update renderer/src/typings.d.ts Co-authored-by: Julian Gruber --- renderer/src/typings.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renderer/src/typings.d.ts b/renderer/src/typings.d.ts index f82ca5be9..fac275606 100644 --- a/renderer/src/typings.d.ts +++ b/renderer/src/typings.d.ts @@ -33,7 +33,7 @@ export declare global { setDestinationWalletAddress: (address: string | undefined) => Promise, getStationWalletBalance: () => Promise, getStationWalletTransactionsHistory: () => Promise, - trasnferAllFundsToDestinationWallet: () => Promise, + transferAllFundsToDestinationWallet: () => Promise, browseTransactionTracker: (transactionHash: string) => void }, stationEvents: { From 7593fe7a79784fc843187964b1607ac9bcebecda Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Tue, 29 Nov 2022 21:48:09 +0000 Subject: [PATCH 24/33] feat: wallet ui --- main/station-config.js | 4 +- renderer/src/App.css | 105 ++++++-------- renderer/src/App.tsx | 2 - renderer/src/assets/img/header-curtain.png | Bin 0 -> 64255 bytes .../src/assets/img/{ => icons}/arrow-left.svg | 0 renderer/src/assets/img/icons/cafe.svg | 12 ++ renderer/src/assets/img/icons/edit.svg | 4 + renderer/src/assets/img/{ => icons}/error.svg | 0 renderer/src/assets/img/icons/external.svg | 6 + renderer/src/assets/img/icons/failed.svg | 4 + renderer/src/assets/img/icons/income.svg | 4 + renderer/src/assets/img/icons/info.svg | 8 ++ renderer/src/assets/img/{ => icons}/job.svg | 0 renderer/src/assets/img/icons/outcome.svg | 4 + .../img/{ => icons}/paginator-current.svg | 0 .../assets/img/{ => icons}/paginator-page.svg | 0 renderer/src/assets/img/icons/processing.svg | 6 + renderer/src/assets/img/icons/sent.svg | 4 + renderer/src/assets/img/icons/transfer.svg | 10 ++ renderer/src/assets/img/icons/wallet.svg | 4 + renderer/src/assets/img/icons/warning.svg | 7 + renderer/src/assets/img/wallet.svg | 11 -- renderer/src/assets/img/warning.svg | 12 -- renderer/src/components/ActivityLog.tsx | 6 +- renderer/src/components/FilAddressForm.tsx | 129 +++++++++--------- renderer/src/components/Modal.tsx | 19 +++ renderer/src/components/Onboarding.tsx | 12 +- renderer/src/components/StyleGuide.tsx | 105 -------------- .../src/components/TotalJobsCompleted.tsx | 31 ----- renderer/src/components/TransferFunds.tsx | 44 ++++++ renderer/src/components/UpdateBanner.tsx | 2 +- renderer/src/components/WalletModule.tsx | 118 ++++++++++++++++ renderer/src/components/WalletOnboarding.tsx | 42 ++++++ .../WalletTransactionStatusWidget.tsx | 45 ++++++ .../components/WalletTransactionsHistory.tsx | 104 ++++++++++++++ renderer/src/components/WalletWidget.tsx | 28 ++++ renderer/src/lib/station-config.tsx | 3 +- renderer/src/pages/Dashboard.tsx | 50 ++----- renderer/src/pages/Onboarding.tsx | 2 +- renderer/src/pages/WalletConfig.tsx | 36 ----- tailwind.config.js | 22 ++- 41 files changed, 628 insertions(+), 377 deletions(-) create mode 100644 renderer/src/assets/img/header-curtain.png rename renderer/src/assets/img/{ => icons}/arrow-left.svg (100%) create mode 100644 renderer/src/assets/img/icons/cafe.svg create mode 100644 renderer/src/assets/img/icons/edit.svg rename renderer/src/assets/img/{ => icons}/error.svg (100%) create mode 100644 renderer/src/assets/img/icons/external.svg create mode 100644 renderer/src/assets/img/icons/failed.svg create mode 100644 renderer/src/assets/img/icons/income.svg create mode 100644 renderer/src/assets/img/icons/info.svg rename renderer/src/assets/img/{ => icons}/job.svg (100%) create mode 100644 renderer/src/assets/img/icons/outcome.svg rename renderer/src/assets/img/{ => icons}/paginator-current.svg (100%) rename renderer/src/assets/img/{ => icons}/paginator-page.svg (100%) create mode 100644 renderer/src/assets/img/icons/processing.svg create mode 100644 renderer/src/assets/img/icons/sent.svg create mode 100644 renderer/src/assets/img/icons/transfer.svg create mode 100644 renderer/src/assets/img/icons/wallet.svg create mode 100644 renderer/src/assets/img/icons/warning.svg delete mode 100644 renderer/src/assets/img/wallet.svg delete mode 100644 renderer/src/assets/img/warning.svg create mode 100644 renderer/src/components/Modal.tsx delete mode 100644 renderer/src/components/StyleGuide.tsx delete mode 100644 renderer/src/components/TotalJobsCompleted.tsx create mode 100644 renderer/src/components/TransferFunds.tsx create mode 100644 renderer/src/components/WalletModule.tsx create mode 100644 renderer/src/components/WalletOnboarding.tsx create mode 100644 renderer/src/components/WalletTransactionStatusWidget.tsx create mode 100644 renderer/src/components/WalletTransactionsHistory.tsx create mode 100644 renderer/src/components/WalletWidget.tsx delete mode 100644 renderer/src/pages/WalletConfig.tsx diff --git a/main/station-config.js b/main/station-config.js index 5c023d1d1..1b47258f5 100644 --- a/main/station-config.js +++ b/main/station-config.js @@ -8,7 +8,7 @@ const ConfigKeys = { TrayOperationExplained: 'station.TrayOperationExplained', StationID: 'station.StationID', FilAddress: 'station.FilAddress', - DestinationFilAddress: 'station.FilAddress' // todo - replace by 'station.DestinationFilAddress' + DestinationFilAddress: 'station.DestinationFilAddress' } /** @typedef {import('./typings').FILTransaction} TransactionMessage */ @@ -75,7 +75,7 @@ function setTrayOperationExplained () { * @returns {string | undefined} */ function getFilAddress () { - return FilAddress + return FilAddress || 'f0111' // needs refactor - same as getStationWalletAddress } /** diff --git a/renderer/src/App.css b/renderer/src/App.css index aafa0b1fb..3eb506c4d 100644 --- a/renderer/src/App.css +++ b/renderer/src/App.css @@ -12,21 +12,23 @@ font-family: "SpaceGrotesk"; src: url('assets/fonts/SpaceGrotesk/SpaceGrotesk-Regular.ttf'); } + @font-face { + font-family: "SpaceGrotesk"; + font-weight: 700; + src: url('assets/fonts/SpaceGrotesk/SpaceGrotesk-Bold.ttf'); + } @font-face { font-family: "SpaceMono"; src: url('assets/fonts/SpaceMono/SpaceMono-Regular.ttf'); } - - .title { - @apply text-header-l font-title text-primary leading-[1.25] tracking-[-0.4px]; - } - - .subtitle { - @apply text-header-xxs font-title text-secondary tracking-[-0.4px]; + @font-face { + font-family: "SpaceMono"; + font-weight: 700; + src: url('assets/fonts/SpaceMono/SpaceMono-Bold.ttf'); } body { - @apply font-body text-black tracking-[-0.4px]; + @apply font-body text-black overflow-x-hidden; -webkit-user-select: none; -webkit-app-region: drag; } @@ -40,19 +42,34 @@ input { -webkit-user-select: text; } + p { + margin-block-start: 0; + margin-block-end: 0; + } + .title { + @apply text-header-l font-title text-primary leading-[1.25] tracking-[-0.4px]; + } + + .subtitle { + @apply text-header-xxs font-title text-secondary tracking-[-0.4px]; + } } @layer components { + .input { - @apply text-body-l font-body pt-3 pb-2 px-0 mt-0 border-dashed border-0 border-b - bg-transparent appearance-none focus:outline-none focus:ring-0 focus:border-black border-grayscale-700; + @apply text-header-3xs font-body border-dashed border-0 border-b text-white + bg-transparent appearance-none focus:outline-none focus:ring-0 focus:border-white border-white; } .btn-primary { - @apply py-2 px-4 rounded-full font-body text-body-m text-white - bg-primary hover:bg-primary-hover visited:bg-primary-click - disabled:bg-grayscale-500 disabled:text-white + @apply py-2 px-4 h-12 rounded-full font-body text-body-s w-fit + bg-transparent text-white border border-white border-solid + hover:bg-white hover:text-primary hover:drop-shadow-[0_6px_12px_rgba(0,0,0,0.25)] + active:bg-grayscale-250 active:text-primary active:drop-shadow-[0_2px_4px_rgba(0,0,0,0.32)] + disabled:bg-transparent disabled:text-white disabled:opacity-50 + disabled:outline disabled:outline-1 disabled:outline-white } .btn-primary-small { @@ -70,13 +87,17 @@ group-disabled:fill-grayscale-500 } + .icon-primary-white path { + @apply fill-white + } + .icon-warning path { - @apply fill-warning group-hover:fill-warning group-visited:fill-warning + @apply fill-orange-200 group-hover:fill-orange-200 group-visited:fill-orange-200 group-disabled:fill-grayscale-500 } .icon-error path { - @apply fill-error group-hover:fill-error group-visited:fill-error + @apply fill-red-200 group-hover:fill-red-200 group-visited:fill-red-200 group-disabled:fill-grayscale-500 } @@ -107,51 +128,15 @@ visited:text-primary-click visited:decoration-primary-click/70 } - - .gradient-space-marine { - @apply bg-gradient-to-r from-tertiary-accent to-secondary - } - - .gradient-deep-marine { - @apply bg-gradient-to-r from-primary to-secondary - } - - .gradient-space-turqoise { - @apply bg-gradient-to-r from-tertiary-accent to-accent - } - - .gradient-space-fire { - @apply bg-gradient-to-r from-tertiary-accent to-warning - } - - .gradient-sun-fire { - @apply bg-gradient-to-r from-secondary-accent to-warning - } - - .gradient-bg { - background: radial-gradient(94.55% 459.39% at 92% 11%, #F7F7F7 40.53%, rgba(247, 247, 247, 0.34) 80.12%, rgba(247, 247, 247, 0.58) 100%) /* warning: gradient uses a rotation that is not supported by CSS and may not behave as expected */; - transform: rotate(-180deg); - } - - .gradient-bg-2 { - background: radial-gradient(137.12% 101.03% at 50% 0%, #F8F7F7 40.53%, #F7F7F7 78.03%, rgba(247, 247, 247, 0.95) 100%); - backdrop-filter: blur(5px); - /* Note: backdrop-filter has minimal browser support */ - transform: rotate(-180deg); - } - - input:focus ~ label, - input:not(:placeholder-shown) ~ label{ - --tw-translate-x: 0; - --tw-translate-y: 0; - --tw-rotate: 0; - --tw-skew-x: 0; - --tw-skew-y: 0; - transform: translateX(var(--tw-translate-x)) translateY(var(--tw-translate-y)) rotate(var(--tw-rotate)) - skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); - --tw-scale-x: 0.75; - --tw-scale-y: 0.75; - --tw-translate-y: -1.5rem; + input:focus ~ label, + input:not(:placeholder-shown) ~ label{ + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + transform: translateX(var(--tw-translate-x)) translateY(var(--tw-translate-y)) rotate(var(--tw-rotate)) + skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); + --tw-translate-y: -2rem; + @apply text-body-3xs } } diff --git a/renderer/src/App.tsx b/renderer/src/App.tsx index f98cdba43..f56110a9a 100644 --- a/renderer/src/App.tsx +++ b/renderer/src/App.tsx @@ -1,7 +1,6 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' import './App.css' import Onboarding from './pages/Onboarding' -import WalletConfig from './pages/WalletConfig' import Dashboard from './pages/Dashboard' import Sentry from './components/Sentry' import Plausible from './components/Plausible' @@ -23,7 +22,6 @@ const App = ():JSX.Element => { - } /> } /> diff --git a/renderer/src/assets/img/header-curtain.png b/renderer/src/assets/img/header-curtain.png new file mode 100644 index 0000000000000000000000000000000000000000..639abbc558447f4c146f285e7a9be3503c36a548 GIT binary patch literal 64255 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 literal 0 HcmV?d00001 diff --git a/renderer/src/assets/img/arrow-left.svg b/renderer/src/assets/img/icons/arrow-left.svg similarity index 100% rename from renderer/src/assets/img/arrow-left.svg rename to renderer/src/assets/img/icons/arrow-left.svg diff --git a/renderer/src/assets/img/icons/cafe.svg b/renderer/src/assets/img/icons/cafe.svg new file mode 100644 index 000000000..9802fed1d --- /dev/null +++ b/renderer/src/assets/img/icons/cafe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/renderer/src/assets/img/icons/edit.svg b/renderer/src/assets/img/icons/edit.svg new file mode 100644 index 000000000..bf4b0b75d --- /dev/null +++ b/renderer/src/assets/img/icons/edit.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/renderer/src/assets/img/error.svg b/renderer/src/assets/img/icons/error.svg similarity index 100% rename from renderer/src/assets/img/error.svg rename to renderer/src/assets/img/icons/error.svg diff --git a/renderer/src/assets/img/icons/external.svg b/renderer/src/assets/img/icons/external.svg new file mode 100644 index 000000000..36f2a4ffd --- /dev/null +++ b/renderer/src/assets/img/icons/external.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/renderer/src/assets/img/icons/failed.svg b/renderer/src/assets/img/icons/failed.svg new file mode 100644 index 000000000..cbfdcb309 --- /dev/null +++ b/renderer/src/assets/img/icons/failed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/renderer/src/assets/img/icons/income.svg b/renderer/src/assets/img/icons/income.svg new file mode 100644 index 000000000..d015a3766 --- /dev/null +++ b/renderer/src/assets/img/icons/income.svg @@ -0,0 +1,4 @@ + + + + diff --git a/renderer/src/assets/img/icons/info.svg b/renderer/src/assets/img/icons/info.svg new file mode 100644 index 000000000..477c39693 --- /dev/null +++ b/renderer/src/assets/img/icons/info.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/renderer/src/assets/img/job.svg b/renderer/src/assets/img/icons/job.svg similarity index 100% rename from renderer/src/assets/img/job.svg rename to renderer/src/assets/img/icons/job.svg diff --git a/renderer/src/assets/img/icons/outcome.svg b/renderer/src/assets/img/icons/outcome.svg new file mode 100644 index 000000000..f6691e32e --- /dev/null +++ b/renderer/src/assets/img/icons/outcome.svg @@ -0,0 +1,4 @@ + + + + diff --git a/renderer/src/assets/img/paginator-current.svg b/renderer/src/assets/img/icons/paginator-current.svg similarity index 100% rename from renderer/src/assets/img/paginator-current.svg rename to renderer/src/assets/img/icons/paginator-current.svg diff --git a/renderer/src/assets/img/paginator-page.svg b/renderer/src/assets/img/icons/paginator-page.svg similarity index 100% rename from renderer/src/assets/img/paginator-page.svg rename to renderer/src/assets/img/icons/paginator-page.svg diff --git a/renderer/src/assets/img/icons/processing.svg b/renderer/src/assets/img/icons/processing.svg new file mode 100644 index 000000000..5e2f0f3d0 --- /dev/null +++ b/renderer/src/assets/img/icons/processing.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/renderer/src/assets/img/icons/sent.svg b/renderer/src/assets/img/icons/sent.svg new file mode 100644 index 000000000..dd7dc1cc1 --- /dev/null +++ b/renderer/src/assets/img/icons/sent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/renderer/src/assets/img/icons/transfer.svg b/renderer/src/assets/img/icons/transfer.svg new file mode 100644 index 000000000..5e92115d8 --- /dev/null +++ b/renderer/src/assets/img/icons/transfer.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/renderer/src/assets/img/icons/wallet.svg b/renderer/src/assets/img/icons/wallet.svg new file mode 100644 index 000000000..8e3aef5f1 --- /dev/null +++ b/renderer/src/assets/img/icons/wallet.svg @@ -0,0 +1,4 @@ + + + + diff --git a/renderer/src/assets/img/icons/warning.svg b/renderer/src/assets/img/icons/warning.svg new file mode 100644 index 000000000..79180ba8c --- /dev/null +++ b/renderer/src/assets/img/icons/warning.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/renderer/src/assets/img/wallet.svg b/renderer/src/assets/img/wallet.svg deleted file mode 100644 index bb5539800..000000000 --- a/renderer/src/assets/img/wallet.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/renderer/src/assets/img/warning.svg b/renderer/src/assets/img/warning.svg deleted file mode 100644 index 30c2eeabe..000000000 --- a/renderer/src/assets/img/warning.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/renderer/src/components/ActivityLog.tsx b/renderer/src/components/ActivityLog.tsx index f348d0ed9..e1a9a8627 100644 --- a/renderer/src/components/ActivityLog.tsx +++ b/renderer/src/components/ActivityLog.tsx @@ -1,8 +1,8 @@ import { FC } from 'react' import { ActivityEventMessage } from '../typings' import dayjs from 'dayjs' -import { ReactComponent as WarningIcon } from '../assets/img/warning.svg' -import { ReactComponent as JobIcon } from '../assets/img/job.svg' +import { ReactComponent as WarningIcon } from '../assets/img/icons/warning.svg' +import { ReactComponent as JobIcon } from '../assets/img/icons/job.svg' const dateTimeFormat = new Intl.DateTimeFormat(window.navigator.language, { hour: 'numeric', minute: 'numeric', second: 'numeric' @@ -12,7 +12,7 @@ const ActivityLogItem: FC = (activity) => { const time = dateTimeFormat.format(new Date(activity.timestamp)) return ( -

+
{activity.type === 'info' && } diff --git a/renderer/src/components/FilAddressForm.tsx b/renderer/src/components/FilAddressForm.tsx index 03e45969a..063866fb9 100644 --- a/renderer/src/components/FilAddressForm.tsx +++ b/renderer/src/components/FilAddressForm.tsx @@ -1,84 +1,89 @@ -import { ChangeEvent, FC, useState } from 'react' +import { FC, useState, SyntheticEvent, useEffect } from 'react' import { checkAddressString } from '@glif/filecoin-address' -import { ReactComponent as Warning } from '../assets/img/error.svg' +import { ReactComponent as Warning } from '../assets/img/icons/error.svg' -interface ValidationErrorProps { - message: string | undefined -} - -const ValidationError: FC = ({ message }) => { - return ( - message - ?
- - Invalid wallet address. -
- :   - ) -} interface FilAddressFormProps { - setFilAddress: (address: string | undefined) => void + destinationAddress: string | undefined, + saveDestinationAddress: (address: string | undefined) => void, + editMode: boolean } -const FilAddressForm: FC = ({ setFilAddress }) => { - const [validationError, setValidationError] = useState() - const [inputAddr, setInputAddr] = useState('') +const FilAddressForm: FC = ({ destinationAddress = '', saveDestinationAddress, editMode }) => { + const [addressIsValid, setAddressIsValid] = useState() + const [inputAddr, setInputAddr] = useState(destinationAddress) + + useEffect(() => { setInputAddr(destinationAddress) }, [editMode, destinationAddress]) - const validateAddress = () => { - setValidationError(undefined) - try { - checkAddressString(inputAddr) - return true - } catch (err) { - setValidationError(err instanceof Error ? err.message : '' + err) - return false + useEffect(() => { + if (inputAddr === '') { + setAddressIsValid(true) + } else { + try { + checkAddressString(inputAddr) + setAddressIsValid(true) + } catch { + setAddressIsValid(false) + } + } + }, [inputAddr]) + + const handleSubmit = (event: SyntheticEvent) => { + event.preventDefault() + if (addressIsValid) { + saveDestinationAddress(inputAddr) } } - const handleChangeAddress = (event: ChangeEvent) => { - setInputAddr(event.target.value) + const computeInputClasses = () => { + const listOfClasses = 'input w-full block fil-address mt-[7px]' + if (inputAddr === destinationAddress) { + return listOfClasses + } + if (addressIsValid) { + return `${listOfClasses} border-solid border-green-100 focus:border-solid focus:border-green-100` + } else { + return `${listOfClasses} border-red-200 focus:border-red-200` + } } - const handleAuthenticate = (event: React.SyntheticEvent) => { - event.preventDefault() - if (inputAddr && validateAddress()) { - setFilAddress(inputAddr) + const renderBottomMessage = () => { + if (addressIsValid) { + return (

Enter an address to receive your FIL.

) } + + return ( +
+ + The FIL address entered is invalid. Please check and try again. +
+ ) } return ( - -
-
-
+ <> + +
+ tabIndex={0} value={inputAddr} + onChange={(event) => { setInputAddr(event.target.value) }} + className={computeInputClasses()} /> - - + className="absolute duration-300 top-3 origin-top-lef pointer-events-none text-white opacity-80 font-body text-body-2xs uppercase mb-3"> + Your FIL Address + {renderBottomMessage()}
-
- -
-

Your FIL rewards will be sent regularly to the address entered.

- -
- + {(inputAddr !== destinationAddress || inputAddr.length > 0) && + + } + + ) } diff --git a/renderer/src/components/Modal.tsx b/renderer/src/components/Modal.tsx new file mode 100644 index 000000000..6962fb238 --- /dev/null +++ b/renderer/src/components/Modal.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react' +import WalletModule from '../components/WalletModule' + +interface ModalProps { + isOpen: boolean + setIsOpen: () => void +} +const Modal : FC = ({ isOpen, setIsOpen }) => { + return ( + <> +
+
+ +
+ + ) +} + +export default Modal diff --git a/renderer/src/components/Onboarding.tsx b/renderer/src/components/Onboarding.tsx index dc266d9c8..e867aa068 100644 --- a/renderer/src/components/Onboarding.tsx +++ b/renderer/src/components/Onboarding.tsx @@ -1,7 +1,7 @@ import { FC, useState } from 'react' -import { ReactComponent as Back } from './../assets/img/arrow-left.svg' -import { ReactComponent as Page } from './../assets/img/paginator-page.svg' -import { ReactComponent as CurrentPage } from './../assets/img/paginator-current.svg' +import { ReactComponent as Back } from './../assets/img/icons/arrow-left.svg' +import { ReactComponent as Page } from './../assets/img/icons/paginator-page.svg' +import { ReactComponent as CurrentPage } from './../assets/img/icons/paginator-current.svg' interface FooterProps { page: number, @@ -14,7 +14,7 @@ const Footer: FC = ({ page, pages, next, prev }) => { return (
@@ -103,7 +103,7 @@ const Onboarding: FC = ({ onFinish }) => {
- - - - - - - -
- -
- - - - - - - -
- -
- -
- -
-
- -
-

- Gradient Space - Marine -

- -

- Gradient Deep - Marine -

- -

- Gradient Space - Turqoise -

- -

- Gradient Space - Fire -

- -

- Gradient Sun - Fire -

-
- - - ) -} - -export default StyleGuide diff --git a/renderer/src/components/TotalJobsCompleted.tsx b/renderer/src/components/TotalJobsCompleted.tsx deleted file mode 100644 index bccca6150..000000000 --- a/renderer/src/components/TotalJobsCompleted.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useEffect, useState } from 'react' - -export const TotalJobsCompleted : React.FC = () => { - const [totalJobsCompleted, setTotalJobsCompleted] = useState(0) - - useEffect(() => { - (async () => { - const jobsCompleted = await window.electron.getTotalJobsCompleted() - setTotalJobsCompleted(jobsCompleted) - })() - }, []) - - useEffect(() => { - const unsubscribe = window.electron.onJobStatsUpdated(count => { - setTotalJobsCompleted(count) - }) - return unsubscribe - }, []) - - // TODO: add proper CSS styling :) - const style: React.CSSProperties = { - marginLeft: '2em', - fontSize: '2em', - fontFamily: 'monospace' - } - - return <> -

Total jobs completed

-

{totalJobsCompleted}

- -} diff --git a/renderer/src/components/TransferFunds.tsx b/renderer/src/components/TransferFunds.tsx new file mode 100644 index 000000000..7f675d20f --- /dev/null +++ b/renderer/src/components/TransferFunds.tsx @@ -0,0 +1,44 @@ +import { FC } from 'react' +import { ReactComponent as InfoIcon } from '../assets/img/icons/info.svg' + +interface TransferFundsButtonsProps { + transferMode: boolean, + balance: number, + enableTransferMode: () => void, + transferAllFunds: () => void, + disabled: boolean +} + +const TransferFundsButtons: FC = ({ transferMode, balance, enableTransferMode, transferAllFunds, disabled }) => { + return ( + <> + {transferMode + ?
+ + +
+ :
+ + {disabled && +
+
+

We need a FIL address to transfer your FIL

+
+ +
+ } +
+ } + + ) +} + +export default TransferFundsButtons diff --git a/renderer/src/components/UpdateBanner.tsx b/renderer/src/components/UpdateBanner.tsx index 1a014816f..ae6fba318 100644 --- a/renderer/src/components/UpdateBanner.tsx +++ b/renderer/src/components/UpdateBanner.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { ReactComponent as Warning } from '../assets/img/error.svg' +import { ReactComponent as Warning } from '../assets/img/icons/error.svg' import { openReleaseNotes, restartToUpdate } from '../lib/station-config' const UpdateBanner = () => { diff --git a/renderer/src/components/WalletModule.tsx b/renderer/src/components/WalletModule.tsx new file mode 100644 index 000000000..d5bd971af --- /dev/null +++ b/renderer/src/components/WalletModule.tsx @@ -0,0 +1,118 @@ +import { FC, useEffect, useState } from 'react' +import HeaderBackgroundImage from '../assets/img/header-curtain.png' +import { ReactComponent as EditIcon } from '../assets/img/icons/edit.svg' + +import FilAddressForm from './FilAddressForm' +import WalletTransactionsHistory from './WalletTransactionsHistory' +import useWallet from '../hooks/StationWallet' +import TransferFundsButtons from './TransferFunds' +import { trasnferAllFundsToDestinationWallet } from '../lib/station-config' + +interface PropsWallet { + isOpen: boolean, +} + +const WalletModule: FC = ({ isOpen = false }) => { + const [editMode, setEditMode] = useState(false) + const [trasnferMode, setTransferMode] = useState(false) + const { stationAddress, destinationFilAddress, walletBalance, walletTransactions, editDestinationAddress, currentTransaction, dismissCurrentTransaction } = useWallet() + + useEffect(() => { + dismissCurrentTransaction() + reset() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]) + + const reset = () => { + setEditMode(false) + setTransferMode(false) + } + + const enableEditMode = () => { + setEditMode(true) + setTransferMode(false) + } + + const enableTransferMode = () => { + setEditMode(false) + setTransferMode(true) + } + + const saveAddress = async (address: string | undefined) => { + editDestinationAddress(address) + setEditMode(false) + } + + const transferAllFunds = async () => { + await trasnferAllFundsToDestinationWallet() + setTransferMode(false) + } + + const renderAddress = () => { + if (editMode || !destinationFilAddress) { + return (
(setEditMode(true))}> + +
) + } + return ( +
{ setTransferMode(false) }}> + Your FIL Address +
+

{destinationFilAddress}

+ {!trasnferMode && + + } +
+
+ ) + } + + const renderTransferButtons = () => { + if (!editMode || !destinationFilAddress) { + return ( + + ) + } + } + + return ( +
+
+

STATION ADDRESS

+

{stationAddress}

+
+
+
+
+ { renderAddress() } +
+
{ setEditMode(false) }}> +
+

Total FIL

+

+ {walletBalance.toLocaleString(undefined, { minimumFractionDigits: 3 })}FIL +

+
+ { renderTransferButtons() } +
+
+
+
+ +
+
+ ) +} + +export default WalletModule diff --git a/renderer/src/components/WalletOnboarding.tsx b/renderer/src/components/WalletOnboarding.tsx new file mode 100644 index 000000000..a4897bf21 --- /dev/null +++ b/renderer/src/components/WalletOnboarding.tsx @@ -0,0 +1,42 @@ +import { ReactComponent as CafeIcon } from '../assets/img/icons/cafe.svg' +import { ReactComponent as TransferIcon } from '../assets/img/icons/transfer.svg' +import { ReactComponent as WalletIcon } from '../assets/img/icons/wallet.svg' + +const WalletOnboarding = () => { + return ( +
+
+
+ +
+
+

YOUR STATION WALLET

+

Your Station Wallet has a unique address, where all the FIL you earn will be stored. + Station will send your FIL earnings to this wallet on a daily basis.

+
+
+
+
+ +
+
+

TRANSFERRING YOUR FIL

+

In order to transfer FIL out of your Station Wallet, + you need to set a FIL address to send out your FIL. We recommend you transfer your FIL at least every 30 days.

+
+
+
+
+ +
+
+

GAS FEES

+

All transfers of assets in the blockchain incur on gas fees, + which vary depending on the network's activity and are deducted from the total amount you're transferring.

+
+
+
+ ) +} + +export default WalletOnboarding diff --git a/renderer/src/components/WalletTransactionStatusWidget.tsx b/renderer/src/components/WalletTransactionStatusWidget.tsx new file mode 100644 index 000000000..94c17593a --- /dev/null +++ b/renderer/src/components/WalletTransactionStatusWidget.tsx @@ -0,0 +1,45 @@ +import { FC } from 'react' +import { ReactComponent as SentIcon } from '../assets/img/icons/sent.svg' +import { ReactComponent as FailedIcon } from '../assets/img/icons/failed.svg' +import { ReactComponent as ProcessingIcon } from '../assets/img/icons/processing.svg' +import { FILTransaction } from '../typings' +import { brownseTransactionTracker } from '../lib/station-config' + +interface WalletTransactoinStatusWidgetProps { + currentTransaction: FILTransaction, + renderBackground: boolean +} + +const WalletTransactoinStatusWidget: FC = ({ currentTransaction, renderBackground = true }) => { + const openExternalURL = (hash: string) => { + brownseTransactionTracker(hash) + } + + if (currentTransaction?.status === 'sent') { + return ( +
+ + Sent +
+ ) + } else if (currentTransaction?.status === 'processing') { + return ( +
+ + Processing... +
+ ) + } else if (currentTransaction?.status === 'failed') { + return ( +
+ + Failed +

openExternalURL(currentTransaction.hash)}>View Transaction

+
+ ) + } + + return (<>) +} + +export default WalletTransactoinStatusWidget diff --git a/renderer/src/components/WalletTransactionsHistory.tsx b/renderer/src/components/WalletTransactionsHistory.tsx new file mode 100644 index 000000000..c0c59f13d --- /dev/null +++ b/renderer/src/components/WalletTransactionsHistory.tsx @@ -0,0 +1,104 @@ +import { FC } from 'react' +import { FILTransaction } from '../typings' +import dayjs from 'dayjs' +import { ReactComponent as IncomeIcon } from '../assets/img/icons/income.svg' +import { ReactComponent as OutcomeIcon } from '../assets/img/icons/outcome.svg' +import { ReactComponent as ExternalLinkIcon } from '../assets/img/icons/external.svg' +import WalletTransactoinStatusWidget from './WalletTransactionStatusWidget' +import { brownseTransactionTracker } from '../lib/station-config' +import WalletOnboarding from './WalletOnboarding' + +interface WalletTransactionsHistoryProps { + allTransactions: FILTransaction[] | [], + latestTransaction: FILTransaction | undefined +} + +const WalletTransactionsHistory: FC = ({ allTransactions = [], latestTransaction = undefined }) => { + const confirmedTransactions = allTransactions.filter((t) => (t.timestamp !== latestTransaction?.timestamp)) + + const renderTransactionHistory = () => { + if (allTransactions.length > 0) { + return ( + <> + {latestTransaction && + + } +
+

WALLET HISTORY

+ {confirmedTransactions.map( + (transaction) => )} +
+ + ) + } + return () + } + + return (renderTransactionHistory()) +} + +interface TransactionProps { + transaction: FILTransaction +} + +const RecentTransaction: FC = ({ transaction }) => { + return ( +
+

ONGOING TRANSFER

+
+
+
+ {transaction.outgoing + ? + : + } + + {dayjs(transaction.timestamp).format('HH:MM')} + + + { transaction.status === 'sent' ? 'Sent' : transaction.status === 'failed' ? 'Failed to send' : 'Sending' } + {transaction.amount} FIL + {transaction.outgoing && 'to'} + {transaction.outgoing && {transaction.address}} + +
+
+
+
+
+ ) +} + +const Transaction: FC = ({ transaction }) => { + const openExternalURL = (hash: string) => { + brownseTransactionTracker(hash) + } + + return ( +
+
+
+ {transaction.outgoing + ? + : + } + + {dayjs(transaction.timestamp).format('DD/MM/YYYY')} + + + {transaction.outgoing ? 'Sent' : 'Received'} + {transaction.amount} FIL + {transaction.outgoing && 'to'} + {transaction.outgoing && {transaction.address}} + +
+
+

openExternalURL(transaction.hash)}>

+
+
+
+ ) +} + +export default WalletTransactionsHistory diff --git a/renderer/src/components/WalletWidget.tsx b/renderer/src/components/WalletWidget.tsx new file mode 100644 index 000000000..153e36241 --- /dev/null +++ b/renderer/src/components/WalletWidget.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react' +import useWallet from '../hooks/StationWallet' + +import { ReactComponent as WalletIcon } from '../assets/img/icons/wallet.svg' +import WalletTransactoinStatusWidget from './WalletTransactionStatusWidget' + +interface WalletWidgetProps { + onClick: () => void +} + +const WalletWidget: FC = ({ onClick }) => { + const { walletBalance, currentTransaction, dismissCurrentTransaction } = useWallet() + + return ( +
{ onClick(); dismissCurrentTransaction() }}> +
+ + {walletBalance.toLocaleString(undefined, { minimumFractionDigits: 3 }) || 0 } FIL + +
+ { currentTransaction && } +
+ ) +} + +export default WalletWidget diff --git a/renderer/src/lib/station-config.tsx b/renderer/src/lib/station-config.tsx index 04db50e65..eae9a8864 100644 --- a/renderer/src/lib/station-config.tsx +++ b/renderer/src/lib/station-config.tsx @@ -53,8 +53,7 @@ export async function getDestinationWalletAddress (): Promise { - return await window.electron.saturnNode.setFilAddress(address) - // return await window.electron.stationConfig.setDestinationWalletAddress(address) + return await window.electron.stationConfig.setDestinationWalletAddress(address) } export async function getStationWalletAddress (): Promise { diff --git a/renderer/src/pages/Dashboard.tsx b/renderer/src/pages/Dashboard.tsx index 8ef352fb6..46afcd17f 100644 --- a/renderer/src/pages/Dashboard.tsx +++ b/renderer/src/pages/Dashboard.tsx @@ -1,62 +1,38 @@ -import { useEffect, useState } from 'react' -import { stopSaturnNode, setDestinationWalletAddress, getDestinationWalletAddress } from '../lib/station-config' -import ActivityLog from '../components/ActivityLog' +import { useState } from 'react' import HeaderBackgroundImage from '../assets/img/header.png' -import WalletIcon from '../assets/img/wallet.svg' -import { useNavigate } from 'react-router-dom' -import { confirmChangeWalletAddress } from '../lib/dialogs' +import Modal from '../components/Modal' +import ActivityLog from '../components/ActivityLog' import UpdateBanner from '../components/UpdateBanner' +import WalletWidget from '../components/WalletWidget' import useStationActivity from '../hooks/StationActivity' const Dashboard = (): JSX.Element => { - const navigate = useNavigate() - const [address, setAddress] = useState() const { totalJobs, totalEarnings, activities } = useStationActivity() - const shortAddress = (str: string) => str - ? str.substring(0, 4) + '...' + str.substring(str.length - 4, str.length) - : '' - const disconnect = async () => { - if (!(await confirmChangeWalletAddress())) return - await stopSaturnNode() - await setDestinationWalletAddress('') - setAddress(undefined) - navigate('/wallet', { replace: true }) - } - - useEffect(() => { - const loadStoredInfo = async () => { - setAddress(await getDestinationWalletAddress()) - } - loadStoredInfo() - }, []) + const [walletCurtainIsOpen, setWalletCurtainIsOpen] = useState(false) + const toggleCurtain = () => setWalletCurtainIsOpen(!walletCurtainIsOpen) return (
+
+ +
-
-
+
-
- -
+

Total Jobs Completed

-

{totalJobs}

+

{totalJobs.toLocaleString()}

Total Earnings (coming soon)

diff --git a/renderer/src/pages/Onboarding.tsx b/renderer/src/pages/Onboarding.tsx index 9c40fcef9..d3d91cd2b 100644 --- a/renderer/src/pages/Onboarding.tsx +++ b/renderer/src/pages/Onboarding.tsx @@ -31,7 +31,7 @@ const OnboardingPage = (): JSX.Element => { useEffect(() => { if (isOnboardingCompleted) { - navigate('/wallet', { replace: true }) + navigate('/dashboard', { replace: true }) } }, [isOnboardingCompleted, navigate]) diff --git a/renderer/src/pages/WalletConfig.tsx b/renderer/src/pages/WalletConfig.tsx deleted file mode 100644 index 4565bf020..000000000 --- a/renderer/src/pages/WalletConfig.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useCallback } from 'react' -import FilAddressForm from '../components/FilAddressForm' -import BackgroundGraph from './../assets/img/graph.svg' -import { useNavigate } from 'react-router-dom' -import { startSaturnNode, setDestinationWalletAddress as saveFilAddress } from '../lib/station-config' -import UpdateBanner from '../components/UpdateBanner' - -const WalletConfig = (): JSX.Element => { - const navigate = useNavigate() - - const setStationFilAddress = useCallback(async (address: string | undefined) => { - await saveFilAddress(address) - startSaturnNode() - navigate('/dashboard', { replace: true }) - }, [navigate]) - - return ( -
- - station background -
- -
-
-

- Connect a FIL address to Station to start earning FIL -

- -
-
- -
- ) -} - -export default WalletConfig diff --git a/tailwind.config.js b/tailwind.config.js index e6a7b5a13..aee69c403 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -33,6 +33,7 @@ module.exports = { primary: '#2a1cf7', 'primary-hover': '#1A1199', 'primary-click': '#2317CC', + 'primary-dark': '#330867', accent: '#40ffc4', secondary: '#30b7e8', 'secondary-accent': '#d5f710', @@ -40,16 +41,25 @@ module.exports = { grayscale: { 100: '#f0f0f0', 200: '#f7f7f7', - 300: '#ededed', - 350: '#ebeaea', - 400: '#cccccc', + 250: '#e9ebf1', + 300: '#ebeaea', + 400: '#c3cad9', 500: '#b3b3b3', 600: '#666666', 700: '#313131' }, - success: '#33cc9d', - error: '#ff4d81', - warning: '#f76003', + green: { + 100: '#33cc9d', + 200: '#68cc58' + }, + red: { + 100: '#ce5347', + 200: '#ff4d81' + }, + orange: { + 100: '#f5c451', + 200: '#f76003' + }, transparent: '#ffffff00' } }, From 9c09f54cb304cf5be5218f3247ecb00082f9aef8 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Wed, 30 Nov 2022 17:37:23 +0000 Subject: [PATCH 25/33] feat: status indicator animations --- renderer/src/components/TransferFunds.tsx | 7 +- renderer/src/components/WalletModule.tsx | 7 +- .../WalletTransactionStatusWidget.tsx | 6 +- .../components/WalletTransactionsHistory.tsx | 117 ++++++++++-------- renderer/src/components/WalletWidget.tsx | 15 ++- renderer/src/hooks/StationWallet.tsx | 12 +- 6 files changed, 92 insertions(+), 72 deletions(-) diff --git a/renderer/src/components/TransferFunds.tsx b/renderer/src/components/TransferFunds.tsx index 7f675d20f..27c2ceb24 100644 --- a/renderer/src/components/TransferFunds.tsx +++ b/renderer/src/components/TransferFunds.tsx @@ -6,10 +6,11 @@ interface TransferFundsButtonsProps { balance: number, enableTransferMode: () => void, transferAllFunds: () => void, - disabled: boolean + disabled: boolean, + reset: () => reset } -const TransferFundsButtons: FC = ({ transferMode, balance, enableTransferMode, transferAllFunds, disabled }) => { +const TransferFundsButtons: FC = ({ transferMode, balance, enableTransferMode, transferAllFunds, disabled, reset }) => { return ( <> {transferMode @@ -17,7 +18,7 @@ const TransferFundsButtons: FC = ({ transferMode, bal -
diff --git a/renderer/src/components/WalletModule.tsx b/renderer/src/components/WalletModule.tsx index d5bd971af..721d9568b 100644 --- a/renderer/src/components/WalletModule.tsx +++ b/renderer/src/components/WalletModule.tsx @@ -81,18 +81,19 @@ const WalletModule: FC = ({ isOpen = false }) => { balance={walletBalance} enableTransferMode={enableTransferMode} transferAllFunds={transferAllFunds} + reset={reset} disabled={!destinationFilAddress} /> ) } } return ( -
+

STATION ADDRESS

{stationAddress}

-
+
{ renderAddress() } @@ -109,7 +110,7 @@ const WalletModule: FC = ({ isOpen = false }) => {
- +
) diff --git a/renderer/src/components/WalletTransactionStatusWidget.tsx b/renderer/src/components/WalletTransactionStatusWidget.tsx index 94c17593a..6b916e440 100644 --- a/renderer/src/components/WalletTransactionStatusWidget.tsx +++ b/renderer/src/components/WalletTransactionStatusWidget.tsx @@ -17,21 +17,21 @@ const WalletTransactoinStatusWidget: FC = ({ if (currentTransaction?.status === 'sent') { return ( -
+
Sent
) } else if (currentTransaction?.status === 'processing') { return ( -
+
Processing...
) } else if (currentTransaction?.status === 'failed') { return ( -
+
Failed

openExternalURL(currentTransaction.hash)}>View Transaction

diff --git a/renderer/src/components/WalletTransactionsHistory.tsx b/renderer/src/components/WalletTransactionsHistory.tsx index c0c59f13d..c51a503c6 100644 --- a/renderer/src/components/WalletTransactionsHistory.tsx +++ b/renderer/src/components/WalletTransactionsHistory.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import { FC, useState, useEffect } from 'react' import { FILTransaction } from '../typings' import dayjs from 'dayjs' import { ReactComponent as IncomeIcon } from '../assets/img/icons/income.svg' @@ -13,92 +13,101 @@ interface WalletTransactionsHistoryProps { latestTransaction: FILTransaction | undefined } -const WalletTransactionsHistory: FC = ({ allTransactions = [], latestTransaction = undefined }) => { - const confirmedTransactions = allTransactions.filter((t) => (t.timestamp !== latestTransaction?.timestamp)) - +const WalletTransactionsHistory: FC = ({ allTransactions = [], latestTransaction }) => { const renderTransactionHistory = () => { - if (allTransactions.length > 0) { - return ( + return ( <> - {latestTransaction && - - } -
-

WALLET HISTORY

- {confirmedTransactions.map( - (transaction) => )} -
+
0 ? ' fixed opacity-0 invisible translate-y-[200px]' : 'visible'}`}>
+
0 ? 'visible' : ' fixed opacity-0 invisible -translate-y-[50px]'}`}> +

WALLET HISTORY

+ { allTransactions.map((transaction, index) =>
)} +
- ) - } - return () + ) } - return (renderTransactionHistory()) + return ( +
+ +
+ {renderTransactionHistory()} +
+
+ ) } interface TransactionProps { - transaction: FILTransaction + transaction: FILTransaction | undefined } const RecentTransaction: FC = ({ transaction }) => { + const [displayTransition, setDisplayTransaction] = useState({} as FILTransaction) + + useEffect(() => { + if (transaction !== undefined) { + setDisplayTransaction(transaction) + } + }, [transaction]) + return ( -
+
!transaction && setDisplayTransaction(undefined)}>

ONGOING TRANSFER

- {transaction.outgoing + {displayTransition?.outgoing ? : } - {dayjs(transaction.timestamp).format('HH:MM')} + {dayjs(displayTransition?.timestamp).format('HH:MM')} - { transaction.status === 'sent' ? 'Sent' : transaction.status === 'failed' ? 'Failed to send' : 'Sending' } - {transaction.amount} FIL - {transaction.outgoing && 'to'} - {transaction.outgoing && {transaction.address}} + {displayTransition?.status === 'sent' ? 'Sent' : displayTransition?.status === 'failed' ? 'Failed to send' : 'Sending'} + {displayTransition?.amount} FIL + {displayTransition?.outgoing && 'to'} + {displayTransition?.outgoing && {displayTransition?.address}}
-
+
{displayTransition && }
) } const Transaction: FC = ({ transaction }) => { - const openExternalURL = (hash: string) => { - brownseTransactionTracker(hash) - } - - return ( -
-
-
- {transaction.outgoing - ? - : - } - - {dayjs(transaction.timestamp).format('DD/MM/YYYY')} - - - {transaction.outgoing ? 'Sent' : 'Received'} - {transaction.amount} FIL - {transaction.outgoing && 'to'} - {transaction.outgoing && {transaction.address}} - -
-
-

openExternalURL(transaction.hash)}>

+ const openExternalURL = (hash: string) => { brownseTransactionTracker(hash) } + if (transaction) { + return ( +
+
+
+ {transaction.outgoing + ? + : + } + + {dayjs(transaction.timestamp).format('DD/MM/YYYY')} + + + {transaction.outgoing ? 'Sent' : 'Received'} + {transaction.amount} FIL + {transaction.outgoing && 'to'} + {transaction.outgoing && {transaction.address}} + +
+
+

openExternalURL(transaction.hash)}>

+
-
- ) + ) + } + return (<>) } export default WalletTransactionsHistory diff --git a/renderer/src/components/WalletWidget.tsx b/renderer/src/components/WalletWidget.tsx index 153e36241..cb3492b8b 100644 --- a/renderer/src/components/WalletWidget.tsx +++ b/renderer/src/components/WalletWidget.tsx @@ -1,8 +1,9 @@ -import { FC } from 'react' +import { FC, useEffect, useState } from 'react' import useWallet from '../hooks/StationWallet' import { ReactComponent as WalletIcon } from '../assets/img/icons/wallet.svg' import WalletTransactoinStatusWidget from './WalletTransactionStatusWidget' +import { FILTransaction } from '../typings' interface WalletWidgetProps { onClick: () => void @@ -10,6 +11,13 @@ interface WalletWidgetProps { const WalletWidget: FC = ({ onClick }) => { const { walletBalance, currentTransaction, dismissCurrentTransaction } = useWallet() + const [displayTransition, setDisplayTransaction] = useState(undefined) + + useEffect(() => { + if (currentTransaction !== undefined) { + setDisplayTransaction(currentTransaction) + } + }, [currentTransaction]) return (
{ onClick(); dismissCurrentTransaction() }}> @@ -20,7 +28,10 @@ const WalletWidget: FC = ({ onClick }) => { Open Wallet
- { currentTransaction && } +
!currentTransaction && setDisplayTransaction(undefined)}> + {displayTransition && } +
) } diff --git a/renderer/src/hooks/StationWallet.tsx b/renderer/src/hooks/StationWallet.tsx index 3d0582ef1..5e95a06ad 100644 --- a/renderer/src/hooks/StationWallet.tsx +++ b/renderer/src/hooks/StationWallet.tsx @@ -23,7 +23,7 @@ const useWallet = (): Wallet => { const [destinationFilAddress, setDestinationFilAddress] = useState() const [walletBalance, setWalletBalance] = useState(0) const [walletTransactions, setWalletTransactions] = useState([]) - const [currentTransaction, setCurrentTransaction] = useState() + const [currentTransaction, setCurrentTransaction] = useState() const editDestinationAddress = async (address: string | undefined) => { await setDestinationWalletAddress(address) @@ -32,7 +32,6 @@ const useWallet = (): Wallet => { const dismissCurrentTransaction = () => { if (currentTransaction && currentTransaction.status !== 'processing') { - setWalletTransactions([currentTransaction, ...walletTransactions]) setCurrentTransaction(undefined) } } @@ -63,17 +62,16 @@ const useWallet = (): Wallet => { setWalletTransactions(await getStationWalletTransactionsHistory()) } loadStoredInfo() + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { const updateWalletTransactionsArray = (transactions: FILTransaction[]) => { - const newCurrentTransaction = transactions[0] + const [newCurrentTransaction, ...confirmedTransactions] = transactions if (newCurrentTransaction.status === 'processing' || (currentTransaction && +currentTransaction.timestamp === +newCurrentTransaction.timestamp)) { setCurrentTransaction(newCurrentTransaction) if (newCurrentTransaction.status !== 'processing') { setTimeout(() => { setWalletTransactions(transactions); setCurrentTransaction(undefined) }, 6000) } - - const transactionsExceptLatest = transactions.filter((t) => { return t !== newCurrentTransaction }) - setWalletTransactions(transactionsExceptLatest) + setWalletTransactions(confirmedTransactions) } else { setWalletTransactions(transactions) } @@ -92,7 +90,7 @@ const useWallet = (): Wallet => { } }, [walletBalance]) - return { stationAddress, destinationFilAddress, walletBalance, walletTransactions, editDestinationAddress, currentTransaction, dismissCurrentTransaction } + return { stationAddress, destinationFilAddress, walletBalance, walletTransactions, editDestinationAddress, currentTransaction, dismissCurrentTransaction} } export default useWallet From 80281df5abe56840d1b2038cb2871a9deefeba48 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Mon, 5 Dec 2022 19:08:22 +0000 Subject: [PATCH 26/33] wip: animations and transitions --- renderer/src/components/TransferFunds.tsx | 20 ++++----- renderer/src/components/WalletModule.tsx | 53 ++++++++--------------- 2 files changed, 28 insertions(+), 45 deletions(-) diff --git a/renderer/src/components/TransferFunds.tsx b/renderer/src/components/TransferFunds.tsx index 27c2ceb24..485a6c616 100644 --- a/renderer/src/components/TransferFunds.tsx +++ b/renderer/src/components/TransferFunds.tsx @@ -7,29 +7,30 @@ interface TransferFundsButtonsProps { enableTransferMode: () => void, transferAllFunds: () => void, disabled: boolean, - reset: () => reset + reset: () => void } const TransferFundsButtons: FC = ({ transferMode, balance, enableTransferMode, transferAllFunds, disabled, reset }) => { return ( - <> - {transferMode - ?
- -
- :
+
{disabled && -
+

We need a FIL address to transfer your FIL

@@ -37,8 +38,7 @@ const TransferFundsButtons: FC = ({ transferMode, bal
}
- } - +
) } diff --git a/renderer/src/components/WalletModule.tsx b/renderer/src/components/WalletModule.tsx index 721d9568b..67c4432d7 100644 --- a/renderer/src/components/WalletModule.tsx +++ b/renderer/src/components/WalletModule.tsx @@ -1,6 +1,5 @@ import { FC, useEffect, useState } from 'react' import HeaderBackgroundImage from '../assets/img/header-curtain.png' -import { ReactComponent as EditIcon } from '../assets/img/icons/edit.svg' import FilAddressForm from './FilAddressForm' import WalletTransactionsHistory from './WalletTransactionsHistory' @@ -13,8 +12,8 @@ interface PropsWallet { } const WalletModule: FC = ({ isOpen = false }) => { - const [editMode, setEditMode] = useState(false) - const [trasnferMode, setTransferMode] = useState(false) + const [editMode, setEditMode] = useState(true) + const [transferMode, setTransferMode] = useState(false) const { stationAddress, destinationFilAddress, walletBalance, walletTransactions, editDestinationAddress, currentTransaction, dismissCurrentTransaction } = useWallet() useEffect(() => { @@ -23,12 +22,18 @@ const WalletModule: FC = ({ isOpen = false }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]) + useEffect(() => { + if (!destinationFilAddress || destinationFilAddress === '') { + setEditMode(true) + } + }, [editMode, destinationFilAddress]) + const reset = () => { setEditMode(false) setTransferMode(false) } - const enableEditMode = () => { + const enableEditMode = () => { setEditMode(true) setTransferMode(false) } @@ -40,7 +45,7 @@ const WalletModule: FC = ({ isOpen = false }) => { const saveAddress = async (address: string | undefined) => { editDestinationAddress(address) - setEditMode(false) + reset() } const transferAllFunds = async () => { @@ -48,36 +53,11 @@ const WalletModule: FC = ({ isOpen = false }) => { setTransferMode(false) } - const renderAddress = () => { - if (editMode || !destinationFilAddress) { - return (
(setEditMode(true))}> - -
) - } - return ( -
{ setTransferMode(false) }}> - Your FIL Address -
-

{destinationFilAddress}

- {!trasnferMode && - - } -
-
- ) - } - const renderTransferButtons = () => { if (!editMode || !destinationFilAddress) { return ( = ({ isOpen = false }) => {

{stationAddress}

-
-
- { renderAddress() } +
+
setTransferMode(false)}> +
+ +
-
{ setEditMode(false) }}> +
setEditMode(false) }>

Total FIL

From 1401d585ffbb33a06bb0e56cefd17d70d547152e Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Mon, 5 Dec 2022 19:08:50 +0000 Subject: [PATCH 27/33] wip: address input animations and transitions --- renderer/src/components/FilAddressForm.tsx | 58 +++++++++++++++------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/renderer/src/components/FilAddressForm.tsx b/renderer/src/components/FilAddressForm.tsx index 063866fb9..7210a6aae 100644 --- a/renderer/src/components/FilAddressForm.tsx +++ b/renderer/src/components/FilAddressForm.tsx @@ -1,18 +1,23 @@ -import { FC, useState, SyntheticEvent, useEffect } from 'react' +import { FC, useState, useEffect } from 'react' import { checkAddressString } from '@glif/filecoin-address' import { ReactComponent as Warning } from '../assets/img/icons/error.svg' +import { ReactComponent as EditIcon } from '../assets/img/icons/edit.svg' interface FilAddressFormProps { destinationAddress: string | undefined, saveDestinationAddress: (address: string | undefined) => void, - editMode: boolean + editMode: boolean, + transferMode: boolean, + enableEditMode: () => void } -const FilAddressForm: FC = ({ destinationAddress = '', saveDestinationAddress, editMode }) => { +const FilAddressForm: FC = ({ destinationAddress = '', saveDestinationAddress, editMode, transferMode, enableEditMode }) => { const [addressIsValid, setAddressIsValid] = useState() const [inputAddr, setInputAddr] = useState(destinationAddress) + const [internalEditMode, setInternalEditMode] = useState(false) - useEffect(() => { setInputAddr(destinationAddress) }, [editMode, destinationAddress]) + useEffect(() => setInternalEditMode(editMode), [editMode]) + useEffect(() => { setInputAddr(destinationAddress) }, [destinationAddress]) useEffect(() => { if (inputAddr === '') { @@ -27,7 +32,7 @@ const FilAddressForm: FC = ({ destinationAddress = '', save } }, [inputAddr]) - const handleSubmit = (event: SyntheticEvent) => { + const handleSubmit = (event: { preventDefault: () => void }) => { event.preventDefault() if (addressIsValid) { saveDestinationAddress(inputAddr) @@ -35,8 +40,8 @@ const FilAddressForm: FC = ({ destinationAddress = '', save } const computeInputClasses = () => { - const listOfClasses = 'input w-full block fil-address mt-[7px]' - if (inputAddr === destinationAddress) { + const listOfClasses = `input fil-address mt-[7px] min-w-[90px] w-[460px] ease-in-out transition duration-700 ${internalEditMode ? '' : 'w-fit border-opacity-0'}` + if (inputAddr === destinationAddress || !internalEditMode) { return listOfClasses } if (addressIsValid) { @@ -47,6 +52,10 @@ const FilAddressForm: FC = ({ destinationAddress = '', save } const renderBottomMessage = () => { + if (!internalEditMode) { + return (

 

) + } + if (addressIsValid) { return (

Enter an address to receive your FIL.

) } @@ -59,13 +68,31 @@ const FilAddressForm: FC = ({ destinationAddress = '', save ) } + const renderActionButton = () => { + return ( + <> + + + + ) + } + return ( <> -
-
+
+
{ setInputAddr(event.target.value) }} className={computeInputClasses()} /> @@ -74,15 +101,8 @@ const FilAddressForm: FC = ({ destinationAddress = '', save Your FIL Address {renderBottomMessage()}
- {(inputAddr !== destinationAddress || inputAddr.length > 0) && - - } - + { renderActionButton() } +
) } From f6f95094b5be9435deef41edc26411b4a9aebf25 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Wed, 7 Dec 2022 00:18:36 +0000 Subject: [PATCH 28/33] wip: address input animations and transitions --- renderer/src/App.css | 4 +- renderer/src/components/FilAddressForm.tsx | 52 +++++++++++----------- renderer/src/components/Modal.tsx | 4 +- renderer/src/components/TransferFunds.tsx | 40 ++++++++++------- renderer/src/components/WalletModule.tsx | 25 +++++------ 5 files changed, 64 insertions(+), 61 deletions(-) diff --git a/renderer/src/App.css b/renderer/src/App.css index 3eb506c4d..b2937f266 100644 --- a/renderer/src/App.css +++ b/renderer/src/App.css @@ -59,8 +59,8 @@ @layer components { .input { - @apply text-header-3xs font-body border-dashed border-0 border-b text-white - bg-transparent appearance-none focus:outline-none focus:ring-0 focus:border-white border-white; + @apply text-header-3xs font-body text-white + bg-transparent appearance-none focus:outline-none focus:ring-0; } .btn-primary { diff --git a/renderer/src/components/FilAddressForm.tsx b/renderer/src/components/FilAddressForm.tsx index 7210a6aae..7116b19db 100644 --- a/renderer/src/components/FilAddressForm.tsx +++ b/renderer/src/components/FilAddressForm.tsx @@ -17,7 +17,7 @@ const FilAddressForm: FC = ({ destinationAddress = '', save const [internalEditMode, setInternalEditMode] = useState(false) useEffect(() => setInternalEditMode(editMode), [editMode]) - useEffect(() => { setInputAddr(destinationAddress) }, [destinationAddress]) + useEffect(() => { setInputAddr(destinationAddress) }, [editMode, destinationAddress]) useEffect(() => { if (inputAddr === '') { @@ -39,31 +39,26 @@ const FilAddressForm: FC = ({ destinationAddress = '', save } } - const computeInputClasses = () => { - const listOfClasses = `input fil-address mt-[7px] min-w-[90px] w-[460px] ease-in-out transition duration-700 ${internalEditMode ? '' : 'w-fit border-opacity-0'}` + const computeBorderClasses = () => { + const listOfClasses = `border border-b-[0.5px] h-[1px] border-dashed ease-[cubic-bezier(0.85,0,0.15,1)] duration-500 ${internalEditMode ? '' : 'invisible opacity-0'}` if (inputAddr === destinationAddress || !internalEditMode) { - return listOfClasses + return `${listOfClasses} border-white` } if (addressIsValid) { - return `${listOfClasses} border-solid border-green-100 focus:border-solid focus:border-green-100` + return `${listOfClasses} border-solid border-green-100` } else { - return `${listOfClasses} border-red-200 focus:border-red-200` + return `${listOfClasses} border-red-200` } } const renderBottomMessage = () => { - if (!internalEditMode) { - return (

 

) - } - - if (addressIsValid) { - return (

Enter an address to receive your FIL.

) - } - return ( -
- - The FIL address entered is invalid. Please check and try again. +
+
+ + The FIL address entered is invalid. Please check and try again. +
+

Enter an address to receive your FIL.

) } @@ -71,23 +66,25 @@ const FilAddressForm: FC = ({ destinationAddress = '', save const renderActionButton = () => { return ( <> - + - ) } return ( <> -
+
= ({ destinationAddress = '', save disabled={!internalEditMode} tabIndex={0} value={inputAddr} onChange={(event) => { setInputAddr(event.target.value) }} - className={computeInputClasses()} /> + className='input fil-address mt-[7px] min-w-[90px] w-[460px] ease-in-out transition duration-300' /> - {renderBottomMessage()} +
+ { renderBottomMessage() }
{ renderActionButton() }
diff --git a/renderer/src/components/Modal.tsx b/renderer/src/components/Modal.tsx index 6962fb238..bae0b417c 100644 --- a/renderer/src/components/Modal.tsx +++ b/renderer/src/components/Modal.tsx @@ -8,8 +8,8 @@ interface ModalProps { const Modal : FC = ({ isOpen, setIsOpen }) => { return ( <> -
-
+
+
diff --git a/renderer/src/components/TransferFunds.tsx b/renderer/src/components/TransferFunds.tsx index 485a6c616..d57dddc55 100644 --- a/renderer/src/components/TransferFunds.tsx +++ b/renderer/src/components/TransferFunds.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import { FC, useEffect, useState } from 'react' import { ReactComponent as InfoIcon } from '../assets/img/icons/info.svg' interface TransferFundsButtonsProps { @@ -6,25 +6,21 @@ interface TransferFundsButtonsProps { balance: number, enableTransferMode: () => void, transferAllFunds: () => void, - disabled: boolean, - reset: () => void + reset: () => void, + destinationFilAddress: string | undefined, + editMode: boolean } -const TransferFundsButtons: FC = ({ transferMode, balance, enableTransferMode, transferAllFunds, disabled, reset }) => { +const TransferFundsButtons: FC = ({ transferMode, balance, enableTransferMode, transferAllFunds, reset, destinationFilAddress, editMode }) => { + const [internalEditMode, setInternalEditMode] = useState(false) + + useEffect(() => { setInternalEditMode(transferMode) }, [transferMode]) + + const disabled = !destinationFilAddress return ( -
-
- - -
-
-
}
+
+ + +
) } diff --git a/renderer/src/components/WalletModule.tsx b/renderer/src/components/WalletModule.tsx index 67c4432d7..f674bb85c 100644 --- a/renderer/src/components/WalletModule.tsx +++ b/renderer/src/components/WalletModule.tsx @@ -33,7 +33,7 @@ const WalletModule: FC = ({ isOpen = false }) => { setTransferMode(false) } - const enableEditMode = () => { + const enableEditMode = () => { setEditMode(true) setTransferMode(false) } @@ -54,17 +54,16 @@ const WalletModule: FC = ({ isOpen = false }) => { } const renderTransferButtons = () => { - if (!editMode || !destinationFilAddress) { - return ( - - ) - } + return ( + + ) } return ( @@ -77,7 +76,7 @@ const WalletModule: FC = ({ isOpen = false }) => {
setTransferMode(false)}>
-
From 22c3c733f68853b17d4cadcf979359582e0aa291 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Mon, 12 Dec 2022 11:16:30 +0000 Subject: [PATCH 29/33] feat: address input animations and transitions --- renderer/src/components/FilAddressForm.tsx | 24 ++++++---- renderer/src/components/TransferFunds.tsx | 54 +++++++++++----------- renderer/src/components/WalletModule.tsx | 16 ++----- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/renderer/src/components/FilAddressForm.tsx b/renderer/src/components/FilAddressForm.tsx index 7116b19db..cbf9fb8a2 100644 --- a/renderer/src/components/FilAddressForm.tsx +++ b/renderer/src/components/FilAddressForm.tsx @@ -16,8 +16,10 @@ const FilAddressForm: FC = ({ destinationAddress = '', save const [inputAddr, setInputAddr] = useState(destinationAddress) const [internalEditMode, setInternalEditMode] = useState(false) - useEffect(() => setInternalEditMode(editMode), [editMode]) - useEffect(() => { setInputAddr(destinationAddress) }, [editMode, destinationAddress]) + useEffect(() => { + setInternalEditMode(editMode || destinationAddress === '') + setInputAddr(destinationAddress) + }, [editMode, destinationAddress]) useEffect(() => { if (inputAddr === '') { @@ -40,9 +42,12 @@ const FilAddressForm: FC = ({ destinationAddress = '', save } const computeBorderClasses = () => { - const listOfClasses = `border border-b-[0.5px] h-[1px] border-dashed ease-[cubic-bezier(0.85,0,0.15,1)] duration-500 ${internalEditMode ? '' : 'invisible opacity-0'}` - if (inputAddr === destinationAddress || !internalEditMode) { - return `${listOfClasses} border-white` + const listOfClasses = `border-b-[0.5px] border-white h-[1px] border-solid ease-[cubic-bezier(0.85,0,0.15,1)] duration-500 ${internalEditMode ? '' : 'invisible opacity-0'}` + if (!editMode) { + return `${listOfClasses} border-white border-dashed` + } + if (internalEditMode && inputAddr === destinationAddress) { + return listOfClasses } if (addressIsValid) { return `${listOfClasses} border-solid border-green-100` @@ -67,8 +72,8 @@ const FilAddressForm: FC = ({ destinationAddress = '', save return ( <> @@ -87,12 +92,13 @@ const FilAddressForm: FC = ({ destinationAddress = '', save
{ setInputAddr(event.target.value) }} - className='input fil-address mt-[7px] min-w-[90px] w-[460px] ease-in-out transition duration-300' /> + className='input fil-address mt-[7px] min-w-[90px] w-[460px] ease-in-out transition duration-300' + onFocus={() => { enableEditMode() }}/> diff --git a/renderer/src/components/TransferFunds.tsx b/renderer/src/components/TransferFunds.tsx index d57dddc55..1c8c8964e 100644 --- a/renderer/src/components/TransferFunds.tsx +++ b/renderer/src/components/TransferFunds.tsx @@ -12,38 +12,38 @@ interface TransferFundsButtonsProps { } const TransferFundsButtons: FC = ({ transferMode, balance, enableTransferMode, transferAllFunds, reset, destinationFilAddress, editMode }) => { - const [internalEditMode, setInternalEditMode] = useState(false) + const [internalTransferMode, setInternalTransferMode] = useState(false) - useEffect(() => { setInternalEditMode(transferMode) }, [transferMode]) + useEffect(() => { setInternalTransferMode(transferMode) }, [transferMode]) const disabled = !destinationFilAddress return ( -
-
- - {disabled && -
-
-

We need a FIL address to transfer your FIL

-
- +
+
+ + {disabled && +
+
+

We need a FIL address to transfer your FIL

- } -
-
- - -
+ +
+ } +
+
+ + +
) } diff --git a/renderer/src/components/WalletModule.tsx b/renderer/src/components/WalletModule.tsx index f674bb85c..2d6a136dd 100644 --- a/renderer/src/components/WalletModule.tsx +++ b/renderer/src/components/WalletModule.tsx @@ -12,7 +12,7 @@ interface PropsWallet { } const WalletModule: FC = ({ isOpen = false }) => { - const [editMode, setEditMode] = useState(true) + const [editMode, setEditMode] = useState(false) const [transferMode, setTransferMode] = useState(false) const { stationAddress, destinationFilAddress, walletBalance, walletTransactions, editDestinationAddress, currentTransaction, dismissCurrentTransaction } = useWallet() @@ -22,12 +22,6 @@ const WalletModule: FC = ({ isOpen = false }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]) - useEffect(() => { - if (!destinationFilAddress || destinationFilAddress === '') { - setEditMode(true) - } - }, [editMode, destinationFilAddress]) - const reset = () => { setEditMode(false) setTransferMode(false) @@ -73,17 +67,17 @@ const WalletModule: FC = ({ isOpen = false }) => {

{stationAddress}

-
+
setTransferMode(false)}>
-
setEditMode(false) }> +
setEditMode(false) }>
-

Total FIL

-

+

Total FIL

+

{walletBalance.toLocaleString(undefined, { minimumFractionDigits: 3 })}FIL

From 5640a7b196cfceb7c873d22d2a8a637c2721e39c Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Mon, 12 Dec 2022 12:23:17 +0000 Subject: [PATCH 30/33] fixup: eslint cleanup --- renderer/src/hooks/StationWallet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renderer/src/hooks/StationWallet.tsx b/renderer/src/hooks/StationWallet.tsx index 5e95a06ad..a72da7e43 100644 --- a/renderer/src/hooks/StationWallet.tsx +++ b/renderer/src/hooks/StationWallet.tsx @@ -90,7 +90,7 @@ const useWallet = (): Wallet => { } }, [walletBalance]) - return { stationAddress, destinationFilAddress, walletBalance, walletTransactions, editDestinationAddress, currentTransaction, dismissCurrentTransaction} + return { stationAddress, destinationFilAddress, walletBalance, walletTransactions, editDestinationAddress, currentTransaction, dismissCurrentTransaction } } export default useWallet From 0f588279a4b0f452aeeed13f1f53707896aa2149 Mon Sep 17 00:00:00 2001 From: Pedro Oliveira Date: Mon, 12 Dec 2022 16:28:39 +0000 Subject: [PATCH 31/33] feat: input focus on edit --- renderer/src/components/FilAddressForm.tsx | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/renderer/src/components/FilAddressForm.tsx b/renderer/src/components/FilAddressForm.tsx index cbf9fb8a2..e239edfcc 100644 --- a/renderer/src/components/FilAddressForm.tsx +++ b/renderer/src/components/FilAddressForm.tsx @@ -1,4 +1,4 @@ -import { FC, useState, useEffect } from 'react' +import { FC, useState, useEffect, useRef } from 'react' import { checkAddressString } from '@glif/filecoin-address' import { ReactComponent as Warning } from '../assets/img/icons/error.svg' import { ReactComponent as EditIcon } from '../assets/img/icons/edit.svg' @@ -15,12 +15,21 @@ const FilAddressForm: FC = ({ destinationAddress = '', save const [addressIsValid, setAddressIsValid] = useState() const [inputAddr, setInputAddr] = useState(destinationAddress) const [internalEditMode, setInternalEditMode] = useState(false) + const ref = useRef(null) useEffect(() => { setInternalEditMode(editMode || destinationAddress === '') setInputAddr(destinationAddress) }, [editMode, destinationAddress]) + useEffect(() => { + if (internalEditMode && ref.current) { + ref.current.focus() + if (destinationAddress.length > 0) { ref.current.setSelectionRange(destinationAddress.length, destinationAddress.length) } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [internalEditMode]) + useEffect(() => { if (inputAddr === '') { setAddressIsValid(true) @@ -39,6 +48,7 @@ const FilAddressForm: FC = ({ destinationAddress = '', save if (addressIsValid) { saveDestinationAddress(inputAddr) } + ref.current.blur() } const computeBorderClasses = () => { @@ -77,8 +87,9 @@ const FilAddressForm: FC = ({ destinationAddress = '', save title="save address" type="submit" value="connect" onClick={handleSubmit}> Save -