Compare commits
13 commits
master
...
test-updat
Author | SHA1 | Date | |
---|---|---|---|
|
8b3e0ed325 | ||
|
b90141e9b0 | ||
|
f46d0084eb | ||
|
620126d255 | ||
|
d3ed068911 | ||
|
5386ec1908 | ||
|
f82231c194 | ||
|
063fd8fe0a | ||
|
7a6ca51893 | ||
|
207a1ad88c | ||
|
0476a547b1 | ||
|
412a0c2c36 | ||
|
55b20a8778 |
184 changed files with 6500 additions and 2992 deletions
19
.github/workflows/deploy.yml
vendored
19
.github/workflows/deploy.yml
vendored
|
@ -38,22 +38,7 @@ jobs:
|
|||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
if: startsWith(runner.os, 'mac')
|
||||
with:
|
||||
xcode-version: '13.1.0'
|
||||
# This is gonna be hacky.
|
||||
# Github made us upgrade xcode, which would force an upgrade of electron-builder to fix mac.
|
||||
# But there were bugs with copyfiles / extraFiles that kept seeing duplicates erroring on ln.
|
||||
# A flag USE_HARD_LINKS=false in electron-builder.json was suggested in comments, but that broke windows builds.
|
||||
# So for now we'll install python2 on mac and make sure it can find it.
|
||||
# Remove this after successfully upgrading electron-builder.
|
||||
# HACK part 1
|
||||
- uses: Homebrew/actions/setup-homebrew@master
|
||||
if: startsWith(runner.os, 'mac')
|
||||
# HACK part 2
|
||||
- name: Install Python2
|
||||
if: startsWith(runner.os, 'mac')
|
||||
run: |
|
||||
/bin/bash -c "$(curl -fsSL https://github.com/alfredapp/dependency-scripts/raw/main/scripts/install-python2.sh)"
|
||||
echo "PYTHON_PATH=/usr/local/bin/python" >> $GITHUB_ENV
|
||||
xcode-version: '12.4.0'
|
||||
|
||||
- name: Download blockchain headers
|
||||
run: |
|
||||
|
@ -73,7 +58,7 @@ jobs:
|
|||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
|
||||
WIN_CSC_LINK: https://raw.githubusercontent.com/lbryio/lbry-desktop/master/build/cert2023.pfx
|
||||
WIN_CSC_LINK: https://raw.githubusercontent.com/lbryio/lbry-desktop/master/build/cert-2021-2022.pfx
|
||||
CSC_LINK: https://s3.amazonaws.com/files.lbry.io/cert/osx-csc-2021-2022.p12
|
||||
|
||||
# UI
|
||||
|
|
0
.yarn/versions/5f1212ad.yml
vendored
0
.yarn/versions/5f1212ad.yml
vendored
0
.yarn/versions/6b35c994.yml
vendored
0
.yarn/versions/6b35c994.yml
vendored
0
.yarn/versions/6be5ab70.yml
vendored
0
.yarn/versions/6be5ab70.yml
vendored
0
.yarn/versions/8e384637.yml
vendored
0
.yarn/versions/8e384637.yml
vendored
0
.yarn/versions/c6e2b914.yml
vendored
0
.yarn/versions/c6e2b914.yml
vendored
0
.yarn/versions/d1a18cef.yml
vendored
0
.yarn/versions/d1a18cef.yml
vendored
0
.yarn/versions/ec3a9ddf.yml
vendored
0
.yarn/versions/ec3a9ddf.yml
vendored
77
CHANGELOG.md
77
CHANGELOG.md
|
@ -1,85 +1,18 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [0.53.9] - [2023-2-8]
|
||||
|
||||
### Changed
|
||||
- Updated lbrynet to [0.113.0](https://github.com/lbryio/lbry-sdk/releases/tag/v0.113.0)
|
||||
|
||||
## [0.53.8] - [2022-11-17]
|
||||
|
||||
### Fixed
|
||||
- Selecting a large file in publish no longer crashes ([#7736](https://github.com/lbryio/lbry-desktop/pull/7736))
|
||||
- Unfollowing unpublished channels ([#7737](https://github.com/lbryio/lbry-desktop/pull/7737))
|
||||
|
||||
|
||||
### Changed
|
||||
- Updated xcode to 13.1 and hacked a fix for release ([#7736](https://github.com/lbryio/lbry-desktop/pull/7736))
|
||||
|
||||
## [0.53.7] - [2022-11-10]
|
||||
|
||||
### Added
|
||||
- 'Collections' to txo filter _community pr!_ ([#7711](https://github.com/lbryio/lbry-desktop/pull/7711))
|
||||
- Swap comment servers _community pr!_ ([#7670](https://github.com/lbryio/lbry-desktop/pull/7670))
|
||||
|
||||
### Fixed
|
||||
- Thumbnails no longer disable publish ([#7714](https://github.com/lbryio/lbry-desktop/pull/7714))
|
||||
- Publishing posts were empty ([#7715](https://github.com/lbryio/lbry-desktop/pull/7715))
|
||||
- Minor layout fixes _community pr!_ ([#7709](https://github.com/lbryio/lbry-desktop/pull/7709))
|
||||
- Comment section buttons layout ([#7716](https://github.com/lbryio/lbry-desktop/pull/7716))
|
||||
|
||||
### Changed
|
||||
- Removed watchman and its errors ([#7710](https://github.com/lbryio/lbry-desktop/pull/7710))
|
||||
- Updated lbrynet to [0.112.0](https://github.com/lbryio/lbry-sdk/releases/tag/v0.112.0)
|
||||
|
||||
## [0.53.6] - [2022-10-21]
|
||||
|
||||
### Fixed
|
||||
- Make thumbnails optional ([#7690](https://github.com/lbryio/lbry-desktop/pull/7690))
|
||||
- Show downloads newest first ([#7684](https://github.com/lbryio/lbry-desktop/pull/7684))
|
||||
- Only allow images in image uploader ([#7672](https://github.com/lbryio/lbry-desktop/pull/7672))
|
||||
- Fixed bug with csv exports ([#7697](https://github.com/lbryio/lbry-desktop/pull/7697))
|
||||
- Fixed various upload bugs including transcoding ([#7688](https://github.com/lbryio/lbry-desktop/pull/7688))
|
||||
- Fallback for files with no extension ([#7704](https://github.com/lbryio/lbry-desktop/pull/7704))
|
||||
|
||||
### Changed
|
||||
- Upgraded Electron to v17.2.0 ([#7703](https://github.com/lbryio/lbry-desktop/pull/7703))
|
||||
- Upgraded Electron to v17.0.0 ([#7691](https://github.com/lbryio/lbry-desktop/pull/7691))
|
||||
- Updated lbrynet to [0.111.0](https://github.com/lbryio/lbry-sdk/releases/tag/v0.111.0)
|
||||
|
||||
## [0.53.5] - [2022-08-26]
|
||||
|
||||
### Added
|
||||
- Checkbox to disable background wallpaper ([#7630](https://github.com/lbryio/lbry-desktop/pull/7630))
|
||||
- Handle content blocking from hub ([#7665](https://github.com/lbryio/lbry-desktop/pull/7665))
|
||||
|
||||
### Fixed
|
||||
- Better handle decimals liquidating supports ([#7648](https://github.com/lbryio/lbry-desktop/pull/7648))
|
||||
- Better handle cover uploads ([#7647](https://github.com/lbryio/lbry-desktop/pull/7647))
|
||||
- Use default path when first choosing file on windows ([#7625](https://github.com/lbryio/lbry-desktop/pull/7625))
|
||||
- Emoji button hover ([#7620](https://github.com/lbryio/lbry-desktop/pull/7620))
|
||||
- Prevent infinite retries on thumbs ([#7618](https://github.com/lbryio/lbry-desktop/pull/7618))
|
||||
- Double splash/error on app startup ([#7615](https://github.com/lbryio/lbry-desktop/pull/7615))
|
||||
- App updates are now more coherent, also debs work. ([#7502](https://github.com/lbryio/lbry-desktop/pull/7502))
|
||||
- Better handle many channels moderation calls at startup ([#7674](https://github.com/lbryio/lbry-desktop/pull/7674))
|
||||
- Fix mobile floating viewer position ([#7677](https://github.com/lbryio/lbry-desktop/pull/7677))
|
||||
|
||||
### Changed
|
||||
- Upgraded Electron to v15.5.5 ([#7614](https://github.com/lbryio/lbry-desktop/pull/7614))
|
||||
- Upgraded to lbrynet v0.110.0 ([#7680](https://github.com/lbryio/lbry-desktop/pull/7680))
|
||||
|
||||
|
||||
## [0.53.4] - [2022-06-10]
|
||||
|
||||
### Added
|
||||
## Added
|
||||
- Add top in language category for non-english on homepage ([#7585](https://github.com/lbryio/lbry-desktop/pull/7585))
|
||||
- Auto hosting in settings and hosting first run page ([#7598](https://github.com/lbryio/lbry-desktop/pull/7598))
|
||||
|
||||
### Changed
|
||||
- Updated lbry-sdk to [0.107.2](https://github.com/lbryio/lbry-sdk/releases/tag/v0.107.2)
|
||||
|
||||
### Fixed
|
||||
## Fixed
|
||||
- Better handle empty collections ([#7571](https://github.com/lbryio/lbry-desktop/pull/7571))
|
||||
- Better handle thumbnails in uploads/collections ([#7574](https://github.com/lbryio/lbry-desktop/pull/7574))
|
||||
- Work towards supporting collections of any claim type ([#7578](https://github.com/lbryio/lbry-desktop/pull/7578))
|
||||
|
@ -89,7 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
|
||||
## [0.53.3] - [2022-04-27]
|
||||
|
||||
### Fixed
|
||||
## Fixed
|
||||
- Reverted lbry.tv changes that broke production login ([#7569](https://github.com/lbryio/lbry-desktop/pull/7569))
|
||||
- Reverted lbry.tv changes that broke login ([#7570](https://github.com/lbryio/lbry-desktop/pull/7570))
|
||||
|
||||
|
@ -100,7 +33,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
- Removed some lbrytv references ([#7560](https://github.com/lbryio/lbry-desktop/pull/7560))
|
||||
- Removed some lbrytv player references ([#7552](https://github.com/lbryio/lbry-desktop/pull/7552))
|
||||
|
||||
### Fixed
|
||||
## Fixed
|
||||
- Repost style issues ([#7559](https://github.com/lbryio/lbry-desktop/pull/7559))
|
||||
- Disappearing sidebar thumbs ([#7556](https://github.com/lbryio/lbry-desktop/pull/7556))
|
||||
- Restore tags sidebar link ([#7555](https://github.com/lbryio/lbry-desktop/pull/7555))
|
||||
|
|
10
README.md
10
README.md
|
@ -65,19 +65,21 @@ _Note: If coming from a deb install, the directory structure is different and yo
|
|||
|
||||
| | Flatpak | Arch | Nixpkgs | ARM/ARM64 |
|
||||
| -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| Latest Release | [FlatHub Page](https://flathub.org/apps/details/io.lbry.lbry-app) | [AUR Package](https://aur.archlinux.org/packages/lbry-desktop-bin/) | [Nixpkgs](https://search.nixos.org/packages?channel=unstable&show=lbry&query=lbry) | [Build Guide](https://lbry.tv/@LBRYarm:5) |
|
||||
| Maintainers | N/A | [@RubenKelevra](https://github.com/RubenKelevra) | [@Enderger](https://github.com/enderger) | [@Madiator2011](https://github.com/kodxana) |
|
||||
| Latest Release | [FlatHub Page](https://flathub.org/apps/details/io.lbry.lbry-app) | [AUR Package](https://aur.archlinux.org/packages/lbry-app-bin/) | [Nixpkgs](https://search.nixos.org/packages?channel=unstable&show=lbry&query=lbry) | [Build Guide](https://lbry.tv/@LBRYarm:5) |
|
||||
| Maintainers | [@kcSeb](https://keybase.io/kcseb) | [@kcSeb](https://keybase.io/kcseb) | [@Enderger](https://github.com/enderger) | [@Madiator2011](https://github.com/kodxana) |
|
||||
|
||||
## Usage
|
||||
|
||||
Start the installed application to interact with the LBRY network.
|
||||
Double click the installed application to interact with the LBRY network.
|
||||
|
||||
## Running from Source
|
||||
|
||||
You can run the web version (lbry.tv), the electron app, or both at the same time.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
- [Node.js](https://nodejs.org/en/download/) (v16 required)
|
||||
- [Node.js](https://nodejs.org/en/download/) (v14 required)
|
||||
- [Corepack](https://nodejs.org/dist/latest-v17.x/docs/api/corepack.html) `npm i -g corepack` (Included in nodejs 14 LTS, 16 LTS and 17)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
|
||||
|
|
Binary file not shown.
|
@ -20,12 +20,9 @@ import path from 'path';
|
|||
import { diskSpaceLinux, diskSpaceWindows, diskSpaceMac } from '../ui/util/diskspace';
|
||||
|
||||
const { download } = require('electron-dl');
|
||||
const mime = require('mime');
|
||||
const remote = require('@electron/remote/main');
|
||||
const os = require('os');
|
||||
const sudo = require('sudo-prompt');
|
||||
const probe = require('ffmpeg-probe');
|
||||
const MAX_IPC_SEND_BUFFER_SIZE = 500000000; // large files crash when serialized for ipc message
|
||||
|
||||
remote.initialize();
|
||||
const filePath = path.join(process.resourcesPath, 'static', 'upgradeDisabled');
|
||||
|
@ -302,96 +299,6 @@ app.on('before-quit', () => {
|
|||
appState.isQuitting = true;
|
||||
});
|
||||
|
||||
// Get the content of a file as a raw buffer of bytes.
|
||||
// Useful to convert a file path to a File instance.
|
||||
// Example:
|
||||
// const result = await ipcMain.invoke('get-file-from-path', 'path/to/file');
|
||||
// const file = new File([result.buffer], result.name);
|
||||
// NOTE: if path points to a folder, an empty
|
||||
// file will be given.
|
||||
ipcMain.handle('get-file-from-path', (event, path, readContents = true) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.stat(path, (error, stats) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
// Separate folders considering "\" and "/"
|
||||
// as separators (cross platform)
|
||||
const folders = path.split(/[\\/]/);
|
||||
const name = folders[folders.length - 1];
|
||||
if (stats.isDirectory()) {
|
||||
resolve({
|
||||
name,
|
||||
mime: undefined,
|
||||
path,
|
||||
buffer: new ArrayBuffer(0),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!readContents) {
|
||||
resolve({
|
||||
name,
|
||||
mime: mime.getType(name) || undefined,
|
||||
path,
|
||||
buffer: new ArrayBuffer(0),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Encoding null ensures data results in a Buffer.
|
||||
fs.readFile(path, { encoding: null }, (err, data) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
name,
|
||||
mime: mime.getType(name) || undefined,
|
||||
path,
|
||||
buffer: data,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle('get-file-details-from-path', async (event, path) => {
|
||||
const isFfMp4 = (ffprobeResults) => {
|
||||
return ffprobeResults &&
|
||||
ffprobeResults.format &&
|
||||
ffprobeResults.format.format_name &&
|
||||
ffprobeResults.format.format_name.includes('mp4');
|
||||
};
|
||||
const folders = path.split(/[\\/]/);
|
||||
const name = folders[folders.length - 1];
|
||||
let duration = 0, size = 0, mimeType;
|
||||
try {
|
||||
await fs.promises.stat(path);
|
||||
let ffprobeResults;
|
||||
try {
|
||||
ffprobeResults = await probe(path);
|
||||
duration = ffprobeResults.format.duration;
|
||||
size = ffprobeResults.format.size;
|
||||
} catch (e) {
|
||||
}
|
||||
let fileReadResult;
|
||||
if (size < MAX_IPC_SEND_BUFFER_SIZE) {
|
||||
try {
|
||||
fileReadResult = await fs.promises.readFile(path);
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
// TODO: use mmmagic to inspect file and get mime type
|
||||
mimeType = isFfMp4(ffprobeResults) ? 'video/mp4' : mime.getType(name);
|
||||
const fileData = {name, mime: mimeType || undefined, path, duration: duration, size, buffer: fileReadResult };
|
||||
return fileData;
|
||||
} catch (e) {
|
||||
// no stat
|
||||
return { error: 'no file' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('get-disk-space', async (event) => {
|
||||
try {
|
||||
const { data_dir } = await Lbry.settings_get();
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
// involve moving it from 'extras' to 'ui' (big change).
|
||||
|
||||
import { createCachedSelector } from 're-reselect';
|
||||
import { selectClaimForUri, makeSelectIsBlacklisted } from 'redux/selectors/claims';
|
||||
import { selectClaimForUri } from 'redux/selectors/claims';
|
||||
import { selectMutedChannels } from 'redux/selectors/blocked';
|
||||
import { selectModerationBlockList } from 'redux/selectors/comments';
|
||||
import { selectBlacklistedOutpointMap, selectFilteredOutpointMap } from 'lbryinc';
|
||||
|
@ -18,8 +18,7 @@ export const selectBanStateForUri = createCachedSelector(
|
|||
selectFilteredOutpointMap,
|
||||
selectMutedChannels,
|
||||
selectModerationBlockList,
|
||||
(state, uri) => makeSelectIsBlacklisted(uri)(state),
|
||||
(claim, blackListedOutpointMap, filteredOutpointMap, mutedChannelUris, personalBlocklist, isBlacklisted) => {
|
||||
(claim, blackListedOutpointMap, filteredOutpointMap, mutedChannelUris, personalBlocklist) => {
|
||||
const banState = {};
|
||||
|
||||
if (!claim) {
|
||||
|
@ -28,10 +27,6 @@ export const selectBanStateForUri = createCachedSelector(
|
|||
|
||||
const channelClaim = getChannelFromClaim(claim);
|
||||
|
||||
if (isBlacklisted) {
|
||||
banState['blacklisted'] = true;
|
||||
}
|
||||
|
||||
// This will be replaced once blocking is done at the wallet server level.
|
||||
if (blackListedOutpointMap) {
|
||||
if (
|
||||
|
|
37
flow-typed/Claim.js
vendored
37
flow-typed/Claim.js
vendored
|
@ -145,49 +145,12 @@ declare type PurchaseReceipt = {
|
|||
type: 'purchase',
|
||||
};
|
||||
|
||||
declare type ClaimErrorCensor = {
|
||||
address: string,
|
||||
amount: string,
|
||||
canonical_url: string,
|
||||
claim_id: string,
|
||||
claim_op: string,
|
||||
confirmations: number,
|
||||
has_signing_key: boolean,
|
||||
height: number,
|
||||
meta: {
|
||||
activation_height: number,
|
||||
claims_in_channel: number,
|
||||
creation_height: number,
|
||||
creation_timestamp: number,
|
||||
effective_amount: string,
|
||||
expiration_height: number,
|
||||
is_controlling: boolean,
|
||||
reposted: number,
|
||||
support_amount: string,
|
||||
take_over_height: number,
|
||||
},
|
||||
name: string,
|
||||
normalized_name: string,
|
||||
nout: number,
|
||||
permanent_url: string,
|
||||
short_url: string,
|
||||
timestamp: number,
|
||||
txid: string,
|
||||
type: string,
|
||||
value: {
|
||||
public_key: string,
|
||||
public_key_id: string,
|
||||
},
|
||||
value_type: string,
|
||||
}
|
||||
|
||||
declare type ClaimActionResolveInfo = {
|
||||
[string]: {
|
||||
stream: ?StreamClaim,
|
||||
channel: ?ChannelClaim,
|
||||
claimsInChannel: ?number,
|
||||
collection: ?CollectionClaim,
|
||||
errorCensor: ?ClaimErrorCensor,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
10
flow-typed/file-data.js
vendored
10
flow-typed/file-data.js
vendored
|
@ -1,10 +0,0 @@
|
|||
// @flow
|
||||
|
||||
declare type FileData = {
|
||||
file?: Blob,
|
||||
path: string,
|
||||
duration?: number,
|
||||
size?: number,
|
||||
mimeType: string,
|
||||
error?: string,
|
||||
}
|
9
flow-typed/file-with-path.js
vendored
9
flow-typed/file-with-path.js
vendored
|
@ -1,9 +0,0 @@
|
|||
// @flow
|
||||
|
||||
declare type FileWithPath = {
|
||||
file: File,
|
||||
// The full path will only be available in
|
||||
// the application. For browser, the name
|
||||
// of the file will be used.
|
||||
path: string,
|
||||
}
|
6
flow-typed/web-file.js
vendored
Normal file
6
flow-typed/web-file.js
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
// @flow
|
||||
|
||||
declare type WebFile = File & {
|
||||
path?: string,
|
||||
title?: string,
|
||||
}
|
47
package.json
47
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "lbry",
|
||||
"version": "0.53.9",
|
||||
"version": "0.53.5-alpha.test7495b4",
|
||||
"description": "A browser for the LBRY network, a digital marketplace controlled by its users.",
|
||||
"keywords": [
|
||||
"lbry"
|
||||
|
@ -23,8 +23,11 @@
|
|||
"analyze": "source-map-explorer --only-mapped dist/electron/webpack/ui*.js --html dist/sourceMap.html",
|
||||
"compile:electron": "node --max_old_space_size=4096 ./node_modules/webpack/bin/webpack.js --config webpack.electron.config.js",
|
||||
"compile": "cross-env NODE_ENV=production yarn compile:electron",
|
||||
"copyenv": "copyfiles ./.env* web/",
|
||||
"dev": "yarn dev:electron",
|
||||
"dev:electron": "cross-env NODE_ENV=development node ./electron/devServer.js",
|
||||
"dev:internal-apis": "LBRY_API_URL='http://localhost:8080' yarn dev:electron",
|
||||
"dev:iatv": "LBRY_API_URL='http://localhost:15400' SDK_API_URL='http://localhost:15100' yarn dev:web",
|
||||
"pack": "electron-builder --dir",
|
||||
"dist": "electron-builder",
|
||||
"build": "cross-env NODE_ENV=production yarn compile:electron && electron-builder build",
|
||||
|
@ -41,29 +44,32 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@electron/remote": "^2.0.1",
|
||||
"@emotion/react": "^11.10.4",
|
||||
"@emotion/styled": "^11.10.4",
|
||||
"@emotion/react": "^11.6.0",
|
||||
"@emotion/styled": "^11.6.0",
|
||||
"@mui/material": "^5.2.1",
|
||||
"@ungap/from-entries": "^0.2.1",
|
||||
"auto-launch": "^5.0.5",
|
||||
"electron-dl": "^3.2.0",
|
||||
"electron-log": "^4.4.8",
|
||||
"electron-log": "^2.2.12",
|
||||
"electron-notarize": "^1.0.0",
|
||||
"electron-updater": "^4.2.4",
|
||||
"express": "^4.17.1",
|
||||
"ffmpeg-probe": "^1.0.6",
|
||||
"humanize-duration": "^3.27.0",
|
||||
"if-env": "^1.0.4",
|
||||
"match-sorter": "^6.3.0",
|
||||
"mime": "^3.0.0",
|
||||
"node-html-parser": "^5.1.0",
|
||||
"parse-duration": "^1.0.0",
|
||||
"proxy-polyfill": "0.1.6",
|
||||
"re-reselect": "^4.0.0",
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-datetime-picker": "^3.4.3",
|
||||
"remove-markdown": "^0.3.0",
|
||||
"rss": "^1.2.2",
|
||||
"source-map-explorer": "^2.5.2",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"tempy": "^0.6.0"
|
||||
"tempy": "^0.6.0",
|
||||
"videojs-logo": "^2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
|
@ -74,7 +80,7 @@
|
|||
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
|
||||
"@babel/plugin-transform-flow-strip-types": "^7.2.3",
|
||||
"@babel/plugin-transform-runtime": "^7.4.3",
|
||||
"@babel/polyfill": "^7.12.1",
|
||||
"@babel/polyfill": "^7.2.5",
|
||||
"@babel/preset-env": "^7.12.11",
|
||||
"@babel/preset-flow": "^7.12.1",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
|
@ -82,6 +88,7 @@
|
|||
"@datapunt/matomo-tracker-js": "^0.1.4",
|
||||
"@hot-loader/react-dom": "^16.13",
|
||||
"@meetfranz/electron-cookies": "^3.0.2",
|
||||
"@reach/auto-id": "^0.13.0",
|
||||
"@reach/combobox": "^0.12.1",
|
||||
"@reach/menu-button": "0.8.6",
|
||||
"@reach/rect": "^0.16.0",
|
||||
|
@ -92,17 +99,21 @@
|
|||
"@sentry/webpack-plugin": "^1.10.0",
|
||||
"@types/three": "^0.103.2",
|
||||
"adm-zip": "^0.4.13",
|
||||
"async-exit-hook": "^2.0.1",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-loader": "^8.0.5",
|
||||
"babel-plugin-add-module-exports": "^1.0.4",
|
||||
"babel-plugin-import-glob": "^2.0.0",
|
||||
"babel-plugin-transform-imports": "^1.5.1",
|
||||
"babel-plugin-transform-object-rest-spread": "^6.26.0",
|
||||
"bluebird": "^3.5.1",
|
||||
"chalk": "^4.1.0",
|
||||
"classnames": "^2.2.5",
|
||||
"codemirror": "^5.39.2",
|
||||
"concurrently": "^4.1.2",
|
||||
"connected-react-router": "^6.8.0",
|
||||
"copy-webpack-plugin": "^6.4.1",
|
||||
"copyfiles": "^2.4.1",
|
||||
"country-data": "^0.0.31",
|
||||
"cross-env": "^7.0.3",
|
||||
"crypto-js": "^4.0.0",
|
||||
|
@ -113,9 +124,10 @@
|
|||
"decompress": "^4.2.1",
|
||||
"del": "^3.0.0",
|
||||
"devtron": "^1.4.0",
|
||||
"dom-scroll-into-view": "^1.2.1",
|
||||
"dotenv-defaults": "^2.0.1",
|
||||
"dotenv-webpack": "^1.8.0",
|
||||
"electron": "17.2.0",
|
||||
"electron": "15.4.0",
|
||||
"electron-builder": "^22.10.5",
|
||||
"electron-devtools-installer": "^3.1.1",
|
||||
"electron-is-dev": "^0.3.0",
|
||||
|
@ -150,7 +162,10 @@
|
|||
"lodash-es": "^4.17.21",
|
||||
"mammoth": "^1.4.16",
|
||||
"moment": "^2.29.2",
|
||||
"node-abi": "^2.5.1",
|
||||
"node-fetch": "^2.6.7",
|
||||
"node-html-parser": "^5.1.0",
|
||||
"node-libs-browser": "^2.1.0",
|
||||
"node-loader": "^0.6.0",
|
||||
"node-wget": "^0.4.3",
|
||||
"nodemon": "^1.19.1",
|
||||
|
@ -165,6 +180,7 @@
|
|||
"rc-progress": "^2.0.6",
|
||||
"react": "^16.8.2",
|
||||
"react-awesome-lightbox": "^1.7.3",
|
||||
"react-confetti": "^4.0.1",
|
||||
"react-dom": "^16.8.2",
|
||||
"react-draggable": "^3.3.0",
|
||||
"react-google-recaptcha": "^2.0.1",
|
||||
|
@ -175,6 +191,7 @@
|
|||
"react-router": "^5.1.0",
|
||||
"react-router-dom": "^5.1.0",
|
||||
"react-simplemde-editor": "^4.1.3",
|
||||
"react-spring": "^8.0.20",
|
||||
"reakit": "^1.0.0-beta.13",
|
||||
"redux": "^3.6.0",
|
||||
"redux-persist": "^5.10.0",
|
||||
|
@ -191,16 +208,20 @@
|
|||
"sass": "^1.29.0",
|
||||
"sass-loader": "^7.1.0",
|
||||
"semver": "^5.3.0",
|
||||
"stream-to-blob-url": "^2.1.1",
|
||||
"strip-markdown": "^3.0.3",
|
||||
"style-loader": "^0.23.1",
|
||||
"terser-webpack-plugin": "^4.2.3",
|
||||
"three-full": "^28.0.2",
|
||||
"tiny-relative-date": "^1.3.0",
|
||||
"tree-kill": "^1.1.0",
|
||||
"unist-util-visit": "^2.0.3",
|
||||
"uuid": "^8.3.2",
|
||||
"video.js": "^7.14.3",
|
||||
"videojs-contrib-quality-levels": "^2.0.9",
|
||||
"videojs-event-tracking": "^1.0.1",
|
||||
"villain-react": "^1.0.9",
|
||||
"wavesurfer.js": "^2.2.1",
|
||||
"webpack": "^4.44.2",
|
||||
"webpack-bundle-analyzer": "^3.1.0",
|
||||
"webpack-cli": "^3.3.10",
|
||||
|
@ -210,17 +231,19 @@
|
|||
"webpack-hot-middleware": "^2.24.3",
|
||||
"webpack-merge": "^4.2.1",
|
||||
"webpack-node-externals": "^1.7.2",
|
||||
"y18n": "^4.0.1",
|
||||
"yarnhook": "^0.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.13",
|
||||
"node": ">=7",
|
||||
"yarn": "^1.3"
|
||||
},
|
||||
"lbrySettings": {
|
||||
"lbrynetDaemonVersion": "0.113.0",
|
||||
"lbrynetDaemonVersion": "0.107.2",
|
||||
"lbrynetDaemonUrlTemplate": "https://github.com/lbryio/lbry/releases/download/vDAEMONVER/lbrynet-OSNAME.zip",
|
||||
"lbrynetDaemonDir": "static/daemon",
|
||||
"lbrynetDaemonFileName": "lbrynet"
|
||||
},
|
||||
"packageManager": "yarn@3.2.0"
|
||||
"packageManager": "yarn@3.2.0",
|
||||
"stableVersion": "0.53.4"
|
||||
}
|
||||
|
|
|
@ -2315,12 +2315,5 @@
|
|||
"Disable background": "Disable background",
|
||||
"Installing, please wait...": "Installing, please wait...",
|
||||
"There was an error during installation. Please, try again.": "There was an error during installation. Please, try again.",
|
||||
"Odysee Connect --[Section in Help Page]--": "Odysee Connect",
|
||||
"Your hub has blocked this content because it subscribes to the following blocking channel:": "Your hub has blocked this content because it subscribes to the following blocking channel:",
|
||||
"Your hub has blocked access to this content do to a complaint received under the US Digital Millennium Copyright Act.": "Your hub has blocked access to this content do to a complaint received under the US Digital Millennium Copyright Act.",
|
||||
"Autoplay Next is on.": "Autoplay Next is on.",
|
||||
"This will be visible in a few minutes after you submit this form.": "This will be visible in a few minutes after you submit this form.",
|
||||
"Anon --[used in <%anonymous% Reposted>]--": "Anon",
|
||||
"Your update is now pending. It will take a few minutes to appear for other users.": "Your update is now pending. It will take a few minutes to appear for other users.",
|
||||
"--end--": "--end--"
|
||||
}
|
||||
|
|
164
ui/analytics.js
164
ui/analytics.js
|
@ -1,11 +1,4 @@
|
|||
// @flow
|
||||
/*
|
||||
Removed Watchman (internal view tracking) code.
|
||||
This file may eventually implement cantina
|
||||
Refer to 0cc0e213a5c5bf9e2a76316df5d9da4b250a13c3 for initial integration commit
|
||||
refer to ___ for removal commit.
|
||||
*/
|
||||
|
||||
import { Lbryio } from 'lbryinc';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import MatomoTracker from '@datapunt/matomo-tracker-js';
|
||||
|
@ -21,6 +14,9 @@ const devInternalApis = process.env.LBRY_API_URL && process.env.LBRY_API_URL.inc
|
|||
export const SHARE_INTERNAL = 'shareInternal';
|
||||
const SHARE_THIRD_PARTY = 'shareThirdParty';
|
||||
|
||||
const WATCHMAN_BACKEND_ENDPOINT = 'https://watchman.na-backend.odysee.com/reports/playback';
|
||||
// const SEND_DATA_TO_WATCHMAN_INTERVAL = 10; // in seconds
|
||||
|
||||
if (isProduction) {
|
||||
ElectronCookies.enable({
|
||||
origin: 'https://lbry.tv',
|
||||
|
@ -72,10 +68,114 @@ type LogPublishParams = {
|
|||
let internalAnalyticsEnabled: boolean = false;
|
||||
if (window.localStorage.getItem(SHARE_INTERNAL) === 'true') internalAnalyticsEnabled = true;
|
||||
|
||||
/**
|
||||
* Determine the mobile device type viewing the data
|
||||
* This function returns one of 'and' (Android), 'ios', or 'web'.
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
function getDeviceType() {
|
||||
return 'dsk';
|
||||
}
|
||||
// variables initialized for watchman
|
||||
let amountOfBufferEvents = 0;
|
||||
let amountOfBufferTimeInMS = 0;
|
||||
let videoType, userId, claimUrl, playerPoweredBy, videoPlayer, bitrateAsBitsPerSecond;
|
||||
let lastSentTime;
|
||||
|
||||
// calculate data for backend, send them, and reset buffer data for next interval
|
||||
async function sendAndResetWatchmanData() {
|
||||
if (!userId) {
|
||||
return 'Can only be used with a user id';
|
||||
}
|
||||
|
||||
if (!videoPlayer) {
|
||||
return 'Video player not initialized';
|
||||
}
|
||||
|
||||
let timeSinceLastIntervalSend = new Date() - lastSentTime;
|
||||
lastSentTime = new Date();
|
||||
|
||||
let protocol;
|
||||
if (videoType === 'application/x-mpegURL') {
|
||||
protocol = 'hls';
|
||||
// get bandwidth if it exists from the texttrack (so it's accurate if user changes quality)
|
||||
// $FlowFixMe
|
||||
bitrateAsBitsPerSecond = videoPlayer.textTracks?.().tracks_[0]?.activeCues[0]?.value?.bandwidth;
|
||||
} else {
|
||||
protocol = 'stb';
|
||||
}
|
||||
|
||||
// current position in video in MS
|
||||
const positionInVideo = Math.round(videoPlayer.currentTime()) * 1000;
|
||||
|
||||
// get the duration marking the time in the video for relative position calculation
|
||||
const totalDurationInSeconds = Math.round(videoPlayer.duration());
|
||||
|
||||
// build object for watchman backend
|
||||
const objectToSend = {
|
||||
rebuf_count: amountOfBufferEvents,
|
||||
rebuf_duration: amountOfBufferTimeInMS,
|
||||
url: claimUrl.replace('lbry://', ''),
|
||||
device: getDeviceType(),
|
||||
duration: timeSinceLastIntervalSend,
|
||||
protocol,
|
||||
player: playerPoweredBy,
|
||||
user_id: userId.toString(),
|
||||
position: Math.round(positionInVideo),
|
||||
rel_position: Math.round((positionInVideo / (totalDurationInSeconds * 1000)) * 100),
|
||||
bitrate: bitrateAsBitsPerSecond,
|
||||
bandwidth: undefined,
|
||||
// ...(userDownloadBandwidthInBitsPerSecond && {bandwidth: userDownloadBandwidthInBitsPerSecond}), // add bandwidth if populated
|
||||
};
|
||||
|
||||
// post to watchman
|
||||
await sendWatchmanData(objectToSend);
|
||||
|
||||
// reset buffer data
|
||||
amountOfBufferEvents = 0;
|
||||
amountOfBufferTimeInMS = 0;
|
||||
}
|
||||
|
||||
let watchmanInterval;
|
||||
// clear watchman interval and mark it as null (when video paused)
|
||||
function stopWatchmanInterval() {
|
||||
clearInterval(watchmanInterval);
|
||||
watchmanInterval = null;
|
||||
}
|
||||
|
||||
// creates the setInterval that will run send to watchman on recurring basis
|
||||
function startWatchmanIntervalIfNotRunning() {
|
||||
if (!watchmanInterval) {
|
||||
// instantiate the first time to calculate duration from
|
||||
lastSentTime = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// post data to the backend
|
||||
async function sendWatchmanData(body) {
|
||||
try {
|
||||
const response = await fetch(WATCHMAN_BACKEND_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
const analytics: Analytics = {
|
||||
// receive buffer events from tracking plugin and save buffer amounts and times for backend call
|
||||
videoBufferEvent: async (claim, data) => {
|
||||
// stub
|
||||
amountOfBufferEvents = amountOfBufferEvents + 1;
|
||||
amountOfBufferTimeInMS = amountOfBufferTimeInMS + data.bufferDuration;
|
||||
},
|
||||
onDispose: () => {
|
||||
stopWatchmanInterval();
|
||||
},
|
||||
/**
|
||||
* Is told whether video is being started or paused, and adjusts interval accordingly
|
||||
|
@ -83,9 +183,40 @@ const analytics: Analytics = {
|
|||
* @param {object} passedPlayer - VideoJS Player object
|
||||
*/
|
||||
videoIsPlaying: (isPlaying, passedPlayer) => {
|
||||
// stub
|
||||
let playerIsSeeking = false;
|
||||
// have to use this because videojs pauses/unpauses during seek
|
||||
// sometimes the seeking function isn't populated yet so check for it as well
|
||||
if (passedPlayer && passedPlayer.seeking) {
|
||||
playerIsSeeking = passedPlayer.seeking();
|
||||
}
|
||||
|
||||
// if being paused, and not seeking, send existing data and stop interval
|
||||
if (!isPlaying && !playerIsSeeking) {
|
||||
sendAndResetWatchmanData();
|
||||
stopWatchmanInterval();
|
||||
// if being told to pause, and seeking, send and restart interval
|
||||
} else if (!isPlaying && playerIsSeeking) {
|
||||
sendAndResetWatchmanData();
|
||||
stopWatchmanInterval();
|
||||
startWatchmanIntervalIfNotRunning();
|
||||
// is being told to play, and seeking, don't do anything,
|
||||
// assume it's been started already from pause
|
||||
} else if (isPlaying && playerIsSeeking) {
|
||||
// start but not a seek, assuming a start from paused content
|
||||
} else if (isPlaying && !playerIsSeeking) {
|
||||
startWatchmanIntervalIfNotRunning();
|
||||
}
|
||||
},
|
||||
videoStartEvent: (claimId, timeToStartVideo, poweredBy, passedUserId, canonicalUrl, passedPlayer, videoBitrate) => {
|
||||
// populate values for watchman when video starts
|
||||
userId = passedUserId;
|
||||
claimUrl = canonicalUrl;
|
||||
playerPoweredBy = poweredBy;
|
||||
|
||||
videoType = passedPlayer.currentSource().type;
|
||||
videoPlayer = passedPlayer;
|
||||
bitrateAsBitsPerSecond = videoBitrate;
|
||||
|
||||
// sendPromMetric('time_to_start', duration);
|
||||
sendMatomoEvent('Media', 'TimeToStart', claimId, timeToStartVideo);
|
||||
},
|
||||
|
@ -251,9 +382,24 @@ function sendMatomoEvent(category, action, name, value) {
|
|||
}
|
||||
}
|
||||
|
||||
// Prometheus
|
||||
// function sendPromMetric(name: string, value?: number) {
|
||||
// if (IS_WEB) {
|
||||
// let url = new URL(SDK_API_PATH + '/metric/ui');
|
||||
// const params = { name: name, value: value ? value.toString() : '' };
|
||||
// url.search = new URLSearchParams(params).toString();
|
||||
// return fetch(url, { method: 'post' });
|
||||
// }
|
||||
// }
|
||||
|
||||
const MatomoInstance = new MatomoTracker({
|
||||
urlBase: MATOMO_URL,
|
||||
siteId: MATOMO_ID, // optional, default value: `1`
|
||||
// heartBeat: { // optional, enabled by default
|
||||
// active: true, // optional, default value: true
|
||||
// seconds: 10 // optional, default value: `15
|
||||
// },
|
||||
// linkTracking: false // optional, default value: true
|
||||
});
|
||||
|
||||
analytics.pageView(generateInitialUrl(window.location.hash));
|
||||
|
|
|
@ -5,6 +5,7 @@ import Icon from 'component/common/icon';
|
|||
import classnames from 'classnames';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { formatLbryUrlForWeb } from 'util/url';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import useCombinedRefs from 'effects/use-combined-refs';
|
||||
|
||||
type Props = {
|
||||
|
@ -33,6 +34,7 @@ type Props = {
|
|||
onMouseLeave: ?(any) => any,
|
||||
pathname: string,
|
||||
emailVerified: boolean,
|
||||
requiresAuth: ?boolean,
|
||||
myref: any,
|
||||
dispatch: any,
|
||||
'aria-label'?: string,
|
||||
|
@ -64,6 +66,7 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
iconColor,
|
||||
activeClass,
|
||||
emailVerified,
|
||||
requiresAuth,
|
||||
myref,
|
||||
dispatch, // <button> doesn't know what to do with dispatch
|
||||
pathname,
|
||||
|
@ -72,7 +75,7 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
...otherProps
|
||||
} = props;
|
||||
|
||||
const disable = disabled;
|
||||
const disable = disabled || (user === null && requiresAuth);
|
||||
|
||||
const combinedClassName = classnames(
|
||||
'button',
|
||||
|
@ -180,6 +183,31 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (requiresAuth && !emailVerified) {
|
||||
let redirectUrl = `/$/${PAGES.AUTH}?redirect=${pathname}`;
|
||||
|
||||
if (authSrc) {
|
||||
redirectUrl += `&src=${authSrc}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
exact
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
to={redirectUrl}
|
||||
title={title || defaultTooltip}
|
||||
disabled={disable}
|
||||
className={combinedClassName}
|
||||
activeClassName={activeClass}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{content}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
return path ? (
|
||||
<NavLink
|
||||
exact
|
||||
|
|
|
@ -3,7 +3,7 @@ import * as MODALS from 'constants/modal_types';
|
|||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { FormField, FormFieldAreaAdvanced } from 'component/common/form';
|
||||
import { FormField } from 'component/common/form';
|
||||
import Button from 'component/button';
|
||||
import TagsSearch from 'component/tagsSearch';
|
||||
import ErrorText from 'component/common/error-text';
|
||||
|
@ -94,11 +94,10 @@ function ChannelForm(props: Props) {
|
|||
const [nameError, setNameError] = React.useState(undefined);
|
||||
const [bidError, setBidError] = React.useState('');
|
||||
const [isUpload, setIsUpload] = React.useState({ cover: false, thumbnail: false });
|
||||
const [coverError, setCoverError] = React.useState(false);
|
||||
const [thumbError, setThumbError] = React.useState(false);
|
||||
const { claim_id: claimId } = claim || {};
|
||||
const [params, setParams]: [any, (any) => void] = React.useState(getChannelParams());
|
||||
const [coverError, setCoverError] = React.useState(false);
|
||||
const [coverPreview, setCoverPreview] = React.useState(params.coverUrl);
|
||||
const { channelName } = parseURI(uri);
|
||||
const name = params.name;
|
||||
const isNewChannel = !uri;
|
||||
|
@ -207,8 +206,7 @@ function ChannelForm(props: Props) {
|
|||
setThumbError(false);
|
||||
}
|
||||
|
||||
function handleCoverChange(coverUrl: string, uploadSelected: boolean, preview: ?string) {
|
||||
setCoverPreview(preview || '');
|
||||
function handleCoverChange(coverUrl: string, uploadSelected: boolean) {
|
||||
setParams({ ...params, coverUrl });
|
||||
setIsUpload({ ...isUpload, cover: uploadSelected });
|
||||
setCoverError(false);
|
||||
|
@ -261,7 +259,7 @@ function ChannelForm(props: Props) {
|
|||
}
|
||||
}, [hasClaimedInitialRewards, claimInitialRewards]);
|
||||
|
||||
const coverSrc = coverError ? ThumbnailBrokenImage : coverPreview;
|
||||
const coverSrc = coverError ? ThumbnailBrokenImage : params.coverUrl;
|
||||
|
||||
let thumbnailPreview;
|
||||
if (!params.thumbnailUrl) {
|
||||
|
@ -283,7 +281,7 @@ function ChannelForm(props: Props) {
|
|||
title={__('Cover')}
|
||||
onClick={() =>
|
||||
openModal(MODALS.IMAGE_UPLOAD, {
|
||||
onUpdate: (coverUrl, isUpload, preview) => handleCoverChange(coverUrl, isUpload, preview),
|
||||
onUpdate: (coverUrl, isUpload) => handleCoverChange(coverUrl, isUpload),
|
||||
title: __('Edit Cover Image'),
|
||||
helpText: __('(6.25:1)'),
|
||||
assetName: __('Cover Image'),
|
||||
|
@ -325,6 +323,7 @@ function ChannelForm(props: Props) {
|
|||
uri={uri}
|
||||
thumbnailPreview={thumbnailPreview}
|
||||
allowGifs
|
||||
showDelayedMessage={isUpload.thumbnail}
|
||||
setThumbUploadError={setThumbError}
|
||||
thumbUploadError={thumbError}
|
||||
/>
|
||||
|
@ -376,7 +375,7 @@ function ChannelForm(props: Props) {
|
|||
onChange={(e) => setParams({ ...params, title: e.target.value })}
|
||||
maxLength={MAX_TITLE_LEN}
|
||||
/>
|
||||
<FormFieldAreaAdvanced
|
||||
<FormField
|
||||
type="markdown"
|
||||
name="content_description2"
|
||||
label={__('Description')}
|
||||
|
|
|
@ -17,7 +17,7 @@ import { useHistory } from 'react-router-dom';
|
|||
import { isNameValid, regexInvalidURI } from 'util/lbryURI';
|
||||
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
|
||||
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
|
||||
import { FormField, FormFieldAreaAdvanced } from 'component/common/form';
|
||||
import { FormField } from 'component/common/form';
|
||||
import { handleBidChange } from 'util/publish';
|
||||
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
|
||||
import { INVALID_NAME_ERROR } from 'constants/claim';
|
||||
|
@ -371,7 +371,7 @@ function CollectionForm(props: Props) {
|
|||
usePublishFormMode
|
||||
/>
|
||||
</fieldset-section>
|
||||
<FormFieldAreaAdvanced
|
||||
<FormField
|
||||
type="markdown"
|
||||
name="content_description2"
|
||||
label={__('Description')}
|
||||
|
|
|
@ -17,7 +17,7 @@ import CommentBadge from 'component/common/comment-badge'; // have this?
|
|||
import ChannelThumbnail from 'component/channelThumbnail';
|
||||
import { Menu, MenuButton } from '@reach/menu-button';
|
||||
import Icon from 'component/common/icon';
|
||||
import { FormFieldAreaAdvanced, Form } from 'component/common/form';
|
||||
import { FormField, Form } from 'component/common/form';
|
||||
import classnames from 'classnames';
|
||||
import usePersistedState from 'effects/use-persisted-state';
|
||||
import CommentReactions from 'component/commentReactions';
|
||||
|
@ -319,7 +319,7 @@ function CommentView(props: Props) {
|
|||
<div>
|
||||
{isEditing ? (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormFieldAreaAdvanced
|
||||
<FormField
|
||||
className="comment__edit-input"
|
||||
type={advancedEditor ? 'markdown' : 'textarea'}
|
||||
name="editing_comment"
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import SelectChannel from 'component/selectChannel';
|
||||
import Button from 'component/button';
|
||||
import * as ICONS from 'constants/icons';
|
||||
|
||||
type Props = {
|
||||
isReply: boolean,
|
||||
advancedHandler: () => void,
|
||||
advanced: boolean,
|
||||
};
|
||||
|
||||
export default function CommentCreateHeader(props: Props) {
|
||||
const { isReply, advancedHandler, advanced } = props;
|
||||
|
||||
return (
|
||||
<div className="comment-create__header">
|
||||
<div className="comment-create__label-wrapper">
|
||||
<span className="comment-create__label">{(isReply ? __('Replying as') : __('Comment as')) + ' '}</span>
|
||||
<SelectChannel tiny />
|
||||
</div>
|
||||
<div className="form-field__quick-action">
|
||||
<Button
|
||||
button="alt"
|
||||
icon={advanced ? ICONS.SIMPLE_EDITOR : ICONS.ADVANCED_EDITOR}
|
||||
onClick={advancedHandler}
|
||||
aria-label={isReply ? undefined : advanced ? __('Simple Editor') : __('Advanced Editor')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -4,7 +4,7 @@ import 'scss/component/_comment-create.scss';
|
|||
|
||||
import { buildValidSticker } from 'util/comments';
|
||||
import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field';
|
||||
import { FormFieldAreaAdvanced, Form } from 'component/common/form';
|
||||
import { FormField, Form } from 'component/common/form';
|
||||
import { getChannelIdFromClaim } from 'util/claim';
|
||||
import { Lbryio } from 'lbryinc';
|
||||
import { useHistory } from 'react-router';
|
||||
|
@ -22,12 +22,13 @@ import I18nMessage from 'component/i18nMessage';
|
|||
import Icon from 'component/common/icon';
|
||||
import OptimizedImage from 'component/optimizedImage';
|
||||
import React from 'react';
|
||||
import SelectChannel from 'component/selectChannel';
|
||||
import StickerSelector from './sticker-selector';
|
||||
import CommentCreateHeader from './comment-create-header';
|
||||
import type { ElementRef } from 'react';
|
||||
import UriIndicator from 'component/uriIndicator';
|
||||
import usePersistedState from 'effects/use-persisted-state';
|
||||
import WalletTipAmountSelector from 'component/walletTipAmountSelector';
|
||||
|
||||
import { getStripeEnvironment } from 'util/stripe';
|
||||
const stripeEnvironment = getStripeEnvironment();
|
||||
|
||||
|
@ -363,6 +364,31 @@ export function CommentCreate(props: Props) {
|
|||
.catch(() => {});
|
||||
}, [canReceiveFiatTip, claim.claim_id, claim.name, claim.signing_channel, stickerSelector]);
|
||||
|
||||
// LIVESTREAM ONLY - REMOVE
|
||||
// Handle keyboard shortcut comment creation
|
||||
// React.useEffect(() => {
|
||||
// function altEnterListener(e: SyntheticKeyboardEvent<*>) {
|
||||
// const inputRef = formFieldRef && formFieldRef.current && formFieldRef.current.input;
|
||||
//
|
||||
// if (inputRef && inputRef.current === document.activeElement) {
|
||||
// // $FlowFixMe
|
||||
// const isTyping = e.target.attributes['term'];
|
||||
//
|
||||
// if (((isLivestream && !isTyping) || e.ctrlKey || e.metaKey) && e.keyCode === KEYCODES.ENTER) {
|
||||
// e.preventDefault();
|
||||
// buttonRef.current.click();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// window.addEventListener('keydown', altEnterListener);
|
||||
//
|
||||
// // removes the listener so it doesn't cause problems elsewhere in the app
|
||||
// return () => {
|
||||
// window.removeEventListener('keydown', altEnterListener);
|
||||
// };
|
||||
// }, [isLivestream]);
|
||||
|
||||
// **************************************************************************
|
||||
// Render
|
||||
// **************************************************************************
|
||||
|
@ -384,11 +410,7 @@ export function CommentCreate(props: Props) {
|
|||
push(pathPlusRedirect);
|
||||
}}
|
||||
>
|
||||
<FormFieldAreaAdvanced
|
||||
type="textarea"
|
||||
name={'comment_signup_prompt'}
|
||||
placeholder={__('Say something about this...')}
|
||||
/>
|
||||
<FormField type="textarea" name={'comment_signup_prompt'} placeholder={__('Say something about this...')} />
|
||||
<div className="section__actions--no-margin">
|
||||
<Button disabled button="primary" label={__('Post --[button to submit something]--')} />
|
||||
</div>
|
||||
|
@ -399,22 +421,22 @@ export function CommentCreate(props: Props) {
|
|||
return (
|
||||
<Form
|
||||
onSubmit={() => {}}
|
||||
className={classnames('comment-create', {
|
||||
'comment-create--reply': isReply,
|
||||
'comment-create--nestedReply': isNested,
|
||||
'comment-create--bottom': bottom,
|
||||
className={classnames('commentCreate', {
|
||||
'commentCreate--reply': isReply,
|
||||
'commentCreate--nestedReply': isNested,
|
||||
'commentCreate--bottom': bottom,
|
||||
})}
|
||||
>
|
||||
{/* Input Box/Preview Box */}
|
||||
{stickerSelector ? (
|
||||
<StickerSelector onSelect={(sticker) => handleSelectSticker(sticker)} claimIsMine={claimIsMine} />
|
||||
) : isReviewingStickerComment && activeChannelClaim && selectedSticker ? (
|
||||
<div className="comment-create__stickerPreview">
|
||||
<div className="comment-create__stickerPreviewInfo">
|
||||
<div className="commentCreate__stickerPreview">
|
||||
<div className="commentCreate__stickerPreviewInfo">
|
||||
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
|
||||
<UriIndicator uri={activeChannelClaim.canonical_url} link />
|
||||
</div>
|
||||
<div className="comment-create__stickerPreviewImage">
|
||||
<div className="commentCreate__stickerPreviewImage">
|
||||
<OptimizedImage src={selectedSticker && selectedSticker.url} waitLoad loading="lazy" />
|
||||
</div>
|
||||
{/* figure out lbc sticker prices */}
|
||||
|
@ -426,15 +448,15 @@ export function CommentCreate(props: Props) {
|
|||
)}
|
||||
</div>
|
||||
) : isReviewingSupportComment && activeChannelClaim ? (
|
||||
<div className="comment-create__supportCommentPreview">
|
||||
<div className="commentCreate__supportCommentPreview">
|
||||
<CreditAmount
|
||||
amount={tipAmount}
|
||||
className="comment-create__supportCommentPreviewAmount"
|
||||
className="commentCreate__supportCommentPreviewAmount"
|
||||
isFiat={activeTab === TAB_FIAT}
|
||||
size={activeTab === TAB_LBC ? 18 : 2}
|
||||
/>
|
||||
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
|
||||
<div className="comment-create__supportCommentBody">
|
||||
<div className="commentCreate__supportCommentBody">
|
||||
<UriIndicator uri={activeChannelClaim.canonical_url} link />
|
||||
<div>{commentValue}</div>
|
||||
</div>
|
||||
|
@ -449,22 +471,23 @@ export function CommentCreate(props: Props) {
|
|||
/>
|
||||
)}
|
||||
|
||||
<FormFieldAreaAdvanced
|
||||
<FormField
|
||||
autoFocus={isReply}
|
||||
charCount={charCount}
|
||||
className={isReply ? 'content_reply' : 'content_comment'}
|
||||
disabled={isFetchingChannels}
|
||||
header={
|
||||
<CommentCreateHeader
|
||||
isReply={isReply}
|
||||
advanced={advancedEditor}
|
||||
advancedHandler={() => setAdvancedEditor(!advancedEditor)}
|
||||
/>
|
||||
label={
|
||||
<div className="commentCreate__labelWrapper">
|
||||
<span className="commentCreate__label">{(isReply ? __('Replying as') : __('Comment as')) + ' '}</span>
|
||||
<SelectChannel tiny />
|
||||
</div>
|
||||
}
|
||||
name={isReply ? 'content_reply' : 'content_description'}
|
||||
quickActionLabel={isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor')}
|
||||
ref={formFieldRef}
|
||||
onChange={handleCommentChange}
|
||||
openEmoteMenu={() => setShowEmotes(!showEmotes)}
|
||||
quickActionHandler={() => setAdvancedEditor(!advancedEditor)}
|
||||
onFocus={onTextareaFocus}
|
||||
onBlur={onTextareaBlur}
|
||||
placeholder={__('Say something about this...')}
|
||||
|
@ -632,7 +655,7 @@ export function CommentCreate(props: Props) {
|
|||
{/* Help Text */}
|
||||
{deletedComment && <div className="error__text">{__('This comment has been deleted.')}</div>}
|
||||
{!!minAmount && (
|
||||
<div className="help--notice comment-create__minAmountNotice">
|
||||
<div className="help--notice commentCreate__minAmountNotice">
|
||||
<I18nMessage tokens={{ lbc: <CreditAmount noFormat amount={minAmount} /> }}>
|
||||
{minTip ? 'Comment min: %lbc%' : minSuper ? 'HyperChat min: %lbc%' : ''}
|
||||
</I18nMessage>
|
||||
|
|
|
@ -23,9 +23,6 @@ import { doCommentReset, doCommentList, doCommentById, doCommentReactList } from
|
|||
import { selectActiveChannelClaim } from 'redux/selectors/app';
|
||||
import { getChannelIdFromClaim } from 'util/claim';
|
||||
import CommentsList from './view';
|
||||
import { makeSelectClientSetting } from 'redux/selectors/settings';
|
||||
import * as SETTINGS from 'constants/settings';
|
||||
import { doSetClientSetting } from 'redux/actions/settings';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { uri } = props;
|
||||
|
@ -59,19 +56,15 @@ const select = (state, props) => {
|
|||
myReactsByCommentId: selectMyReacts(state),
|
||||
othersReactsById: selectOthersReacts(state),
|
||||
activeChannelId: activeChannelClaim && activeChannelClaim.claim_id,
|
||||
customCommentServers: makeSelectClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVERS)(state),
|
||||
commentServer: makeSelectClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVER_URL)(state),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch, ownProps) => ({
|
||||
fetchTopLevelComments: (uri, parentId, page, pageSize, sortBy) =>
|
||||
dispatch(doCommentList(uri, parentId, page, pageSize, sortBy)),
|
||||
fetchComment: (commentId) => dispatch(doCommentById(commentId)),
|
||||
fetchReacts: (commentIds) => dispatch(doCommentReactList(commentIds)),
|
||||
resetComments: (claimId) => dispatch(doCommentReset(claimId)),
|
||||
doResolveUris: (uris, returnCachedClaims) => dispatch(doResolveUris(uris, returnCachedClaims)),
|
||||
setCommentServer: (url) => dispatch(doSetClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVER_URL, url, true)),
|
||||
});
|
||||
const perform = {
|
||||
fetchTopLevelComments: doCommentList,
|
||||
fetchComment: doCommentById,
|
||||
fetchReacts: doCommentReactList,
|
||||
resetComments: doCommentReset,
|
||||
doResolveUris,
|
||||
};
|
||||
|
||||
export default connect(select, perform)(CommentsList);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// @flow
|
||||
import { COMMENT_PAGE_SIZE_TOP_LEVEL, SORT_BY } from 'constants/comment';
|
||||
import { ENABLE_COMMENT_REACTIONS, COMMENT_SERVER_API, COMMENT_SERVER_NAME } from 'config';
|
||||
import { ENABLE_COMMENT_REACTIONS } from 'config';
|
||||
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
|
||||
import { getCommentsListTitle } from 'util/comments';
|
||||
import * as ICONS from 'constants/icons';
|
||||
|
@ -15,8 +15,6 @@ import Empty from 'component/common/empty';
|
|||
import React, { useEffect } from 'react';
|
||||
import Spinner from 'component/spinner';
|
||||
import usePersistedState from 'effects/use-persisted-state';
|
||||
import { FormField } from 'component/common/form';
|
||||
import Comments from 'comments';
|
||||
|
||||
const DEBOUNCE_SCROLL_HANDLER_MS = 200;
|
||||
|
||||
|
@ -54,9 +52,6 @@ type Props = {
|
|||
fetchReacts: (commentIds: Array<string>) => Promise<any>,
|
||||
resetComments: (claimId: string) => void,
|
||||
doResolveUris: (uris: Array<string>, returnCachedClaims: boolean) => void,
|
||||
customCommentServers: Array<CommentServerDetails>,
|
||||
setCommentServer: (string) => void,
|
||||
commentServer: string,
|
||||
};
|
||||
|
||||
export default function CommentList(props: Props) {
|
||||
|
@ -85,17 +80,11 @@ export default function CommentList(props: Props) {
|
|||
fetchReacts,
|
||||
resetComments,
|
||||
doResolveUris,
|
||||
customCommentServers,
|
||||
setCommentServer,
|
||||
commentServer,
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isMediumScreen = useIsMediumScreen();
|
||||
|
||||
const defaultServer = { name: COMMENT_SERVER_NAME, url: COMMENT_SERVER_API };
|
||||
const allServers = [defaultServer, ...(customCommentServers || [])];
|
||||
|
||||
const spinnerRef = React.useRef();
|
||||
const DEFAULT_SORT = ENABLE_COMMENT_REACTIONS ? SORT_BY.POPULARITY : SORT_BY.NEWEST;
|
||||
const [sort, setSort] = usePersistedState('comment-sort-by', DEFAULT_SORT);
|
||||
|
@ -266,16 +255,7 @@ export default function CommentList(props: Props) {
|
|||
}, [alreadyResolved, doResolveUris, topLevelComments]);
|
||||
|
||||
const commentProps = { isTopLevel: true, threadDepth: 3, uri, claimIsMine, linkedCommentId };
|
||||
const actionButtonsProps = {
|
||||
totalComments,
|
||||
sort,
|
||||
changeSort,
|
||||
setPage,
|
||||
allServers,
|
||||
commentServer,
|
||||
defaultServer,
|
||||
setCommentServer,
|
||||
};
|
||||
const actionButtonsProps = { totalComments, sort, changeSort, setPage };
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
@ -354,21 +334,17 @@ type ActionButtonsProps = {
|
|||
sort: string,
|
||||
changeSort: (string) => void,
|
||||
setPage: (number) => void,
|
||||
allServers: Array<CommentServerDetails>,
|
||||
commentServer: string,
|
||||
setCommentServer: (string) => void,
|
||||
defaultServer: CommentServerDetails,
|
||||
};
|
||||
|
||||
const CommentActionButtons = (actionButtonsProps: ActionButtonsProps) => {
|
||||
const { totalComments, sort, changeSort, setPage, allServers, commentServer, setCommentServer, defaultServer } =
|
||||
actionButtonsProps;
|
||||
const { totalComments, sort, changeSort, setPage } = actionButtonsProps;
|
||||
|
||||
const sortButtonProps = { activeSort: sort, changeSort };
|
||||
|
||||
return (
|
||||
<div className={'comment__actions-row'}>
|
||||
<>
|
||||
{totalComments > 1 && ENABLE_COMMENT_REACTIONS && (
|
||||
<div className="comment__sort-group">
|
||||
<span className="comment__sort">
|
||||
<SortButton {...sortButtonProps} label={__('Best')} icon={ICONS.BEST} sortOption={SORT_BY.POPULARITY} />
|
||||
<SortButton
|
||||
{...sortButtonProps}
|
||||
|
@ -377,39 +353,11 @@ const CommentActionButtons = (actionButtonsProps: ActionButtonsProps) => {
|
|||
sortOption={SORT_BY.CONTROVERSY}
|
||||
/>
|
||||
<SortButton {...sortButtonProps} label={__('New')} icon={ICONS.NEW} sortOption={SORT_BY.NEWEST} />
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{allServers.length >= 2 && (
|
||||
<div className="button__selected-server">
|
||||
<FormField
|
||||
type="select-tiny"
|
||||
onChange={function (x) {
|
||||
const selectedServer = x.target.value;
|
||||
setPage(0);
|
||||
setCommentServer(selectedServer);
|
||||
if (selectedServer === defaultServer.url) {
|
||||
Comments.setServerUrl(undefined);
|
||||
} else {
|
||||
Comments.setServerUrl(selectedServer);
|
||||
}
|
||||
}}
|
||||
value={commentServer}
|
||||
>
|
||||
{allServers.map(function (server) {
|
||||
return (
|
||||
<option key={server.url} value={server.url}>
|
||||
{server.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
<div className="button_refresh">
|
||||
<Button button="alt" icon={ICONS.REFRESH} title={__('Refresh')} onClick={() => setPage(0)} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -3,8 +3,8 @@ import React from 'react';
|
|||
import { useRadioState, Radio, RadioGroup } from 'reakit/Radio';
|
||||
|
||||
type Props = {
|
||||
files: Array<File>,
|
||||
onChange: (File | void) => void,
|
||||
files: Array<WebFile>,
|
||||
onChange: (WebFile | void) => void,
|
||||
};
|
||||
|
||||
type RadioProps = {
|
||||
|
@ -26,16 +26,16 @@ function FileList(props: Props) {
|
|||
|
||||
const getFile = (value?: string) => {
|
||||
if (files && files.length) {
|
||||
return files.find((file: File) => file.name === value);
|
||||
return files.find((file: WebFile) => file.name === value);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (radio.items.length) {
|
||||
if (radio.stops.length) {
|
||||
if (!radio.currentId) {
|
||||
radio.first();
|
||||
} else {
|
||||
const first = radio.items[0].ref.current;
|
||||
const first = radio.stops[0].ref.current;
|
||||
// First auto-selection
|
||||
if (first && first.id === radio.currentId && !radio.state) {
|
||||
const file = getFile(first.value);
|
||||
|
@ -46,12 +46,12 @@ function FileList(props: Props) {
|
|||
|
||||
if (radio.state) {
|
||||
// Find selected element
|
||||
const stop = radio.items.find((item) => item.id === radio.currentId);
|
||||
const stop = radio.stops.find(item => item.id === radio.currentId);
|
||||
const element = stop && stop.ref.current;
|
||||
// Only update state if new item is selected
|
||||
if (element && element.value !== radio.state) {
|
||||
const file = getFile(element.value);
|
||||
// Select new file and update state
|
||||
// Sselect new file and update state
|
||||
onChange(file);
|
||||
radio.setState(element.value);
|
||||
}
|
||||
|
|
|
@ -1,29 +1,25 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import * as remote from '@electron/remote';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import Button from 'component/button';
|
||||
import { FormField } from 'component/common/form';
|
||||
|
||||
type Props = {
|
||||
type: string,
|
||||
currentPath?: ?string,
|
||||
onFileChosen: (FileWithPath) => void,
|
||||
onFileChosen: (WebFile) => void,
|
||||
label?: string,
|
||||
placeholder?: string,
|
||||
accept?: string,
|
||||
error?: string,
|
||||
disabled?: boolean,
|
||||
autoFocus?: boolean,
|
||||
filters?: Array<{ name: string, extension: string[] }>,
|
||||
readFile?: boolean,
|
||||
};
|
||||
|
||||
class FileSelector extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
autoFocus: false,
|
||||
type: 'file',
|
||||
readFile: true,
|
||||
};
|
||||
|
||||
fileInput: React.ElementRef<any>;
|
||||
|
@ -45,7 +41,7 @@ class FileSelector extends React.PureComponent<Props> {
|
|||
const file = files[0];
|
||||
|
||||
if (this.props.onFileChosen) {
|
||||
this.props.onFileChosen({ file, path: file.path || file.name });
|
||||
this.props.onFileChosen(file);
|
||||
}
|
||||
this.fileInput.current.value = null; // clear the file input
|
||||
};
|
||||
|
@ -68,26 +64,12 @@ class FileSelector extends React.PureComponent<Props> {
|
|||
properties = ['openDirectory'];
|
||||
}
|
||||
|
||||
remote.dialog
|
||||
.showOpenDialog({
|
||||
properties,
|
||||
defaultPath,
|
||||
filters: this.props.filters,
|
||||
})
|
||||
.then((result) => {
|
||||
remote.dialog.showOpenDialog({ properties, defaultPath }).then((result) => {
|
||||
const path = result && result.filePaths[0];
|
||||
if (path) {
|
||||
return ipcRenderer.invoke('get-file-from-path', path, this.props.readFile);
|
||||
// $FlowFixMe
|
||||
this.props.onFileChosen({ path });
|
||||
}
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const file = new File([result.buffer], result.name, {
|
||||
type: result.mime,
|
||||
});
|
||||
this.props.onFileChosen({ file, path: result.path });
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -1,240 +0,0 @@
|
|||
// @flow
|
||||
import 'easymde/dist/easymde.min.css';
|
||||
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
|
||||
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import Button from 'component/button';
|
||||
import MarkdownPreview from 'component/common/markdown-preview';
|
||||
import React from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import SimpleMDE from 'react-simplemde-editor';
|
||||
import TextareaWithSuggestions from 'component/textareaWithSuggestions';
|
||||
import type { ElementRef, Node } from 'react';
|
||||
|
||||
type Props = {
|
||||
autoFocus?: boolean,
|
||||
blockWrap: boolean,
|
||||
charCount?: number,
|
||||
children?: React$Node,
|
||||
disabled?: boolean,
|
||||
helper?: string | React$Node,
|
||||
hideSuggestions?: boolean,
|
||||
isLivestream?: boolean,
|
||||
label?: string | Node,
|
||||
labelOnLeft: boolean,
|
||||
name: string,
|
||||
noEmojis?: boolean,
|
||||
placeholder?: string | number,
|
||||
quickActionLabel?: string,
|
||||
textAreaMaxLength?: number,
|
||||
type?: string,
|
||||
value?: string | number,
|
||||
onChange?: (any) => any,
|
||||
openEmoteMenu?: () => void,
|
||||
quickActionHandler?: (any) => any,
|
||||
render?: () => React$Node,
|
||||
header?: React$Node,
|
||||
};
|
||||
|
||||
export class FormFieldAreaAdvanced extends React.PureComponent<Props> {
|
||||
static defaultProps = { labelOnLeft: false, blockWrap: true };
|
||||
|
||||
input: { current: ElementRef<any> };
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.input = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { autoFocus } = this.props;
|
||||
const input = this.input.current;
|
||||
|
||||
if (input && autoFocus) input.focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
autoFocus,
|
||||
blockWrap,
|
||||
charCount,
|
||||
children,
|
||||
helper,
|
||||
hideSuggestions,
|
||||
isLivestream,
|
||||
label,
|
||||
header,
|
||||
labelOnLeft,
|
||||
name,
|
||||
noEmojis,
|
||||
quickActionLabel,
|
||||
textAreaMaxLength,
|
||||
type,
|
||||
openEmoteMenu,
|
||||
quickActionHandler,
|
||||
render,
|
||||
...inputProps
|
||||
} = this.props;
|
||||
|
||||
// Ideally, the character count should (and can) be appended to the
|
||||
// SimpleMDE's "options::status" bar. However, I couldn't figure out how
|
||||
// to pass the current value to it's callback, nor query the current
|
||||
// text length from the callback. So, we'll use our own widget.
|
||||
const hasCharCount = charCount !== undefined && charCount >= 0;
|
||||
const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
|
||||
<span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
|
||||
);
|
||||
|
||||
const quickAction =
|
||||
quickActionLabel && quickActionHandler ? (
|
||||
<div className="form-field__quick-action">
|
||||
<Button button="link" onClick={quickActionHandler} label={quickActionLabel} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const input = () => {
|
||||
switch (type) {
|
||||
case 'markdown':
|
||||
const handleEvents = { contextmenu: openEditorMenu };
|
||||
|
||||
const getInstance = (editor) => {
|
||||
// SimpleMDE max char check
|
||||
editor.codemirror.on('beforeChange', (instance, changes) => {
|
||||
if (textAreaMaxLength && changes.update) {
|
||||
var str = changes.text.join('\n');
|
||||
var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from));
|
||||
|
||||
if (delta <= 0) return;
|
||||
|
||||
delta = instance.getValue().length + delta - textAreaMaxLength;
|
||||
if (delta > 0) {
|
||||
str = str.substring(0, str.length - delta);
|
||||
changes.update(changes.from, changes.to, str.split('\n'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// "Create Link (Ctrl-K)": highlight URL instead of label:
|
||||
editor.codemirror.on('changes', (instance, changes) => {
|
||||
try {
|
||||
// Grab the last change from the buffered list. I assume the
|
||||
// buffered one ('changes', instead of 'change') is more efficient,
|
||||
// and that "Create Link" will always end up last in the list.
|
||||
const lastChange = changes[changes.length - 1];
|
||||
if (lastChange.origin === '+input') {
|
||||
// https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765
|
||||
const EASYMDE_URL_PLACEHOLDER = '(https://)';
|
||||
|
||||
// The URL placeholder is always placed last, so just look at the
|
||||
// last text in the array to also cover the multi-line case:
|
||||
const urlLineText = lastChange.text[lastChange.text.length - 1];
|
||||
|
||||
if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) {
|
||||
const from = lastChange.from;
|
||||
const to = lastChange.to;
|
||||
const isSelectionMultiline = lastChange.text.length > 1;
|
||||
const baseIndex = isSelectionMultiline ? 0 : from.ch;
|
||||
|
||||
// Everything works fine for the [Ctrl-K] case, but for the
|
||||
// [Button] case, this handler happens before the original
|
||||
// code, thus our change got wiped out.
|
||||
// Add a small delay to handle that case.
|
||||
setTimeout(() => {
|
||||
instance.setSelection(
|
||||
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 },
|
||||
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') }
|
||||
);
|
||||
}, 25);
|
||||
}
|
||||
}
|
||||
} catch (e) {} // Do nothing (revert to original behavior)
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}>
|
||||
<fieldset-section>
|
||||
{!header && (
|
||||
<div className="form-field__two-column">
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
</div>
|
||||
{quickAction}
|
||||
</div>
|
||||
)}
|
||||
{!!header && <div className="form-field__textarea-header">{header}</div>}
|
||||
<SimpleMDE
|
||||
{...inputProps}
|
||||
id={name}
|
||||
type="textarea"
|
||||
events={handleEvents}
|
||||
getMdeInstance={getInstance}
|
||||
options={{
|
||||
spellChecker: true,
|
||||
hideIcons: ['heading', 'image', 'fullscreen', 'side-by-side'],
|
||||
previewRender(plainText) {
|
||||
const preview = <MarkdownPreview content={plainText} noDataStore />;
|
||||
return ReactDOMServer.renderToString(preview);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{countInfo}
|
||||
</fieldset-section>
|
||||
</div>
|
||||
);
|
||||
case 'textarea':
|
||||
return (
|
||||
<fieldset-section>
|
||||
{!header && (label || quickAction) && (
|
||||
<div className="form-field__two-column">
|
||||
<label htmlFor={name}>{label}</label>
|
||||
{quickAction}
|
||||
</div>
|
||||
)}
|
||||
{!!header && <div className="form-field__textarea-header">{header}</div>}
|
||||
{hideSuggestions ? (
|
||||
<textarea
|
||||
type={type}
|
||||
id={name}
|
||||
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
||||
ref={this.input}
|
||||
{...inputProps}
|
||||
/>
|
||||
) : (
|
||||
<TextareaWithSuggestions
|
||||
type={type}
|
||||
id={name}
|
||||
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
||||
inputRef={this.input}
|
||||
isLivestream={isLivestream}
|
||||
{...inputProps}
|
||||
/>
|
||||
)}
|
||||
<div className="form-field__textarea-info">
|
||||
{!noEmojis && openEmoteMenu && (
|
||||
<Button
|
||||
type="alt"
|
||||
className="button--comment-icons"
|
||||
title="Emotes"
|
||||
onClick={openEmoteMenu}
|
||||
icon={ICONS.EMOJI}
|
||||
iconSize={20}
|
||||
/>
|
||||
)}
|
||||
{countInfo}
|
||||
</div>
|
||||
</fieldset-section>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{type && input()}
|
||||
{helper && <div className="form-field__help">{helper}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default FormFieldAreaAdvanced;
|
|
@ -1,7 +1,14 @@
|
|||
// @flow
|
||||
import 'easymde/dist/easymde.min.css';
|
||||
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
|
||||
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import Button from 'component/button';
|
||||
import MarkdownPreview from 'component/common/markdown-preview';
|
||||
import React from 'react';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import SimpleMDE from 'react-simplemde-editor';
|
||||
import TextareaWithSuggestions from 'component/textareaWithSuggestions';
|
||||
import type { ElementRef, Node } from 'react';
|
||||
|
||||
type Props = {
|
||||
|
@ -14,15 +21,19 @@ type Props = {
|
|||
disabled?: boolean,
|
||||
error?: string | boolean,
|
||||
helper?: string | React$Node,
|
||||
hideSuggestions?: boolean,
|
||||
inputButton?: React$Node,
|
||||
isLivestream?: boolean,
|
||||
label?: string | Node,
|
||||
labelOnLeft: boolean,
|
||||
max?: number,
|
||||
min?: number,
|
||||
name: string,
|
||||
noEmojis?: boolean,
|
||||
placeholder?: string | number,
|
||||
postfix?: string,
|
||||
prefix?: string,
|
||||
quickActionLabel?: string,
|
||||
range?: number,
|
||||
readOnly?: boolean,
|
||||
stretch?: boolean,
|
||||
|
@ -30,6 +41,8 @@ type Props = {
|
|||
type?: string,
|
||||
value?: string | number,
|
||||
onChange?: (any) => any,
|
||||
openEmoteMenu?: () => void,
|
||||
quickActionHandler?: (any) => any,
|
||||
render?: () => React$Node,
|
||||
};
|
||||
|
||||
|
@ -59,15 +72,21 @@ export class FormField extends React.PureComponent<Props> {
|
|||
children,
|
||||
error,
|
||||
helper,
|
||||
hideSuggestions,
|
||||
inputButton,
|
||||
isLivestream,
|
||||
label,
|
||||
labelOnLeft,
|
||||
name,
|
||||
noEmojis,
|
||||
postfix,
|
||||
prefix,
|
||||
quickActionLabel,
|
||||
stretch,
|
||||
textAreaMaxLength,
|
||||
type,
|
||||
openEmoteMenu,
|
||||
quickActionHandler,
|
||||
render,
|
||||
...inputProps
|
||||
} = this.props;
|
||||
|
@ -82,10 +101,18 @@ export class FormField extends React.PureComponent<Props> {
|
|||
const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
|
||||
<span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
|
||||
);
|
||||
|
||||
const Wrapper = blockWrap
|
||||
? ({ children: innerChildren }) => <fieldset-section class="radio">{innerChildren}</fieldset-section>
|
||||
: ({ children: innerChildren }) => <span className="radio">{innerChildren}</span>;
|
||||
|
||||
const quickAction =
|
||||
quickActionLabel && quickActionHandler ? (
|
||||
<div className="form-field__quick-action">
|
||||
<Button button="link" onClick={quickActionHandler} label={quickActionLabel} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const inputSimple = (type: string) => (
|
||||
<>
|
||||
<input id={name} type={type} {...inputProps} />
|
||||
|
@ -116,14 +143,102 @@ export class FormField extends React.PureComponent<Props> {
|
|||
return inputSelect('');
|
||||
case 'select-tiny':
|
||||
return inputSelect('select--slim');
|
||||
case 'markdown':
|
||||
const handleEvents = { contextmenu: openEditorMenu };
|
||||
|
||||
const getInstance = (editor) => {
|
||||
// SimpleMDE max char check
|
||||
editor.codemirror.on('beforeChange', (instance, changes) => {
|
||||
if (textAreaMaxLength && changes.update) {
|
||||
var str = changes.text.join('\n');
|
||||
var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from));
|
||||
|
||||
if (delta <= 0) return;
|
||||
|
||||
delta = instance.getValue().length + delta - textAreaMaxLength;
|
||||
if (delta > 0) {
|
||||
str = str.substring(0, str.length - delta);
|
||||
changes.update(changes.from, changes.to, str.split('\n'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// "Create Link (Ctrl-K)": highlight URL instead of label:
|
||||
editor.codemirror.on('changes', (instance, changes) => {
|
||||
try {
|
||||
// Grab the last change from the buffered list. I assume the
|
||||
// buffered one ('changes', instead of 'change') is more efficient,
|
||||
// and that "Create Link" will always end up last in the list.
|
||||
const lastChange = changes[changes.length - 1];
|
||||
if (lastChange.origin === '+input') {
|
||||
// https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765
|
||||
const EASYMDE_URL_PLACEHOLDER = '(https://)';
|
||||
|
||||
// The URL placeholder is always placed last, so just look at the
|
||||
// last text in the array to also cover the multi-line case:
|
||||
const urlLineText = lastChange.text[lastChange.text.length - 1];
|
||||
|
||||
if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) {
|
||||
const from = lastChange.from;
|
||||
const to = lastChange.to;
|
||||
const isSelectionMultiline = lastChange.text.length > 1;
|
||||
const baseIndex = isSelectionMultiline ? 0 : from.ch;
|
||||
|
||||
// Everything works fine for the [Ctrl-K] case, but for the
|
||||
// [Button] case, this handler happens before the original
|
||||
// code, thus our change got wiped out.
|
||||
// Add a small delay to handle that case.
|
||||
setTimeout(() => {
|
||||
instance.setSelection(
|
||||
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 },
|
||||
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') }
|
||||
);
|
||||
}, 25);
|
||||
}
|
||||
}
|
||||
} catch (e) {} // Do nothing (revert to original behavior)
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}>
|
||||
<fieldset-section>
|
||||
<div className="form-field__two-column">
|
||||
<div>
|
||||
<label htmlFor={name}>{label}</label>
|
||||
</div>
|
||||
{quickAction}
|
||||
</div>
|
||||
<SimpleMDE
|
||||
{...inputProps}
|
||||
id={name}
|
||||
type="textarea"
|
||||
events={handleEvents}
|
||||
getMdeInstance={getInstance}
|
||||
options={{
|
||||
spellChecker: true,
|
||||
hideIcons: ['heading', 'image', 'fullscreen', 'side-by-side'],
|
||||
previewRender(plainText) {
|
||||
const preview = <MarkdownPreview content={plainText} noDataStore />;
|
||||
return ReactDOMServer.renderToString(preview);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{countInfo}
|
||||
</fieldset-section>
|
||||
</div>
|
||||
);
|
||||
case 'textarea':
|
||||
return (
|
||||
<fieldset-section>
|
||||
{label && (
|
||||
{(label || quickAction) && (
|
||||
<div className="form-field__two-column">
|
||||
<label htmlFor={name}>{label}</label>
|
||||
{quickAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hideSuggestions ? (
|
||||
<textarea
|
||||
type={type}
|
||||
id={name}
|
||||
|
@ -131,7 +246,30 @@ export class FormField extends React.PureComponent<Props> {
|
|||
ref={this.input}
|
||||
{...inputProps}
|
||||
/>
|
||||
<div className="form-field__textarea-info">{countInfo}</div>
|
||||
) : (
|
||||
<TextareaWithSuggestions
|
||||
type={type}
|
||||
id={name}
|
||||
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
|
||||
inputRef={this.input}
|
||||
isLivestream={isLivestream}
|
||||
{...inputProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="form-field__textarea-info">
|
||||
{!noEmojis && openEmoteMenu && (
|
||||
<Button
|
||||
type="alt"
|
||||
className="button--file-action"
|
||||
title="Emotes"
|
||||
onClick={openEmoteMenu}
|
||||
icon={ICONS.EMOJI}
|
||||
iconSize={20}
|
||||
/>
|
||||
)}
|
||||
{countInfo}
|
||||
</div>
|
||||
</fieldset-section>
|
||||
);
|
||||
default:
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
export { Form } from './form-components/form';
|
||||
export { FormField } from './form-components/form-field';
|
||||
export { FormFieldAreaAdvanced } from './form-components/form-field-area-advanced';
|
||||
export { FormFieldPrice } from './form-components/form-field-price';
|
||||
export { Submit } from './form-components/submit';
|
||||
|
|
|
@ -2054,15 +2054,4 @@ export const icons = {
|
|||
<path d="M12.5,23.24v-1A10.74,10.74,0,0,1,23.24,11.52" />
|
||||
</g>
|
||||
),
|
||||
[ICONS.SIMPLE_EDITOR]: buildIcon(
|
||||
<g>
|
||||
<path d="M1 18V6c0-1 1-2 2-2h18c1 0 2 1 2 2v12c0 1-1 2-2 2H3c-1 0-2-1-2-2ZM5 7v4" />
|
||||
</g>
|
||||
),
|
||||
[ICONS.ADVANCED_EDITOR]: buildIcon(
|
||||
<g>
|
||||
<path d="M1 20V4c0-1 1-2 2-2h18c1 0 2 1 2 2v16c0 1-1 2-2 2H3c-1 0-2-1-2-2ZM1 11h22" />
|
||||
<path d="M5 8V6h2v2H5ZM11 8V6h2v2h-2ZM17 8V6h2v2h-2ZM5 14v4" />
|
||||
</g>
|
||||
),
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from 'redux/selectors/claims';
|
||||
import { makeSelectPendingAmountByUri } from 'redux/selectors/wallet';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { selectUser } from 'redux/selectors/user';
|
||||
import FileDescription from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
|
@ -16,6 +17,7 @@ const select = (state, props) => {
|
|||
claim,
|
||||
claimIsMine: selectClaimIsMine(state, claim),
|
||||
metadata: makeSelectMetadataForUri(props.uri)(state),
|
||||
user: selectUser(state),
|
||||
pendingAmount: makeSelectPendingAmountByUri(props.uri)(state),
|
||||
tags: makeSelectTagsForUri(props.uri)(state),
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ type Props = {
|
|||
uri: string,
|
||||
claim: StreamClaim,
|
||||
metadata: StreamMetadata,
|
||||
user: ?any,
|
||||
tags: any,
|
||||
pendingAmount: number,
|
||||
doOpenModal: (id: string, {}) => void,
|
||||
|
|
|
@ -11,10 +11,10 @@ import Icon from 'component/common/icon';
|
|||
|
||||
type Props = {
|
||||
modal: { id: string, modalProps: {} },
|
||||
filePath: ?string,
|
||||
filePath: string | WebFile,
|
||||
clearPublish: () => void,
|
||||
updatePublishForm: ({}) => void,
|
||||
openModal: (id: string, { files: Array<File> }) => void,
|
||||
openModal: (id: string, { files: Array<WebFile> }) => void,
|
||||
// React router
|
||||
history: {
|
||||
entities: {}[],
|
||||
|
@ -37,7 +37,7 @@ function FileDrop(props: Props) {
|
|||
const { drag, dropData } = useDragDrop();
|
||||
const [files, setFiles] = React.useState([]);
|
||||
const [error, setError] = React.useState(false);
|
||||
const [target, setTarget] = React.useState<?File>(null);
|
||||
const [target, setTarget] = React.useState<?WebFile>(null);
|
||||
const hideTimer = React.useRef(null);
|
||||
const targetTimer = React.useRef(null);
|
||||
const navigationTimer = React.useRef(null);
|
||||
|
@ -65,26 +65,24 @@ function FileDrop(props: Props) {
|
|||
}
|
||||
}, [history]);
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelected = React.useCallback(
|
||||
(selectedFile) => {
|
||||
// Delay hide and navigation for a smooth transition
|
||||
const hideDropArea = React.useCallback(() => {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setFiles([]);
|
||||
// Navigate to publish area
|
||||
navigationTimer.current = setTimeout(() => {
|
||||
// Navigate first, THEN assign filePath, otherwise
|
||||
// the file selected will get reset (that's how the
|
||||
// publish file view works, when the user switches to
|
||||
// publish a file, the pathFile value gets reset to undefined)
|
||||
navigateToPublish();
|
||||
updatePublishForm({
|
||||
filePath: selectedFile.path || selectedFile.name,
|
||||
});
|
||||
}, NAVIGATE_TIME_OUT);
|
||||
}, HIDE_TIME_OUT);
|
||||
}, [navigateToPublish]);
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelected = React.useCallback(
|
||||
(selectedFile) => {
|
||||
updatePublishForm({ filePath: selectedFile });
|
||||
hideDropArea();
|
||||
},
|
||||
[setFiles, navigateToPublish, updatePublishForm]
|
||||
[updatePublishForm, hideDropArea]
|
||||
);
|
||||
|
||||
// Clear timers when unmounted
|
||||
|
|
|
@ -12,7 +12,7 @@ import { PRIMARY_PLAYER_WRAPPER_CLASS } from 'page/file/view';
|
|||
import Draggable from 'react-draggable';
|
||||
import { onFullscreenChange } from 'util/full-screen';
|
||||
import { generateListSearchUrlParams, formatLbryUrlForWeb } from 'util/url';
|
||||
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
|
||||
import { useIsMobile } from 'effects/use-screensize';
|
||||
import debounce from 'util/debounce';
|
||||
import { useHistory } from 'react-router';
|
||||
import { isURIEqual } from 'util/lbryURI';
|
||||
|
@ -21,6 +21,7 @@ import AutoplayCountdown from 'component/autoplayCountdown';
|
|||
// scss/init/vars.scss
|
||||
// --header-height
|
||||
const HEADER_HEIGHT = 60;
|
||||
const HEADER_HEIGHT_MOBILE = 60;
|
||||
|
||||
const IS_DESKTOP_MAC = typeof process === 'object' ? process.platform === 'darwin' : false;
|
||||
const DEBOUNCE_WINDOW_RESIZE_HANDLER_MS = 100;
|
||||
|
@ -132,7 +133,6 @@ export default function FileRenderFloating(props: Props) {
|
|||
const playingUriSource = playingUri && playingUri.source;
|
||||
const isComment = playingUriSource === 'comment';
|
||||
const isMobile = useIsMobile();
|
||||
const isMediumScreen = useIsMediumScreen();
|
||||
const mainFilePlaying = !isFloating && primaryUri && isURIEqual(uri, primaryUri);
|
||||
|
||||
const [fileViewerRect, setFileViewerRect] = useState();
|
||||
|
@ -344,8 +344,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
'content__viewer--floating': isFloating,
|
||||
'content__viewer--inline': !isFloating,
|
||||
'content__viewer--secondary': isComment,
|
||||
'content__viewer--theater-mode':
|
||||
!isFloating && videoTheaterMode && !isMediumScreen && playingUri?.uri === primaryUri,
|
||||
'content__viewer--theater-mode': !isFloating && videoTheaterMode && playingUri?.uri === primaryUri,
|
||||
'content__viewer--disable-click': wasDragging,
|
||||
})}
|
||||
style={
|
||||
|
@ -357,7 +356,7 @@ export default function FileRenderFloating(props: Props) {
|
|||
top:
|
||||
fileViewerRect.windowOffset +
|
||||
fileViewerRect.top -
|
||||
(isMobile ? 0 : HEADER_HEIGHT) -
|
||||
(isMobile ? HEADER_HEIGHT_MOBILE : HEADER_HEIGHT) -
|
||||
(IS_DESKTOP_MAC ? 24 : 0),
|
||||
}
|
||||
: {}
|
||||
|
|
|
@ -9,7 +9,6 @@ import * as PAGES from 'constants/pages';
|
|||
import * as RENDER_MODES from 'constants/file_render_modes';
|
||||
import * as KEYCODES from 'constants/keycodes';
|
||||
import Button from 'component/button';
|
||||
import { useIsMediumScreen } from 'effects/use-screensize';
|
||||
import isUserTyping from 'util/detect-typing';
|
||||
import { getThumbnailCdnUrl } from 'util/thumbnail';
|
||||
import Nag from 'component/common/nag';
|
||||
|
@ -64,7 +63,6 @@ export default function FileRenderInitiator(props: Props) {
|
|||
const fileStatus = fileInfo && fileInfo.status;
|
||||
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode);
|
||||
const isText = RENDER_MODES.TEXT_MODES.includes(renderMode);
|
||||
const isMediumScreen = useIsMediumScreen();
|
||||
const [thumbnail, setThumbnail] = React.useState(FileRenderPlaceholder);
|
||||
const containerRef = React.useRef<any>();
|
||||
|
||||
|
@ -153,7 +151,7 @@ export default function FileRenderInitiator(props: Props) {
|
|||
style={thumbnail && !obscurePreview ? { backgroundImage: `url("${thumbnail}")` } : {}}
|
||||
className={classnames('content__cover', {
|
||||
'content__cover--disabled': disabled,
|
||||
'content__cover--theater-mode': videoTheaterMode && !isMediumScreen,
|
||||
'content__cover--theater-mode': videoTheaterMode,
|
||||
'content__cover--text': isText,
|
||||
'card__media--nsfw': obscurePreview,
|
||||
})}
|
||||
|
|
|
@ -13,7 +13,7 @@ const select = (state, props) => {
|
|||
if (claimUriBeingPlayed) {
|
||||
const claim = makeSelectClaimForUri(props.uri)(state);
|
||||
const claimBeingPlayed = makeSelectClaimForUri(claimUriBeingPlayed)(state);
|
||||
isBeingPlayed = claim && claim.claim_id === claimBeingPlayed.claim_id;
|
||||
isBeingPlayed = claim.claim_id === claimBeingPlayed.claim_id;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from 'redux/selectors/claims';
|
||||
import { makeSelectPendingAmountByUri } from 'redux/selectors/wallet';
|
||||
import { makeSelectFileInfoForUri } from 'redux/selectors/file_info';
|
||||
import { selectUser } from 'redux/selectors/user';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
|
||||
import FileValues from './view';
|
||||
|
@ -19,6 +20,7 @@ const select = (state, props) => {
|
|||
contentType: makeSelectContentTypeForUri(props.uri)(state),
|
||||
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
|
||||
metadata: makeSelectMetadataForUri(props.uri)(state),
|
||||
user: selectUser(state),
|
||||
pendingAmount: makeSelectPendingAmountByUri(props.uri)(state),
|
||||
claimIsMine: selectClaimIsMine(state, claim),
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ type Props = {
|
|||
metadata: StreamMetadata,
|
||||
openFolder: (string) => void,
|
||||
contentType: string,
|
||||
user: ?any,
|
||||
pendingAmount: string,
|
||||
openModal: (id: string, { uri: string }) => void,
|
||||
claimIsMine: boolean,
|
||||
|
|
|
@ -6,11 +6,12 @@ import { selectClientSetting } from 'redux/selectors/settings';
|
|||
import { selectGetSyncErrorMessage } from 'redux/selectors/sync';
|
||||
import { selectHasNavigated } from 'redux/selectors/app';
|
||||
import { selectTotalBalance, selectBalance } from 'redux/selectors/wallet';
|
||||
import { selectEmailToVerify, selectUser } from 'redux/selectors/user';
|
||||
import { selectUserVerifiedEmail, selectEmailToVerify, selectUser } from 'redux/selectors/user';
|
||||
import * as SETTINGS from 'constants/settings';
|
||||
import Header from './view';
|
||||
|
||||
const select = (state) => ({
|
||||
authenticated: selectUserVerifiedEmail(state),
|
||||
balance: selectBalance(state),
|
||||
emailToVerify: selectEmailToVerify(state),
|
||||
hasNavigated: selectHasNavigated(state),
|
||||
|
|
|
@ -4,10 +4,11 @@ import { selectActiveChannelStakedLevel } from 'redux/selectors/app';
|
|||
import { selectClientSetting } from 'redux/selectors/settings';
|
||||
import * as SETTINGS from 'constants/settings';
|
||||
import HeaderMenuButtons from './view';
|
||||
import { selectUser } from 'redux/selectors/user';
|
||||
import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user';
|
||||
|
||||
const select = (state) => ({
|
||||
activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
|
||||
authenticated: selectUserVerifiedEmail(state),
|
||||
automaticDarkModeEnabled: selectClientSetting(state, SETTINGS.AUTOMATIC_DARK_MODE_ENABLED),
|
||||
currentTheme: selectClientSetting(state, SETTINGS.THEME),
|
||||
user: selectUser(state),
|
||||
|
|
|
@ -12,6 +12,7 @@ import React from 'react';
|
|||
import Tooltip from 'component/common/tooltip';
|
||||
|
||||
type HeaderMenuButtonProps = {
|
||||
authenticated: boolean,
|
||||
automaticDarkModeEnabled: boolean,
|
||||
currentTheme: string,
|
||||
user: ?User,
|
||||
|
|
14
ui/component/inviteList/index.js
Normal file
14
ui/component/inviteList/index.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectReferralReward } from 'redux/selectors/rewards';
|
||||
import { selectUserInvitees, selectUserInviteStatusIsPending } from 'redux/selectors/user';
|
||||
import InviteList from './view';
|
||||
|
||||
const select = state => ({
|
||||
invitees: selectUserInvitees(state),
|
||||
isPending: selectUserInviteStatusIsPending(state),
|
||||
referralReward: selectReferralReward(state),
|
||||
});
|
||||
|
||||
const perform = () => ({});
|
||||
|
||||
export default connect(select, perform)(InviteList);
|
99
ui/component/inviteList/view.jsx
Normal file
99
ui/component/inviteList/view.jsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import RewardLink from 'component/rewardLink';
|
||||
import Icon from 'component/common/icon';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import Card from 'component/common/card';
|
||||
import LbcMessage from 'component/common/lbc-message';
|
||||
|
||||
type Props = {
|
||||
invitees: ?Array<{
|
||||
email: string,
|
||||
invite_accepted: boolean,
|
||||
invite_reward_claimed: boolean,
|
||||
invite_reward_claimable: boolean,
|
||||
}>,
|
||||
referralReward: ?Reward,
|
||||
};
|
||||
|
||||
class InviteList extends React.PureComponent<Props> {
|
||||
render() {
|
||||
const { invitees, referralReward } = this.props;
|
||||
|
||||
if (!invitees || !invitees.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rewardAmount = 0;
|
||||
let rewardHelp = __(
|
||||
"Woah, you have a lot of friends! You've claimed the maximum amount of invite rewards. Email %email% if you'd like to be whitelisted for more invites.",
|
||||
{ email: 'hello@lbry.com' }
|
||||
);
|
||||
|
||||
if (referralReward) {
|
||||
rewardAmount = referralReward.reward_amount;
|
||||
rewardHelp = referralReward.reward_description;
|
||||
}
|
||||
const showClaimable = invitees.some(invite => invite.invite_reward_claimable && !invite.invite_reward_claimed);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<div className="table__header-text">{__('Invite History')}</div>}
|
||||
subtitle={
|
||||
<div className="table__header-text">
|
||||
<LbcMessage>{rewardHelp}</LbcMessage>
|
||||
</div>
|
||||
}
|
||||
titleActions={
|
||||
referralReward &&
|
||||
showClaimable && (
|
||||
<div className="card__actions--inline">
|
||||
<RewardLink
|
||||
button
|
||||
label={__(`Claim Your %reward_amount% Credit Invite Reward`, { reward_amount: rewardAmount })}
|
||||
claim_code={referralReward.claim_code}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
isBodyList
|
||||
body={
|
||||
<div className="table__wrapper">
|
||||
<table className="table section">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{__('Invitee Email')}</th>
|
||||
<th>{__('Invite Status')}</th>
|
||||
<th>{__('Reward')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitees.map(invitee => (
|
||||
<tr key={invitee.email}>
|
||||
<td>{invitee.email}</td>
|
||||
<td>
|
||||
<span>{invitee.invite_accepted ? __('Accepted') : __('Not Accepted')}</span>
|
||||
</td>
|
||||
<td>
|
||||
{invitee.invite_reward_claimed && (
|
||||
<React.Fragment>
|
||||
<span>{__('Claimed')}</span>
|
||||
<Icon icon={ICONS.COMPLETE} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{!invitee.invite_reward_claimed &&
|
||||
(invitee.invite_reward_claimable ? <span>{__('Claimable')}</span> : __('Unclaimable'))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default InviteList;
|
29
ui/component/inviteNew/index.js
Normal file
29
ui/component/inviteNew/index.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { connect } from 'react-redux';
|
||||
import {
|
||||
selectUserInvitesRemaining,
|
||||
selectUserInviteNewIsPending,
|
||||
selectUserInviteNewErrorMessage,
|
||||
selectUserInviteReferralLink,
|
||||
selectUserInviteReferralCode,
|
||||
} from 'redux/selectors/user';
|
||||
// import { doUserInviteNew } from 'redux/actions/user';
|
||||
import { selectMyChannelClaims, selectFetchingMyChannels } from 'redux/selectors/claims';
|
||||
import { doFetchChannelListMine } from 'redux/actions/claims';
|
||||
import InviteNew from './view';
|
||||
|
||||
const select = (state) => ({
|
||||
errorMessage: selectUserInviteNewErrorMessage(state),
|
||||
invitesRemaining: selectUserInvitesRemaining(state),
|
||||
referralLink: selectUserInviteReferralLink(state),
|
||||
referralCode: selectUserInviteReferralCode(state),
|
||||
isPending: selectUserInviteNewIsPending(state),
|
||||
channels: selectMyChannelClaims(state),
|
||||
fetchingChannels: selectFetchingMyChannels(state),
|
||||
});
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
// inviteNew: (email) => dispatch(doUserInviteNew(email)),
|
||||
fetchChannelListMine: () => dispatch(doFetchChannelListMine()),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(InviteNew);
|
154
ui/component/inviteNew/view.jsx
Normal file
154
ui/component/inviteNew/view.jsx
Normal file
|
@ -0,0 +1,154 @@
|
|||
// @flow
|
||||
import { URL, SITE_NAME } from 'config';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Button from 'component/button';
|
||||
import { Form, FormField } from 'component/common/form';
|
||||
import CopyableText from 'component/copyableText';
|
||||
import Card from 'component/common/card';
|
||||
import analytics from 'analytics';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
|
||||
type Props = {
|
||||
errorMessage: ?string,
|
||||
inviteNew: (string) => void,
|
||||
isPending: boolean,
|
||||
referralLink: string,
|
||||
referralCode: string,
|
||||
channels: ?Array<ChannelClaim>,
|
||||
};
|
||||
|
||||
function InviteNew(props: Props) {
|
||||
const { inviteNew, errorMessage, isPending, referralCode = '', channels } = props;
|
||||
|
||||
// Email
|
||||
const [email, setEmail] = useState('');
|
||||
function handleSubmit() {
|
||||
inviteNew(email);
|
||||
}
|
||||
|
||||
function handleEmailChanged(event: any) {
|
||||
setEmail(event.target.value);
|
||||
}
|
||||
|
||||
// Referral link
|
||||
const [referralSource, setReferralSource] = useState(referralCode);
|
||||
|
||||
const handleReferralChange = React.useCallback(
|
||||
(code) => {
|
||||
setReferralSource(code);
|
||||
// TODO: keep track of this in an array?
|
||||
const matchingChannel = channels && channels.find((ch) => ch.name === code);
|
||||
if (matchingChannel) {
|
||||
analytics.apiLogPublish(matchingChannel);
|
||||
}
|
||||
},
|
||||
[setReferralSource]
|
||||
);
|
||||
|
||||
const topChannel =
|
||||
channels &&
|
||||
channels.reduce((top, channel) => {
|
||||
const topClaimCount = (top && top.meta && top.meta.claims_in_channel) || 0;
|
||||
const currentClaimCount = (channel && channel.meta && channel.meta.claims_in_channel) || 0;
|
||||
return topClaimCount >= currentClaimCount ? top : channel;
|
||||
});
|
||||
const referralString =
|
||||
channels && channels.length && referralSource !== referralCode
|
||||
? lookupUrlByClaimName(referralSource, channels)
|
||||
: referralSource;
|
||||
|
||||
const referral = `${URL}/$/invite/${referralString.replace('#', ':')}`;
|
||||
|
||||
useEffect(() => {
|
||||
// set default channel
|
||||
if (topChannel) {
|
||||
handleReferralChange(topChannel.name);
|
||||
}
|
||||
}, [topChannel, handleReferralChange]);
|
||||
|
||||
function lookupUrlByClaimName(name, channels) {
|
||||
const claim = channels.find((channel) => channel.name === name);
|
||||
return claim && claim.canonical_url ? claim.canonical_url.replace('lbry://', '') : name;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'columns'}>
|
||||
<div className="column">
|
||||
<Card
|
||||
title={__('Invites')}
|
||||
subtitle={
|
||||
<I18nMessage tokens={{ SITE_NAME, lbc: <LbcSymbol /> }}>
|
||||
Earn %lbc% for inviting subscribers, followers, fans, friends, etc. to join and follow you on %SITE_NAME%.
|
||||
You can use invites just like affiliate links.
|
||||
</I18nMessage>
|
||||
}
|
||||
actions={
|
||||
<React.Fragment>
|
||||
<CopyableText label={__('Your invite link')} copyable={referral} />
|
||||
{channels && channels.length > 0 && (
|
||||
<FormField
|
||||
type="select"
|
||||
label={__('Customize link')}
|
||||
value={referralSource}
|
||||
onChange={(e) => handleReferralChange(e.target.value)}
|
||||
>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.claim_id} value={channel.name}>
|
||||
{channel.name}
|
||||
</option>
|
||||
))}
|
||||
<option value={referralCode}>{referralCode}</option>
|
||||
</FormField>
|
||||
)}
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="column">
|
||||
<Card
|
||||
title={__('Invite by email')}
|
||||
subtitle={
|
||||
<I18nMessage tokens={{ SITE_NAME, lbc: <LbcSymbol /> }}>
|
||||
Invite someone you know by email and earn %lbc% when they join %SITE_NAME%.
|
||||
</I18nMessage>
|
||||
}
|
||||
actions={
|
||||
<React.Fragment>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormField
|
||||
type="text"
|
||||
label={__('Email')}
|
||||
placeholder="youremail@example.org"
|
||||
name="email"
|
||||
value={email}
|
||||
error={errorMessage}
|
||||
inputButton={
|
||||
<Button button="secondary" type="submit" label={__('Invite')} disabled={isPending || !email} />
|
||||
}
|
||||
onChange={(event) => {
|
||||
handleEmailChanged(event);
|
||||
}}
|
||||
/>
|
||||
<p className="help">
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
rewards_link: <Button button="link" navigate="/$/rewards" label={__('rewards')} />,
|
||||
referral_faq_link: (
|
||||
<Button button="link" label={__('FAQ')} href="https://lbry.com/faq/referrals" />
|
||||
),
|
||||
}}
|
||||
>
|
||||
Read our %referral_faq_link% to learn more about rewards.
|
||||
</I18nMessage>
|
||||
</p>
|
||||
</Form>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InviteNew;
|
30
ui/component/invited/index.js
Normal file
30
ui/component/invited/index.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router';
|
||||
import REWARDS from 'rewards';
|
||||
import { selectUser, selectSetReferrerPending, selectSetReferrerError } from 'redux/selectors/user';
|
||||
import { doClaimRewardType } from 'redux/actions/rewards';
|
||||
import { selectUnclaimedRewards } from 'redux/selectors/rewards';
|
||||
import { doUserSetReferrer } from 'redux/actions/user';
|
||||
import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
|
||||
import { doChannelSubscribe } from 'redux/actions/subscriptions';
|
||||
import Invited from './view';
|
||||
|
||||
const select = (state, props) => {
|
||||
return {
|
||||
user: selectUser(state),
|
||||
referrerSetPending: selectSetReferrerPending(state),
|
||||
referrerSetError: selectSetReferrerError(state),
|
||||
rewards: selectUnclaimedRewards(state),
|
||||
isSubscribed: selectIsSubscribedForUri(state, props.fullUri),
|
||||
fullUri: props.fullUri,
|
||||
referrer: props.referrer,
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
claimReward: () => dispatch(doClaimRewardType(REWARDS.TYPE_REFEREE)),
|
||||
setReferrer: (referrer) => dispatch(doUserSetReferrer(referrer)),
|
||||
channelSubscribe: (uri) => dispatch(doChannelSubscribe(uri)),
|
||||
});
|
||||
|
||||
export default withRouter(connect(select, perform)(Invited));
|
225
ui/component/invited/view.jsx
Normal file
225
ui/component/invited/view.jsx
Normal file
|
@ -0,0 +1,225 @@
|
|||
// @flow
|
||||
import { SITE_NAME } from 'config';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import React, { useEffect } from 'react';
|
||||
import Button from 'component/button';
|
||||
import ClaimPreview from 'component/claimPreview';
|
||||
import Card from 'component/common/card';
|
||||
import { buildURI, parseURI } from 'util/lbryURI';
|
||||
import { ERRORS } from 'lbryinc';
|
||||
import REWARDS from 'rewards';
|
||||
import { formatLbryUrlForWeb } from 'util/url';
|
||||
import ChannelContent from 'component/channelContent';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
|
||||
type Props = {
|
||||
user: any,
|
||||
claimReward: () => void,
|
||||
setReferrer: (string) => void,
|
||||
referrerSetPending: boolean,
|
||||
referrerSetError: string,
|
||||
channelSubscribe: (sub: Subscription) => void,
|
||||
history: { push: (string) => void },
|
||||
rewards: Array<Reward>,
|
||||
referrer: string,
|
||||
fullUri: string,
|
||||
isSubscribed: boolean,
|
||||
};
|
||||
|
||||
function Invited(props: Props) {
|
||||
const {
|
||||
user,
|
||||
claimReward,
|
||||
setReferrer,
|
||||
referrerSetPending,
|
||||
referrerSetError,
|
||||
channelSubscribe,
|
||||
history,
|
||||
rewards,
|
||||
fullUri,
|
||||
referrer,
|
||||
isSubscribed,
|
||||
} = props;
|
||||
|
||||
const refUri = referrer && 'lbry://' + referrer.replace(':', '#');
|
||||
const {
|
||||
isChannel: referrerIsChannel,
|
||||
claimName: referrerChannelName,
|
||||
channelClaimId: referrerChannelClaimId,
|
||||
} = parseURI(refUri);
|
||||
const channelUri =
|
||||
referrerIsChannel &&
|
||||
formatLbryUrlForWeb(buildURI({ channelName: referrerChannelName, channelClaimId: referrerChannelClaimId }));
|
||||
const rewardsApproved = user && user.is_reward_approved;
|
||||
const hasVerifiedEmail = user && user.has_verified_email;
|
||||
const referredRewardAvailable = rewards && rewards.some((reward) => reward.reward_type === REWARDS.TYPE_REFEREE);
|
||||
const redirect = channelUri || `/`;
|
||||
|
||||
// always follow if it's a channel
|
||||
useEffect(() => {
|
||||
if (fullUri && !isSubscribed && fullUri) {
|
||||
let channelName;
|
||||
try {
|
||||
const { claimName } = parseURI(fullUri);
|
||||
channelName = claimName;
|
||||
} catch (e) {}
|
||||
if (channelName) {
|
||||
channelSubscribe({
|
||||
channelName: channelName,
|
||||
uri: fullUri,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [fullUri, isSubscribed, channelSubscribe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!referrerSetPending && hasVerifiedEmail) {
|
||||
claimReward();
|
||||
}
|
||||
}, [referrerSetPending, hasVerifiedEmail, claimReward]);
|
||||
|
||||
useEffect(() => {
|
||||
if (referrer) {
|
||||
setReferrer(referrer.replace(':', '#'));
|
||||
}
|
||||
}, [referrer, setReferrer]);
|
||||
|
||||
function handleDone() {
|
||||
history.push(redirect);
|
||||
}
|
||||
|
||||
if (referrerSetError === ERRORS.ALREADY_CLAIMED) {
|
||||
return (
|
||||
<Card
|
||||
title={__(`Whoa`)}
|
||||
subtitle={
|
||||
referrerIsChannel
|
||||
? __(`You've already claimed your referrer, but we've followed this channel for you.`)
|
||||
: __(`You've already claimed your referrer.`)
|
||||
}
|
||||
body={
|
||||
referrerIsChannel && (
|
||||
<div className="claim-preview--channel">
|
||||
<ClaimPreview key={refUri} uri={refUri} actions={''} type={'small'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<div className="card__actions">
|
||||
<Button button="primary" label={__('Done!')} onClick={handleDone} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (referrerSetError && referredRewardAvailable) {
|
||||
return (
|
||||
<Card
|
||||
title={__(`Welcome!`)}
|
||||
subtitle={__(
|
||||
`Something went wrong with your invite link. You can set and claim your invite reward after signing in.`
|
||||
)}
|
||||
actions={
|
||||
<>
|
||||
<p className="error__text">{__('Not a valid invite')}</p>
|
||||
<div className="card__actions">
|
||||
<Button
|
||||
button="primary"
|
||||
label={hasVerifiedEmail ? __('Verify') : __('Create Account')}
|
||||
navigate={`/$/${PAGES.AUTH}?redirect=/$/${PAGES.REWARDS}`}
|
||||
/>
|
||||
<Button button="link" label={__('Explore')} onClick={handleDone} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!rewardsApproved) {
|
||||
const signUpButton = (
|
||||
<Button
|
||||
button="link"
|
||||
label={hasVerifiedEmail ? __(`Finish verification `) : __(`Create an account `)}
|
||||
navigate={`/$/${PAGES.AUTH}?redirect=/$/${PAGES.INVITE}/${referrer}`}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
referrerIsChannel
|
||||
? __('%channel_name% invites you to the party!', { channel_name: referrerChannelName })
|
||||
: __(`You're invited!`)
|
||||
}
|
||||
subtitle={
|
||||
<p>
|
||||
{referrerIsChannel ? (
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
channel_name: referrerChannelName,
|
||||
signup_link: signUpButton,
|
||||
SITE_NAME,
|
||||
}}
|
||||
>
|
||||
%channel_name% is waiting for you on %SITE_NAME%. Create your account now.
|
||||
</I18nMessage>
|
||||
) : (
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
signup_link: signUpButton,
|
||||
}}
|
||||
>
|
||||
Content freedom and a present are waiting for you. %signup_link% to claim it.
|
||||
</I18nMessage>
|
||||
)}
|
||||
</p>
|
||||
}
|
||||
body={
|
||||
referrerIsChannel && (
|
||||
<div className="claim-preview--channel">
|
||||
<div className="section">
|
||||
<ClaimPreview key={refUri} uri={refUri} actions={''} type={'small'} />
|
||||
</div>
|
||||
<div className="section">
|
||||
<ChannelContent uri={fullUri} defaultPageSize={3} defaultInfiniteScroll={false} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
button="primary"
|
||||
label={hasVerifiedEmail ? __('Finish Account') : __('Create Account')}
|
||||
navigate={`/$/${PAGES.AUTH}?redirect=/$/${PAGES.INVITE}/${referrer}`}
|
||||
/>
|
||||
<Button button="link" label={__('Skip')} onClick={handleDone} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={__(`Welcome!`)}
|
||||
subtitle={referrerIsChannel ? __(`We've followed your invitee for you. Check them out!`) : __(`Congrats!`)}
|
||||
body={
|
||||
referrerIsChannel && (
|
||||
<div className="claim-preview--channel">
|
||||
<ClaimPreview key={refUri} uri={refUri} actions={''} type={'small'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<div className="section__actions">
|
||||
<Button button="primary" label={__('Done')} onClick={handleDone} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Invited;
|
|
@ -92,7 +92,7 @@ function Page(props: Props) {
|
|||
<div
|
||||
className={classnames('main-wrapper__inner', {
|
||||
'main-wrapper__inner--filepage': isOnFilePage,
|
||||
'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode && !isMediumScreen,
|
||||
'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode,
|
||||
})}
|
||||
>
|
||||
{!authPage &&
|
||||
|
@ -124,7 +124,7 @@ function Page(props: Props) {
|
|||
'main--file-page': filePage,
|
||||
'main--settings-page': settingsPage,
|
||||
'main--markdown': isMarkdown,
|
||||
'main--theater-mode': isOnFilePage && videoTheaterMode && !isMediumScreen && !isMarkdown,
|
||||
'main--theater-mode': isOnFilePage && videoTheaterMode && !isMarkdown,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
// @flow
|
||||
import React, { useEffect } from 'react';
|
||||
import { FormFieldAreaAdvanced } from 'component/common/form';
|
||||
import { FormField } from 'component/common/form';
|
||||
|
||||
type Props = {
|
||||
uri: ?string,
|
||||
label: ?string,
|
||||
disabled: ?boolean,
|
||||
filePath: File,
|
||||
filePath: string | WebFile,
|
||||
fileText: ?string,
|
||||
fileMimeType: ?string,
|
||||
streamingUrl: ?string,
|
||||
|
@ -99,7 +99,7 @@ function PostEditor(props: Props) {
|
|||
]);
|
||||
|
||||
return (
|
||||
<FormFieldAreaAdvanced
|
||||
<FormField
|
||||
type={'markdown'}
|
||||
name="content_post"
|
||||
label={label}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @flow
|
||||
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
|
||||
import React from 'react';
|
||||
import { FormFieldAreaAdvanced } from 'component/common/form';
|
||||
import { FormField } from 'component/common/form';
|
||||
import usePersistedState from 'effects/use-persisted-state';
|
||||
import Card from 'component/common/card';
|
||||
|
||||
|
@ -27,7 +27,7 @@ function PublishDescription(props: Props) {
|
|||
return (
|
||||
<Card
|
||||
actions={
|
||||
<FormFieldAreaAdvanced
|
||||
<FormField
|
||||
type={advancedEditor ? 'markdown' : 'textarea'}
|
||||
name="content_description"
|
||||
label={__('Description')}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
import type { Node } from 'react';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ipcRenderer } from 'electron';
|
||||
import { regexInvalidURI } from 'util/lbryURI';
|
||||
import PostEditor from 'component/postEditor';
|
||||
import FileSelector from 'component/common/file-selector';
|
||||
|
@ -14,13 +13,13 @@ import I18nMessage from 'component/i18nMessage';
|
|||
import usePersistedState from 'effects/use-persisted-state';
|
||||
import * as PUBLISH_MODES from 'constants/publish_types';
|
||||
import PublishName from 'component/publishName';
|
||||
import path from 'path';
|
||||
|
||||
type Props = {
|
||||
uri: ?string,
|
||||
mode: ?string,
|
||||
name: ?string,
|
||||
title: ?string,
|
||||
filePath: ?string,
|
||||
filePath: string | WebFile,
|
||||
fileMimeType: ?string,
|
||||
isStillEditing: boolean,
|
||||
balance: number,
|
||||
|
@ -78,7 +77,7 @@ function PublishFile(props: Props) {
|
|||
const sizeInMB = Number(size) / 1000000;
|
||||
const secondsToProcess = sizeInMB / PROCESSING_MB_PER_SECOND;
|
||||
const ffmpegAvail = ffmpegStatus.available;
|
||||
const currentFile = filePath;
|
||||
const [currentFile, setCurrentFile] = useState(null);
|
||||
const [currentFileType, setCurrentFileType] = useState(null);
|
||||
const [optimizeAvail, setOptimizeAvail] = useState(false);
|
||||
const [userOptimize, setUserOptimize] = usePersistedState('publish-file-user-optimize', false);
|
||||
|
@ -92,35 +91,17 @@ function PublishFile(props: Props) {
|
|||
}
|
||||
}, [currentFileType, mode, isStillEditing, updatePublishForm]);
|
||||
|
||||
// Since the filePath can be updated from outside this component
|
||||
// (for instance, when the user drags & drops a file), we need
|
||||
// to check for changes in the selected file using an effect.
|
||||
useEffect(() => {
|
||||
if (!filePath) {
|
||||
return;
|
||||
if (!filePath || filePath === '') {
|
||||
setCurrentFile('');
|
||||
updateFileInfo(0, 0, false);
|
||||
} else if (typeof filePath !== 'string') {
|
||||
// Update currentFile file
|
||||
if (filePath.name !== currentFile && filePath.path !== currentFile) {
|
||||
handleFileChange(filePath);
|
||||
}
|
||||
async function readSelectedFileDetails() {
|
||||
// Read the file to get the file's duration (if possible)
|
||||
// and offer transcoding it.
|
||||
const result = await ipcRenderer.invoke('get-file-details-from-path', filePath);
|
||||
let file;
|
||||
if (result.buffer) {
|
||||
file = new File([result.buffer], result.name, {
|
||||
type: result.mime,
|
||||
});
|
||||
}
|
||||
const fileData: FileData = {
|
||||
path: result.path,
|
||||
name: result.name,
|
||||
mimeType: result.mime || 'application/octet-stream',
|
||||
size: result.size,
|
||||
duration: result.duration,
|
||||
file: file,
|
||||
};
|
||||
processSelectedFile(fileData);
|
||||
}
|
||||
readSelectedFileDetails();
|
||||
}, [filePath]);
|
||||
}, [filePath, currentFile, handleFileChange, updateFileInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const isOptimizeAvail = currentFile && currentFile !== '' && isVid && ffmpegAvail;
|
||||
|
@ -228,11 +209,11 @@ function PublishFile(props: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
function processSelectedFile(fileData: FileData, clearName = true) {
|
||||
function handleFileChange(file: WebFile, clearName = true) {
|
||||
window.URL = window.URL || window.webkitURL;
|
||||
|
||||
// select file, start to select a new one, then cancel
|
||||
if (!fileData || fileData.error) {
|
||||
if (!file) {
|
||||
if (isStillEditing || !clearName) {
|
||||
updatePublishForm({ filePath: '' });
|
||||
} else {
|
||||
|
@ -241,12 +222,8 @@ function PublishFile(props: Props) {
|
|||
return;
|
||||
}
|
||||
|
||||
// if video, extract duration so we can warn about bitrate if (typeof file !== 'string')
|
||||
const file = fileData.file;
|
||||
// Check to see if it's a video and if mp4
|
||||
const contentType = fileData.mimeType && fileData.mimeType.split('/'); // get this from electron side
|
||||
const duration = fileData.duration;
|
||||
const size = fileData.size;
|
||||
// if video, extract duration so we can warn about bitrateif (typeof file !== 'string') {
|
||||
const contentType = file.type && file.type.split('/');
|
||||
const isVideo = contentType && contentType[0] === 'video';
|
||||
const isMp4 = contentType && contentType[1] === 'mp4';
|
||||
|
||||
|
@ -254,25 +231,34 @@ function PublishFile(props: Props) {
|
|||
|
||||
if (contentType && contentType[0] === 'text') {
|
||||
isTextPost = contentType[1] === 'plain' || contentType[1] === 'markdown';
|
||||
setCurrentFileType(contentType.join('/'));
|
||||
} else if (path.parse(fileData.path).ext) {
|
||||
// If user's machine is missing a valid content type registration
|
||||
setCurrentFileType(contentType);
|
||||
} else if (file.name) {
|
||||
// If user's machine is missign a valid content type registration
|
||||
// for markdown content: text/markdown, file extension will be used instead
|
||||
const extension = path.parse(fileData.path).ext;
|
||||
const extension = file.name.split('.').pop();
|
||||
isTextPost = MARKDOWN_FILE_EXTENSIONS.includes(extension);
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
if (isMp4) {
|
||||
updateFileInfo(duration || 0, size, isVideo);
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
updateFileInfo(video.duration, file.size, isVideo);
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
};
|
||||
video.onerror = () => {
|
||||
updateFileInfo(0, file.size, isVideo);
|
||||
};
|
||||
video.src = window.URL.createObjectURL(file);
|
||||
} else {
|
||||
updateFileInfo(duration || 0, size, isVideo);
|
||||
updateFileInfo(0, file.size, isVideo);
|
||||
}
|
||||
} else {
|
||||
updateFileInfo(0, size, isVideo);
|
||||
updateFileInfo(0, file.size, isVideo);
|
||||
}
|
||||
|
||||
if (isTextPost && file) {
|
||||
if (isTextPost) {
|
||||
// Create reader
|
||||
const reader = new FileReader();
|
||||
// Handler for file reader
|
||||
|
@ -284,17 +270,21 @@ function PublishFile(props: Props) {
|
|||
setPublishMode(PUBLISH_MODES.FILE);
|
||||
}
|
||||
|
||||
// Strip off extension and replace invalid characters
|
||||
const publishFormParams: { filePath: string | WebFile, name?: string, optimize?: boolean } = {
|
||||
// if electron, we'll set filePath to the path string because SDK is handling publishing.
|
||||
// File.path will be undefined from web due to browser security, so it will default to the File Object.
|
||||
filePath: file.path || file,
|
||||
};
|
||||
// Strip off extention and replace invalid characters
|
||||
let fileName = name || (file.name && file.name.substring(0, file.name.lastIndexOf('.'))) || '';
|
||||
|
||||
if (!isStillEditing) {
|
||||
const fileWithoutExtension = path.parse(fileData.path).name;
|
||||
updatePublishForm({ name: parseName(fileWithoutExtension) });
|
||||
}
|
||||
publishFormParams.name = parseName(fileName);
|
||||
}
|
||||
|
||||
function handleFileChange(fileWithPath: FileWithPath) {
|
||||
if (fileWithPath) {
|
||||
updatePublishForm({ filePath: fileWithPath.path });
|
||||
}
|
||||
// File path is not supported on web for security reasons so we use the name instead.
|
||||
setCurrentFile(file.path || file.name);
|
||||
updatePublishForm(publishFormParams);
|
||||
}
|
||||
|
||||
const showFileUpload = mode === PUBLISH_MODES.FILE;
|
||||
|
@ -342,7 +332,6 @@ function PublishFile(props: Props) {
|
|||
onFileChosen={handleFileChange}
|
||||
// https://stackoverflow.com/questions/19107685/safari-input-type-file-accept-video-ignores-mp4-files
|
||||
placeholder={__('Select file to upload')}
|
||||
readFile={false}
|
||||
/>
|
||||
{getUploadMessage()}
|
||||
</>
|
||||
|
|
|
@ -35,8 +35,8 @@ import tempy from 'tempy';
|
|||
type Props = {
|
||||
disabled: boolean,
|
||||
tags: Array<Tag>,
|
||||
publish: (source: ?File, ?boolean) => void,
|
||||
filePath: ?File,
|
||||
publish: (source?: string | File, ?boolean) => void,
|
||||
filePath: string | File,
|
||||
fileText: string,
|
||||
bid: ?number,
|
||||
bidError: ?string,
|
||||
|
@ -208,6 +208,7 @@ function PublishForm(props: Props) {
|
|||
isNameValid(name) &&
|
||||
title &&
|
||||
bid &&
|
||||
thumbnail &&
|
||||
!bidError &&
|
||||
!emptyPostError &&
|
||||
!(thumbnailError && !thumbnailUploaded) &&
|
||||
|
@ -372,6 +373,9 @@ function PublishForm(props: Props) {
|
|||
if (!output || output === '') {
|
||||
// Generate a temporary file:
|
||||
output = tempy.file({ name: 'post.md' });
|
||||
} else if (typeof filePath === 'string') {
|
||||
// Use current file
|
||||
output = filePath;
|
||||
}
|
||||
// Create a temporary file and save file changes
|
||||
if (output && output !== '') {
|
||||
|
@ -443,7 +447,7 @@ function PublishForm(props: Props) {
|
|||
// with other properties such as name, title, etc.) for security reasons.
|
||||
useEffect(() => {
|
||||
if (mode === PUBLISH_MODES.FILE) {
|
||||
updatePublishForm({ filePath: undefined, fileDur: 0, fileSize: 0 });
|
||||
updatePublishForm({ filePath: '', fileDur: 0, fileSize: 0 });
|
||||
}
|
||||
}, [mode, updatePublishForm]);
|
||||
|
||||
|
|
|
@ -47,7 +47,11 @@ function PublishFormErrors(props: Props) {
|
|||
{!bid && <div>{__('A deposit amount is required')}</div>}
|
||||
{bidError && <div>{__('Please check your deposit amount.')}</div>}
|
||||
{isUploadingThumbnail && <div>{__('Please wait for thumbnail to finish uploading')}</div>}
|
||||
{thumbnailError && !thumbnailUploaded && <div>{__('Thumbnail is invalid.')}</div>}
|
||||
{!isUploadingThumbnail && !thumbnail ? (
|
||||
<div>{__('A thumbnail is required. Please upload or provide an image URL above.')}</div>
|
||||
) : (
|
||||
thumbnailError && !thumbnailUploaded && <div>{__('Thumbnail is invalid.')}</div>
|
||||
)}
|
||||
{editingURI && !isStillEditing && !filePath && (
|
||||
<div>{__('Please reselect a file after changing the LBRY URL')}</div>
|
||||
)}
|
||||
|
|
9
ui/component/rewardAuthIntro/index.js
Normal file
9
ui/component/rewardAuthIntro/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectUnclaimedRewardValue } from 'redux/selectors/rewards';
|
||||
import RewardAuthIntro from './view';
|
||||
|
||||
const select = state => ({
|
||||
totalRewardValue: selectUnclaimedRewardValue(state),
|
||||
});
|
||||
|
||||
export default connect(select, null)(RewardAuthIntro);
|
46
ui/component/rewardAuthIntro/view.jsx
Normal file
46
ui/component/rewardAuthIntro/view.jsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
// @flow
|
||||
import { SITE_NAME } from 'config';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import React from 'react';
|
||||
import CreditAmount from 'component/common/credit-amount';
|
||||
import Button from 'component/button';
|
||||
import Card from 'component/common/card';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
|
||||
type Props = {
|
||||
balance: number,
|
||||
totalRewardValue: number,
|
||||
title?: string,
|
||||
};
|
||||
|
||||
function RewardAuthIntro(props: Props) {
|
||||
const { totalRewardValue, title } = props;
|
||||
const totalRewardRounded = Math.floor(totalRewardValue / 10) * 10;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={title || __('Log in to %SITE_NAME% to earn rewards', { SITE_NAME })}
|
||||
subtitle={
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
credit_amount: <CreditAmount inheritStyle amount={totalRewardRounded} />,
|
||||
site_name: SITE_NAME,
|
||||
}}
|
||||
>
|
||||
A %site_name% account allows you to earn more than %credit_amount% in rewards, backup your data, and get
|
||||
content and security updates.
|
||||
</I18nMessage>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
requiresAuth
|
||||
button="primary"
|
||||
navigate={`/$/${PAGES.REWARDS_VERIFY}?redirect=/$/${PAGES.REWARDS}`}
|
||||
label={__('Unlock Rewards')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RewardAuthIntro;
|
17
ui/component/rewardLink/index.js
Normal file
17
ui/component/rewardLink/index.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { makeSelectRewardByClaimCode, makeSelectIsRewardClaimPending } from 'redux/selectors/rewards';
|
||||
import { doClaimRewardType } from 'redux/actions/rewards';
|
||||
|
||||
import RewardLink from './view';
|
||||
|
||||
const select = (state, props) => ({
|
||||
isPending: makeSelectIsRewardClaimPending()(state, props),
|
||||
reward: makeSelectRewardByClaimCode()(state, props.claim_code),
|
||||
});
|
||||
|
||||
const perform = dispatch => ({
|
||||
claimReward: reward =>
|
||||
dispatch(doClaimRewardType(reward.reward_type, { notifyError: true, params: { claim_code: reward.claim_code } })),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(RewardLink);
|
48
ui/component/rewardLink/view.jsx
Normal file
48
ui/component/rewardLink/view.jsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import LbcMessage from 'component/common/lbc-message';
|
||||
|
||||
type Reward = {
|
||||
reward_amount: number,
|
||||
reward_range: string,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isPending: boolean,
|
||||
label: ?string,
|
||||
reward: Reward,
|
||||
button: ?boolean,
|
||||
disabled: boolean,
|
||||
claimReward: (Reward) => void,
|
||||
};
|
||||
|
||||
const RewardLink = (props: Props) => {
|
||||
const { reward, claimReward, label, isPending, button, disabled = false } = props;
|
||||
let displayLabel = label;
|
||||
if (isPending) {
|
||||
displayLabel = __('Claiming...');
|
||||
} else if (label) {
|
||||
displayLabel = label;
|
||||
} else if (reward && reward.reward_range && reward.reward_range.includes('-')) {
|
||||
displayLabel = __('Claim %range% LBC', { range: reward.reward_range });
|
||||
} else if (reward && reward.reward_amount > 0) {
|
||||
displayLabel = __('Claim %amount% LBC', { amount: reward.reward_amount });
|
||||
} else {
|
||||
displayLabel = __('Claim ??? LBC');
|
||||
}
|
||||
|
||||
return !reward ? null : (
|
||||
<Button
|
||||
button={button ? 'primary' : 'link'}
|
||||
disabled={disabled || isPending}
|
||||
label={<LbcMessage>{displayLabel}</LbcMessage>}
|
||||
aria-label={displayLabel}
|
||||
onClick={() => {
|
||||
claimReward(reward);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RewardLink;
|
9
ui/component/rewardListClaimed/index.js
Normal file
9
ui/component/rewardListClaimed/index.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectClaimedRewards } from 'redux/selectors/rewards';
|
||||
import RewardListClaimed from './view';
|
||||
|
||||
const select = state => ({
|
||||
rewards: selectClaimedRewards(state),
|
||||
});
|
||||
|
||||
export default connect(select, null)(RewardListClaimed);
|
68
ui/component/rewardListClaimed/view.jsx
Normal file
68
ui/component/rewardListClaimed/view.jsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import ButtonTransaction from 'component/common/transaction-link';
|
||||
import moment from 'moment';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import Card from 'component/common/card';
|
||||
|
||||
type Reward = {
|
||||
id: string,
|
||||
reward_title: string,
|
||||
reward_amount: number,
|
||||
transaction_id: string,
|
||||
created_at: string,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
rewards: Array<Reward>,
|
||||
};
|
||||
|
||||
const RewardListClaimed = (props: Props) => {
|
||||
const { rewards } = props;
|
||||
|
||||
if (!rewards || !rewards.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={<div className="table__header-text">{__('Claimed Rewards')}</div>}
|
||||
subtitle={
|
||||
<div className="table__header-text">
|
||||
{__(
|
||||
'Reward history is tied to your email. In case of lost or multiple wallets, your balance may differ from the amounts claimed'
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
isBodyList
|
||||
body={
|
||||
<div className="table__wrapper">
|
||||
<table className="table table--rewards">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{__('Title')}</th>
|
||||
<th>
|
||||
<LbcSymbol size={20} />
|
||||
</th>
|
||||
<th>{__('Transaction')}</th>
|
||||
<th>{__('Date')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rewards.reverse().map(reward => (
|
||||
<tr key={reward.id}>
|
||||
<td>{reward.reward_title}</td>
|
||||
<td>{reward.reward_amount}</td>
|
||||
<td>{reward.transaction_id && <ButtonTransaction id={reward.transaction_id} />}</td>
|
||||
<td>{moment(reward.created_at).format('LLL')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RewardListClaimed;
|
10
ui/component/rewardSummary/index.js
Normal file
10
ui/component/rewardSummary/index.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectUnclaimedRewardValue, selectFetchingRewards } from 'redux/selectors/rewards';
|
||||
import RewardSummary from './view';
|
||||
|
||||
const select = state => ({
|
||||
unclaimedRewardAmount: selectUnclaimedRewardValue(state),
|
||||
fetching: selectFetchingRewards(state),
|
||||
});
|
||||
|
||||
export default connect(select, null)(RewardSummary);
|
52
ui/component/rewardSummary/view.jsx
Normal file
52
ui/component/rewardSummary/view.jsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import Button from 'component/button';
|
||||
import CreditAmount from 'component/common/credit-amount';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import Card from 'component/common/card';
|
||||
|
||||
type Props = {
|
||||
unclaimedRewardAmount: number,
|
||||
fetching: boolean,
|
||||
};
|
||||
|
||||
class RewardSummary extends React.Component<Props> {
|
||||
render() {
|
||||
const { unclaimedRewardAmount, fetching } = this.props;
|
||||
const hasRewards = unclaimedRewardAmount > 0;
|
||||
return (
|
||||
<Card
|
||||
title={__('Available rewards')}
|
||||
subtitle={
|
||||
<React.Fragment>
|
||||
{fetching && __('You have...')}
|
||||
{!fetching && hasRewards ? (
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
credit_amount: <CreditAmount inheritStyle amount={unclaimedRewardAmount} precision={8} />,
|
||||
}}
|
||||
f
|
||||
>
|
||||
You have %credit_amount% in unclaimed rewards.
|
||||
</I18nMessage>
|
||||
) : (
|
||||
__('You have no rewards available.')
|
||||
)}
|
||||
</React.Fragment>
|
||||
}
|
||||
actions={
|
||||
<React.Fragment>
|
||||
<Button
|
||||
button="primary"
|
||||
navigate="/$/rewards"
|
||||
label={hasRewards ? __('Claim Rewards') : __('View Rewards')}
|
||||
/>
|
||||
<Button button="link" label={__('Learn more')} href="https://lbry.com/faq/rewards" />
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default RewardSummary;
|
15
ui/component/rewardTile/index.js
Normal file
15
ui/component/rewardTile/index.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
import * as MODALS from 'constants/modal_types';
|
||||
import { connect } from 'react-redux';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { selectUser } from 'redux/selectors/user';
|
||||
import RewardTile from './view';
|
||||
|
||||
const select = state => ({
|
||||
user: selectUser(state),
|
||||
});
|
||||
const perform = dispatch => ({
|
||||
openRewardCodeModal: () => dispatch(doOpenModal(MODALS.REWARD_GENERATED_CODE)),
|
||||
openSetReferrerModal: () => dispatch(doOpenModal(MODALS.SET_REFERRER)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(RewardTile);
|
78
ui/component/rewardTile/view.jsx
Normal file
78
ui/component/rewardTile/view.jsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
// @flow
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React from 'react';
|
||||
import Icon from 'component/common/icon';
|
||||
import RewardLink from 'component/rewardLink';
|
||||
import Button from 'component/button';
|
||||
import Card from 'component/common/card';
|
||||
import rewards from 'rewards';
|
||||
import LbcMessage from 'component/common/lbc-message';
|
||||
|
||||
type Props = {
|
||||
openRewardCodeModal: () => void,
|
||||
openSetReferrerModal: () => void,
|
||||
reward: {
|
||||
id: string,
|
||||
reward_title: string,
|
||||
reward_amount: number,
|
||||
reward_range?: string,
|
||||
transaction_id: string,
|
||||
created_at: string,
|
||||
reward_description: string,
|
||||
reward_type: string,
|
||||
claim_code: string,
|
||||
},
|
||||
user: User,
|
||||
disabled: boolean,
|
||||
};
|
||||
|
||||
const RewardTile = (props: Props) => {
|
||||
const { reward, openRewardCodeModal, openSetReferrerModal, user, disabled = false } = props;
|
||||
const referrerSet = user && user.invited_by_id;
|
||||
const claimed = !!reward.transaction_id;
|
||||
const customActionsRewards = [rewards.TYPE_REFERRAL, rewards.TYPE_REFEREE];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={__(reward.reward_title)}
|
||||
subtitle={<LbcMessage>{reward.reward_description}</LbcMessage>}
|
||||
actions={
|
||||
<div className="section__actions">
|
||||
{reward.reward_type === rewards.TYPE_GENERATED_CODE && (
|
||||
<Button button="primary" onClick={openRewardCodeModal} label={__('Enter Code')} disabled={disabled} />
|
||||
)}
|
||||
{reward.reward_type === rewards.TYPE_REFERRAL && (
|
||||
<Button
|
||||
button="primary"
|
||||
navigate="/$/invite"
|
||||
label={__('Go To Invites')}
|
||||
aria-hidden={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
/>
|
||||
)}
|
||||
{reward.reward_type === rewards.TYPE_REFEREE && (
|
||||
<>
|
||||
{referrerSet && <RewardLink button reward_type={reward.reward_type} disabled={disabled} />}
|
||||
<Button
|
||||
button={referrerSet ? 'link' : 'primary'}
|
||||
onClick={openSetReferrerModal}
|
||||
label={referrerSet ? __('Change Inviter') : __('Set Inviter')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!customActionsRewards.some((i) => i === reward.reward_type) &&
|
||||
(claimed ? (
|
||||
<span>
|
||||
<Icon icon={ICONS.COMPLETED} /> {__('Reward claimed.')}
|
||||
</span>
|
||||
) : (
|
||||
<RewardLink button claim_code={reward.claim_code} disabled={disabled} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RewardTile;
|
16
ui/component/rewardTotal/index.js
Normal file
16
ui/component/rewardTotal/index.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectUnclaimedRewardValue, selectFetchingRewards, selectClaimedRewards } from 'redux/selectors/rewards';
|
||||
import { doRewardList } from 'redux/actions/rewards';
|
||||
import RewardSummary from './view';
|
||||
|
||||
const select = state => ({
|
||||
unclaimedRewardAmount: selectUnclaimedRewardValue(state),
|
||||
fetching: selectFetchingRewards(state),
|
||||
rewards: selectClaimedRewards(state),
|
||||
});
|
||||
|
||||
const perform = dispatch => ({
|
||||
fetchRewards: () => dispatch(doRewardList()),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(RewardSummary);
|
BIN
ui/component/rewardTotal/total-background.png
Normal file
BIN
ui/component/rewardTotal/total-background.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
26
ui/component/rewardTotal/view.jsx
Normal file
26
ui/component/rewardTotal/view.jsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import TotalBackground from './total-background.png';
|
||||
import useTween from 'effects/use-tween';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
|
||||
type Props = {
|
||||
rewards: Array<Reward>,
|
||||
};
|
||||
|
||||
function RewardTotal(props: Props) {
|
||||
const { rewards } = props;
|
||||
const rewardTotal = rewards.reduce((acc, val) => acc + val.reward_amount, 0);
|
||||
const modifier = rewardTotal > 500 ? 1 : 15; // used to tweak the reward count speed
|
||||
const total = useTween(rewardTotal * modifier);
|
||||
const integer = Math.round(total * rewardTotal);
|
||||
|
||||
return (
|
||||
<section className="card card--section card--reward-total" style={{ backgroundImage: `url(${TotalBackground})` }}>
|
||||
<I18nMessage tokens={{ amount: integer, lbc: <LbcSymbol /> }}>%amount% %lbc% earned from rewards</I18nMessage>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default RewardTotal;
|
|
@ -37,6 +37,7 @@ import ChannelsPage from 'page/channels';
|
|||
import CreatorDashboard from 'page/creatorDashboard';
|
||||
import DiscoverPage from 'page/discover';
|
||||
import FileListPublished from 'page/fileListPublished';
|
||||
import FourOhFourPage from 'page/fourOhFour';
|
||||
import HelpPage from 'page/help';
|
||||
import LibraryPage from 'page/library';
|
||||
import ListBlockedPage from 'page/listBlocked';
|
||||
|
@ -226,20 +227,18 @@ function AppRouter(props: Props) {
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* Odysee signin */}
|
||||
<Route path={`/$/${PAGES.AUTH_SIGNIN}`} exact component={SignInPage} />
|
||||
<Route path={`/$/${PAGES.AUTH_PASSWORD_RESET}`} exact component={PasswordResetPage} />
|
||||
<Route path={`/$/${PAGES.AUTH_PASSWORD_SET}`} exact component={PasswordSetPage} />
|
||||
<Route path={`/$/${PAGES.AUTH}`} exact component={SignUpPage} />
|
||||
<Route path={`/$/${PAGES.AUTH}/*`} exact component={SignUpPage} />
|
||||
<Route path={`/$/${PAGES.AUTH_VERIFY}`} exact component={SignInVerifyPage} />
|
||||
|
||||
<Route path={`/$/${PAGES.WELCOME}`} exact component={Welcome} />
|
||||
|
||||
<Route path={`/$/${PAGES.HELP}`} exact component={HelpPage} />
|
||||
{/* @if TARGET='app' */}
|
||||
<Route path={`/$/${PAGES.BACKUP}`} exact component={BackupPage} />
|
||||
{/* @endif */}
|
||||
<Route path={`/$/${PAGES.AUTH_VERIFY}`} exact component={SignInVerifyPage} />
|
||||
<Route path={`/$/${PAGES.SEARCH}`} exact component={SearchPage} />
|
||||
<Route path={`/$/${PAGES.TOP}`} exact component={TopPage} />
|
||||
<Route path={`/$/${PAGES.SETTINGS}`} exact component={SettingsPage} />
|
||||
|
@ -280,6 +279,7 @@ function AppRouter(props: Props) {
|
|||
{/* Below need to go at the end to make sure we don't match any of our pages first */}
|
||||
<Route path="/:claimName" exact component={ShowPage} />
|
||||
<Route path="/:claimName/:streamName" exact component={ShowPage} />
|
||||
<Route path="/*" component={FourOhFourPage} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -89,6 +89,7 @@ export default function SearchChannelField(props: Props) {
|
|||
return (
|
||||
<Button
|
||||
ref={addTagRef}
|
||||
requiresAuth
|
||||
button="primary"
|
||||
label={labelFoundAction}
|
||||
onClick={() => handleFoundChannelClick(claim)}
|
||||
|
|
|
@ -15,44 +15,15 @@ const SPEECH_UPLOADING = 'UPLOADING';
|
|||
type Props = {
|
||||
assetName: string,
|
||||
currentValue: ?string,
|
||||
onUpdate: (string, boolean, ?string) => void,
|
||||
onUpdate: (string, boolean) => void,
|
||||
recommended: string,
|
||||
title: string,
|
||||
onDone?: () => void,
|
||||
inline?: boolean,
|
||||
// When uploading pictures, the upload service
|
||||
// can return success but the image isn't ready
|
||||
// to be displayed yet. This is when a local preview
|
||||
// comes in handy. The preview (base 64) will be
|
||||
// passed to the onUpdate function after the
|
||||
// upload service returns success.
|
||||
buildImagePreview?: boolean,
|
||||
// File extension filtering. Files can be filtered
|
||||
// but the "All Files" options always shows up. To
|
||||
// avoid that, you can use the filters property.
|
||||
// For example, to only accept images pass the
|
||||
// following filter:
|
||||
// { name: 'Images', extensions: ['jpg', 'png', 'gif'] },
|
||||
filters?: Array<{ name: string, extension: string[] }>,
|
||||
type?: string,
|
||||
};
|
||||
|
||||
function filePreview(file) {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result.toString());
|
||||
};
|
||||
reader.onerror = () => {
|
||||
resolve(undefined);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function SelectAsset(props: Props) {
|
||||
const { onUpdate, onDone, assetName, currentValue, recommended, title, inline, buildImagePreview, filters, type } =
|
||||
props;
|
||||
const { onUpdate, onDone, assetName, currentValue, recommended, title, inline } = props;
|
||||
const [pathSelected, setPathSelected] = React.useState('');
|
||||
const [fileSelected, setFileSelected] = React.useState<any>(null);
|
||||
const [uploadStatus, setUploadStatus] = React.useState(SPEECH_READY);
|
||||
|
@ -65,15 +36,9 @@ function SelectAsset(props: Props) {
|
|||
setError(error);
|
||||
};
|
||||
|
||||
const onSuccess = async (thumbnailUrl) => {
|
||||
let preview;
|
||||
const onSuccess = (thumbnailUrl) => {
|
||||
setUploadStatus(SPEECH_READY);
|
||||
|
||||
if (buildImagePreview) {
|
||||
preview = await filePreview(fileSelected);
|
||||
}
|
||||
|
||||
onUpdate(thumbnailUrl, !useUrl, preview);
|
||||
onUpdate(thumbnailUrl, !useUrl);
|
||||
|
||||
if (onDone) {
|
||||
onDone();
|
||||
|
@ -130,17 +95,17 @@ function SelectAsset(props: Props) {
|
|||
/>
|
||||
) : (
|
||||
<FileSelector
|
||||
filters={filters}
|
||||
type={type}
|
||||
autoFocus
|
||||
disabled={uploadStatus === SPEECH_UPLOADING}
|
||||
label={fileSelectorLabel}
|
||||
name="assetSelector"
|
||||
currentPath={pathSelected}
|
||||
onFileChosen={(fileWithPath) => {
|
||||
if (fileWithPath.file.name) {
|
||||
setFileSelected(fileWithPath.file);
|
||||
setPathSelected(fileWithPath.path);
|
||||
onFileChosen={(file) => {
|
||||
if (file.name) {
|
||||
setFileSelected(file);
|
||||
// what why? why not target=WEB this?
|
||||
// file.path is undefined in web but available in electron
|
||||
setPathSelected(file.name || file.path);
|
||||
}
|
||||
}}
|
||||
accept={accept}
|
||||
|
|
|
@ -106,7 +106,7 @@ function SelectThumbnail(props: Props) {
|
|||
__('This will be visible in a few minutes after you submit this form.')}
|
||||
<img
|
||||
style={{ display: 'none' }}
|
||||
src={thumbnail || ThumbnailMissingImage}
|
||||
src={thumbnail}
|
||||
alt={__('Thumbnail Preview')}
|
||||
onError={() => {
|
||||
if (updateThumbnailParams) {
|
||||
|
@ -160,9 +160,9 @@ function SelectThumbnail(props: Props) {
|
|||
label={__('Thumbnail')}
|
||||
placeholder={__('Choose an enticing thumbnail')}
|
||||
accept={accept}
|
||||
onFileChosen={(fileWithPath) =>
|
||||
onFileChosen={(file) =>
|
||||
openModal(MODALS.CONFIRM_THUMBNAIL_UPLOAD, {
|
||||
file: fileWithPath,
|
||||
file,
|
||||
cb: (url) => updateThumbnailParams && updateThumbnailParams({ thumbnail_url: url }),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -53,7 +53,9 @@ export default function SettingAccount(props: Props) {
|
|||
</SettingsRow>
|
||||
)}
|
||||
|
||||
{/* @if TARGET='app' */}
|
||||
<SyncToggle disabled={walletEncrypted && !storedPassword && storedPassword !== ''} />
|
||||
{/* @endif */}
|
||||
|
||||
{hasChannels && (
|
||||
<SettingsRow title={__('Comments')} subtitle={__('View your past comments.')}>
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { doSetDaemonSetting, doClearDaemonSetting, doFindFFmpeg } from 'redux/actions/settings';
|
||||
import { selectAllowAnalytics } from 'redux/selectors/app';
|
||||
import { selectDaemonSettings, selectFfmpegStatus, selectFindingFFmpeg } from 'redux/selectors/settings';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user'; // here
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
|
||||
import SettingSystem from './view';
|
||||
|
||||
|
|
|
@ -130,7 +130,7 @@ export default function SettingSystem(props: Props) {
|
|||
<FileSelector
|
||||
type="openDirectory"
|
||||
currentPath={daemonSettings.download_dir}
|
||||
onFileChosen={(newDirectory: FileWithPath) => {
|
||||
onFileChosen={(newDirectory: WebFile) => {
|
||||
setDaemonSetting('download_dir', newDirectory.path);
|
||||
}}
|
||||
/>
|
||||
|
@ -224,7 +224,7 @@ export default function SettingSystem(props: Props) {
|
|||
type="openDirectory"
|
||||
placeholder={__('A Folder containing FFmpeg')}
|
||||
currentPath={ffmpegPath || daemonSettings.ffmpeg_path}
|
||||
onFileChosen={(newDirectory: FileWithPath) => {
|
||||
onFileChosen={(newDirectory: WebFile) => {
|
||||
// $FlowFixMe
|
||||
setDaemonSetting('ffmpeg_path', newDirectory.path);
|
||||
findFFmpeg();
|
||||
|
|
|
@ -6,13 +6,15 @@ import * as KEYCODES from 'constants/keycodes';
|
|||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import classnames from 'classnames';
|
||||
import Icon from 'component/common/icon';
|
||||
import NotificationBubble from 'component/notificationBubble';
|
||||
import DebouncedInput from 'component/common/debounced-input';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import ChannelThumbnail from 'component/channelThumbnail';
|
||||
import { useIsMobile, isTouch } from 'effects/use-screensize';
|
||||
import { IS_MAC } from 'component/app/view';
|
||||
import { useHistory } from 'react-router';
|
||||
import { ENABLE_UI_NOTIFICATIONS } from 'config';
|
||||
import { DOMAIN, ENABLE_UI_NOTIFICATIONS } from 'config';
|
||||
|
||||
const FOLLOWED_ITEM_INITIAL_LIMIT = 10;
|
||||
const touch = isTouch();
|
||||
|
@ -182,6 +184,7 @@ function SideNavigation(props: Props) {
|
|||
];
|
||||
|
||||
const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui);
|
||||
const isAuthenticated = Boolean(email);
|
||||
|
||||
const [pulseLibrary, setPulseLibrary] = React.useState(false);
|
||||
const [expandSubscriptions, setExpandSubscriptions] = React.useState(false);
|
||||
|
@ -354,6 +357,23 @@ function SideNavigation(props: Props) {
|
|||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
}, [sidebarOpen, setSidebarOpen, isAbsolute]);
|
||||
|
||||
const unAuthNudge =
|
||||
DOMAIN === 'lbry.tv' ? null : (
|
||||
<div className="navigation__auth-nudge">
|
||||
<span>
|
||||
<I18nMessage tokens={{ lbc: <Icon icon={ICONS.LBC} /> }}>
|
||||
Sign up to earn %lbc% for you and your favorite creators.
|
||||
</I18nMessage>
|
||||
</span>
|
||||
<Button
|
||||
button="secondary"
|
||||
label={__('Sign Up')}
|
||||
navigate={`/$/${PAGES.AUTH}?src=sidenav_nudge`}
|
||||
disabled={user === null}
|
||||
/>{' '}
|
||||
</div>
|
||||
);
|
||||
|
||||
const helpLinks = (
|
||||
<ul className="navigation__tertiary navigation-links--small">
|
||||
<li className="navigation-link">
|
||||
|
@ -417,6 +437,7 @@ function SideNavigation(props: Props) {
|
|||
|
||||
{getSubscriptionSection()}
|
||||
{getFollowedTagsSection()}
|
||||
{!isAuthenticated && sidebarOpen && unAuthNudge}
|
||||
</div>
|
||||
)}
|
||||
{(!canDisposeMenu || sidebarOpen) && shouldRenderLargeMenu && helpLinks}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// @flow
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import CreditAmount from 'component/common/credit-amount';
|
||||
import Button from 'component/button';
|
||||
import { Form, FormField } from 'component/common/form';
|
||||
|
@ -24,100 +24,67 @@ type Props = {
|
|||
const SupportsLiquidate = (props: Props) => {
|
||||
const { claim, abandonSupportForClaim, handleClose, abandonClaimError } = props;
|
||||
const [previewBalance, setPreviewBalance] = useState(undefined);
|
||||
const [defaultValueAssigned, setDefaultValueAssigned] = useState(false);
|
||||
const [unlockTextAmount, setUnlockTextAmount] = useState('');
|
||||
const [amount, setAmount] = useState(-1);
|
||||
const [error, setError] = useState(false);
|
||||
const initialMessage = __('How much would you like to unlock?');
|
||||
const [message, setMessage] = useState(initialMessage);
|
||||
const amount = Number(unlockTextAmount) || 0;
|
||||
const defaultValue = previewBalance ? previewBalance * 0.25 : 0;
|
||||
const keep =
|
||||
amount >= 0
|
||||
? Boolean(previewBalance) && Number.parseFloat(String(Number(previewBalance) - amount)).toFixed(8)
|
||||
: Boolean(previewBalance) && Number.parseFloat(String(defaultValue * 3)).toFixed(8);
|
||||
? Boolean(previewBalance) && Number.parseFloat(String(Number(previewBalance) - Number(amount))).toFixed(8)
|
||||
: Boolean(previewBalance) && Number.parseFloat(String((Number(previewBalance) / 4) * 3)).toFixed(8); // default unlock 25%
|
||||
const claimId = claim && claim.claim_id;
|
||||
const type = claim.value_type;
|
||||
|
||||
useEffect(() => {
|
||||
if (claimId && abandonSupportForClaim) {
|
||||
abandonSupportForClaim(claimId, type, false, true).then((r) => {
|
||||
abandonSupportForClaim(claimId, type, false, true).then(r => {
|
||||
setPreviewBalance(r.total_input);
|
||||
});
|
||||
}
|
||||
}, [abandonSupportForClaim, claimId, type, setPreviewBalance]);
|
||||
|
||||
function handleSubmit() {
|
||||
abandonSupportForClaim(claimId, type, keep, false).then((r) => {
|
||||
abandonSupportForClaim(claimId, type, keep, false).then(r => {
|
||||
if (r) {
|
||||
handleClose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
(newValue) => {
|
||||
setUnlockTextAmount(String(newValue));
|
||||
},
|
||||
[setUnlockTextAmount]
|
||||
);
|
||||
|
||||
const handleChangeUnlockText = useCallback(
|
||||
(newValue) => {
|
||||
// Get rid of all characters except digits, commas and periods.
|
||||
const onlyValidAmount = newValue.replace(/[^0-9.,]+/, '');
|
||||
setUnlockTextAmount(onlyValidAmount);
|
||||
},
|
||||
[setUnlockTextAmount]
|
||||
);
|
||||
|
||||
const handleUnlockTextFocus = useCallback(() => {
|
||||
// Get rid of empty zero when user starts typing (small ux improvement)
|
||||
if (Number(unlockTextAmount) === 0) {
|
||||
setUnlockTextAmount('');
|
||||
}
|
||||
}, [unlockTextAmount, setUnlockTextAmount]);
|
||||
|
||||
const handleUnlockTextBlur = useCallback(() => {
|
||||
if (!unlockTextAmount || isNaN(Number(unlockTextAmount))) {
|
||||
setUnlockTextAmount(previewBalance ? String(defaultValue) : '0');
|
||||
}
|
||||
}, [unlockTextAmount, setUnlockTextAmount, previewBalance, defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValueAssigned || !previewBalance || unlockTextAmount) {
|
||||
return;
|
||||
}
|
||||
setUnlockTextAmount(String(defaultValue));
|
||||
setDefaultValueAssigned(true);
|
||||
}, [defaultValueAssigned, previewBalance, unlockTextAmount, setUnlockTextAmount, setDefaultValueAssigned]);
|
||||
|
||||
// Update message & error based on unlock amount.
|
||||
useEffect(() => {
|
||||
const unlockAmount = Number(unlockTextAmount);
|
||||
const previewBalanceNumber = Number(previewBalance);
|
||||
if (unlockTextAmount && isNaN(unlockAmount)) {
|
||||
function handleChange(a) {
|
||||
if (a === undefined || isNaN(Number(a))) {
|
||||
setMessage(__('Amount must be a number'));
|
||||
setError(true);
|
||||
} else if (unlockAmount > previewBalanceNumber) {
|
||||
setAmount(0);
|
||||
} else if (a === '') {
|
||||
setAmount(0);
|
||||
setError(true);
|
||||
setMessage(__('Amount cannot be blank'));
|
||||
} else if (Number(a) > Number(previewBalance)) {
|
||||
setMessage(__('Amount cannot be more than available'));
|
||||
setError(true);
|
||||
} else if (Math.abs(unlockAmount - previewBalanceNumber) <= Number.EPSILON) {
|
||||
setError(false);
|
||||
} else if (Number(a) === Number(previewBalance)) {
|
||||
setMessage(__(`She's about to close up the library!`));
|
||||
setAmount(a);
|
||||
setError(false);
|
||||
} else if (unlockAmount > previewBalanceNumber / 2) {
|
||||
} else if (Number(a) > Number(previewBalance) / 2) {
|
||||
setMessage(__('Your content will do better with more staked on it'));
|
||||
setAmount(a);
|
||||
setError(false);
|
||||
} else if (unlockAmount === 0) {
|
||||
} else if (Number(a) === 0) {
|
||||
setMessage(__('Amount cannot be zero'));
|
||||
setAmount(0);
|
||||
setError(true);
|
||||
} else if (unlockAmount <= previewBalanceNumber / 2) {
|
||||
} else if (Number(a) <= Number(previewBalance) / 2) {
|
||||
setMessage(__('A prudent choice'));
|
||||
setAmount(Number(a));
|
||||
setError(false);
|
||||
} else {
|
||||
setMessage(initialMessage);
|
||||
setAmount(a);
|
||||
setError(false);
|
||||
}
|
||||
}, [unlockTextAmount, previewBalance, setMessage, setError]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
@ -173,8 +140,8 @@ const SupportsLiquidate = (props: Props) => {
|
|||
min={0}
|
||||
step={0.01}
|
||||
max={previewBalance}
|
||||
value={amount}
|
||||
onChange={(e) => handleRangeChange(e.target.value)}
|
||||
value={Number(amount) >= 0 ? amount : previewBalance / 4} // by default, set it to 25% of available
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
/>
|
||||
<label className="range__label">
|
||||
<span>0</span>
|
||||
|
@ -183,11 +150,9 @@ const SupportsLiquidate = (props: Props) => {
|
|||
</label>
|
||||
<FormField
|
||||
type="text"
|
||||
value={unlockTextAmount}
|
||||
value={amount >= 0 ? amount || '' : previewBalance && previewBalance / 4}
|
||||
helper={message}
|
||||
onFocus={handleUnlockTextFocus}
|
||||
onChange={(e) => handleChangeUnlockText(e.target.value)}
|
||||
onBlur={handleUnlockTextBlur}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
|
|
|
@ -41,7 +41,7 @@ function SyncToggle(props: Props) {
|
|||
{!verifiedEmail && (
|
||||
<div>
|
||||
<p className="help">{__('An email address is required to sync your account.')}</p>
|
||||
<Button button="primary" label={__('Add Email')} />
|
||||
<Button requiresAuth button="primary" label={__('Add Email')} />
|
||||
</div>
|
||||
)}
|
||||
</SettingsRow>
|
||||
|
|
|
@ -103,8 +103,6 @@ function TxoList(props: Props) {
|
|||
params[TXO.TX_TYPE] = currentUrlParams.type;
|
||||
} else if (currentUrlParams.type === TXO.PUBLISH) {
|
||||
params[TXO.TX_TYPE] = TXO.STREAM;
|
||||
} else if (currentUrlParams.type === TXO.COLLECTION) {
|
||||
params[TXO.TX_TYPE] = currentUrlParams.type;
|
||||
}
|
||||
}
|
||||
if (currentUrlParams.active) {
|
||||
|
|
14
ui/component/userPhoneNew/index.js
Normal file
14
ui/component/userPhoneNew/index.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { selectPhoneNewErrorMessage } from 'redux/selectors/user';
|
||||
import { doUserPhoneNew } from 'redux/actions/user';
|
||||
import UserPhoneNew from './view';
|
||||
|
||||
const select = state => ({
|
||||
phoneErrorMessage: selectPhoneNewErrorMessage(state),
|
||||
});
|
||||
|
||||
const perform = dispatch => ({
|
||||
addUserPhone: (phone, countryCode) => dispatch(doUserPhoneNew(phone, countryCode)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(UserPhoneNew);
|
124
ui/component/userPhoneNew/view.jsx
Normal file
124
ui/component/userPhoneNew/view.jsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Form, FormField, Submit } from 'component/common/form';
|
||||
import Card from 'component/common/card';
|
||||
|
||||
const os = require('os').type();
|
||||
const countryCodes = require('country-data')
|
||||
.callingCountries.all.filter(_ => _.emoji)
|
||||
.reduce((acc, cur) => acc.concat(cur.countryCallingCodes.map(_ => ({ ...cur, countryCallingCode: _ }))), [])
|
||||
.sort((a, b) => {
|
||||
if (a.countryCallingCode < b.countryCallingCode) {
|
||||
return -1;
|
||||
}
|
||||
if (a.countryCallingCode > b.countryCallingCode) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
type Props = {
|
||||
addUserPhone: (string, string) => void,
|
||||
cancelButton: React.Node,
|
||||
phoneErrorMessage: ?string,
|
||||
isPending: boolean,
|
||||
};
|
||||
|
||||
type State = {
|
||||
phone: string,
|
||||
countryCode: string,
|
||||
};
|
||||
|
||||
class UserPhoneNew extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
phone: '',
|
||||
countryCode: '+1',
|
||||
};
|
||||
|
||||
(this: any).formatPhone = this.formatPhone.bind(this);
|
||||
(this: any).handleSubmit = this.handleSubmit.bind(this);
|
||||
(this: any).handleSelect = this.handleSelect.bind(this);
|
||||
}
|
||||
|
||||
formatPhone(value: string) {
|
||||
const { countryCode } = this.state;
|
||||
const formattedNumber = value.replace(/\D/g, '');
|
||||
|
||||
if (countryCode === '+1') {
|
||||
if (!formattedNumber) {
|
||||
return '';
|
||||
} else if (formattedNumber.length < 4) {
|
||||
return formattedNumber;
|
||||
} else if (formattedNumber.length < 7) {
|
||||
return `(${formattedNumber.substring(0, 3)}) ${formattedNumber.substring(3)}`;
|
||||
}
|
||||
const fullNumber = `(${formattedNumber.substring(0, 3)}) ${formattedNumber.substring(
|
||||
3,
|
||||
6
|
||||
)}-${formattedNumber.substring(6)}`;
|
||||
return fullNumber.length <= 14 ? fullNumber : fullNumber.substring(0, 14);
|
||||
}
|
||||
return formattedNumber;
|
||||
}
|
||||
|
||||
handleChanged(event: SyntheticInputEvent<*>) {
|
||||
this.setState({
|
||||
phone: this.formatPhone(event.target.value),
|
||||
});
|
||||
}
|
||||
|
||||
handleSelect(event: SyntheticInputEvent<*>) {
|
||||
this.setState({ countryCode: event.target.value });
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
const { phone, countryCode } = this.state;
|
||||
this.props.addUserPhone(phone.replace(/\D/g, ''), countryCode.substring(1));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { cancelButton, phoneErrorMessage, isPending } = this.props;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={__('Enter your phone number')}
|
||||
subtitle={__(
|
||||
'Enter your phone number and we will send you a verification code. We will not share your phone number with third parties.'
|
||||
)}
|
||||
actions={
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<fieldset-group class="fieldset-group--smushed">
|
||||
<FormField label={__('Country')} type="select" name="country-codes" onChange={this.handleSelect}>
|
||||
{countryCodes.map((country, index) => (
|
||||
<option key={index} value={country.countryCallingCode}>
|
||||
{os === 'Darwin' ? country.emoji : `(${country.alpha2})`} {country.countryCallingCode}
|
||||
</option>
|
||||
))}
|
||||
</FormField>
|
||||
<FormField
|
||||
type="text"
|
||||
label={__('Number')}
|
||||
placeholder={this.state.countryCode === '+1' ? '(555) 555-5555' : '5555555555'}
|
||||
name="phone"
|
||||
value={this.state.phone}
|
||||
error={phoneErrorMessage}
|
||||
onChange={event => {
|
||||
this.handleChanged(event);
|
||||
}}
|
||||
/>
|
||||
</fieldset-group>
|
||||
<div className="card__actions">
|
||||
<Submit label="Submit" disabled={isPending} />
|
||||
{cancelButton}
|
||||
</div>
|
||||
</Form>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPhoneNew;
|
17
ui/component/userPhoneVerify/index.js
Normal file
17
ui/component/userPhoneVerify/index.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { doUserPhoneVerify, doUserPhoneReset } from 'redux/actions/user';
|
||||
import { selectPhoneToVerify, selectPhoneVerifyErrorMessage, selectUserCountryCode } from 'redux/selectors/user';
|
||||
import UserPhoneVerify from './view';
|
||||
|
||||
const select = state => ({
|
||||
phone: selectPhoneToVerify(state),
|
||||
countryCode: selectUserCountryCode(state),
|
||||
phoneErrorMessage: selectPhoneVerifyErrorMessage(state),
|
||||
});
|
||||
|
||||
const perform = dispatch => ({
|
||||
resetPhone: () => dispatch(doUserPhoneReset()),
|
||||
verifyUserPhone: code => dispatch(doUserPhoneVerify(code)),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(UserPhoneVerify);
|
90
ui/component/userPhoneVerify/view.jsx
Normal file
90
ui/component/userPhoneVerify/view.jsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import Button from 'component/button';
|
||||
import { Form, FormField, Submit } from 'component/common/form';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import Card from 'component/common/card';
|
||||
import { SITE_HELP_EMAIL } from 'config';
|
||||
|
||||
type Props = {
|
||||
verifyUserPhone: (string) => void,
|
||||
resetPhone: () => void,
|
||||
phoneErrorMessage: string,
|
||||
phone: string,
|
||||
countryCode: string,
|
||||
};
|
||||
|
||||
type State = {
|
||||
code: string,
|
||||
};
|
||||
|
||||
class UserPhoneVerify extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
code: '',
|
||||
};
|
||||
}
|
||||
|
||||
handleCodeChanged(event: SyntheticInputEvent<*>) {
|
||||
this.setState({
|
||||
code: String(event.target.value).trim(),
|
||||
});
|
||||
}
|
||||
|
||||
handleSubmit() {
|
||||
const { code } = this.state;
|
||||
this.props.verifyUserPhone(code);
|
||||
}
|
||||
|
||||
reset() {
|
||||
const { resetPhone } = this.props;
|
||||
resetPhone();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { phoneErrorMessage, phone, countryCode } = this.props;
|
||||
return (
|
||||
<Card
|
||||
title={__('Enter the verification code')}
|
||||
subtitle={
|
||||
<>
|
||||
{__(`Please enter the verification code sent to +${countryCode}${phone}. Didn't receive it? `)}
|
||||
<Button button="link" onClick={this.reset.bind(this)} label={__('Go back.')} />
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Form onSubmit={this.handleSubmit.bind(this)}>
|
||||
<FormField
|
||||
type="text"
|
||||
name="code"
|
||||
placeholder="1234"
|
||||
value={this.state.code}
|
||||
onChange={(event) => {
|
||||
this.handleCodeChanged(event);
|
||||
}}
|
||||
label={__('Verification Code')}
|
||||
error={phoneErrorMessage}
|
||||
inputButton={<Submit label={__('Verify')} />}
|
||||
/>
|
||||
</Form>
|
||||
<p className="help">
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
help_link: <Button button="link" href={`mailto:${SITE_HELP_EMAIL}`} label={`${SITE_HELP_EMAIL}`} />,
|
||||
chat_link: <Button button="link" href="https://chat.lbry.com" label={__('chat')} />,
|
||||
}}
|
||||
>
|
||||
Email %help_link% or join our %chat_link% if you encounter any trouble with your code.
|
||||
</I18nMessage>
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPhoneVerify;
|
26
ui/component/userVerify/index.js
Normal file
26
ui/component/userVerify/index.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import * as MODALS from 'constants/modal_types';
|
||||
import { connect } from 'react-redux';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { doUserIdentityVerify, doUserFetch } from 'redux/actions/user';
|
||||
import { makeSelectRewardByType } from 'redux/selectors/rewards';
|
||||
import rewards from 'rewards';
|
||||
import { selectIdentityVerifyIsPending, selectIdentityVerifyErrorMessage } from 'redux/selectors/user';
|
||||
import UserVerify from './view';
|
||||
|
||||
const select = state => {
|
||||
const selectReward = makeSelectRewardByType();
|
||||
|
||||
return {
|
||||
isPending: selectIdentityVerifyIsPending(state),
|
||||
errorMessage: selectIdentityVerifyErrorMessage(state),
|
||||
reward: selectReward(state, rewards.TYPE_NEW_USER),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = dispatch => ({
|
||||
verifyUserIdentity: token => dispatch(doUserIdentityVerify(token)),
|
||||
verifyPhone: () => dispatch(doOpenModal(MODALS.PHONE_COLLECTION)),
|
||||
fetchUser: () => dispatch(doUserFetch()),
|
||||
});
|
||||
|
||||
export default connect(select, perform)(UserVerify);
|
164
ui/component/userVerify/view.jsx
Normal file
164
ui/component/userVerify/view.jsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
// @flow
|
||||
import { SITE_NAME } from 'config';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React, { Fragment } from 'react';
|
||||
import Button from 'component/button';
|
||||
import CardVerify from 'component/cardVerify';
|
||||
import { Lbryio } from 'lbryinc';
|
||||
import Card from 'component/common/card';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
|
||||
type Props = {
|
||||
errorMessage: ?string,
|
||||
isPending: boolean,
|
||||
verifyUserIdentity: (string) => void,
|
||||
verifyPhone: () => void,
|
||||
fetchUser: () => void,
|
||||
skipLink?: string,
|
||||
onSkip: () => void,
|
||||
};
|
||||
|
||||
class UserVerify extends React.PureComponent<Props> {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
(this: any).onToken = this.onToken.bind(this);
|
||||
}
|
||||
|
||||
onToken(data: { id: string }) {
|
||||
this.props.verifyUserIdentity(data.id);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { errorMessage, isPending, verifyPhone, fetchUser, onSkip } = this.props;
|
||||
const skipButtonProps = {
|
||||
onClick: onSkip,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main__auth-content">
|
||||
<section className="section__header">
|
||||
<h1 className="section__title--large">
|
||||
{''}
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
lbc: <LbcSymbol size={48} />,
|
||||
}}
|
||||
>
|
||||
Verify to earn %lbc%
|
||||
</I18nMessage>
|
||||
</h1>
|
||||
<p>
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
rewards_program: (
|
||||
<Button button="link" label={__('other rewards')} href="https://lbry.com/faq/rewards" />
|
||||
),
|
||||
Refresh: <Button onClick={() => fetchUser()} button="link" label={__('Refresh')} />,
|
||||
Skip: <Button {...skipButtonProps} button="link" label={__('Skip')} />,
|
||||
SITE_NAME,
|
||||
}}
|
||||
>
|
||||
Verified accounts are eligible to earn LBRY Credits for views, watching and reposting content, sharing
|
||||
invite links etc. Verifying also helps us keep the %SITE_NAME% community safe too! %Refresh% or %Skip%.
|
||||
</I18nMessage>
|
||||
</p>
|
||||
<p className="help">
|
||||
{__('This step is not mandatory and not required in order for you to use %SITE_NAME%.', { SITE_NAME })}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="section">
|
||||
<Card
|
||||
icon={ICONS.PHONE}
|
||||
title={__('Verify phone number')}
|
||||
subtitle={__(
|
||||
'You will receive an SMS text message confirming your phone number is valid. May not be available in all regions.'
|
||||
)}
|
||||
actions={
|
||||
<Fragment>
|
||||
<Button
|
||||
onClick={() => {
|
||||
verifyPhone();
|
||||
}}
|
||||
button="primary"
|
||||
label={__('Verify Via Text')}
|
||||
/>
|
||||
<p className="help">
|
||||
{__('Standard messaging rates apply. Having trouble?')}{' '}
|
||||
<Button button="link" href="https://lbry.com/faq/phone" label={__('Read more')} />.
|
||||
</p>
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="section__divider">
|
||||
<hr />
|
||||
<p>{__('OR')}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
icon={ICONS.WALLET}
|
||||
title={__('Verify via credit card')}
|
||||
subtitle={__('Your card information will not be stored or charged, now or in the future.')}
|
||||
actions={
|
||||
<Fragment>
|
||||
{errorMessage && <p className="error__text">{errorMessage}</p>}
|
||||
<CardVerify
|
||||
label={__('Verify Card')}
|
||||
disabled={isPending}
|
||||
token={this.onToken}
|
||||
stripeKey={Lbryio.getStripeToken()}
|
||||
/>
|
||||
<p className="help">
|
||||
{__('A $1 authorization may temporarily appear with your provider.')}{' '}
|
||||
<Button button="link" href="https://lbry.com/faq/identity-requirements" label={__('Read more')} />.
|
||||
</p>
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="section__divider">
|
||||
<hr />
|
||||
<p>{__('OR')}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
icon={ICONS.CHAT}
|
||||
title={__('Verify via chat')}
|
||||
subtitle={
|
||||
<>
|
||||
<p>
|
||||
{__(
|
||||
'A moderator can approve you within our discord server. Please review the instructions within #rewards-approval carefully.'
|
||||
)}
|
||||
</p>
|
||||
<p>{__('You will be asked to provide proof of identity.')}</p>
|
||||
</>
|
||||
}
|
||||
actions={<Button href="https://chat.lbry.com" button="primary" label={__('Join LBRY Chat')} />}
|
||||
/>
|
||||
|
||||
<div className="section__divider">
|
||||
<hr />
|
||||
<p>{__('OR')}</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
icon={ICONS.REMOVE}
|
||||
title={__('Skip')}
|
||||
subtitle={__("Verifying is optional. If you skip this, it just means you can't earn LBRY Credits.")}
|
||||
actions={
|
||||
<Fragment>
|
||||
<Button {...skipButtonProps} button="primary" label={__('Continue Without Verifying')} />
|
||||
</Fragment>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserVerify;
|
|
@ -43,6 +43,7 @@ type Props = {
|
|||
toggleVideoTheaterMode: () => void,
|
||||
toggleAutoplayNext: () => void,
|
||||
setVideoPlaybackRate: (number) => void,
|
||||
authenticated: boolean,
|
||||
userId: number,
|
||||
homepageData?: { [string]: HomepageCat },
|
||||
shareTelemetry: boolean,
|
||||
|
|
27
ui/component/walletSwap/index.js
Normal file
27
ui/component/walletSwap/index.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router';
|
||||
import WalletSwap from './view';
|
||||
import { doOpenModal } from 'redux/actions/app';
|
||||
import { doAddCoinSwap, doQueryCoinSwapStatus } from 'redux/actions/coinSwap';
|
||||
import { doToast } from 'redux/actions/notifications';
|
||||
import { selectCoinSwaps } from 'redux/selectors/coinSwap';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { doGetNewAddress, doCheckAddressIsMine } from 'redux/actions/wallet';
|
||||
import { selectReceiveAddress } from 'redux/selectors/wallet';
|
||||
|
||||
const select = (state, props) => ({
|
||||
receiveAddress: selectReceiveAddress(state),
|
||||
coinSwaps: selectCoinSwaps(state),
|
||||
isAuthenticated: selectUserVerifiedEmail(state),
|
||||
});
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
|
||||
doToast: (options) => dispatch(doToast(options)),
|
||||
addCoinSwap: (coinSwap) => dispatch(doAddCoinSwap(coinSwap)),
|
||||
getNewAddress: () => dispatch(doGetNewAddress()),
|
||||
checkAddressIsMine: (address) => dispatch(doCheckAddressIsMine(address)),
|
||||
queryCoinSwapStatus: (sendAddress) => dispatch(doQueryCoinSwapStatus(sendAddress)),
|
||||
});
|
||||
|
||||
export default withRouter(connect(select, perform)(WalletSwap));
|
707
ui/component/walletSwap/view.jsx
Normal file
707
ui/component/walletSwap/view.jsx
Normal file
|
@ -0,0 +1,707 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import Button from 'component/button';
|
||||
import { FormField, Form } from 'component/common/form';
|
||||
import { Lbryio } from 'lbryinc';
|
||||
import Card from 'component/common/card';
|
||||
import LbcSymbol from 'component/common/lbc-symbol';
|
||||
import Spinner from 'component/spinner';
|
||||
import Nag from 'component/common/nag';
|
||||
import CopyableText from 'component/copyableText';
|
||||
import Icon from 'component/common/icon';
|
||||
import QRCode from 'component/common/qr-code';
|
||||
import usePersistedState from 'effects/use-persisted-state';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import * as MODALS from 'constants/modal_types';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import { clipboard } from 'electron';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import { Redirect, useHistory } from 'react-router';
|
||||
|
||||
const ENABLE_ALTERNATIVE_COINS = true;
|
||||
|
||||
const BTC_SATOSHIS = 100000000;
|
||||
const LBC_MAX = 21000000;
|
||||
const LBC_MIN = 1;
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== 'production';
|
||||
const DEBOUNCE_BTC_CHANGE_MS = 400;
|
||||
|
||||
const INTERNAL_APIS_DOWN = 'internal_apis_down';
|
||||
const BTC_API_STATUS_PENDING = 'NEW'; // Started swap, waiting for coin.
|
||||
const BTC_API_STATUS_CONFIRMING = 'PENDING'; // Coin receiving, waiting confirmation.
|
||||
const BTC_API_STATUS_PROCESSING = 'COMPLETED'; // Coin confirmed. Sending LBC.
|
||||
const BTC_API_STATUS_UNRESOLVED = 'UNRESOLVED'; // Underpaid, overpaid, etc.
|
||||
const BTC_API_STATUS_EXPIRED = 'EXPIRED'; // Charge expired (60 minutes).
|
||||
const BTC_API_STATUS_ERROR = 'Error';
|
||||
|
||||
const ACTION_MAIN = 'action_main';
|
||||
const ACTION_STATUS_PENDING = 'action_pending';
|
||||
const ACTION_STATUS_CONFIRMING = 'action_confirming';
|
||||
const ACTION_STATUS_PROCESSING = 'action_processing';
|
||||
const ACTION_STATUS_SUCCESS = 'action_success';
|
||||
const ACTION_PAST_SWAPS = 'action_past_swaps';
|
||||
|
||||
const NAG_API_STATUS_PENDING = 'Waiting to receive your crypto.';
|
||||
const NAG_API_STATUS_CONFIRMING = 'Confirming transaction.';
|
||||
const NAG_API_STATUS_PROCESSING = 'Crypto received. Sending your Credits.';
|
||||
const NAG_API_STATUS_SUCCESS = 'Credits sent. You should see it in your wallet.';
|
||||
const NAG_API_STATUS_ERROR = 'An error occurred on the previous swap.';
|
||||
const NAG_SWAP_CALL_FAILED = 'Failed to initiate swap.';
|
||||
// const NAG_STATUS_CALL_FAILED = 'Failed to query swap status.';
|
||||
const NAG_SERVER_DOWN = 'The system is currently down. Come back later.';
|
||||
const NAG_RATE_CALL_FAILED = 'Unable to obtain exchange rate. Try again later.';
|
||||
const NAG_EXPIRED = 'Swap expired.';
|
||||
|
||||
type Props = {
|
||||
receiveAddress: string,
|
||||
coinSwaps: Array<CoinSwapInfo>,
|
||||
isAuthenticated: boolean,
|
||||
doToast: ({ message: string }) => void,
|
||||
addCoinSwap: (CoinSwapInfo) => void,
|
||||
getNewAddress: () => void,
|
||||
checkAddressIsMine: (string) => void,
|
||||
openModal: (string, {}) => void,
|
||||
queryCoinSwapStatus: (string) => void,
|
||||
};
|
||||
|
||||
function WalletSwap(props: Props) {
|
||||
const {
|
||||
receiveAddress,
|
||||
doToast,
|
||||
coinSwaps,
|
||||
isAuthenticated,
|
||||
addCoinSwap,
|
||||
getNewAddress,
|
||||
checkAddressIsMine,
|
||||
openModal,
|
||||
queryCoinSwapStatus,
|
||||
} = props;
|
||||
|
||||
const [btc, setBtc] = React.useState(0);
|
||||
const [lbcError, setLbcError] = React.useState();
|
||||
const [lbc, setLbc] = usePersistedState('swap-desired-lbc', LBC_MIN);
|
||||
const [action, setAction] = React.useState(ACTION_MAIN);
|
||||
const [nag, setNag] = React.useState(null);
|
||||
const [showQr, setShowQr] = React.useState(false);
|
||||
const [isFetchingRate, setIsFetchingRate] = React.useState(false);
|
||||
const [isSwapping, setIsSwapping] = React.useState(false);
|
||||
const [isRefreshingStatus, setIsRefreshingStatus] = React.useState(false);
|
||||
const { location } = useHistory();
|
||||
const [swap, setSwap] = React.useState({});
|
||||
const [coin, setCoin] = React.useState('bitcoin');
|
||||
const [lastStatusQuery, setLastStatusQuery] = React.useState();
|
||||
const { goBack } = useHistory();
|
||||
|
||||
function formatCoinAmountString(amount) {
|
||||
return amount === 0 ? '---' : amount.toLocaleString(undefined, { minimumFractionDigits: 8 });
|
||||
}
|
||||
|
||||
function returnToMainAction() {
|
||||
setIsSwapping(false);
|
||||
setAction(ACTION_MAIN);
|
||||
setSwap(null);
|
||||
}
|
||||
|
||||
function removeCoinSwap(chargeCode) {
|
||||
openModal(MODALS.CONFIRM_REMOVE_BTC_SWAP_ADDRESS, {
|
||||
chargeCode: chargeCode,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure 'receiveAddress' is populated
|
||||
React.useEffect(() => {
|
||||
if (!receiveAddress) {
|
||||
getNewAddress();
|
||||
} else {
|
||||
checkAddressIsMine(receiveAddress);
|
||||
}
|
||||
}, [receiveAddress, getNewAddress, checkAddressIsMine]);
|
||||
|
||||
// Get 'btc/rate' and calculate required BTC.
|
||||
React.useEffect(() => {
|
||||
if (isNaN(lbc) || lbc === 0) {
|
||||
setBtc(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFetchingRate(true);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
Lbryio.call('btc', 'rate', { satoshi: BTC_SATOSHIS })
|
||||
.then((rate) => {
|
||||
setIsFetchingRate(false);
|
||||
setBtc((lbc * Math.round(BTC_SATOSHIS * rate)) / BTC_SATOSHIS);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsFetchingRate(false);
|
||||
setBtc(0);
|
||||
setNag({ msg: NAG_RATE_CALL_FAILED, type: 'error' });
|
||||
});
|
||||
}, DEBOUNCE_BTC_CHANGE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [lbc]);
|
||||
|
||||
// Resolve 'swap' with the latest info from 'coinSwaps'
|
||||
React.useEffect(() => {
|
||||
const swapInfo = swap && coinSwaps.find((x) => x.chargeCode === swap.chargeCode);
|
||||
if (!swapInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonSwap = JSON.stringify(swap);
|
||||
const jsonSwapInfo = JSON.stringify(swapInfo);
|
||||
if (jsonSwap !== jsonSwapInfo) {
|
||||
setSwap({ ...swapInfo });
|
||||
}
|
||||
|
||||
if (!swapInfo.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (swapInfo.status.status) {
|
||||
case BTC_API_STATUS_PENDING:
|
||||
setAction(ACTION_STATUS_PENDING);
|
||||
setNag({ msg: NAG_API_STATUS_PENDING, type: 'helpful' });
|
||||
break;
|
||||
case BTC_API_STATUS_CONFIRMING:
|
||||
setAction(ACTION_STATUS_CONFIRMING);
|
||||
setNag({ msg: NAG_API_STATUS_CONFIRMING, type: 'helpful' });
|
||||
break;
|
||||
case BTC_API_STATUS_PROCESSING:
|
||||
if (swapInfo.status.lbcTxid) {
|
||||
setAction(ACTION_STATUS_SUCCESS);
|
||||
setNag({ msg: NAG_API_STATUS_SUCCESS, type: 'helpful' });
|
||||
setIsSwapping(false);
|
||||
} else {
|
||||
setAction(ACTION_STATUS_PROCESSING);
|
||||
setNag({ msg: NAG_API_STATUS_PROCESSING, type: 'helpful' });
|
||||
}
|
||||
break;
|
||||
case BTC_API_STATUS_ERROR:
|
||||
setNag({ msg: NAG_API_STATUS_ERROR, type: 'error' });
|
||||
break;
|
||||
case INTERNAL_APIS_DOWN:
|
||||
setNag({ msg: NAG_SERVER_DOWN, type: 'error' });
|
||||
break;
|
||||
case BTC_API_STATUS_EXPIRED:
|
||||
setNag({ msg: NAG_EXPIRED, type: 'error' });
|
||||
if (action === ACTION_PAST_SWAPS) {
|
||||
setAction(ACTION_STATUS_PENDING);
|
||||
}
|
||||
break;
|
||||
case BTC_API_STATUS_UNRESOLVED:
|
||||
setNag({
|
||||
msg: __(
|
||||
'Received amount did not match order code %chargeCode%. Contact hello@lbry.com to resolve the payment.',
|
||||
{ chargeCode: swapInfo.chargeCode }
|
||||
),
|
||||
type: 'error',
|
||||
});
|
||||
if (action === ACTION_PAST_SWAPS) {
|
||||
setAction(ACTION_STATUS_PENDING);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
setNag({ msg: swapInfo.status.status, type: 'error' });
|
||||
break;
|
||||
}
|
||||
}, [swap, coinSwaps]);
|
||||
|
||||
// Validate entered LBC
|
||||
React.useEffect(() => {
|
||||
let msg;
|
||||
if (lbc < LBC_MIN) {
|
||||
msg = __('The amount needs to be higher');
|
||||
} else if (lbc > LBC_MAX) {
|
||||
msg = __('The amount is too high');
|
||||
}
|
||||
setLbcError(msg);
|
||||
}, [lbc]);
|
||||
|
||||
// 'Refresh' button feedback
|
||||
React.useEffect(() => {
|
||||
let timer;
|
||||
if (isRefreshingStatus) {
|
||||
timer = setTimeout(() => {
|
||||
setIsRefreshingStatus(false);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isRefreshingStatus]);
|
||||
|
||||
function getCoinAddress(coin) {
|
||||
if (swap && swap.sendAddresses) {
|
||||
return swap.sendAddresses[coin];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getCoinSendAmountStr(coin) {
|
||||
if (swap && swap.sendAmounts && swap.sendAmounts[coin]) {
|
||||
return `${swap.sendAmounts[coin].amount} ${swap.sendAmounts[coin].currency}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function currencyToCoin(currency) {
|
||||
const MAP = {
|
||||
DAI: 'dai',
|
||||
USDC: 'usdc',
|
||||
BTC: 'bitcoin',
|
||||
ETH: 'ethereum',
|
||||
LTC: 'litecoin',
|
||||
BCH: 'bitcoincash',
|
||||
};
|
||||
return MAP[currency] || 'bitcoin';
|
||||
}
|
||||
|
||||
function getSentAmountStr(swapInfo) {
|
||||
if (swapInfo && swapInfo.status) {
|
||||
const currency = swapInfo.status.receiptCurrency;
|
||||
const coin = currencyToCoin(currency);
|
||||
return getCoinSendAmountStr(coin);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getCoinLabel(coin) {
|
||||
const COIN_LABEL = {
|
||||
dai: 'Dai',
|
||||
usdc: 'USD Coin',
|
||||
bitcoin: 'Bitcoin',
|
||||
ethereum: 'Ethereum',
|
||||
litecoin: 'Litecoin',
|
||||
bitcoincash: 'Bitcoin Cash',
|
||||
};
|
||||
|
||||
return COIN_LABEL[coin] || coin;
|
||||
}
|
||||
|
||||
function getLbcAmountStrForSwap(swap) {
|
||||
if (swap && swap.lbcAmount) {
|
||||
return formatCoinAmountString(swap.lbcAmount);
|
||||
}
|
||||
return '---';
|
||||
}
|
||||
|
||||
function handleStartSwap() {
|
||||
setIsSwapping(true);
|
||||
setSwap(null);
|
||||
setNag(null);
|
||||
|
||||
Lbryio.call('btc', 'swap', {
|
||||
lbc_satoshi_requested: parseInt(lbc * BTC_SATOSHIS + 0.5),
|
||||
btc_satoshi_provided: parseInt(btc * BTC_SATOSHIS + 0.5),
|
||||
pay_to_wallet_address: receiveAddress,
|
||||
})
|
||||
.then((response) => {
|
||||
const btcAmount = response.Charge.data.pricing['bitcoin'].amount;
|
||||
const rate = response.Exchange.rate;
|
||||
|
||||
const timeline = response.Charge.data.timeline;
|
||||
const lastTimeline = timeline[timeline.length - 1];
|
||||
|
||||
const newSwap = {
|
||||
chargeCode: response.Exchange.charge_code,
|
||||
coins: Object.keys(response.Charge.data.addresses),
|
||||
sendAddresses: response.Charge.data.addresses,
|
||||
sendAmounts: response.Charge.data.pricing,
|
||||
lbcAmount: (btcAmount * BTC_SATOSHIS) / rate,
|
||||
status: {
|
||||
status: lastTimeline.status,
|
||||
receiptCurrency: lastTimeline.payment.value.currency,
|
||||
receiptTxid: lastTimeline.payment.transaction_id,
|
||||
lbcTxid: response.Exchange.lbc_txid || '',
|
||||
},
|
||||
};
|
||||
|
||||
setSwap({ ...newSwap });
|
||||
addCoinSwap({ ...newSwap });
|
||||
})
|
||||
.catch((err) => {
|
||||
const translateError = (err) => {
|
||||
// TODO: https://github.com/lbryio/lbry.go/issues/87
|
||||
// Translate error codes instead of strings when it is available.
|
||||
if (err === 'users are currently limited to 4 transactions per month') {
|
||||
return __('Users are currently limited to 4 completed swaps per month or 5 pending swaps.');
|
||||
}
|
||||
return err;
|
||||
};
|
||||
setNag({ msg: err === INTERNAL_APIS_DOWN ? NAG_SWAP_CALL_FAILED : translateError(err.message), type: 'error' });
|
||||
returnToMainAction();
|
||||
});
|
||||
}
|
||||
|
||||
function handleViewPastSwaps() {
|
||||
setAction(ACTION_PAST_SWAPS);
|
||||
setNag(null);
|
||||
setIsRefreshingStatus(true);
|
||||
|
||||
const now = Date.now();
|
||||
if (!lastStatusQuery || now - lastStatusQuery > 30000) {
|
||||
// There is a '200/minute' limit in the commerce API. If the history is
|
||||
// long, or if the user goes trigger-happy, the limit could be reached
|
||||
// easily. Statuses don't change often, so just limit it to every 30s.
|
||||
setLastStatusQuery(now);
|
||||
coinSwaps.forEach((x) => {
|
||||
queryCoinSwapStatus(x.chargeCode);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getShortStatusStr(coinSwap: CoinSwapInfo) {
|
||||
const swapInfo = coinSwaps.find((x) => x.chargeCode === coinSwap.chargeCode);
|
||||
if (!swapInfo || !swapInfo.status) {
|
||||
return '---';
|
||||
}
|
||||
|
||||
let msg;
|
||||
switch (swapInfo.status.status) {
|
||||
case BTC_API_STATUS_PENDING:
|
||||
msg = __('Waiting');
|
||||
break;
|
||||
case BTC_API_STATUS_CONFIRMING:
|
||||
msg = __('Confirming');
|
||||
break;
|
||||
case BTC_API_STATUS_PROCESSING:
|
||||
if (swapInfo.status.lbcTxid) {
|
||||
msg = __('Credits sent');
|
||||
} else {
|
||||
msg = __('Sending Credits');
|
||||
}
|
||||
break;
|
||||
case BTC_API_STATUS_ERROR:
|
||||
msg = __('Failed');
|
||||
break;
|
||||
case BTC_API_STATUS_EXPIRED:
|
||||
msg = __('Expired');
|
||||
break;
|
||||
case BTC_API_STATUS_UNRESOLVED:
|
||||
msg = __('Unresolved');
|
||||
break;
|
||||
default:
|
||||
msg = swapInfo.status.status;
|
||||
// if (IS_DEV) throw new Error('Unhandled "status": ' + status.Status);
|
||||
break;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function getViewTransactionElement(swap, isSend) {
|
||||
if (!swap || !swap.status) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const explorerUrl = (coin, txid) => {
|
||||
// It's unclear whether we can link to sites like blockchain.com.
|
||||
// Don't do it for now.
|
||||
return '';
|
||||
};
|
||||
|
||||
if (isSend) {
|
||||
const sendTxId = swap.status.receiptTxid;
|
||||
const url = explorerUrl(swap.status.receiptCurrency, sendTxId);
|
||||
return sendTxId ? (
|
||||
<>
|
||||
{url && <Button button="link" href={url} label={__('View transaction')} />}
|
||||
{!url && (
|
||||
<Button
|
||||
button="link"
|
||||
label={__('Copy transaction ID')}
|
||||
title={sendTxId}
|
||||
onClick={() => {
|
||||
clipboard.writeText(sendTxId);
|
||||
doToast({
|
||||
message: __('Transaction ID copied.'),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
} else {
|
||||
const lbcTxId = swap.status.lbcTxid;
|
||||
return lbcTxId ? (
|
||||
<Button button="link" href={`https://explorer.lbry.com/tx/${lbcTxId}`} label={__('View transaction')} />
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
function getCloseButton() {
|
||||
return (
|
||||
<>
|
||||
<Button autoFocus button="primary" label={__('Close')} onClick={() => goBack()} />
|
||||
<Icon
|
||||
className="icon--help"
|
||||
icon={ICONS.HELP}
|
||||
tooltip
|
||||
size={16}
|
||||
customTooltipText={__(
|
||||
'This page can be closed while the transactions are in progress.\nYou can view the status later from:\n • Wallet » Swap » View Past Swaps'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getGap() {
|
||||
return <div className="confirm__value" />; // better way?
|
||||
}
|
||||
|
||||
function getActionElement() {
|
||||
switch (action) {
|
||||
case ACTION_MAIN:
|
||||
return actionMain;
|
||||
|
||||
case ACTION_STATUS_PENDING:
|
||||
return actionPending;
|
||||
|
||||
case ACTION_STATUS_CONFIRMING:
|
||||
return actionConfirmingSend;
|
||||
|
||||
case ACTION_STATUS_PROCESSING: // fall-through
|
||||
case ACTION_STATUS_SUCCESS:
|
||||
return actionProcessingAndSuccess;
|
||||
|
||||
case ACTION_PAST_SWAPS:
|
||||
return actionPastSwaps;
|
||||
|
||||
default:
|
||||
if (IS_DEV) throw new Error('Unhandled action: ' + action);
|
||||
return actionMain;
|
||||
}
|
||||
}
|
||||
|
||||
const actionMain = (
|
||||
<>
|
||||
<div className="section section--padded card--inline confirm__wrapper">
|
||||
<div className="section">
|
||||
<FormField
|
||||
autoFocus
|
||||
label={
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
lbc: <LbcSymbol size={22} />,
|
||||
}}
|
||||
>
|
||||
Enter desired %lbc%
|
||||
</I18nMessage>
|
||||
}
|
||||
type="number"
|
||||
name="lbc"
|
||||
className="form-field--price-amount--auto"
|
||||
affixClass="form-field--fix-no-height"
|
||||
max={LBC_MAX}
|
||||
min={LBC_MIN}
|
||||
step={1 / BTC_SATOSHIS}
|
||||
placeholder="12.34"
|
||||
value={lbc}
|
||||
error={lbcError}
|
||||
disabled={isSwapping}
|
||||
onChange={(event) => setLbc(parseFloat(event.target.value))}
|
||||
/>
|
||||
{getGap()}
|
||||
<div className="confirm__label">{__('Estimated BTC price')}</div>
|
||||
<div className="confirm__value">
|
||||
{formatCoinAmountString(btc)} {btc === 0 ? '' : 'BTC'}
|
||||
{isFetchingRate && <Spinner type="small" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
autoFocus
|
||||
onClick={handleStartSwap}
|
||||
button="primary"
|
||||
disabled={isSwapping || isNaN(btc) || btc === 0 || lbc === 0 || lbcError}
|
||||
label={isSwapping ? __('Processing...') : __('Start Swap')}
|
||||
/>
|
||||
{!isSwapping && coinSwaps.length !== 0 && (
|
||||
<Button button="link" label={__('View Past Swaps')} onClick={handleViewPastSwaps} />
|
||||
)}
|
||||
{isSwapping && <Spinner type="small" />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const actionPending = (
|
||||
<>
|
||||
<div className="section section--padded card--inline confirm__wrapper">
|
||||
<div className="section">
|
||||
{swap && swap.coins && ENABLE_ALTERNATIVE_COINS && (
|
||||
<>
|
||||
<FormField
|
||||
type="select"
|
||||
name="select_coin"
|
||||
value={coin}
|
||||
label={__('Alternative coins')}
|
||||
onChange={(e) => setCoin(e.target.value)}
|
||||
>
|
||||
{swap.coins.map((x) => (
|
||||
<option key={x} value={x}>
|
||||
{getCoinLabel(x)}
|
||||
</option>
|
||||
))}
|
||||
</FormField>
|
||||
{getGap()}
|
||||
</>
|
||||
)}
|
||||
<div className="confirm__label">{__('Send')}</div>
|
||||
<CopyableText
|
||||
primaryButton
|
||||
copyable={getCoinSendAmountStr(coin)}
|
||||
snackMessage={__('Amount copied.')}
|
||||
onCopy={(inputElem) => {
|
||||
const inputStr = inputElem.value;
|
||||
const selectEndIndex = inputStr.lastIndexOf(' ');
|
||||
if (selectEndIndex > -1 && inputStr.substring(0, selectEndIndex).match(/[\d.]/)) {
|
||||
inputElem.setSelectionRange(0, selectEndIndex, 'forward');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="help">{__('Use the copy button to ensure the EXACT amount is sent!')}</div>
|
||||
{getGap()}
|
||||
<div className="confirm__label">{__('To')}</div>
|
||||
<CopyableText primaryButton copyable={getCoinAddress(coin)} snackMessage={__('Address copied.')} />
|
||||
<div className="confirm__value--subitem">
|
||||
<Button
|
||||
button="link"
|
||||
label={showQr ? __('Hide QR code') : __('Show QR code')}
|
||||
onClick={() => setShowQr(!showQr)}
|
||||
/>
|
||||
{showQr && getCoinAddress(coin) && <QRCode value={getCoinAddress(coin)} />}
|
||||
</div>
|
||||
{getGap()}
|
||||
<div className="confirm__label">{__('Receive')}</div>
|
||||
<div className="confirm__value">{<LbcSymbol postfix={getLbcAmountStrForSwap(swap)} size={22} />}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section__actions">{getCloseButton()}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const actionConfirmingSend = (
|
||||
<>
|
||||
<div className="section section--padded card--inline confirm__wrapper">
|
||||
<div className="section">
|
||||
<div className="confirm__label">{__('Confirming')}</div>
|
||||
<div className="confirm__value confirm__value--no-gap">{getSentAmountStr(swap)}</div>
|
||||
<div className="confirm__value--subitem">{getViewTransactionElement(swap, true)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section__actions">{getCloseButton()}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const actionProcessingAndSuccess = (
|
||||
<>
|
||||
<div className="section section--padded card--inline confirm__wrapper">
|
||||
<div className="section">
|
||||
<div className="confirm__label">{__('Sent')}</div>
|
||||
<div className="confirm__value confirm__value--no-gap">{getSentAmountStr(swap)}</div>
|
||||
<div className="confirm__value--subitem">{getViewTransactionElement(swap, true)}</div>
|
||||
{getGap()}
|
||||
<div className="confirm__label">{action === ACTION_STATUS_SUCCESS ? __('Received') : __('Receiving')}</div>
|
||||
<div className="confirm__value confirm__value--no-gap">
|
||||
{<LbcSymbol postfix={getLbcAmountStrForSwap(swap)} size={22} />}
|
||||
</div>
|
||||
{action === ACTION_STATUS_SUCCESS && (
|
||||
<div className="confirm__value--subitem">{getViewTransactionElement(swap, false)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="section__actions">{getCloseButton()}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const actionPastSwaps = (
|
||||
<>
|
||||
<div className="section section--padded card--inline confirm__wrapper">
|
||||
<div className="section">
|
||||
<div className="table__wrapper">
|
||||
<table className="table table--btc-swap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{__('Code')}</th>
|
||||
<th>{__('Status')}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{coinSwaps.length === 0 && (
|
||||
<tr>
|
||||
<td>{'---'}</td>
|
||||
</tr>
|
||||
)}
|
||||
{coinSwaps.length !== 0 &&
|
||||
coinSwaps.map((x) => {
|
||||
return (
|
||||
<tr key={x.chargeCode}>
|
||||
<td>
|
||||
<Button
|
||||
button="link"
|
||||
className="button--hash-id"
|
||||
title={x.chargeCode}
|
||||
label={x.chargeCode}
|
||||
onClick={() => {
|
||||
setSwap({ ...x });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>{isRefreshingStatus ? '...' : getShortStatusStr(x)}</td>
|
||||
<td>
|
||||
<Button
|
||||
button="link"
|
||||
icon={ICONS.REMOVE}
|
||||
title={__('Remove swap')}
|
||||
onClick={() => removeCoinSwap(x.chargeCode)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="section__actions">
|
||||
<Button
|
||||
autoFocus
|
||||
button="primary"
|
||||
label={__('Go Back')}
|
||||
onClick={() => {
|
||||
returnToMainAction();
|
||||
setNag(null);
|
||||
}}
|
||||
/>
|
||||
{coinSwaps.length !== 0 && !isRefreshingStatus && (
|
||||
<Button button="link" label={__('Refresh')} onClick={handleViewPastSwaps} />
|
||||
)}
|
||||
{isRefreshingStatus && <Spinner type="small" />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Redirect to={`/$/${PAGES.AUTH_SIGNIN}?redirect=${location.pathname}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleStartSwap}>
|
||||
<Card
|
||||
title={<I18nMessage tokens={{ lbc: <LbcSymbol size={22} /> }}>Swap Crypto for %lbc%</I18nMessage>}
|
||||
subtitle={__(
|
||||
'Send crypto to the address provided and you will be sent an equivalent amount of Credits. You can pay with BCH, LTC, ETH, USDC or DAI after starting the swap.'
|
||||
)}
|
||||
actions={getActionElement()}
|
||||
nag={nag ? <Nag relative type={nag.type} message={__(nag.msg)} /> : null}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default WalletSwap;
|
|
@ -3,12 +3,14 @@ import { doSetDaemonSetting } from 'redux/actions/settings';
|
|||
import { doSetWelcomeVersion } from 'redux/actions/app';
|
||||
import * as DAEMON_SETTINGS from 'constants/daemon_settings';
|
||||
import { WELCOME_VERSION } from 'config.js';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
import { selectDaemonSettings, selectDaemonStatus } from 'redux/selectors/settings';
|
||||
|
||||
import WelcomeSplash from './view';
|
||||
import { selectDiskSpace } from 'redux/selectors/app';
|
||||
|
||||
const select = (state) => ({
|
||||
authenticated: selectUserVerifiedEmail(state),
|
||||
diskSpace: selectDiskSpace(state),
|
||||
daemonSettings: selectDaemonSettings(state),
|
||||
daemonStatus: selectDaemonStatus(state),
|
||||
|
|
|
@ -9,6 +9,7 @@ import YrblHappy from 'static/img/yrblhappy.svg';
|
|||
type Props = {
|
||||
setWelcomeVersion: (number) => void,
|
||||
setShareDataInternal: (boolean) => void,
|
||||
authenticated: boolean,
|
||||
handleNextPage: () => void,
|
||||
diskSpace?: DiskSpace,
|
||||
};
|
||||
|
|
|
@ -431,6 +431,11 @@ export const COMMENT_SUPER_CHAT_LIST_FAILED = 'COMMENT_SUPER_CHAT_LIST_FAILED';
|
|||
// Blocked channels
|
||||
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL';
|
||||
|
||||
// Coin swap
|
||||
export const ADD_COIN_SWAP = 'ADD_COIN_SWAP';
|
||||
export const REMOVE_COIN_SWAP = 'REMOVE_COIN_SWAP';
|
||||
export const COIN_SWAP_STATUS_RECEIVED = 'COIN_SWAP_STATUS_RECEIVED';
|
||||
|
||||
// Tags
|
||||
export const TOGGLE_TAG_FOLLOW = 'TOGGLE_TAG_FOLLOW';
|
||||
export const TAG_ADD = 'TAG_ADD';
|
||||
|
|
|
@ -186,5 +186,3 @@ export const MYSTERIES = 'Mysteries';
|
|||
export const TECHNOLOGY = 'Technology';
|
||||
export const EMOJI = 'Emoji';
|
||||
export const STICKER = 'Sticker';
|
||||
export const SIMPLE_EDITOR = 'SimpleEditor';
|
||||
export const ADVANCED_EDITOR = 'AdvancedEditor';
|
||||
|
|
|
@ -33,13 +33,17 @@ export const WALLET_UNLOCK = 'wallet_unlock';
|
|||
export const WALLET_SYNC = 'wallet_sync';
|
||||
export const WALLET_PASSWORD_UNSAVE = 'wallet_password_unsave';
|
||||
export const CREATE_CHANNEL = 'create_channel';
|
||||
export const YOUTUBE_WELCOME = 'youtube_welcome';
|
||||
export const SET_REFERRER = 'set_referrer';
|
||||
export const SIGN_OUT = 'sign_out';
|
||||
export const LIQUIDATE_SUPPORTS = 'liquidate_supports';
|
||||
export const MASS_TIP_UNLOCK = 'mass_tip_unlock';
|
||||
export const CONFIRM_AGE = 'confirm_age';
|
||||
export const SYNC_ENABLE = 'SYNC_ENABLE';
|
||||
export const IMAGE_UPLOAD = 'image_upload';
|
||||
export const MOBILE_SEARCH = 'mobile_search';
|
||||
export const VIEW_IMAGE = 'view_image';
|
||||
export const CONFIRM_REMOVE_BTC_SWAP_ADDRESS = 'confirm_remove_btc_swap_address';
|
||||
export const BLOCK_CHANNEL = 'block_channel';
|
||||
export const COLLECTION_ADD = 'collection_add';
|
||||
export const COLLECTION_DELETE = 'collection_delete';
|
||||
|
|
|
@ -51,6 +51,7 @@ export const PAGE_TITLE = {
|
|||
[PAGES.SETTINGS_STRIPE_CARD]: 'Payment Methods',
|
||||
[PAGES.SETTINGS_UPDATE_PWD]: 'Update password',
|
||||
[PAGES.SETTINGS_OWN_COMMENTS]: 'Your comments',
|
||||
[PAGES.SWAP]: 'Swap Credits',
|
||||
[PAGES.TAGS_FOLLOWING]: 'Tags',
|
||||
[PAGES.TAGS_FOLLOWING_MANAGE]: 'Manage tags',
|
||||
[PAGES.UPLOAD]: 'Upload',
|
||||
|
|
|
@ -70,6 +70,7 @@ exports.CODE_2257 = '2257';
|
|||
exports.BUY = 'buy';
|
||||
exports.RECEIVE = 'receive';
|
||||
exports.SEND = 'send';
|
||||
exports.SWAP = 'swap';
|
||||
exports.CHANNEL_NEW = 'channel/new';
|
||||
exports.NOTIFICATIONS = 'notifications';
|
||||
exports.YOUTUBE_SYNC = 'youtube';
|
||||
|
|
|
@ -11,8 +11,7 @@ export const SUPPORT = 'support';
|
|||
export const CHANNEL = 'channel';
|
||||
export const PUBLISH = 'publish';
|
||||
export const REPOST = 'repost';
|
||||
export const COLLECTION = 'collection';
|
||||
export const DROPDOWN_TYPES = [ALL, SENT, RECEIVED, SUPPORT, CHANNEL, PUBLISH, REPOST, COLLECTION];
|
||||
export const DROPDOWN_TYPES = [ALL, SENT, RECEIVED, SUPPORT, CHANNEL, PUBLISH, REPOST];
|
||||
// dropdown subtypes
|
||||
export const TIP = 'tip';
|
||||
export const PURCHASE = 'purchase';
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import '@babel/polyfill';
|
||||
import 'babel-polyfill';
|
||||
import ErrorBoundary from 'component/errorBoundary';
|
||||
import App from 'component/app';
|
||||
import SnackBar from 'component/snackBar';
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue