Compare commits

...

68 commits

Author SHA1 Message Date
Kenneth C
d14c9141db
Update readme (#7755)
Removed my name from the readme as I have no association with this project anymore, and have not touched the Flatpak in ages. 

RIP LBRY.
2023-04-10 12:17:11 -04:00
zeppi
06c350c4db v0.53.9 2023-02-08 15:29:43 -05:00
zeppi
c3a9d9d002 fix changelog 2023-02-08 15:28:26 -05:00
zeppi
aeada6dc74 v0.53.9 2023-02-08 14:38:35 -05:00
zeppi
6ba985fd28 lbrynet version 113, changelog 2023-02-08 14:32:16 -05:00
jessopb
2a0bc85738
bump lbrynet to 113 (#7747) 2023-02-08 10:36:14 -05:00
jessopb
523ea284a2
update signing certificate for 2023 (#7744) 2023-01-25 16:25:45 -05:00
jessopb
a66d7534c2
add csc 2023-01-25 16:05:39 -05:00
zeppi
89ec07622f v0.53.8 2022-11-18 14:15:03 -05:00
zeppi
7e6ad31392 Revert "v0.53.8"
Incorrect Changelog
This reverts commit 4ab23f03fc.
2022-11-18 14:12:52 -05:00
zeppi
4ab23f03fc v0.53.8 2022-11-18 14:05:00 -05:00
zeppi
29cea5cc07 changelog 2022-11-18 14:02:37 -05:00
jessopb
8dd7150d67
fix unfollowing unpublished channels (#7737) 2022-11-18 10:22:32 -05:00
zeppi
f1b1523017 v0.53.8-alpha.1 2022-11-17 13:26:53 -05:00
jessopb
88ac250fee
fix large file uploads (#7736)
* fix large file uploads

* changelog

* update github action xcode version

* update electronbuilder for macos

* try use_hard_links=false

* no USE_HARD_LINKS

* upgrate electron-builder 23_3_3

* revert to electron-builder 22_10_5
electron-builder/issues/6124 says regressions happen after this version.

* try mac install homebrew, python2

* typo and ln /usr/bin/python

* oops

* try sudo

* try PYTHON_PATH

* comment github action mac python hack
2022-11-17 13:20:01 -05:00
zeppi
0a5e9e87ed v0.53.7 2022-11-10 14:45:50 -05:00
zeppi
20413d79b6 changelog 2022-11-10 14:44:54 -05:00
zeppi
ff9011e6ac v0.53.7-alpha.1 2022-11-04 12:37:02 -04:00
jessopb
802139d0a4
upgrade lbrynet to 112 (#7717) 2022-11-04 12:31:44 -04:00
zeppi
68d307fa50 changelog 2022-11-04 10:47:56 -04:00
jessopb
d3900e39b6
fix comment area display (#7716) 2022-11-04 10:44:36 -04:00
jessopb
35769dede6
separate out advanced textarea, fix comment channel selector width, a… (#7634)
* separate out advanced textarea, fix comment channel selector width, add advanced text icon

* fix master conflicts

* fixes

* fix channel description edit
2022-11-04 08:42:36 -04:00
zeppi
ae1e20d131 changelog 2022-11-03 17:46:08 -04:00
jessopb
051af8b6ad
fix post publish erased when confirmation (#7715) 2022-11-03 17:17:56 -04:00
jessopb
5d77b115f9
fix thumbnails disabling publish (#7714) 2022-11-03 15:55:46 -04:00
Byron Eric Perez
7dbeeac112
Add 'Collections' into txo filter (#7711) 2022-10-28 18:31:56 -04:00
zeppi
de062c4aee yarn lock 2022-10-25 13:31:32 -04:00
dependabot[bot]
18a3336714
Bump @xmldom/xmldom from 0.7.5 to 0.7.6 (#7701)
Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.7.5 to 0.7.6.
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.7.5...0.7.6)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-25 13:02:03 -04:00
jessopb
ebf35a1df8
remove watchman (and errors!) (#7710) 2022-10-25 12:56:38 -04:00
jm-morani
28e168d5e5
Minor layout fixes depending on window shape (#7709)
Disable theatre mode styles for medium screen (or lower)
Fix the width of the cover in theater mode
Fix conflicting styles when width is 1150px
Remove duplicate from CHANGELOG

Co-authored-by: Jean-Michel Morani <probono+lbry@morani.org>
2022-10-25 10:03:20 -04:00
Byron Eric Perez
7ad66b99e7
Swap comment servers without going to settings page (#7670)
work in progress

added input to select custom and default server when creating comment

the problem of synchronizing the two commentServer of the application was solved

btn moved to the correct component and syncs correctly

Fixed why it didn't show comments

Aligned input

size and location of the refresh btn changed, and added maximum and minimum width for the comment server

margin removed

change using overflow

refresh
2022-10-24 12:36:30 -04:00
jahway603
7eb7c1a5ff
Updated README.md (#7695)
Fixed Arch AUR package name and current maintainer
2022-10-22 15:16:05 -04:00
zeppi
b88e704e6b v0.53.6 2022-10-21 16:54:07 -04:00
zeppi
8d85af8064 changelog 2022-10-21 16:53:03 -04:00
zeppi
f9d7340729 v0.53.6-alpha.1 2022-10-21 12:21:45 -04:00
zeppi
d57300f785 changelog 2022-10-20 14:41:43 -04:00
jessopb
09baf1d9b9
fall back on contentType for extension on file view (#7704) 2022-10-20 14:17:43 -04:00
Franco Montenegro
ce692d38ea
Upgrade to Electron 17.2.0. (#7703) 2022-10-20 14:03:12 -04:00
zeppi
b1ca3b0183 bump electron 17.2 and lbrynet 0.111.0 2022-10-20 13:09:47 -04:00
Franco Montenegro
a4c34d89e2
Sanitize values for CSV. (#7697)
* Sanitize values for CSV.

* Remove unnecessary escape sequence.
2022-10-17 11:07:33 -04:00
Franco Montenegro
d7b9ca3391
7683 upgrade to electron 17 (#7691)
* Upgrade to electron 17.

* Remove unused dependencies.

* Update recommended node version in readme.

* Move all the dependencies back to devDependencies.

* Move dependencies back as they were.
2022-10-07 13:56:33 -04:00
jessopb
55a5c7b051
make thumbnail optional (#7690) 2022-09-19 18:39:45 -04:00
Franco Montenegro
0e2a9a1033
Better handling of uploaded files. (#7688)
* Better handling of uploaded files.

* Read file when uploading it so we can properly read metadata.
2022-09-19 16:42:16 -04:00
Franco Montenegro
3fd38be789
Sort downloads (show newest first) (#7684) 2022-09-05 16:21:01 -04:00
Franco Montenegro
329d434c83
Allow only images in modal image uploader. (#7672)
* Allow only images in modal image uploader.

* Set file path and mime in file selector.

* Refactor WebFile.

* Update get-file-from-path to work with folders; fix file-list component.

* Get rid of File | string for filePath property in components.

* Show instant preview while updating channel thumbnail.

* Fix publish.

* Add jpeg and svg to image filter.
2022-09-02 12:43:35 -04:00
zeppi
6a2939d9fc v0.53.5 2022-08-26 16:31:37 -04:00
zeppi
65781e33f7 sdk 110 release changelog 2022-08-26 16:21:34 -04:00
jessopb
608721c7ac
upgrade lbrynet sdk to 0.110.0 (#7680) 2022-08-26 16:12:08 -04:00
zeppi
2846dd926b changelog 2022-08-26 16:03:32 -04:00
jm-morani
7a8a16cd9c
Fix viewer position and shape in mobile mode (#7677)
Co-authored-by: Jean-Michel Morani <probono+lbry@morani.org>
2022-08-26 15:36:48 -04:00
jessopb
1c17ff5dd9
doFetchModBlockedList: don't block ui thread (#7674)
* doFetchModBlockedList: don't block ui thread

doFetchModBlockedList is blocking the ui thread.

Duplicate data in `doFetchModBlockedList::blockListsPerChannel` to about 1000. The tab is dead when function hits, about 4s after reload.

- Yield occasionally using the `setTimeout` method.
- Doing a chunk size of 1 for now so we don't have to yield the inner loop as well (seems good enough). This is just based on a relatively large blocklist size.

- Can't do `await` in a callback, so must change the `forEach` to a `for`.

* yield thread in storeList

Co-authored-by: infinite-persistence <inf.persistence@gmail.com>
2022-08-12 17:23:04 -04:00
jessopb
b9be8d9f3a
small fixes for blocked (#7669) 2022-08-09 13:13:57 -04:00
Franco Montenegro
6b1069f02a
Properly handle blacklisted claims. (#7665)
* Properly handle blacklisted claims.

* Identify blacklist cause and display the proper message.
2022-08-09 11:19:23 -04:00
jessopb
b92fb03856
Remove swap (#7659)
* remove requiresAuth

* remove centralized btcswap code
2022-08-02 17:17:00 -04:00
Franco Montenegro
1a9743e639
Properly handle decimals in supports liquidate component. (#7648) 2022-07-23 18:33:00 -04:00
Byron Eric Perez
2773cbbe6e
changed the color of the editor-toolbar_hover class (#7642)
* changed the color of the editor-toolbar_hover class

* Fixed hover, colors and spacing of editor-toolbar_hover class

* active editor-toolbar class modified

* var --color-editor-button-hover-bg and --color-editor-button-active-bg created

* --color-editor-button-hover-bg and --color-editor-button-active-bg
color corrected in light
2022-07-21 16:58:12 -04:00
zeppi
e11fb5d225 changelog 2022-07-16 18:24:46 -04:00
Franco Montenegro
38200b9912
Use image preview to display the cover image recently uploaded. (#7647) 2022-07-13 20:09:11 -04:00
zeppi
c69826a887 remove old scripts 2022-07-12 15:33:46 -04:00
zeppi
ce4fadbdf9 remove readme 'web' mention 2022-07-12 15:24:31 -04:00
jessopb
2895e93323
Sync pre cleanup (#7635)
* missing yarn lock

* rm modal youtube welcome

* prune some isAuthenticated

* remove invite components

* remove reward/verify components

* more odysee feature cleanup
2022-07-08 14:51:53 -04:00
jessopb
3859124c05
missing yarn lock (#7644) 2022-07-07 18:43:38 -04:00
dependabot[bot]
da5ec6edc1
Bump electron from 15.4.0 to 15.5.5 (#7614)
Bumps [electron](https://github.com/electron/electron) from 15.4.0 to 15.5.5.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/main/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v15.4.0...v15.5.5)

---
updated-dependencies:
- dependency-name: electron
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-07 17:34:07 -04:00
Byron Eric Perez
9825bccf4a
fix hover style for char count field (#7620)
* fix hover style for char count field

* vertically align character counter and emoji button

* add new comment icon class

* Revert "add new comment icon class"

This reverts commit 8a163e0f02.

* created and corrected the icon class

* button--comment-icons-active removed
2022-07-07 16:55:31 -04:00
Franco Montenegro
f065218ff4
Prevent .deb packages from being opened with archive manager. (#7502)
* Prevent .deb packages from being opened with archive manager.

* Allow to properly cancel download upgrade and prevent multiple downloads.

* Fix missing app-update.yml file for .deb builds.

* Small fix for allowPrerelease prop in autoUpdater.

* Use release/tags endpoint to get the release details.

* Handle error case for auto updater.

* Make install now button display the upgrade modal.

* Use GitHub as provider for manual update url.

* Small fixes in updater.

* Fix small lint errors.

* Properly handle auto download on/off.
2022-07-07 16:48:42 -04:00
Byron Eric Perez
f79c622edf
Added checkbox to enable/disable background (#7630)
* Added checkbox to enable/disable background

* bug fix in enable disable button

* small fix in translation

* rename background setting to be disable backgroud; add default value for background setting.
2022-06-28 16:01:19 -04:00
Byron Eric Perez
c7ab47f54d
use default path in windows when choosing a file (#7625)
* use default path in windows when choosing a file

* fix, changed var to let
2022-06-28 15:49:41 -04:00
Franco Montenegro
8c10617259
Use a retry counter/limit to fetch thumbnails. (#7618) 2022-06-28 11:25:20 -04:00
201 changed files with 3956 additions and 7093 deletions

View file

@ -38,7 +38,22 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1 - uses: maxim-lobanov/setup-xcode@v1
if: startsWith(runner.os, 'mac') if: startsWith(runner.os, 'mac')
with: with:
xcode-version: '12.4.0' 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
- name: Download blockchain headers - name: Download blockchain headers
run: | run: |
@ -58,7 +73,7 @@ jobs:
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
WIN_CSC_LINK: https://raw.githubusercontent.com/lbryio/lbry-desktop/master/build/cert-2021-2022.pfx WIN_CSC_LINK: https://raw.githubusercontent.com/lbryio/lbry-desktop/master/build/cert2023.pfx
CSC_LINK: https://s3.amazonaws.com/files.lbry.io/cert/osx-csc-2021-2022.p12 CSC_LINK: https://s3.amazonaws.com/files.lbry.io/cert/osx-csc-2021-2022.p12
# UI # UI

0
.yarn/versions/5bc94294.yml vendored Normal file
View file

0
.yarn/versions/5f1212ad.yml vendored Normal file
View file

0
.yarn/versions/6b35c994.yml vendored Normal file
View file

0
.yarn/versions/6be5ab70.yml vendored Normal file
View file

0
.yarn/versions/8e384637.yml vendored Normal file
View file

0
.yarn/versions/c6e2b914.yml vendored Normal file
View file

0
.yarn/versions/d1a18cef.yml vendored Normal file
View file

0
.yarn/versions/ec3a9ddf.yml vendored Normal file
View file

View file

@ -1,18 +1,85 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. 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/). 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] ## [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)) - 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)) - Auto hosting in settings and hosting first run page ([#7598](https://github.com/lbryio/lbry-desktop/pull/7598))
### Changed ### Changed
- Updated lbry-sdk to [0.107.2](https://github.com/lbryio/lbry-sdk/releases/tag/v0.107.2) - 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 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)) - 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)) - Work towards supporting collections of any claim type ([#7578](https://github.com/lbryio/lbry-desktop/pull/7578))
@ -22,7 +89,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.53.3] - [2022-04-27] ## [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 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)) - Reverted lbry.tv changes that broke login ([#7570](https://github.com/lbryio/lbry-desktop/pull/7570))
@ -33,7 +100,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 references ([#7560](https://github.com/lbryio/lbry-desktop/pull/7560))
- Removed some lbrytv player references ([#7552](https://github.com/lbryio/lbry-desktop/pull/7552)) - 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)) - Repost style issues ([#7559](https://github.com/lbryio/lbry-desktop/pull/7559))
- Disappearing sidebar thumbs ([#7556](https://github.com/lbryio/lbry-desktop/pull/7556)) - 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)) - Restore tags sidebar link ([#7555](https://github.com/lbryio/lbry-desktop/pull/7555))

View file

@ -65,21 +65,19 @@ _Note: If coming from a deb install, the directory structure is different and yo
| | Flatpak | Arch | Nixpkgs | ARM/ARM64 | | | 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-app-bin/) | [Nixpkgs](https://search.nixos.org/packages?channel=unstable&show=lbry&query=lbry) | [Build Guide](https://lbry.tv/@LBRYarm:5) | | 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 | [@kcSeb](https://keybase.io/kcseb) | [@kcSeb](https://keybase.io/kcseb) | [@Enderger](https://github.com/enderger) | [@Madiator2011](https://github.com/kodxana) | | Maintainers | N/A | [@RubenKelevra](https://github.com/RubenKelevra) | [@Enderger](https://github.com/enderger) | [@Madiator2011](https://github.com/kodxana) |
## Usage ## Usage
Double click the installed application to interact with the LBRY network. Start the installed application to interact with the LBRY network.
## Running from Source ## Running from Source
You can run the web version (lbry.tv), the electron app, or both at the same time.
#### Prerequisites #### Prerequisites
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
- [Node.js](https://nodejs.org/en/download/) (v14 required) - [Node.js](https://nodejs.org/en/download/) (v16 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) - [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) - [Yarn](https://yarnpkg.com/en/docs/install)

BIN
build/cert2023.pfx Normal file

Binary file not shown.

View file

@ -29,6 +29,10 @@
"from": "./static/font", "from": "./static/font",
"to": "static/font", "to": "static/font",
"filter": ["**/*"] "filter": ["**/*"]
},
{
"from": "./static/app-update.yml",
"to": "app-update.yml"
} }
], ],
"publish": [ "publish": [

View file

@ -20,8 +20,12 @@ import path from 'path';
import { diskSpaceLinux, diskSpaceWindows, diskSpaceMac } from '../ui/util/diskspace'; import { diskSpaceLinux, diskSpaceWindows, diskSpaceMac } from '../ui/util/diskspace';
const { download } = require('electron-dl'); const { download } = require('electron-dl');
const mime = require('mime');
const remote = require('@electron/remote/main'); const remote = require('@electron/remote/main');
const os = require('os'); 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(); remote.initialize();
const filePath = path.join(process.resourcesPath, 'static', 'upgradeDisabled'); const filePath = path.join(process.resourcesPath, 'static', 'upgradeDisabled');
@ -33,29 +37,23 @@ try {
upgradeDisabled = false; upgradeDisabled = false;
} }
autoUpdater.autoDownload = !upgradeDisabled; autoUpdater.autoDownload = !upgradeDisabled;
autoUpdater.allowPrerelease = false;
// This is set to true if an auto update has been downloaded through the Electron const UPDATE_STATE_INIT = 0;
// auto-update system and is ready to install. If the user declined an update earlier, const UPDATE_STATE_CHECKING = 1;
// it will still install on shutdown. const UPDATE_STATE_UPDATES_FOUND = 2;
let autoUpdateDownloaded = false; const UPDATE_STATE_NO_UPDATES_FOUND = 3;
const UPDATE_STATE_DOWNLOADING = 4;
const UPDATE_STATE_DOWNLOADED = 5;
let updateState = UPDATE_STATE_INIT;
let updateDownloadItem;
const isAutoUpdateSupported = ['win32', 'darwin'].includes(process.platform) || !!process.env.APPIMAGE;
// This is used to keep track of whether we are showing the special dialog // This is used to keep track of whether we are showing the special dialog
// that we show on Windows after you decline an upgrade and close the app later. // that we show on Windows after you decline an upgrade and close the app later.
let showingAutoUpdateCloseAlert = false; let showingAutoUpdateCloseAlert = false;
// This is used to prevent downloading updates multiple times when
// using the auto updater API.
// As read in the documentation:
// "Calling autoUpdater.checkForUpdates() twice will download the update two times."
// https://www.electronjs.org/docs/latest/api/auto-updater#autoupdatercheckforupdates
let keepCheckingForUpdates = true;
// Auto updater doesn't support Linux installations (only trough AppImages)
// this is why, for that case, we download a full executable (.deb package)
// as a fallback support. This variable will be used to prevent
// multiple downloads when auto updater isn't supported.
let downloadUpgradeInProgress = false;
// Keep a global reference, if you don't, they will be closed automatically when the JavaScript // Keep a global reference, if you don't, they will be closed automatically when the JavaScript
// object is garbage collected. // object is garbage collected.
let rendererWindow; let rendererWindow;
@ -243,7 +241,8 @@ app.on('activate', () => {
app.on('will-quit', event => { app.on('will-quit', event => {
if ( if (
process.platform === 'win32' && process.platform === 'win32' &&
autoUpdateDownloaded && updateState === UPDATE_STATE_DOWNLOADED &&
isAutoUpdateSupported &&
!appState.autoUpdateAccepted && !appState.autoUpdateAccepted &&
!showingAutoUpdateCloseAlert !showingAutoUpdateCloseAlert
) { ) {
@ -303,6 +302,96 @@ app.on('before-quit', () => {
appState.isQuitting = true; 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) => { ipcMain.on('get-disk-space', async (event) => {
try { try {
const { data_dir } = await Lbry.settings_get(); const { data_dir } = await Lbry.settings_get();
@ -327,87 +416,6 @@ ipcMain.on('get-disk-space', async (event) => {
} }
}); });
ipcMain.on('download-upgrade', async (event, params) => {
if (downloadUpgradeInProgress) {
return;
}
const { url, options } = params;
const dir = fs.mkdtempSync(app.getPath('temp') + path.sep);
options.onProgress = function(p) {
rendererWindow.webContents.send('download-progress-update', p);
};
options.directory = dir;
options.onCompleted = function(c) {
downloadUpgradeInProgress = false;
rendererWindow.webContents.send('download-update-complete', c);
};
const win = BrowserWindow.getFocusedWindow();
downloadUpgradeInProgress = true;
await download(win, url, options).catch(e => console.log('e', e));
});
ipcMain.on('upgrade', (event, installerPath) => {
app.on('quit', () => {
console.log('Launching upgrade installer at', installerPath);
// This gets triggered called after *all* other quit-related events, so
// we'll only get here if we're fully prepared and quitting for real.
shell.openPath(installerPath);
});
// what to do if no shutdown in a long time?
console.log('Update downloaded to', installerPath);
console.log('The app will close and you will be prompted to install the latest version of LBRY.');
console.log('After the install is complete, please reopen the app.');
app.quit();
});
ipcMain.on('check-for-updates', (event, autoDownload) => {
// Prevent downloading the same update multiple times.
if (!keepCheckingForUpdates) {
return;
}
keepCheckingForUpdates = false;
autoUpdater.autoDownload = autoDownload;
autoUpdater.checkForUpdates();
});
autoUpdater.on('update-downloaded', () => {
autoUpdateDownloaded = true;
// If this download was trigger by
// autoUpdateAccepted it means, the user
// wants to install the new update but
// needed to downloaded the files first.
if (appState.autoUpdateAccepted) {
autoUpdater.quitAndInstall();
}
});
autoUpdater.on('update-not-available', () => {
keepCheckingForUpdates = true;
});
ipcMain.on('autoUpdateAccepted', () => {
appState.autoUpdateAccepted = true;
// quitAndInstall can only be called if the
// update has been downloaded. Since the user
// can disable auto updates, we have to make
// sure it has been downloaded first.
if (autoUpdateDownloaded) {
autoUpdater.quitAndInstall();
return;
}
// If the update hasn't been downloaded,
// start downloading it. After it's done, the
// event 'update-downloaded' will be triggered,
// where we will be able to resume the
// update installation.
autoUpdater.downloadUpdate();
});
ipcMain.on('version-info-requested', () => { ipcMain.on('version-info-requested', () => {
function formatRc(ver) { function formatRc(ver) {
// Adds dash if needed to make RC suffix SemVer friendly // Adds dash if needed to make RC suffix SemVer friendly
@ -500,3 +508,162 @@ process.on('uncaughtException', error => {
if (daemon) daemon.quit(); if (daemon) daemon.quit();
app.exit(1); app.exit(1);
}); });
// Auto updater
autoUpdater.on('download-progress', () => {
updateState = UPDATE_STATE_DOWNLOADING;
});
autoUpdater.on('update-downloaded', () => {
updateState = UPDATE_STATE_DOWNLOADED;
// If this download was trigger by
// autoUpdateAccepted it means, the user
// wants to install the new update but
// needed to downloaded the files first.
if (appState.autoUpdateAccepted) {
autoUpdater.quitAndInstall();
}
});
autoUpdater.on('update-available', () => {
if (updateState === UPDATE_STATE_DOWNLOADING) {
return;
}
updateState = UPDATE_STATE_UPDATES_FOUND;
});
autoUpdater.on('update-not-available', () => {
updateState = UPDATE_STATE_NO_UPDATES_FOUND;
});
autoUpdater.on('error', () => {
if (updateState === UPDATE_STATE_DOWNLOADING) {
updateState = UPDATE_STATE_UPDATES_FOUND;
return;
}
updateState = UPDATE_STATE_INIT;
});
// Manual (.deb) update
ipcMain.on('cancel-download-upgrade', () => {
if (updateDownloadItem) {
// Cancel the download and execute the onCancel
// callback set in the options.
updateDownloadItem.cancel();
}
});
ipcMain.on('download-upgrade', (event, params) => {
if (updateState !== UPDATE_STATE_UPDATES_FOUND) {
return;
}
if (isAutoUpdateSupported) {
updateState = UPDATE_STATE_DOWNLOADING;
autoUpdater.downloadUpdate();
return;
}
const { url, options } = params;
const dir = fs.mkdtempSync(app.getPath('temp') + path.sep);
updateState = UPDATE_STATE_DOWNLOADING;
// Grab the download item's handler to allow
// cancelling the operation if required.
options.onStarted = function(downloadItem) {
updateDownloadItem = downloadItem;
};
options.onCancel = function() {
updateState = UPDATE_STATE_UPDATES_FOUND;
updateDownloadItem = undefined;
};
options.onProgress = function(p) {
rendererWindow.webContents.send('download-progress-update', p);
};
options.onCompleted = function(c) {
updateState = UPDATE_STATE_DOWNLOADED;
updateDownloadItem = undefined;
rendererWindow.webContents.send('download-update-complete', c);
};
options.directory = dir;
const win = BrowserWindow.getFocusedWindow();
download(win, url, options).catch(e => {
updateState = UPDATE_STATE_UPDATES_FOUND;
console.log('e', e);
});
});
// Update behavior
ipcMain.on('autoUpdateAccepted', () => {
appState.autoUpdateAccepted = true;
// quitAndInstall can only be called if the
// update has been downloaded. Since the user
// can disable auto updates, we have to make
// sure it has been downloaded first.
if (updateState === UPDATE_STATE_DOWNLOADED) {
autoUpdater.quitAndInstall();
return;
}
if (updateState !== UPDATE_STATE_UPDATES_FOUND) {
return;
}
// If the update hasn't been downloaded,
// start downloading it. After it's done, the
// event 'update-downloaded' will be triggered,
// where we will be able to resume the
// update installation.
updateState = UPDATE_STATE_DOWNLOADING;
autoUpdater.downloadUpdate();
});
ipcMain.on('check-for-updates', (event, autoDownload) => {
if (![UPDATE_STATE_INIT, UPDATE_STATE_NO_UPDATES_FOUND].includes(updateState)) {
return;
}
updateState = UPDATE_STATE_CHECKING;
// If autoDownload is true, checkForUpdates will begin the
// download automatically.
if (autoDownload) {
updateState = UPDATE_STATE_DOWNLOADING;
}
autoUpdater.autoDownload = autoDownload;
autoUpdater.checkForUpdates();
});
ipcMain.on('upgrade', (event, installerPath) => {
// what to do if no shutdown in a long time?
console.log('Update downloaded to', installerPath);
console.log('The app will close and you will be prompted to install the latest version of LBRY.');
console.log('After the install is complete, please reopen the app.');
// Prevent .deb package from opening with archive manager (Ubuntu >= 20)
if (process.platform === 'linux' && !process.env.APPIMAGE) {
sudo.exec(`dpkg -i ${installerPath}`, { name: app.name }, (err, stdout, stderr) => {
if (err || stderr) {
rendererWindow.webContents.send('upgrade-installing-error');
return;
}
// Re-launch the application when the installation finishes.
app.relaunch();
app.quit();
});
return;
}
app.on('quit', () => {
console.log('Launching upgrade installer at', installerPath);
// This gets triggered called after *all* other quit-related events, so
// we'll only get here if we're fully prepared and quitting for real.
shell.openPath(installerPath);
});
app.quit();
});

View file

@ -5,7 +5,7 @@
// involve moving it from 'extras' to 'ui' (big change). // involve moving it from 'extras' to 'ui' (big change).
import { createCachedSelector } from 're-reselect'; import { createCachedSelector } from 're-reselect';
import { selectClaimForUri } from 'redux/selectors/claims'; import { selectClaimForUri, makeSelectIsBlacklisted } from 'redux/selectors/claims';
import { selectMutedChannels } from 'redux/selectors/blocked'; import { selectMutedChannels } from 'redux/selectors/blocked';
import { selectModerationBlockList } from 'redux/selectors/comments'; import { selectModerationBlockList } from 'redux/selectors/comments';
import { selectBlacklistedOutpointMap, selectFilteredOutpointMap } from 'lbryinc'; import { selectBlacklistedOutpointMap, selectFilteredOutpointMap } from 'lbryinc';
@ -18,7 +18,8 @@ export const selectBanStateForUri = createCachedSelector(
selectFilteredOutpointMap, selectFilteredOutpointMap,
selectMutedChannels, selectMutedChannels,
selectModerationBlockList, selectModerationBlockList,
(claim, blackListedOutpointMap, filteredOutpointMap, mutedChannelUris, personalBlocklist) => { (state, uri) => makeSelectIsBlacklisted(uri)(state),
(claim, blackListedOutpointMap, filteredOutpointMap, mutedChannelUris, personalBlocklist, isBlacklisted) => {
const banState = {}; const banState = {};
if (!claim) { if (!claim) {
@ -27,6 +28,10 @@ export const selectBanStateForUri = createCachedSelector(
const channelClaim = getChannelFromClaim(claim); const channelClaim = getChannelFromClaim(claim);
if (isBlacklisted) {
banState['blacklisted'] = true;
}
// This will be replaced once blocking is done at the wallet server level. // This will be replaced once blocking is done at the wallet server level.
if (blackListedOutpointMap) { if (blackListedOutpointMap) {
if ( if (

37
flow-typed/Claim.js vendored
View file

@ -145,12 +145,49 @@ declare type PurchaseReceipt = {
type: 'purchase', 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 = { declare type ClaimActionResolveInfo = {
[string]: { [string]: {
stream: ?StreamClaim, stream: ?StreamClaim,
channel: ?ChannelClaim, channel: ?ChannelClaim,
claimsInChannel: ?number, claimsInChannel: ?number,
collection: ?CollectionClaim, collection: ?CollectionClaim,
errorCensor: ?ClaimErrorCensor,
}, },
} }

10
flow-typed/file-data.js vendored Normal file
View file

@ -0,0 +1,10 @@
// @flow
declare type FileData = {
file?: Blob,
path: string,
duration?: number,
size?: number,
mimeType: string,
error?: string,
}

9
flow-typed/file-with-path.js vendored Normal file
View file

@ -0,0 +1,9 @@
// @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,
}

View file

@ -1,6 +0,0 @@
// @flow
declare type WebFile = File & {
path?: string,
title?: string,
}

View file

@ -1,6 +1,6 @@
{ {
"name": "lbry", "name": "lbry",
"version": "0.53.4", "version": "0.53.9",
"description": "A browser for the LBRY network, a digital marketplace controlled by its users.", "description": "A browser for the LBRY network, a digital marketplace controlled by its users.",
"keywords": [ "keywords": [
"lbry" "lbry"
@ -23,11 +23,8 @@
"analyze": "source-map-explorer --only-mapped dist/electron/webpack/ui*.js --html dist/sourceMap.html", "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: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", "compile": "cross-env NODE_ENV=production yarn compile:electron",
"copyenv": "copyfiles ./.env* web/",
"dev": "yarn dev:electron", "dev": "yarn dev:electron",
"dev:electron": "cross-env NODE_ENV=development node ./electron/devServer.js", "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", "pack": "electron-builder --dir",
"dist": "electron-builder", "dist": "electron-builder",
"build": "cross-env NODE_ENV=production yarn compile:electron && electron-builder build", "build": "cross-env NODE_ENV=production yarn compile:electron && electron-builder build",
@ -44,31 +41,29 @@
}, },
"dependencies": { "dependencies": {
"@electron/remote": "^2.0.1", "@electron/remote": "^2.0.1",
"@emotion/react": "^11.6.0", "@emotion/react": "^11.10.4",
"@emotion/styled": "^11.6.0", "@emotion/styled": "^11.10.4",
"@mui/material": "^5.2.1", "@mui/material": "^5.2.1",
"@ungap/from-entries": "^0.2.1", "@ungap/from-entries": "^0.2.1",
"auto-launch": "^5.0.5", "auto-launch": "^5.0.5",
"electron-dl": "^3.2.0", "electron-dl": "^3.2.0",
"electron-log": "^2.2.12", "electron-log": "^4.4.8",
"electron-notarize": "^1.0.0", "electron-notarize": "^1.0.0",
"electron-updater": "^4.2.4", "electron-updater": "^4.2.4",
"express": "^4.17.1", "express": "^4.17.1",
"ffmpeg-probe": "^1.0.6",
"humanize-duration": "^3.27.0", "humanize-duration": "^3.27.0",
"if-env": "^1.0.4",
"match-sorter": "^6.3.0", "match-sorter": "^6.3.0",
"mime": "^3.0.0",
"node-html-parser": "^5.1.0", "node-html-parser": "^5.1.0",
"parse-duration": "^1.0.0", "parse-duration": "^1.0.0",
"proxy-polyfill": "0.1.6", "proxy-polyfill": "0.1.6",
"re-reselect": "^4.0.0", "re-reselect": "^4.0.0",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-color": "^2.19.3",
"react-datetime-picker": "^3.4.3", "react-datetime-picker": "^3.4.3",
"remove-markdown": "^0.3.0",
"rss": "^1.2.2",
"source-map-explorer": "^2.5.2", "source-map-explorer": "^2.5.2",
"tempy": "^0.6.0", "sudo-prompt": "^9.2.1",
"videojs-logo": "^2.1.4" "tempy": "^0.6.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.0.0", "@babel/core": "^7.0.0",
@ -79,7 +74,7 @@
"@babel/plugin-syntax-dynamic-import": "^7.2.0", "@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/plugin-transform-flow-strip-types": "^7.2.3", "@babel/plugin-transform-flow-strip-types": "^7.2.3",
"@babel/plugin-transform-runtime": "^7.4.3", "@babel/plugin-transform-runtime": "^7.4.3",
"@babel/polyfill": "^7.2.5", "@babel/polyfill": "^7.12.1",
"@babel/preset-env": "^7.12.11", "@babel/preset-env": "^7.12.11",
"@babel/preset-flow": "^7.12.1", "@babel/preset-flow": "^7.12.1",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
@ -87,7 +82,6 @@
"@datapunt/matomo-tracker-js": "^0.1.4", "@datapunt/matomo-tracker-js": "^0.1.4",
"@hot-loader/react-dom": "^16.13", "@hot-loader/react-dom": "^16.13",
"@meetfranz/electron-cookies": "^3.0.2", "@meetfranz/electron-cookies": "^3.0.2",
"@reach/auto-id": "^0.13.0",
"@reach/combobox": "^0.12.1", "@reach/combobox": "^0.12.1",
"@reach/menu-button": "0.8.6", "@reach/menu-button": "0.8.6",
"@reach/rect": "^0.16.0", "@reach/rect": "^0.16.0",
@ -98,21 +92,17 @@
"@sentry/webpack-plugin": "^1.10.0", "@sentry/webpack-plugin": "^1.10.0",
"@types/three": "^0.103.2", "@types/three": "^0.103.2",
"adm-zip": "^0.4.13", "adm-zip": "^0.4.13",
"async-exit-hook": "^2.0.1",
"babel-eslint": "^10.0.1", "babel-eslint": "^10.0.1",
"babel-loader": "^8.0.5", "babel-loader": "^8.0.5",
"babel-plugin-add-module-exports": "^1.0.4", "babel-plugin-add-module-exports": "^1.0.4",
"babel-plugin-import-glob": "^2.0.0", "babel-plugin-import-glob": "^2.0.0",
"babel-plugin-transform-imports": "^1.5.1", "babel-plugin-transform-imports": "^1.5.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0", "babel-plugin-transform-object-rest-spread": "^6.26.0",
"bluebird": "^3.5.1",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"codemirror": "^5.39.2", "codemirror": "^5.39.2",
"concurrently": "^4.1.2",
"connected-react-router": "^6.8.0", "connected-react-router": "^6.8.0",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"copyfiles": "^2.4.1",
"country-data": "^0.0.31", "country-data": "^0.0.31",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"crypto-js": "^4.0.0", "crypto-js": "^4.0.0",
@ -123,10 +113,9 @@
"decompress": "^4.2.1", "decompress": "^4.2.1",
"del": "^3.0.0", "del": "^3.0.0",
"devtron": "^1.4.0", "devtron": "^1.4.0",
"dom-scroll-into-view": "^1.2.1",
"dotenv-defaults": "^2.0.1", "dotenv-defaults": "^2.0.1",
"dotenv-webpack": "^1.8.0", "dotenv-webpack": "^1.8.0",
"electron": "15.4.0", "electron": "17.2.0",
"electron-builder": "^22.10.5", "electron-builder": "^22.10.5",
"electron-devtools-installer": "^3.1.1", "electron-devtools-installer": "^3.1.1",
"electron-is-dev": "^0.3.0", "electron-is-dev": "^0.3.0",
@ -161,10 +150,7 @@
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"mammoth": "^1.4.16", "mammoth": "^1.4.16",
"moment": "^2.29.2", "moment": "^2.29.2",
"node-abi": "^2.5.1",
"node-fetch": "^2.6.7", "node-fetch": "^2.6.7",
"node-html-parser": "^5.1.0",
"node-libs-browser": "^2.1.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"node-wget": "^0.4.3", "node-wget": "^0.4.3",
"nodemon": "^1.19.1", "nodemon": "^1.19.1",
@ -179,7 +165,6 @@
"rc-progress": "^2.0.6", "rc-progress": "^2.0.6",
"react": "^16.8.2", "react": "^16.8.2",
"react-awesome-lightbox": "^1.7.3", "react-awesome-lightbox": "^1.7.3",
"react-confetti": "^4.0.1",
"react-dom": "^16.8.2", "react-dom": "^16.8.2",
"react-draggable": "^3.3.0", "react-draggable": "^3.3.0",
"react-google-recaptcha": "^2.0.1", "react-google-recaptcha": "^2.0.1",
@ -190,7 +175,6 @@
"react-router": "^5.1.0", "react-router": "^5.1.0",
"react-router-dom": "^5.1.0", "react-router-dom": "^5.1.0",
"react-simplemde-editor": "^4.1.3", "react-simplemde-editor": "^4.1.3",
"react-spring": "^8.0.20",
"reakit": "^1.0.0-beta.13", "reakit": "^1.0.0-beta.13",
"redux": "^3.6.0", "redux": "^3.6.0",
"redux-persist": "^5.10.0", "redux-persist": "^5.10.0",
@ -207,20 +191,16 @@
"sass": "^1.29.0", "sass": "^1.29.0",
"sass-loader": "^7.1.0", "sass-loader": "^7.1.0",
"semver": "^5.3.0", "semver": "^5.3.0",
"stream-to-blob-url": "^2.1.1",
"strip-markdown": "^3.0.3", "strip-markdown": "^3.0.3",
"style-loader": "^0.23.1", "style-loader": "^0.23.1",
"terser-webpack-plugin": "^4.2.3", "terser-webpack-plugin": "^4.2.3",
"three-full": "^28.0.2", "three-full": "^28.0.2",
"tiny-relative-date": "^1.3.0",
"tree-kill": "^1.1.0",
"unist-util-visit": "^2.0.3", "unist-util-visit": "^2.0.3",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"video.js": "^7.14.3", "video.js": "^7.14.3",
"videojs-contrib-quality-levels": "^2.0.9", "videojs-contrib-quality-levels": "^2.0.9",
"videojs-event-tracking": "^1.0.1", "videojs-event-tracking": "^1.0.1",
"villain-react": "^1.0.9", "villain-react": "^1.0.9",
"wavesurfer.js": "^2.2.1",
"webpack": "^4.44.2", "webpack": "^4.44.2",
"webpack-bundle-analyzer": "^3.1.0", "webpack-bundle-analyzer": "^3.1.0",
"webpack-cli": "^3.3.10", "webpack-cli": "^3.3.10",
@ -230,15 +210,14 @@
"webpack-hot-middleware": "^2.24.3", "webpack-hot-middleware": "^2.24.3",
"webpack-merge": "^4.2.1", "webpack-merge": "^4.2.1",
"webpack-node-externals": "^1.7.2", "webpack-node-externals": "^1.7.2",
"y18n": "^4.0.1",
"yarnhook": "^0.2.0" "yarnhook": "^0.2.0"
}, },
"engines": { "engines": {
"node": ">=7", "node": ">=16.13",
"yarn": "^1.3" "yarn": "^1.3"
}, },
"lbrySettings": { "lbrySettings": {
"lbrynetDaemonVersion": "0.107.2", "lbrynetDaemonVersion": "0.113.0",
"lbrynetDaemonUrlTemplate": "https://github.com/lbryio/lbry/releases/download/vDAEMONVER/lbrynet-OSNAME.zip", "lbrynetDaemonUrlTemplate": "https://github.com/lbryio/lbry/releases/download/vDAEMONVER/lbrynet-OSNAME.zip",
"lbrynetDaemonDir": "static/daemon", "lbrynetDaemonDir": "static/daemon",
"lbrynetDaemonFileName": "lbrynet" "lbrynetDaemonFileName": "lbrynet"

View file

@ -2312,5 +2312,15 @@
"Free --[legend, unused disk space]--": "Free", "Free --[legend, unused disk space]--": "Free",
"Top content in %language%": "Top content in %language%", "Top content in %language%": "Top content in %language%",
"Apply": "Apply", "Apply": "Apply",
"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--" "--end--": "--end--"
} }

3
static/app-update.yml Normal file
View file

@ -0,0 +1,3 @@
owner: lbryio
repo: lbry-desktop
provider: github

View file

@ -1,4 +1,11 @@
// @flow // @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 { Lbryio } from 'lbryinc';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import MatomoTracker from '@datapunt/matomo-tracker-js'; import MatomoTracker from '@datapunt/matomo-tracker-js';
@ -14,9 +21,6 @@ const devInternalApis = process.env.LBRY_API_URL && process.env.LBRY_API_URL.inc
export const SHARE_INTERNAL = 'shareInternal'; export const SHARE_INTERNAL = 'shareInternal';
const SHARE_THIRD_PARTY = 'shareThirdParty'; 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) { if (isProduction) {
ElectronCookies.enable({ ElectronCookies.enable({
origin: 'https://lbry.tv', origin: 'https://lbry.tv',
@ -68,114 +72,10 @@ type LogPublishParams = {
let internalAnalyticsEnabled: boolean = false; let internalAnalyticsEnabled: boolean = false;
if (window.localStorage.getItem(SHARE_INTERNAL) === 'true') internalAnalyticsEnabled = true; 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 = { const analytics: Analytics = {
// receive buffer events from tracking plugin and save buffer amounts and times for backend call // receive buffer events from tracking plugin and save buffer amounts and times for backend call
videoBufferEvent: async (claim, data) => { videoBufferEvent: async (claim, data) => {
amountOfBufferEvents = amountOfBufferEvents + 1; // stub
amountOfBufferTimeInMS = amountOfBufferTimeInMS + data.bufferDuration;
},
onDispose: () => {
stopWatchmanInterval();
}, },
/** /**
* Is told whether video is being started or paused, and adjusts interval accordingly * Is told whether video is being started or paused, and adjusts interval accordingly
@ -183,40 +83,9 @@ const analytics: Analytics = {
* @param {object} passedPlayer - VideoJS Player object * @param {object} passedPlayer - VideoJS Player object
*/ */
videoIsPlaying: (isPlaying, passedPlayer) => { videoIsPlaying: (isPlaying, passedPlayer) => {
let playerIsSeeking = false; // stub
// 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) => { 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); // sendPromMetric('time_to_start', duration);
sendMatomoEvent('Media', 'TimeToStart', claimId, timeToStartVideo); sendMatomoEvent('Media', 'TimeToStart', claimId, timeToStartVideo);
}, },
@ -382,24 +251,9 @@ 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({ const MatomoInstance = new MatomoTracker({
urlBase: MATOMO_URL, urlBase: MATOMO_URL,
siteId: MATOMO_ID, // optional, default value: `1` 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)); analytics.pageView(generateInitialUrl(window.location.hash));

View file

@ -5,7 +5,6 @@ import Icon from 'component/common/icon';
import classnames from 'classnames'; import classnames from 'classnames';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { formatLbryUrlForWeb } from 'util/url'; import { formatLbryUrlForWeb } from 'util/url';
import * as PAGES from 'constants/pages';
import useCombinedRefs from 'effects/use-combined-refs'; import useCombinedRefs from 'effects/use-combined-refs';
type Props = { type Props = {
@ -34,7 +33,6 @@ type Props = {
onMouseLeave: ?(any) => any, onMouseLeave: ?(any) => any,
pathname: string, pathname: string,
emailVerified: boolean, emailVerified: boolean,
requiresAuth: ?boolean,
myref: any, myref: any,
dispatch: any, dispatch: any,
'aria-label'?: string, 'aria-label'?: string,
@ -66,7 +64,6 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
iconColor, iconColor,
activeClass, activeClass,
emailVerified, emailVerified,
requiresAuth,
myref, myref,
dispatch, // <button> doesn't know what to do with dispatch dispatch, // <button> doesn't know what to do with dispatch
pathname, pathname,
@ -75,7 +72,7 @@ const Button = forwardRef<any, {}>((props: Props, ref: any) => {
...otherProps ...otherProps
} = props; } = props;
const disable = disabled || (user === null && requiresAuth); const disable = disabled;
const combinedClassName = classnames( const combinedClassName = classnames(
'button', 'button',
@ -183,31 +180,6 @@ 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 ? ( return path ? (
<NavLink <NavLink
exact exact

View file

@ -3,7 +3,7 @@ import * as MODALS from 'constants/modal_types';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { FormField } from 'component/common/form'; import { FormField, FormFieldAreaAdvanced } from 'component/common/form';
import Button from 'component/button'; import Button from 'component/button';
import TagsSearch from 'component/tagsSearch'; import TagsSearch from 'component/tagsSearch';
import ErrorText from 'component/common/error-text'; import ErrorText from 'component/common/error-text';
@ -94,10 +94,11 @@ function ChannelForm(props: Props) {
const [nameError, setNameError] = React.useState(undefined); const [nameError, setNameError] = React.useState(undefined);
const [bidError, setBidError] = React.useState(''); const [bidError, setBidError] = React.useState('');
const [isUpload, setIsUpload] = React.useState({ cover: false, thumbnail: false }); const [isUpload, setIsUpload] = React.useState({ cover: false, thumbnail: false });
const [coverError, setCoverError] = React.useState(false);
const [thumbError, setThumbError] = React.useState(false); const [thumbError, setThumbError] = React.useState(false);
const { claim_id: claimId } = claim || {}; const { claim_id: claimId } = claim || {};
const [params, setParams]: [any, (any) => void] = React.useState(getChannelParams()); 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 { channelName } = parseURI(uri);
const name = params.name; const name = params.name;
const isNewChannel = !uri; const isNewChannel = !uri;
@ -206,7 +207,8 @@ function ChannelForm(props: Props) {
setThumbError(false); setThumbError(false);
} }
function handleCoverChange(coverUrl: string, uploadSelected: boolean) { function handleCoverChange(coverUrl: string, uploadSelected: boolean, preview: ?string) {
setCoverPreview(preview || '');
setParams({ ...params, coverUrl }); setParams({ ...params, coverUrl });
setIsUpload({ ...isUpload, cover: uploadSelected }); setIsUpload({ ...isUpload, cover: uploadSelected });
setCoverError(false); setCoverError(false);
@ -259,7 +261,7 @@ function ChannelForm(props: Props) {
} }
}, [hasClaimedInitialRewards, claimInitialRewards]); }, [hasClaimedInitialRewards, claimInitialRewards]);
const coverSrc = coverError ? ThumbnailBrokenImage : params.coverUrl; const coverSrc = coverError ? ThumbnailBrokenImage : coverPreview;
let thumbnailPreview; let thumbnailPreview;
if (!params.thumbnailUrl) { if (!params.thumbnailUrl) {
@ -281,7 +283,7 @@ function ChannelForm(props: Props) {
title={__('Cover')} title={__('Cover')}
onClick={() => onClick={() =>
openModal(MODALS.IMAGE_UPLOAD, { openModal(MODALS.IMAGE_UPLOAD, {
onUpdate: (coverUrl, isUpload) => handleCoverChange(coverUrl, isUpload), onUpdate: (coverUrl, isUpload, preview) => handleCoverChange(coverUrl, isUpload, preview),
title: __('Edit Cover Image'), title: __('Edit Cover Image'),
helpText: __('(6.25:1)'), helpText: __('(6.25:1)'),
assetName: __('Cover Image'), assetName: __('Cover Image'),
@ -323,7 +325,6 @@ function ChannelForm(props: Props) {
uri={uri} uri={uri}
thumbnailPreview={thumbnailPreview} thumbnailPreview={thumbnailPreview}
allowGifs allowGifs
showDelayedMessage={isUpload.thumbnail}
setThumbUploadError={setThumbError} setThumbUploadError={setThumbError}
thumbUploadError={thumbError} thumbUploadError={thumbError}
/> />
@ -375,7 +376,7 @@ function ChannelForm(props: Props) {
onChange={(e) => setParams({ ...params, title: e.target.value })} onChange={(e) => setParams({ ...params, title: e.target.value })}
maxLength={MAX_TITLE_LEN} maxLength={MAX_TITLE_LEN}
/> />
<FormField <FormFieldAreaAdvanced
type="markdown" type="markdown"
name="content_description2" name="content_description2"
label={__('Description')} label={__('Description')}

View file

@ -50,6 +50,7 @@ function ChannelThumbnail(props: Props) {
setThumbUploadError, setThumbUploadError,
ThumbUploadError, ThumbUploadError,
} = props; } = props;
const [retries, setRetries] = React.useState(3);
const [thumbLoadError, setThumbLoadError] = React.useState(ThumbUploadError); const [thumbLoadError, setThumbLoadError] = React.useState(ThumbUploadError);
const shouldResolve = !isResolving && claim === undefined; const shouldResolve = !isResolving && claim === undefined;
const thumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://'); const thumbnail = rawThumbnail && rawThumbnail.trim().replace(/^http:\/\//i, 'https://');
@ -58,6 +59,15 @@ function ChannelThumbnail(props: Props) {
const channelThumbnail = thumbnailPreview || thumbnail || defaultAvatar; const channelThumbnail = thumbnailPreview || thumbnail || defaultAvatar;
const isGif = channelThumbnail && channelThumbnail.endsWith('gif'); const isGif = channelThumbnail && channelThumbnail.endsWith('gif');
const showThumb = (!obscure && !!thumbnail) || thumbnailPreview; const showThumb = (!obscure && !!thumbnail) || thumbnailPreview;
const avatarSrc = React.useMemo(() => {
if (retries <= 0) {
return defaultAvatar;
}
if (!thumbLoadError) {
return channelThumbnail;
}
return defaultAvatar;
}, [retries, thumbLoadError, channelThumbnail, defaultAvatar]);
// Generate a random color class based on the first letter of the channel name // Generate a random color class based on the first letter of the channel name
const { channelName } = parseURI(uri); const { channelName } = parseURI(uri);
@ -100,9 +110,10 @@ function ChannelThumbnail(props: Props) {
<OptimizedImage <OptimizedImage
alt={__('Channel profile picture')} alt={__('Channel profile picture')}
className={!channelThumbnail ? 'channel-thumbnail__default' : 'channel-thumbnail__custom'} className={!channelThumbnail ? 'channel-thumbnail__default' : 'channel-thumbnail__custom'}
src={(!thumbLoadError && channelThumbnail) || defaultAvatar} src={avatarSrc}
loading={noLazyLoad ? undefined : 'lazy'} loading={noLazyLoad ? undefined : 'lazy'}
onError={() => { onError={() => {
setRetries((retries) => retries - 1);
if (setThumbUploadError) { if (setThumbUploadError) {
setThumbUploadError(true); setThumbUploadError(true);
} else { } else {

View file

@ -17,7 +17,7 @@ import { useHistory } from 'react-router-dom';
import { isNameValid, regexInvalidURI } from 'util/lbryURI'; import { isNameValid, regexInvalidURI } from 'util/lbryURI';
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses'; import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs'; import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
import { FormField } from 'component/common/form'; import { FormField, FormFieldAreaAdvanced } from 'component/common/form';
import { handleBidChange } from 'util/publish'; import { handleBidChange } from 'util/publish';
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field'; import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
import { INVALID_NAME_ERROR } from 'constants/claim'; import { INVALID_NAME_ERROR } from 'constants/claim';
@ -371,7 +371,7 @@ function CollectionForm(props: Props) {
usePublishFormMode usePublishFormMode
/> />
</fieldset-section> </fieldset-section>
<FormField <FormFieldAreaAdvanced
type="markdown" type="markdown"
name="content_description2" name="content_description2"
label={__('Description')} label={__('Description')}

View file

@ -17,7 +17,7 @@ import CommentBadge from 'component/common/comment-badge'; // have this?
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import { Menu, MenuButton } from '@reach/menu-button'; import { Menu, MenuButton } from '@reach/menu-button';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import { FormField, Form } from 'component/common/form'; import { FormFieldAreaAdvanced, Form } from 'component/common/form';
import classnames from 'classnames'; import classnames from 'classnames';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import CommentReactions from 'component/commentReactions'; import CommentReactions from 'component/commentReactions';
@ -319,7 +319,7 @@ function CommentView(props: Props) {
<div> <div>
{isEditing ? ( {isEditing ? (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit}>
<FormField <FormFieldAreaAdvanced
className="comment__edit-input" className="comment__edit-input"
type={advancedEditor ? 'markdown' : 'textarea'} type={advancedEditor ? 'markdown' : 'textarea'}
name="editing_comment" name="editing_comment"

View file

@ -0,0 +1,32 @@
// @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>
);
}

View file

@ -4,7 +4,7 @@ import 'scss/component/_comment-create.scss';
import { buildValidSticker } from 'util/comments'; import { buildValidSticker } from 'util/comments';
import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field'; import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field';
import { FormField, Form } from 'component/common/form'; import { FormFieldAreaAdvanced, Form } from 'component/common/form';
import { getChannelIdFromClaim } from 'util/claim'; import { getChannelIdFromClaim } from 'util/claim';
import { Lbryio } from 'lbryinc'; import { Lbryio } from 'lbryinc';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
@ -22,13 +22,12 @@ import I18nMessage from 'component/i18nMessage';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import OptimizedImage from 'component/optimizedImage'; import OptimizedImage from 'component/optimizedImage';
import React from 'react'; import React from 'react';
import SelectChannel from 'component/selectChannel';
import StickerSelector from './sticker-selector'; import StickerSelector from './sticker-selector';
import CommentCreateHeader from './comment-create-header';
import type { ElementRef } from 'react'; import type { ElementRef } from 'react';
import UriIndicator from 'component/uriIndicator'; import UriIndicator from 'component/uriIndicator';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import WalletTipAmountSelector from 'component/walletTipAmountSelector'; import WalletTipAmountSelector from 'component/walletTipAmountSelector';
import { getStripeEnvironment } from 'util/stripe'; import { getStripeEnvironment } from 'util/stripe';
const stripeEnvironment = getStripeEnvironment(); const stripeEnvironment = getStripeEnvironment();
@ -364,31 +363,6 @@ export function CommentCreate(props: Props) {
.catch(() => {}); .catch(() => {});
}, [canReceiveFiatTip, claim.claim_id, claim.name, claim.signing_channel, stickerSelector]); }, [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 // Render
// ************************************************************************** // **************************************************************************
@ -410,7 +384,11 @@ export function CommentCreate(props: Props) {
push(pathPlusRedirect); push(pathPlusRedirect);
}} }}
> >
<FormField type="textarea" name={'comment_signup_prompt'} placeholder={__('Say something about this...')} /> <FormFieldAreaAdvanced
type="textarea"
name={'comment_signup_prompt'}
placeholder={__('Say something about this...')}
/>
<div className="section__actions--no-margin"> <div className="section__actions--no-margin">
<Button disabled button="primary" label={__('Post --[button to submit something]--')} /> <Button disabled button="primary" label={__('Post --[button to submit something]--')} />
</div> </div>
@ -421,22 +399,22 @@ export function CommentCreate(props: Props) {
return ( return (
<Form <Form
onSubmit={() => {}} onSubmit={() => {}}
className={classnames('commentCreate', { className={classnames('comment-create', {
'commentCreate--reply': isReply, 'comment-create--reply': isReply,
'commentCreate--nestedReply': isNested, 'comment-create--nestedReply': isNested,
'commentCreate--bottom': bottom, 'comment-create--bottom': bottom,
})} })}
> >
{/* Input Box/Preview Box */} {/* Input Box/Preview Box */}
{stickerSelector ? ( {stickerSelector ? (
<StickerSelector onSelect={(sticker) => handleSelectSticker(sticker)} claimIsMine={claimIsMine} /> <StickerSelector onSelect={(sticker) => handleSelectSticker(sticker)} claimIsMine={claimIsMine} />
) : isReviewingStickerComment && activeChannelClaim && selectedSticker ? ( ) : isReviewingStickerComment && activeChannelClaim && selectedSticker ? (
<div className="commentCreate__stickerPreview"> <div className="comment-create__stickerPreview">
<div className="commentCreate__stickerPreviewInfo"> <div className="comment-create__stickerPreviewInfo">
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} /> <ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
<UriIndicator uri={activeChannelClaim.canonical_url} link /> <UriIndicator uri={activeChannelClaim.canonical_url} link />
</div> </div>
<div className="commentCreate__stickerPreviewImage"> <div className="comment-create__stickerPreviewImage">
<OptimizedImage src={selectedSticker && selectedSticker.url} waitLoad loading="lazy" /> <OptimizedImage src={selectedSticker && selectedSticker.url} waitLoad loading="lazy" />
</div> </div>
{/* figure out lbc sticker prices */} {/* figure out lbc sticker prices */}
@ -448,15 +426,15 @@ export function CommentCreate(props: Props) {
)} )}
</div> </div>
) : isReviewingSupportComment && activeChannelClaim ? ( ) : isReviewingSupportComment && activeChannelClaim ? (
<div className="commentCreate__supportCommentPreview"> <div className="comment-create__supportCommentPreview">
<CreditAmount <CreditAmount
amount={tipAmount} amount={tipAmount}
className="commentCreate__supportCommentPreviewAmount" className="comment-create__supportCommentPreviewAmount"
isFiat={activeTab === TAB_FIAT} isFiat={activeTab === TAB_FIAT}
size={activeTab === TAB_LBC ? 18 : 2} size={activeTab === TAB_LBC ? 18 : 2}
/> />
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} /> <ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
<div className="commentCreate__supportCommentBody"> <div className="comment-create__supportCommentBody">
<UriIndicator uri={activeChannelClaim.canonical_url} link /> <UriIndicator uri={activeChannelClaim.canonical_url} link />
<div>{commentValue}</div> <div>{commentValue}</div>
</div> </div>
@ -471,23 +449,22 @@ export function CommentCreate(props: Props) {
/> />
)} )}
<FormField <FormFieldAreaAdvanced
autoFocus={isReply} autoFocus={isReply}
charCount={charCount} charCount={charCount}
className={isReply ? 'content_reply' : 'content_comment'} className={isReply ? 'content_reply' : 'content_comment'}
disabled={isFetchingChannels} disabled={isFetchingChannels}
label={ header={
<div className="commentCreate__labelWrapper"> <CommentCreateHeader
<span className="commentCreate__label">{(isReply ? __('Replying as') : __('Comment as')) + ' '}</span> isReply={isReply}
<SelectChannel tiny /> advanced={advancedEditor}
</div> advancedHandler={() => setAdvancedEditor(!advancedEditor)}
/>
} }
name={isReply ? 'content_reply' : 'content_description'} name={isReply ? 'content_reply' : 'content_description'}
quickActionLabel={isReply ? undefined : advancedEditor ? __('Simple Editor') : __('Advanced Editor')}
ref={formFieldRef} ref={formFieldRef}
onChange={handleCommentChange} onChange={handleCommentChange}
openEmoteMenu={() => setShowEmotes(!showEmotes)} openEmoteMenu={() => setShowEmotes(!showEmotes)}
quickActionHandler={() => setAdvancedEditor(!advancedEditor)}
onFocus={onTextareaFocus} onFocus={onTextareaFocus}
onBlur={onTextareaBlur} onBlur={onTextareaBlur}
placeholder={__('Say something about this...')} placeholder={__('Say something about this...')}
@ -655,7 +632,7 @@ export function CommentCreate(props: Props) {
{/* Help Text */} {/* Help Text */}
{deletedComment && <div className="error__text">{__('This comment has been deleted.')}</div>} {deletedComment && <div className="error__text">{__('This comment has been deleted.')}</div>}
{!!minAmount && ( {!!minAmount && (
<div className="help--notice commentCreate__minAmountNotice"> <div className="help--notice comment-create__minAmountNotice">
<I18nMessage tokens={{ lbc: <CreditAmount noFormat amount={minAmount} /> }}> <I18nMessage tokens={{ lbc: <CreditAmount noFormat amount={minAmount} /> }}>
{minTip ? 'Comment min: %lbc%' : minSuper ? 'HyperChat min: %lbc%' : ''} {minTip ? 'Comment min: %lbc%' : minSuper ? 'HyperChat min: %lbc%' : ''}
</I18nMessage> </I18nMessage>

View file

@ -23,6 +23,9 @@ import { doCommentReset, doCommentList, doCommentById, doCommentReactList } from
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
import { getChannelIdFromClaim } from 'util/claim'; import { getChannelIdFromClaim } from 'util/claim';
import CommentsList from './view'; 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 select = (state, props) => {
const { uri } = props; const { uri } = props;
@ -56,15 +59,19 @@ const select = (state, props) => {
myReactsByCommentId: selectMyReacts(state), myReactsByCommentId: selectMyReacts(state),
othersReactsById: selectOthersReacts(state), othersReactsById: selectOthersReacts(state),
activeChannelId: activeChannelClaim && activeChannelClaim.claim_id, activeChannelId: activeChannelClaim && activeChannelClaim.claim_id,
customCommentServers: makeSelectClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVERS)(state),
commentServer: makeSelectClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVER_URL)(state),
}; };
}; };
const perform = { const perform = (dispatch, ownProps) => ({
fetchTopLevelComments: doCommentList, fetchTopLevelComments: (uri, parentId, page, pageSize, sortBy) =>
fetchComment: doCommentById, dispatch(doCommentList(uri, parentId, page, pageSize, sortBy)),
fetchReacts: doCommentReactList, fetchComment: (commentId) => dispatch(doCommentById(commentId)),
resetComments: doCommentReset, fetchReacts: (commentIds) => dispatch(doCommentReactList(commentIds)),
doResolveUris, resetComments: (claimId) => dispatch(doCommentReset(claimId)),
}; doResolveUris: (uris, returnCachedClaims) => dispatch(doResolveUris(uris, returnCachedClaims)),
setCommentServer: (url) => dispatch(doSetClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVER_URL, url, true)),
});
export default connect(select, perform)(CommentsList); export default connect(select, perform)(CommentsList);

View file

@ -1,6 +1,6 @@
// @flow // @flow
import { COMMENT_PAGE_SIZE_TOP_LEVEL, SORT_BY } from 'constants/comment'; import { COMMENT_PAGE_SIZE_TOP_LEVEL, SORT_BY } from 'constants/comment';
import { ENABLE_COMMENT_REACTIONS } from 'config'; import { ENABLE_COMMENT_REACTIONS, COMMENT_SERVER_API, COMMENT_SERVER_NAME } from 'config';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize'; import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import { getCommentsListTitle } from 'util/comments'; import { getCommentsListTitle } from 'util/comments';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
@ -15,6 +15,8 @@ import Empty from 'component/common/empty';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import { FormField } from 'component/common/form';
import Comments from 'comments';
const DEBOUNCE_SCROLL_HANDLER_MS = 200; const DEBOUNCE_SCROLL_HANDLER_MS = 200;
@ -52,6 +54,9 @@ type Props = {
fetchReacts: (commentIds: Array<string>) => Promise<any>, fetchReacts: (commentIds: Array<string>) => Promise<any>,
resetComments: (claimId: string) => void, resetComments: (claimId: string) => void,
doResolveUris: (uris: Array<string>, returnCachedClaims: boolean) => void, doResolveUris: (uris: Array<string>, returnCachedClaims: boolean) => void,
customCommentServers: Array<CommentServerDetails>,
setCommentServer: (string) => void,
commentServer: string,
}; };
export default function CommentList(props: Props) { export default function CommentList(props: Props) {
@ -80,11 +85,17 @@ export default function CommentList(props: Props) {
fetchReacts, fetchReacts,
resetComments, resetComments,
doResolveUris, doResolveUris,
customCommentServers,
setCommentServer,
commentServer,
} = props; } = props;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMediumScreen = useIsMediumScreen(); const isMediumScreen = useIsMediumScreen();
const defaultServer = { name: COMMENT_SERVER_NAME, url: COMMENT_SERVER_API };
const allServers = [defaultServer, ...(customCommentServers || [])];
const spinnerRef = React.useRef(); const spinnerRef = React.useRef();
const DEFAULT_SORT = ENABLE_COMMENT_REACTIONS ? SORT_BY.POPULARITY : SORT_BY.NEWEST; const DEFAULT_SORT = ENABLE_COMMENT_REACTIONS ? SORT_BY.POPULARITY : SORT_BY.NEWEST;
const [sort, setSort] = usePersistedState('comment-sort-by', DEFAULT_SORT); const [sort, setSort] = usePersistedState('comment-sort-by', DEFAULT_SORT);
@ -255,7 +266,16 @@ export default function CommentList(props: Props) {
}, [alreadyResolved, doResolveUris, topLevelComments]); }, [alreadyResolved, doResolveUris, topLevelComments]);
const commentProps = { isTopLevel: true, threadDepth: 3, uri, claimIsMine, linkedCommentId }; const commentProps = { isTopLevel: true, threadDepth: 3, uri, claimIsMine, linkedCommentId };
const actionButtonsProps = { totalComments, sort, changeSort, setPage }; const actionButtonsProps = {
totalComments,
sort,
changeSort,
setPage,
allServers,
commentServer,
defaultServer,
setCommentServer,
};
return ( return (
<Card <Card
@ -334,17 +354,21 @@ type ActionButtonsProps = {
sort: string, sort: string,
changeSort: (string) => void, changeSort: (string) => void,
setPage: (number) => void, setPage: (number) => void,
allServers: Array<CommentServerDetails>,
commentServer: string,
setCommentServer: (string) => void,
defaultServer: CommentServerDetails,
}; };
const CommentActionButtons = (actionButtonsProps: ActionButtonsProps) => { const CommentActionButtons = (actionButtonsProps: ActionButtonsProps) => {
const { totalComments, sort, changeSort, setPage } = actionButtonsProps; const { totalComments, sort, changeSort, setPage, allServers, commentServer, setCommentServer, defaultServer } =
actionButtonsProps;
const sortButtonProps = { activeSort: sort, changeSort }; const sortButtonProps = { activeSort: sort, changeSort };
return ( return (
<> <div className={'comment__actions-row'}>
{totalComments > 1 && ENABLE_COMMENT_REACTIONS && ( {totalComments > 1 && ENABLE_COMMENT_REACTIONS && (
<span className="comment__sort"> <div className="comment__sort-group">
<SortButton {...sortButtonProps} label={__('Best')} icon={ICONS.BEST} sortOption={SORT_BY.POPULARITY} /> <SortButton {...sortButtonProps} label={__('Best')} icon={ICONS.BEST} sortOption={SORT_BY.POPULARITY} />
<SortButton <SortButton
{...sortButtonProps} {...sortButtonProps}
@ -353,11 +377,39 @@ const CommentActionButtons = (actionButtonsProps: ActionButtonsProps) => {
sortOption={SORT_BY.CONTROVERSY} sortOption={SORT_BY.CONTROVERSY}
/> />
<SortButton {...sortButtonProps} label={__('New')} icon={ICONS.NEW} sortOption={SORT_BY.NEWEST} /> <SortButton {...sortButtonProps} label={__('New')} icon={ICONS.NEW} sortOption={SORT_BY.NEWEST} />
</span> </div>
)} )}
<Button button="alt" icon={ICONS.REFRESH} title={__('Refresh')} onClick={() => setPage(0)} /> {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>
); );
}; };

View file

@ -3,8 +3,8 @@ import React from 'react';
import { useRadioState, Radio, RadioGroup } from 'reakit/Radio'; import { useRadioState, Radio, RadioGroup } from 'reakit/Radio';
type Props = { type Props = {
files: Array<WebFile>, files: Array<File>,
onChange: (WebFile | void) => void, onChange: (File | void) => void,
}; };
type RadioProps = { type RadioProps = {
@ -26,16 +26,16 @@ function FileList(props: Props) {
const getFile = (value?: string) => { const getFile = (value?: string) => {
if (files && files.length) { if (files && files.length) {
return files.find((file: WebFile) => file.name === value); return files.find((file: File) => file.name === value);
} }
}; };
React.useEffect(() => { React.useEffect(() => {
if (radio.stops.length) { if (radio.items.length) {
if (!radio.currentId) { if (!radio.currentId) {
radio.first(); radio.first();
} else { } else {
const first = radio.stops[0].ref.current; const first = radio.items[0].ref.current;
// First auto-selection // First auto-selection
if (first && first.id === radio.currentId && !radio.state) { if (first && first.id === radio.currentId && !radio.state) {
const file = getFile(first.value); const file = getFile(first.value);
@ -46,12 +46,12 @@ function FileList(props: Props) {
if (radio.state) { if (radio.state) {
// Find selected element // Find selected element
const stop = radio.stops.find(item => item.id === radio.currentId); const stop = radio.items.find((item) => item.id === radio.currentId);
const element = stop && stop.ref.current; const element = stop && stop.ref.current;
// Only update state if new item is selected // Only update state if new item is selected
if (element && element.value !== radio.state) { if (element && element.value !== radio.state) {
const file = getFile(element.value); const file = getFile(element.value);
// Sselect new file and update state // Select new file and update state
onChange(file); onChange(file);
radio.setState(element.value); radio.setState(element.value);
} }

View file

@ -1,25 +1,29 @@
// @flow // @flow
import * as React from 'react'; import * as React from 'react';
import * as remote from '@electron/remote'; import * as remote from '@electron/remote';
import { ipcRenderer } from 'electron';
import Button from 'component/button'; import Button from 'component/button';
import { FormField } from 'component/common/form'; import { FormField } from 'component/common/form';
type Props = { type Props = {
type: string, type: string,
currentPath?: ?string, currentPath?: ?string,
onFileChosen: (WebFile) => void, onFileChosen: (FileWithPath) => void,
label?: string, label?: string,
placeholder?: string, placeholder?: string,
accept?: string, accept?: string,
error?: string, error?: string,
disabled?: boolean, disabled?: boolean,
autoFocus?: boolean, autoFocus?: boolean,
filters?: Array<{ name: string, extension: string[] }>,
readFile?: boolean,
}; };
class FileSelector extends React.PureComponent<Props> { class FileSelector extends React.PureComponent<Props> {
static defaultProps = { static defaultProps = {
autoFocus: false, autoFocus: false,
type: 'file', type: 'file',
readFile: true,
}; };
fileInput: React.ElementRef<any>; fileInput: React.ElementRef<any>;
@ -41,19 +45,50 @@ class FileSelector extends React.PureComponent<Props> {
const file = files[0]; const file = files[0];
if (this.props.onFileChosen) { if (this.props.onFileChosen) {
this.props.onFileChosen(file); this.props.onFileChosen({ file, path: file.path || file.name });
} }
this.fileInput.current.value = null; // clear the file input this.fileInput.current.value = null; // clear the file input
}; };
handleDirectoryInputSelection = () => { handleDirectoryInputSelection = () => {
remote.dialog.showOpenDialog({ properties: ['openDirectory'] }).then((result) => { let defaultPath;
const path = result && result.filePaths[0]; let properties;
if (path) { let isWin = process.platform === 'win32';
// $FlowFixMe let type = this.props.type;
this.props.onFileChosen({ path });
} if (isWin === true) {
}); defaultPath = process.env.HOME || process.env.HOMEPATH || process.env.USERPROFILE;
}
if (type === 'openFile') {
properties = ['openFile'];
}
if (type === 'openDirectory') {
properties = ['openDirectory'];
}
remote.dialog
.showOpenDialog({
properties,
defaultPath,
filters: this.props.filters,
})
.then((result) => {
const path = result && result.filePaths[0];
if (path) {
return ipcRenderer.invoke('get-file-from-path', path, this.props.readFile);
}
})
.then((result) => {
if (!result) {
return;
}
const file = new File([result.buffer], result.name, {
type: result.mime,
});
this.props.onFileChosen({ file, path: result.path });
});
}; };
fileInputButton = () => { fileInputButton = () => {
@ -82,7 +117,11 @@ class FileSelector extends React.PureComponent<Props> {
autoFocus={autoFocus} autoFocus={autoFocus}
button="primary" button="primary"
disabled={disabled} disabled={disabled}
onClick={type === 'openDirectory' ? this.handleDirectoryInputSelection : this.fileInputButton} onClick={
type === 'openDirectory' || type === 'openFile'
? this.handleDirectoryInputSelection
: this.fileInputButton
}
label={__('Browse')} label={__('Browse')}
/> />
} }

View file

@ -0,0 +1,240 @@
// @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;

View file

@ -1,14 +1,7 @@
// @flow // @flow
import 'easymde/dist/easymde.min.css'; import 'easymde/dist/easymde.min.css';
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field'; 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 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'; import type { ElementRef, Node } from 'react';
type Props = { type Props = {
@ -21,19 +14,15 @@ type Props = {
disabled?: boolean, disabled?: boolean,
error?: string | boolean, error?: string | boolean,
helper?: string | React$Node, helper?: string | React$Node,
hideSuggestions?: boolean,
inputButton?: React$Node, inputButton?: React$Node,
isLivestream?: boolean,
label?: string | Node, label?: string | Node,
labelOnLeft: boolean, labelOnLeft: boolean,
max?: number, max?: number,
min?: number, min?: number,
name: string, name: string,
noEmojis?: boolean,
placeholder?: string | number, placeholder?: string | number,
postfix?: string, postfix?: string,
prefix?: string, prefix?: string,
quickActionLabel?: string,
range?: number, range?: number,
readOnly?: boolean, readOnly?: boolean,
stretch?: boolean, stretch?: boolean,
@ -41,8 +30,6 @@ type Props = {
type?: string, type?: string,
value?: string | number, value?: string | number,
onChange?: (any) => any, onChange?: (any) => any,
openEmoteMenu?: () => void,
quickActionHandler?: (any) => any,
render?: () => React$Node, render?: () => React$Node,
}; };
@ -72,21 +59,15 @@ export class FormField extends React.PureComponent<Props> {
children, children,
error, error,
helper, helper,
hideSuggestions,
inputButton, inputButton,
isLivestream,
label, label,
labelOnLeft, labelOnLeft,
name, name,
noEmojis,
postfix, postfix,
prefix, prefix,
quickActionLabel,
stretch, stretch,
textAreaMaxLength, textAreaMaxLength,
type, type,
openEmoteMenu,
quickActionHandler,
render, render,
...inputProps ...inputProps
} = this.props; } = this.props;
@ -101,18 +82,10 @@ export class FormField extends React.PureComponent<Props> {
const countInfo = hasCharCount && textAreaMaxLength !== undefined && ( const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
<span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span> <span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
); );
const Wrapper = blockWrap const Wrapper = blockWrap
? ({ children: innerChildren }) => <fieldset-section class="radio">{innerChildren}</fieldset-section> ? ({ children: innerChildren }) => <fieldset-section class="radio">{innerChildren}</fieldset-section>
: ({ children: innerChildren }) => <span className="radio">{innerChildren}</span>; : ({ 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) => ( const inputSimple = (type: string) => (
<> <>
<input id={name} type={type} {...inputProps} /> <input id={name} type={type} {...inputProps} />
@ -143,133 +116,22 @@ export class FormField extends React.PureComponent<Props> {
return inputSelect(''); return inputSelect('');
case 'select-tiny': case 'select-tiny':
return inputSelect('select--slim'); 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': case 'textarea':
return ( return (
<fieldset-section> <fieldset-section>
{(label || quickAction) && ( {label && (
<div className="form-field__two-column"> <div className="form-field__two-column">
<label htmlFor={name}>{label}</label> <label htmlFor={name}>{label}</label>
{quickAction}
</div> </div>
)} )}
<textarea
{hideSuggestions ? ( type={type}
<textarea id={name}
type={type} maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
id={name} ref={this.input}
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT} {...inputProps}
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> </fieldset-section>
); );
default: default:

View file

@ -1,4 +1,5 @@
export { Form } from './form-components/form'; export { Form } from './form-components/form';
export { FormField } from './form-components/form-field'; 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 { FormFieldPrice } from './form-components/form-field-price';
export { Submit } from './form-components/submit'; export { Submit } from './form-components/submit';

View file

@ -2054,4 +2054,15 @@ export const icons = {
<path d="M12.5,23.24v-1A10.74,10.74,0,0,1,23.24,11.52" /> <path d="M12.5,23.24v-1A10.74,10.74,0,0,1,23.24,11.52" />
</g> </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>
),
}; };

View file

@ -7,7 +7,6 @@ import {
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { makeSelectPendingAmountByUri } from 'redux/selectors/wallet'; import { makeSelectPendingAmountByUri } from 'redux/selectors/wallet';
import { doOpenModal } from 'redux/actions/app'; import { doOpenModal } from 'redux/actions/app';
import { selectUser } from 'redux/selectors/user';
import FileDescription from './view'; import FileDescription from './view';
const select = (state, props) => { const select = (state, props) => {
@ -17,7 +16,6 @@ const select = (state, props) => {
claim, claim,
claimIsMine: selectClaimIsMine(state, claim), claimIsMine: selectClaimIsMine(state, claim),
metadata: makeSelectMetadataForUri(props.uri)(state), metadata: makeSelectMetadataForUri(props.uri)(state),
user: selectUser(state),
pendingAmount: makeSelectPendingAmountByUri(props.uri)(state), pendingAmount: makeSelectPendingAmountByUri(props.uri)(state),
tags: makeSelectTagsForUri(props.uri)(state), tags: makeSelectTagsForUri(props.uri)(state),
}; };

View file

@ -15,7 +15,6 @@ type Props = {
uri: string, uri: string,
claim: StreamClaim, claim: StreamClaim,
metadata: StreamMetadata, metadata: StreamMetadata,
user: ?any,
tags: any, tags: any,
pendingAmount: number, pendingAmount: number,
doOpenModal: (id: string, {}) => void, doOpenModal: (id: string, {}) => void,

View file

@ -11,10 +11,10 @@ import Icon from 'component/common/icon';
type Props = { type Props = {
modal: { id: string, modalProps: {} }, modal: { id: string, modalProps: {} },
filePath: string | WebFile, filePath: ?string,
clearPublish: () => void, clearPublish: () => void,
updatePublishForm: ({}) => void, updatePublishForm: ({}) => void,
openModal: (id: string, { files: Array<WebFile> }) => void, openModal: (id: string, { files: Array<File> }) => void,
// React router // React router
history: { history: {
entities: {}[], entities: {}[],
@ -37,7 +37,7 @@ function FileDrop(props: Props) {
const { drag, dropData } = useDragDrop(); const { drag, dropData } = useDragDrop();
const [files, setFiles] = React.useState([]); const [files, setFiles] = React.useState([]);
const [error, setError] = React.useState(false); const [error, setError] = React.useState(false);
const [target, setTarget] = React.useState<?WebFile>(null); const [target, setTarget] = React.useState<?File>(null);
const hideTimer = React.useRef(null); const hideTimer = React.useRef(null);
const targetTimer = React.useRef(null); const targetTimer = React.useRef(null);
const navigationTimer = React.useRef(null); const navigationTimer = React.useRef(null);
@ -65,24 +65,26 @@ function FileDrop(props: Props) {
} }
}, [history]); }, [history]);
// Delay hide and navigation for a smooth transition
const hideDropArea = React.useCallback(() => {
hideTimer.current = setTimeout(() => {
setFiles([]);
// Navigate to publish area
navigationTimer.current = setTimeout(() => {
navigateToPublish();
}, NAVIGATE_TIME_OUT);
}, HIDE_TIME_OUT);
}, [navigateToPublish]);
// Handle file selection // Handle file selection
const handleFileSelected = React.useCallback( const handleFileSelected = React.useCallback(
(selectedFile) => { (selectedFile) => {
updatePublishForm({ filePath: selectedFile }); // Delay hide and navigation for a smooth transition
hideDropArea(); 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);
}, },
[updatePublishForm, hideDropArea] [setFiles, navigateToPublish, updatePublishForm]
); );
// Clear timers when unmounted // Clear timers when unmounted

View file

@ -12,7 +12,7 @@ import { PRIMARY_PLAYER_WRAPPER_CLASS } from 'page/file/view';
import Draggable from 'react-draggable'; import Draggable from 'react-draggable';
import { onFullscreenChange } from 'util/full-screen'; import { onFullscreenChange } from 'util/full-screen';
import { generateListSearchUrlParams, formatLbryUrlForWeb } from 'util/url'; import { generateListSearchUrlParams, formatLbryUrlForWeb } from 'util/url';
import { useIsMobile } from 'effects/use-screensize'; import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import debounce from 'util/debounce'; import debounce from 'util/debounce';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { isURIEqual } from 'util/lbryURI'; import { isURIEqual } from 'util/lbryURI';
@ -21,7 +21,6 @@ import AutoplayCountdown from 'component/autoplayCountdown';
// scss/init/vars.scss // scss/init/vars.scss
// --header-height // --header-height
const HEADER_HEIGHT = 60; const HEADER_HEIGHT = 60;
const HEADER_HEIGHT_MOBILE = 60;
const IS_DESKTOP_MAC = typeof process === 'object' ? process.platform === 'darwin' : false; const IS_DESKTOP_MAC = typeof process === 'object' ? process.platform === 'darwin' : false;
const DEBOUNCE_WINDOW_RESIZE_HANDLER_MS = 100; const DEBOUNCE_WINDOW_RESIZE_HANDLER_MS = 100;
@ -133,6 +132,7 @@ export default function FileRenderFloating(props: Props) {
const playingUriSource = playingUri && playingUri.source; const playingUriSource = playingUri && playingUri.source;
const isComment = playingUriSource === 'comment'; const isComment = playingUriSource === 'comment';
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMediumScreen = useIsMediumScreen();
const mainFilePlaying = !isFloating && primaryUri && isURIEqual(uri, primaryUri); const mainFilePlaying = !isFloating && primaryUri && isURIEqual(uri, primaryUri);
const [fileViewerRect, setFileViewerRect] = useState(); const [fileViewerRect, setFileViewerRect] = useState();
@ -344,7 +344,8 @@ export default function FileRenderFloating(props: Props) {
'content__viewer--floating': isFloating, 'content__viewer--floating': isFloating,
'content__viewer--inline': !isFloating, 'content__viewer--inline': !isFloating,
'content__viewer--secondary': isComment, 'content__viewer--secondary': isComment,
'content__viewer--theater-mode': !isFloating && videoTheaterMode && playingUri?.uri === primaryUri, 'content__viewer--theater-mode':
!isFloating && videoTheaterMode && !isMediumScreen && playingUri?.uri === primaryUri,
'content__viewer--disable-click': wasDragging, 'content__viewer--disable-click': wasDragging,
})} })}
style={ style={
@ -356,7 +357,7 @@ export default function FileRenderFloating(props: Props) {
top: top:
fileViewerRect.windowOffset + fileViewerRect.windowOffset +
fileViewerRect.top - fileViewerRect.top -
(isMobile ? HEADER_HEIGHT_MOBILE : HEADER_HEIGHT) - (isMobile ? 0 : HEADER_HEIGHT) -
(IS_DESKTOP_MAC ? 24 : 0), (IS_DESKTOP_MAC ? 24 : 0),
} }
: {} : {}

View file

@ -9,6 +9,7 @@ import * as PAGES from 'constants/pages';
import * as RENDER_MODES from 'constants/file_render_modes'; import * as RENDER_MODES from 'constants/file_render_modes';
import * as KEYCODES from 'constants/keycodes'; import * as KEYCODES from 'constants/keycodes';
import Button from 'component/button'; import Button from 'component/button';
import { useIsMediumScreen } from 'effects/use-screensize';
import isUserTyping from 'util/detect-typing'; import isUserTyping from 'util/detect-typing';
import { getThumbnailCdnUrl } from 'util/thumbnail'; import { getThumbnailCdnUrl } from 'util/thumbnail';
import Nag from 'component/common/nag'; import Nag from 'component/common/nag';
@ -63,6 +64,7 @@ export default function FileRenderInitiator(props: Props) {
const fileStatus = fileInfo && fileInfo.status; const fileStatus = fileInfo && fileInfo.status;
const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode); const isPlayable = RENDER_MODES.FLOATING_MODES.includes(renderMode);
const isText = RENDER_MODES.TEXT_MODES.includes(renderMode); const isText = RENDER_MODES.TEXT_MODES.includes(renderMode);
const isMediumScreen = useIsMediumScreen();
const [thumbnail, setThumbnail] = React.useState(FileRenderPlaceholder); const [thumbnail, setThumbnail] = React.useState(FileRenderPlaceholder);
const containerRef = React.useRef<any>(); const containerRef = React.useRef<any>();
@ -151,7 +153,7 @@ export default function FileRenderInitiator(props: Props) {
style={thumbnail && !obscurePreview ? { backgroundImage: `url("${thumbnail}")` } : {}} style={thumbnail && !obscurePreview ? { backgroundImage: `url("${thumbnail}")` } : {}}
className={classnames('content__cover', { className={classnames('content__cover', {
'content__cover--disabled': disabled, 'content__cover--disabled': disabled,
'content__cover--theater-mode': videoTheaterMode, 'content__cover--theater-mode': videoTheaterMode && !isMediumScreen,
'content__cover--text': isText, 'content__cover--text': isText,
'card__media--nsfw': obscurePreview, 'card__media--nsfw': obscurePreview,
})} })}

View file

@ -13,7 +13,7 @@ const select = (state, props) => {
if (claimUriBeingPlayed) { if (claimUriBeingPlayed) {
const claim = makeSelectClaimForUri(props.uri)(state); const claim = makeSelectClaimForUri(props.uri)(state);
const claimBeingPlayed = makeSelectClaimForUri(claimUriBeingPlayed)(state); const claimBeingPlayed = makeSelectClaimForUri(claimUriBeingPlayed)(state);
isBeingPlayed = claim.claim_id === claimBeingPlayed.claim_id; isBeingPlayed = claim && claim.claim_id === claimBeingPlayed.claim_id;
} }
return { return {

View file

@ -7,7 +7,6 @@ import {
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { makeSelectPendingAmountByUri } from 'redux/selectors/wallet'; import { makeSelectPendingAmountByUri } from 'redux/selectors/wallet';
import { makeSelectFileInfoForUri } from 'redux/selectors/file_info'; import { makeSelectFileInfoForUri } from 'redux/selectors/file_info';
import { selectUser } from 'redux/selectors/user';
import { doOpenModal } from 'redux/actions/app'; import { doOpenModal } from 'redux/actions/app';
import FileValues from './view'; import FileValues from './view';
@ -20,7 +19,6 @@ const select = (state, props) => {
contentType: makeSelectContentTypeForUri(props.uri)(state), contentType: makeSelectContentTypeForUri(props.uri)(state),
fileInfo: makeSelectFileInfoForUri(props.uri)(state), fileInfo: makeSelectFileInfoForUri(props.uri)(state),
metadata: makeSelectMetadataForUri(props.uri)(state), metadata: makeSelectMetadataForUri(props.uri)(state),
user: selectUser(state),
pendingAmount: makeSelectPendingAmountByUri(props.uri)(state), pendingAmount: makeSelectPendingAmountByUri(props.uri)(state),
claimIsMine: selectClaimIsMine(state, claim), claimIsMine: selectClaimIsMine(state, claim),
}; };

View file

@ -15,7 +15,6 @@ type Props = {
metadata: StreamMetadata, metadata: StreamMetadata,
openFolder: (string) => void, openFolder: (string) => void,
contentType: string, contentType: string,
user: ?any,
pendingAmount: string, pendingAmount: string,
openModal: (id: string, { uri: string }) => void, openModal: (id: string, { uri: string }) => void,
claimIsMine: boolean, claimIsMine: boolean,

View file

@ -6,12 +6,11 @@ import { selectClientSetting } from 'redux/selectors/settings';
import { selectGetSyncErrorMessage } from 'redux/selectors/sync'; import { selectGetSyncErrorMessage } from 'redux/selectors/sync';
import { selectHasNavigated } from 'redux/selectors/app'; import { selectHasNavigated } from 'redux/selectors/app';
import { selectTotalBalance, selectBalance } from 'redux/selectors/wallet'; import { selectTotalBalance, selectBalance } from 'redux/selectors/wallet';
import { selectUserVerifiedEmail, selectEmailToVerify, selectUser } from 'redux/selectors/user'; import { selectEmailToVerify, selectUser } from 'redux/selectors/user';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import Header from './view'; import Header from './view';
const select = (state) => ({ const select = (state) => ({
authenticated: selectUserVerifiedEmail(state),
balance: selectBalance(state), balance: selectBalance(state),
emailToVerify: selectEmailToVerify(state), emailToVerify: selectEmailToVerify(state),
hasNavigated: selectHasNavigated(state), hasNavigated: selectHasNavigated(state),

View file

@ -4,11 +4,10 @@ import { selectActiveChannelStakedLevel } from 'redux/selectors/app';
import { selectClientSetting } from 'redux/selectors/settings'; import { selectClientSetting } from 'redux/selectors/settings';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import HeaderMenuButtons from './view'; import HeaderMenuButtons from './view';
import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user'; import { selectUser } from 'redux/selectors/user';
const select = (state) => ({ const select = (state) => ({
activeChannelStakedLevel: selectActiveChannelStakedLevel(state), activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
authenticated: selectUserVerifiedEmail(state),
automaticDarkModeEnabled: selectClientSetting(state, SETTINGS.AUTOMATIC_DARK_MODE_ENABLED), automaticDarkModeEnabled: selectClientSetting(state, SETTINGS.AUTOMATIC_DARK_MODE_ENABLED),
currentTheme: selectClientSetting(state, SETTINGS.THEME), currentTheme: selectClientSetting(state, SETTINGS.THEME),
user: selectUser(state), user: selectUser(state),

View file

@ -12,7 +12,6 @@ import React from 'react';
import Tooltip from 'component/common/tooltip'; import Tooltip from 'component/common/tooltip';
type HeaderMenuButtonProps = { type HeaderMenuButtonProps = {
authenticated: boolean,
automaticDarkModeEnabled: boolean, automaticDarkModeEnabled: boolean,
currentTheme: string, currentTheme: string,
user: ?User, user: ?User,

View file

@ -1,14 +0,0 @@
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);

View file

@ -1,99 +0,0 @@
// @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;

View file

@ -1,29 +0,0 @@
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);

View file

@ -1,154 +0,0 @@
// @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;

View file

@ -1,30 +0,0 @@
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));

View file

@ -1,225 +0,0 @@
// @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;

View file

@ -1,3 +1,11 @@
import { connect } from 'react-redux';
import { selectRemoteVersion } from 'redux/selectors/app';
import LastReleaseChanges from './view'; import LastReleaseChanges from './view';
export default LastReleaseChanges; const select = (state) => ({
releaseVersion: selectRemoteVersion(state),
});
const perform = {};
export default connect(select, perform)(LastReleaseChanges);

View file

@ -5,11 +5,12 @@ import Button from 'component/button';
import I18nMessage from 'component/i18nMessage'; import I18nMessage from 'component/i18nMessage';
type Props = { type Props = {
releaseVersion: string,
hideReleaseVersion?: boolean, hideReleaseVersion?: boolean,
}; };
const LastReleaseChanges = (props: Props) => { const LastReleaseChanges = (props: Props) => {
const { hideReleaseVersion } = props; const { hideReleaseVersion, releaseVersion } = props;
const [releaseTag, setReleaseTag] = useState(''); const [releaseTag, setReleaseTag] = useState('');
const [releaseChanges, setReleaseChanges] = useState(''); const [releaseChanges, setReleaseChanges] = useState('');
const [fetchingReleaseChanges, setFetchingReleaseChanges] = useState(false); const [fetchingReleaseChanges, setFetchingReleaseChanges] = useState(false);
@ -35,7 +36,7 @@ const LastReleaseChanges = (props: Props) => {
); );
useEffect(() => { useEffect(() => {
const lastReleaseUrl = 'https://api.github.com/repos/lbryio/lbry-desktop/releases/latest'; const lastReleaseUrl = `https://api.github.com/repos/lbryio/lbry-desktop/releases/tags/${releaseVersion}`;
const options = { const options = {
method: 'GET', method: 'GET',
headers: { Accept: 'application/vnd.github.v3+json' }, headers: { Accept: 'application/vnd.github.v3+json' },
@ -54,7 +55,7 @@ const LastReleaseChanges = (props: Props) => {
setFetchingReleaseChanges(false); setFetchingReleaseChanges(false);
setFetchReleaseFailed(true); setFetchReleaseFailed(true);
}); });
}, []); }, [releaseVersion, setFetchingReleaseChanges, setReleaseTag, setReleaseChanges, setFetchReleaseFailed]);
if (fetchingReleaseChanges) { if (fetchingReleaseChanges) {
return <p>{__('Loading...')}</p>; return <p>{__('Loading...')}</p>;

View file

@ -92,7 +92,7 @@ function Page(props: Props) {
<div <div
className={classnames('main-wrapper__inner', { className={classnames('main-wrapper__inner', {
'main-wrapper__inner--filepage': isOnFilePage, 'main-wrapper__inner--filepage': isOnFilePage,
'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode, 'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode && !isMediumScreen,
})} })}
> >
{!authPage && {!authPage &&
@ -124,7 +124,7 @@ function Page(props: Props) {
'main--file-page': filePage, 'main--file-page': filePage,
'main--settings-page': settingsPage, 'main--settings-page': settingsPage,
'main--markdown': isMarkdown, 'main--markdown': isMarkdown,
'main--theater-mode': isOnFilePage && videoTheaterMode && !isMarkdown, 'main--theater-mode': isOnFilePage && videoTheaterMode && !isMediumScreen && !isMarkdown,
})} })}
> >
{children} {children}

View file

@ -1,12 +1,12 @@
// @flow // @flow
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { FormField } from 'component/common/form'; import { FormFieldAreaAdvanced } from 'component/common/form';
type Props = { type Props = {
uri: ?string, uri: ?string,
label: ?string, label: ?string,
disabled: ?boolean, disabled: ?boolean,
filePath: string | WebFile, filePath: File,
fileText: ?string, fileText: ?string,
fileMimeType: ?string, fileMimeType: ?string,
streamingUrl: ?string, streamingUrl: ?string,
@ -99,7 +99,7 @@ function PostEditor(props: Props) {
]); ]);
return ( return (
<FormField <FormFieldAreaAdvanced
type={'markdown'} type={'markdown'}
name="content_post" name="content_post"
label={label} label={label}

View file

@ -1,7 +1,7 @@
// @flow // @flow
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field'; import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
import React from 'react'; import React from 'react';
import { FormField } from 'component/common/form'; import { FormFieldAreaAdvanced } from 'component/common/form';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import Card from 'component/common/card'; import Card from 'component/common/card';
@ -27,7 +27,7 @@ function PublishDescription(props: Props) {
return ( return (
<Card <Card
actions={ actions={
<FormField <FormFieldAreaAdvanced
type={advancedEditor ? 'markdown' : 'textarea'} type={advancedEditor ? 'markdown' : 'textarea'}
name="content_description" name="content_description"
label={__('Description')} label={__('Description')}

View file

@ -2,6 +2,7 @@
import type { Node } from 'react'; import type { Node } from 'react';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { ipcRenderer } from 'electron';
import { regexInvalidURI } from 'util/lbryURI'; import { regexInvalidURI } from 'util/lbryURI';
import PostEditor from 'component/postEditor'; import PostEditor from 'component/postEditor';
import FileSelector from 'component/common/file-selector'; import FileSelector from 'component/common/file-selector';
@ -13,13 +14,13 @@ import I18nMessage from 'component/i18nMessage';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import * as PUBLISH_MODES from 'constants/publish_types'; import * as PUBLISH_MODES from 'constants/publish_types';
import PublishName from 'component/publishName'; import PublishName from 'component/publishName';
import path from 'path';
type Props = { type Props = {
uri: ?string, uri: ?string,
mode: ?string, mode: ?string,
name: ?string, name: ?string,
title: ?string, title: ?string,
filePath: string | WebFile, filePath: ?string,
fileMimeType: ?string, fileMimeType: ?string,
isStillEditing: boolean, isStillEditing: boolean,
balance: number, balance: number,
@ -77,7 +78,7 @@ function PublishFile(props: Props) {
const sizeInMB = Number(size) / 1000000; const sizeInMB = Number(size) / 1000000;
const secondsToProcess = sizeInMB / PROCESSING_MB_PER_SECOND; const secondsToProcess = sizeInMB / PROCESSING_MB_PER_SECOND;
const ffmpegAvail = ffmpegStatus.available; const ffmpegAvail = ffmpegStatus.available;
const [currentFile, setCurrentFile] = useState(null); const currentFile = filePath;
const [currentFileType, setCurrentFileType] = useState(null); const [currentFileType, setCurrentFileType] = useState(null);
const [optimizeAvail, setOptimizeAvail] = useState(false); const [optimizeAvail, setOptimizeAvail] = useState(false);
const [userOptimize, setUserOptimize] = usePersistedState('publish-file-user-optimize', false); const [userOptimize, setUserOptimize] = usePersistedState('publish-file-user-optimize', false);
@ -91,17 +92,35 @@ function PublishFile(props: Props) {
} }
}, [currentFileType, mode, isStillEditing, updatePublishForm]); }, [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(() => { useEffect(() => {
if (!filePath || filePath === '') { if (!filePath) {
setCurrentFile(''); return;
updateFileInfo(0, 0, false);
} else if (typeof filePath !== 'string') {
// Update currentFile file
if (filePath.name !== currentFile && filePath.path !== currentFile) {
handleFileChange(filePath);
}
} }
}, [filePath, currentFile, handleFileChange, updateFileInfo]); 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]);
useEffect(() => { useEffect(() => {
const isOptimizeAvail = currentFile && currentFile !== '' && isVid && ffmpegAvail; const isOptimizeAvail = currentFile && currentFile !== '' && isVid && ffmpegAvail;
@ -209,11 +228,11 @@ function PublishFile(props: Props) {
} }
} }
function handleFileChange(file: WebFile, clearName = true) { function processSelectedFile(fileData: FileData, clearName = true) {
window.URL = window.URL || window.webkitURL; window.URL = window.URL || window.webkitURL;
// select file, start to select a new one, then cancel // select file, start to select a new one, then cancel
if (!file) { if (!fileData || fileData.error) {
if (isStillEditing || !clearName) { if (isStillEditing || !clearName) {
updatePublishForm({ filePath: '' }); updatePublishForm({ filePath: '' });
} else { } else {
@ -222,8 +241,12 @@ function PublishFile(props: Props) {
return; return;
} }
// if video, extract duration so we can warn about bitrateif (typeof file !== 'string') { // if video, extract duration so we can warn about bitrate if (typeof file !== 'string')
const contentType = file.type && file.type.split('/'); 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;
const isVideo = contentType && contentType[0] === 'video'; const isVideo = contentType && contentType[0] === 'video';
const isMp4 = contentType && contentType[1] === 'mp4'; const isMp4 = contentType && contentType[1] === 'mp4';
@ -231,34 +254,25 @@ function PublishFile(props: Props) {
if (contentType && contentType[0] === 'text') { if (contentType && contentType[0] === 'text') {
isTextPost = contentType[1] === 'plain' || contentType[1] === 'markdown'; isTextPost = contentType[1] === 'plain' || contentType[1] === 'markdown';
setCurrentFileType(contentType); setCurrentFileType(contentType.join('/'));
} else if (file.name) { } else if (path.parse(fileData.path).ext) {
// If user's machine is missign a valid content type registration // If user's machine is missing a valid content type registration
// for markdown content: text/markdown, file extension will be used instead // for markdown content: text/markdown, file extension will be used instead
const extension = file.name.split('.').pop(); const extension = path.parse(fileData.path).ext;
isTextPost = MARKDOWN_FILE_EXTENSIONS.includes(extension); isTextPost = MARKDOWN_FILE_EXTENSIONS.includes(extension);
} }
if (isVideo) { if (isVideo) {
if (isMp4) { if (isMp4) {
const video = document.createElement('video'); updateFileInfo(duration || 0, size, isVideo);
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 { } else {
updateFileInfo(0, file.size, isVideo); updateFileInfo(duration || 0, size, isVideo);
} }
} else { } else {
updateFileInfo(0, file.size, isVideo); updateFileInfo(0, size, isVideo);
} }
if (isTextPost) { if (isTextPost && file) {
// Create reader // Create reader
const reader = new FileReader(); const reader = new FileReader();
// Handler for file reader // Handler for file reader
@ -270,21 +284,17 @@ function PublishFile(props: Props) {
setPublishMode(PUBLISH_MODES.FILE); setPublishMode(PUBLISH_MODES.FILE);
} }
const publishFormParams: { filePath: string | WebFile, name?: string, optimize?: boolean } = { // Strip off extension and replace invalid characters
// 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) { if (!isStillEditing) {
publishFormParams.name = parseName(fileName); const fileWithoutExtension = path.parse(fileData.path).name;
updatePublishForm({ name: parseName(fileWithoutExtension) });
} }
}
// File path is not supported on web for security reasons so we use the name instead. function handleFileChange(fileWithPath: FileWithPath) {
setCurrentFile(file.path || file.name); if (fileWithPath) {
updatePublishForm(publishFormParams); updatePublishForm({ filePath: fileWithPath.path });
}
} }
const showFileUpload = mode === PUBLISH_MODES.FILE; const showFileUpload = mode === PUBLISH_MODES.FILE;
@ -325,12 +335,14 @@ function PublishFile(props: Props) {
{showFileUpload && ( {showFileUpload && (
<> <>
<FileSelector <FileSelector
type="openFile"
label={__('File')} label={__('File')}
disabled={disabled} disabled={disabled}
currentPath={currentFile} currentPath={currentFile}
onFileChosen={handleFileChange} onFileChosen={handleFileChange}
// https://stackoverflow.com/questions/19107685/safari-input-type-file-accept-video-ignores-mp4-files // https://stackoverflow.com/questions/19107685/safari-input-type-file-accept-video-ignores-mp4-files
placeholder={__('Select file to upload')} placeholder={__('Select file to upload')}
readFile={false}
/> />
{getUploadMessage()} {getUploadMessage()}
</> </>

View file

@ -35,8 +35,8 @@ import tempy from 'tempy';
type Props = { type Props = {
disabled: boolean, disabled: boolean,
tags: Array<Tag>, tags: Array<Tag>,
publish: (source?: string | File, ?boolean) => void, publish: (source: ?File, ?boolean) => void,
filePath: string | File, filePath: ?File,
fileText: string, fileText: string,
bid: ?number, bid: ?number,
bidError: ?string, bidError: ?string,
@ -208,7 +208,6 @@ function PublishForm(props: Props) {
isNameValid(name) && isNameValid(name) &&
title && title &&
bid && bid &&
thumbnail &&
!bidError && !bidError &&
!emptyPostError && !emptyPostError &&
!(thumbnailError && !thumbnailUploaded) && !(thumbnailError && !thumbnailUploaded) &&
@ -373,9 +372,6 @@ function PublishForm(props: Props) {
if (!output || output === '') { if (!output || output === '') {
// Generate a temporary file: // Generate a temporary file:
output = tempy.file({ name: 'post.md' }); output = tempy.file({ name: 'post.md' });
} else if (typeof filePath === 'string') {
// Use current file
output = filePath;
} }
// Create a temporary file and save file changes // Create a temporary file and save file changes
if (output && output !== '') { if (output && output !== '') {
@ -447,7 +443,7 @@ function PublishForm(props: Props) {
// with other properties such as name, title, etc.) for security reasons. // with other properties such as name, title, etc.) for security reasons.
useEffect(() => { useEffect(() => {
if (mode === PUBLISH_MODES.FILE) { if (mode === PUBLISH_MODES.FILE) {
updatePublishForm({ filePath: '', fileDur: 0, fileSize: 0 }); updatePublishForm({ filePath: undefined, fileDur: 0, fileSize: 0 });
} }
}, [mode, updatePublishForm]); }, [mode, updatePublishForm]);

View file

@ -47,11 +47,7 @@ function PublishFormErrors(props: Props) {
{!bid && <div>{__('A deposit amount is required')}</div>} {!bid && <div>{__('A deposit amount is required')}</div>}
{bidError && <div>{__('Please check your deposit amount.')}</div>} {bidError && <div>{__('Please check your deposit amount.')}</div>}
{isUploadingThumbnail && <div>{__('Please wait for thumbnail to finish uploading')}</div>} {isUploadingThumbnail && <div>{__('Please wait for thumbnail to finish uploading')}</div>}
{!isUploadingThumbnail && !thumbnail ? ( {thumbnailError && !thumbnailUploaded && <div>{__('Thumbnail is invalid.')}</div>}
<div>{__('A thumbnail is required. Please upload or provide an image URL above.')}</div>
) : (
thumbnailError && !thumbnailUploaded && <div>{__('Thumbnail is invalid.')}</div>
)}
{editingURI && !isStillEditing && !filePath && ( {editingURI && !isStillEditing && !filePath && (
<div>{__('Please reselect a file after changing the LBRY URL')}</div> <div>{__('Please reselect a file after changing the LBRY URL')}</div>
)} )}

View file

@ -1,9 +0,0 @@
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);

View file

@ -1,46 +0,0 @@
// @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;

View file

@ -1,17 +0,0 @@
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);

View file

@ -1,48 +0,0 @@
// @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;

View file

@ -1,9 +0,0 @@
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);

View file

@ -1,68 +0,0 @@
// @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;

View file

@ -1,10 +0,0 @@
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);

View file

@ -1,52 +0,0 @@
// @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;

View file

@ -1,15 +0,0 @@
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);

View file

@ -1,78 +0,0 @@
// @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;

View file

@ -1,16 +0,0 @@
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);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View file

@ -1,26 +0,0 @@
// @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;

View file

@ -37,7 +37,6 @@ import ChannelsPage from 'page/channels';
import CreatorDashboard from 'page/creatorDashboard'; import CreatorDashboard from 'page/creatorDashboard';
import DiscoverPage from 'page/discover'; import DiscoverPage from 'page/discover';
import FileListPublished from 'page/fileListPublished'; import FileListPublished from 'page/fileListPublished';
import FourOhFourPage from 'page/fourOhFour';
import HelpPage from 'page/help'; import HelpPage from 'page/help';
import LibraryPage from 'page/library'; import LibraryPage from 'page/library';
import ListBlockedPage from 'page/listBlocked'; import ListBlockedPage from 'page/listBlocked';
@ -227,18 +226,20 @@ function AppRouter(props: Props) {
/> />
))} ))}
{/* Odysee signin */}
<Route path={`/$/${PAGES.AUTH_SIGNIN}`} exact component={SignInPage} /> <Route path={`/$/${PAGES.AUTH_SIGNIN}`} exact component={SignInPage} />
<Route path={`/$/${PAGES.AUTH_PASSWORD_RESET}`} exact component={PasswordResetPage} /> <Route path={`/$/${PAGES.AUTH_PASSWORD_RESET}`} exact component={PasswordResetPage} />
<Route path={`/$/${PAGES.AUTH_PASSWORD_SET}`} exact component={PasswordSetPage} /> <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}/*`} 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.WELCOME}`} exact component={Welcome} />
<Route path={`/$/${PAGES.HELP}`} exact component={HelpPage} /> <Route path={`/$/${PAGES.HELP}`} exact component={HelpPage} />
{/* @if TARGET='app' */} {/* @if TARGET='app' */}
<Route path={`/$/${PAGES.BACKUP}`} exact component={BackupPage} /> <Route path={`/$/${PAGES.BACKUP}`} exact component={BackupPage} />
{/* @endif */} {/* @endif */}
<Route path={`/$/${PAGES.AUTH_VERIFY}`} exact component={SignInVerifyPage} />
<Route path={`/$/${PAGES.SEARCH}`} exact component={SearchPage} /> <Route path={`/$/${PAGES.SEARCH}`} exact component={SearchPage} />
<Route path={`/$/${PAGES.TOP}`} exact component={TopPage} /> <Route path={`/$/${PAGES.TOP}`} exact component={TopPage} />
<Route path={`/$/${PAGES.SETTINGS}`} exact component={SettingsPage} /> <Route path={`/$/${PAGES.SETTINGS}`} exact component={SettingsPage} />
@ -279,7 +280,6 @@ function AppRouter(props: Props) {
{/* Below need to go at the end to make sure we don't match any of our pages first */} {/* 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" exact component={ShowPage} />
<Route path="/:claimName/:streamName" exact component={ShowPage} /> <Route path="/:claimName/:streamName" exact component={ShowPage} />
<Route path="/*" component={FourOhFourPage} />
</Switch> </Switch>
); );
} }

View file

@ -89,7 +89,6 @@ export default function SearchChannelField(props: Props) {
return ( return (
<Button <Button
ref={addTagRef} ref={addTagRef}
requiresAuth
button="primary" button="primary"
label={labelFoundAction} label={labelFoundAction}
onClick={() => handleFoundChannelClick(claim)} onClick={() => handleFoundChannelClick(claim)}

View file

@ -15,15 +15,44 @@ const SPEECH_UPLOADING = 'UPLOADING';
type Props = { type Props = {
assetName: string, assetName: string,
currentValue: ?string, currentValue: ?string,
onUpdate: (string, boolean) => void, onUpdate: (string, boolean, ?string) => void,
recommended: string, recommended: string,
title: string, title: string,
onDone?: () => void, onDone?: () => void,
inline?: boolean, 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) { function SelectAsset(props: Props) {
const { onUpdate, onDone, assetName, currentValue, recommended, title, inline } = props; const { onUpdate, onDone, assetName, currentValue, recommended, title, inline, buildImagePreview, filters, type } =
props;
const [pathSelected, setPathSelected] = React.useState(''); const [pathSelected, setPathSelected] = React.useState('');
const [fileSelected, setFileSelected] = React.useState<any>(null); const [fileSelected, setFileSelected] = React.useState<any>(null);
const [uploadStatus, setUploadStatus] = React.useState(SPEECH_READY); const [uploadStatus, setUploadStatus] = React.useState(SPEECH_READY);
@ -36,9 +65,15 @@ function SelectAsset(props: Props) {
setError(error); setError(error);
}; };
const onSuccess = (thumbnailUrl) => { const onSuccess = async (thumbnailUrl) => {
let preview;
setUploadStatus(SPEECH_READY); setUploadStatus(SPEECH_READY);
onUpdate(thumbnailUrl, !useUrl);
if (buildImagePreview) {
preview = await filePreview(fileSelected);
}
onUpdate(thumbnailUrl, !useUrl, preview);
if (onDone) { if (onDone) {
onDone(); onDone();
@ -95,17 +130,17 @@ function SelectAsset(props: Props) {
/> />
) : ( ) : (
<FileSelector <FileSelector
filters={filters}
type={type}
autoFocus autoFocus
disabled={uploadStatus === SPEECH_UPLOADING} disabled={uploadStatus === SPEECH_UPLOADING}
label={fileSelectorLabel} label={fileSelectorLabel}
name="assetSelector" name="assetSelector"
currentPath={pathSelected} currentPath={pathSelected}
onFileChosen={(file) => { onFileChosen={(fileWithPath) => {
if (file.name) { if (fileWithPath.file.name) {
setFileSelected(file); setFileSelected(fileWithPath.file);
// what why? why not target=WEB this? setPathSelected(fileWithPath.path);
// file.path is undefined in web but available in electron
setPathSelected(file.name || file.path);
} }
}} }}
accept={accept} accept={accept}

View file

@ -106,7 +106,7 @@ function SelectThumbnail(props: Props) {
__('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.')}
<img <img
style={{ display: 'none' }} style={{ display: 'none' }}
src={thumbnail} src={thumbnail || ThumbnailMissingImage}
alt={__('Thumbnail Preview')} alt={__('Thumbnail Preview')}
onError={() => { onError={() => {
if (updateThumbnailParams) { if (updateThumbnailParams) {
@ -160,9 +160,9 @@ function SelectThumbnail(props: Props) {
label={__('Thumbnail')} label={__('Thumbnail')}
placeholder={__('Choose an enticing thumbnail')} placeholder={__('Choose an enticing thumbnail')}
accept={accept} accept={accept}
onFileChosen={(file) => onFileChosen={(fileWithPath) =>
openModal(MODALS.CONFIRM_THUMBNAIL_UPLOAD, { openModal(MODALS.CONFIRM_THUMBNAIL_UPLOAD, {
file, file: fileWithPath,
cb: (url) => updateThumbnailParams && updateThumbnailParams({ thumbnail_url: url }), cb: (url) => updateThumbnailParams && updateThumbnailParams({ thumbnail_url: url }),
}) })
} }

View file

@ -53,9 +53,7 @@ export default function SettingAccount(props: Props) {
</SettingsRow> </SettingsRow>
)} )}
{/* @if TARGET='app' */}
<SyncToggle disabled={walletEncrypted && !storedPassword && storedPassword !== ''} /> <SyncToggle disabled={walletEncrypted && !storedPassword && storedPassword !== ''} />
{/* @endif */}
{hasChannels && ( {hasChannels && (
<SettingsRow title={__('Comments')} subtitle={__('View your past comments.')}> <SettingsRow title={__('Comments')} subtitle={__('View your past comments.')}>

View file

@ -5,6 +5,7 @@ import { makeSelectClientSetting } from 'redux/selectors/settings';
import SettingAppearance from './view'; import SettingAppearance from './view';
const select = (state) => ({ const select = (state) => ({
disableBackground: makeSelectClientSetting(SETTINGS.DISABLE_BACKGROUND)(state),
clock24h: makeSelectClientSetting(SETTINGS.CLOCK_24H)(state), clock24h: makeSelectClientSetting(SETTINGS.CLOCK_24H)(state),
searchInLanguage: makeSelectClientSetting(SETTINGS.SEARCH_IN_LANGUAGE)(state), searchInLanguage: makeSelectClientSetting(SETTINGS.SEARCH_IN_LANGUAGE)(state),
hideBalance: makeSelectClientSetting(SETTINGS.HIDE_BALANCE)(state), hideBalance: makeSelectClientSetting(SETTINGS.HIDE_BALANCE)(state),

View file

@ -12,6 +12,7 @@ import ThemeSelector from 'component/themeSelector';
import homepages from 'homepages'; import homepages from 'homepages';
type Props = { type Props = {
disableBackground: boolean,
clock24h: boolean, clock24h: boolean,
searchInLanguage: boolean, searchInLanguage: boolean,
hideBalance: boolean, hideBalance: boolean,
@ -20,7 +21,7 @@ type Props = {
}; };
export default function SettingAppearance(props: Props) { export default function SettingAppearance(props: Props) {
const { clock24h, searchInLanguage, hideBalance, setClientSetting, setSearchInLanguage } = props; const { clock24h, disableBackground, searchInLanguage, hideBalance, setClientSetting, setSearchInLanguage } = props;
return ( return (
<> <>
@ -63,6 +64,14 @@ export default function SettingAppearance(props: Props) {
checked={clock24h} checked={clock24h}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow title={__('Disable background')}>
<FormField
type="checkbox"
name="background"
onChange={() => setClientSetting(SETTINGS.DISABLE_BACKGROUND, !disableBackground)}
checked={disableBackground}
/>
</SettingsRow>
<SettingsRow title={__('Hide wallet balance in header')}> <SettingsRow title={__('Hide wallet balance in header')}>
<FormField <FormField
type="checkbox" type="checkbox"

View file

@ -11,7 +11,7 @@ import {
import { doSetDaemonSetting, doClearDaemonSetting, doFindFFmpeg } from 'redux/actions/settings'; import { doSetDaemonSetting, doClearDaemonSetting, doFindFFmpeg } from 'redux/actions/settings';
import { selectAllowAnalytics } from 'redux/selectors/app'; import { selectAllowAnalytics } from 'redux/selectors/app';
import { selectDaemonSettings, selectFfmpegStatus, selectFindingFFmpeg } from 'redux/selectors/settings'; import { selectDaemonSettings, selectFfmpegStatus, selectFindingFFmpeg } from 'redux/selectors/settings';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail } from 'redux/selectors/user'; // here
import SettingSystem from './view'; import SettingSystem from './view';

View file

@ -130,7 +130,7 @@ export default function SettingSystem(props: Props) {
<FileSelector <FileSelector
type="openDirectory" type="openDirectory"
currentPath={daemonSettings.download_dir} currentPath={daemonSettings.download_dir}
onFileChosen={(newDirectory: WebFile) => { onFileChosen={(newDirectory: FileWithPath) => {
setDaemonSetting('download_dir', newDirectory.path); setDaemonSetting('download_dir', newDirectory.path);
}} }}
/> />
@ -224,7 +224,7 @@ export default function SettingSystem(props: Props) {
type="openDirectory" type="openDirectory"
placeholder={__('A Folder containing FFmpeg')} placeholder={__('A Folder containing FFmpeg')}
currentPath={ffmpegPath || daemonSettings.ffmpeg_path} currentPath={ffmpegPath || daemonSettings.ffmpeg_path}
onFileChosen={(newDirectory: WebFile) => { onFileChosen={(newDirectory: FileWithPath) => {
// $FlowFixMe // $FlowFixMe
setDaemonSetting('ffmpeg_path', newDirectory.path); setDaemonSetting('ffmpeg_path', newDirectory.path);
findFFmpeg(); findFFmpeg();

View file

@ -6,15 +6,13 @@ import * as KEYCODES from 'constants/keycodes';
import React from 'react'; import React from 'react';
import Button from 'component/button'; import Button from 'component/button';
import classnames from 'classnames'; import classnames from 'classnames';
import Icon from 'component/common/icon';
import NotificationBubble from 'component/notificationBubble'; import NotificationBubble from 'component/notificationBubble';
import DebouncedInput from 'component/common/debounced-input'; import DebouncedInput from 'component/common/debounced-input';
import I18nMessage from 'component/i18nMessage';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import { useIsMobile, isTouch } from 'effects/use-screensize'; import { useIsMobile, isTouch } from 'effects/use-screensize';
import { IS_MAC } from 'component/app/view'; import { IS_MAC } from 'component/app/view';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { DOMAIN, ENABLE_UI_NOTIFICATIONS } from 'config'; import { ENABLE_UI_NOTIFICATIONS } from 'config';
const FOLLOWED_ITEM_INITIAL_LIMIT = 10; const FOLLOWED_ITEM_INITIAL_LIMIT = 10;
const touch = isTouch(); const touch = isTouch();
@ -184,7 +182,6 @@ function SideNavigation(props: Props) {
]; ];
const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui); const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui);
const isAuthenticated = Boolean(email);
const [pulseLibrary, setPulseLibrary] = React.useState(false); const [pulseLibrary, setPulseLibrary] = React.useState(false);
const [expandSubscriptions, setExpandSubscriptions] = React.useState(false); const [expandSubscriptions, setExpandSubscriptions] = React.useState(false);
@ -357,23 +354,6 @@ function SideNavigation(props: Props) {
return () => window.removeEventListener('keydown', handleKeydown); return () => window.removeEventListener('keydown', handleKeydown);
}, [sidebarOpen, setSidebarOpen, isAbsolute]); }, [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 = ( const helpLinks = (
<ul className="navigation__tertiary navigation-links--small"> <ul className="navigation__tertiary navigation-links--small">
<li className="navigation-link"> <li className="navigation-link">
@ -437,7 +417,6 @@ function SideNavigation(props: Props) {
{getSubscriptionSection()} {getSubscriptionSection()}
{getFollowedTagsSection()} {getFollowedTagsSection()}
{!isAuthenticated && sidebarOpen && unAuthNudge}
</div> </div>
)} )}
{(!canDisposeMenu || sidebarOpen) && shouldRenderLargeMenu && helpLinks} {(!canDisposeMenu || sidebarOpen) && shouldRenderLargeMenu && helpLinks}

View file

@ -1,6 +1,6 @@
// @flow // @flow
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import React, { useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import CreditAmount from 'component/common/credit-amount'; import CreditAmount from 'component/common/credit-amount';
import Button from 'component/button'; import Button from 'component/button';
import { Form, FormField } from 'component/common/form'; import { Form, FormField } from 'component/common/form';
@ -24,67 +24,100 @@ type Props = {
const SupportsLiquidate = (props: Props) => { const SupportsLiquidate = (props: Props) => {
const { claim, abandonSupportForClaim, handleClose, abandonClaimError } = props; const { claim, abandonSupportForClaim, handleClose, abandonClaimError } = props;
const [previewBalance, setPreviewBalance] = useState(undefined); const [previewBalance, setPreviewBalance] = useState(undefined);
const [amount, setAmount] = useState(-1); const [defaultValueAssigned, setDefaultValueAssigned] = useState(false);
const [unlockTextAmount, setUnlockTextAmount] = useState('');
const [error, setError] = useState(false); const [error, setError] = useState(false);
const initialMessage = __('How much would you like to unlock?'); const initialMessage = __('How much would you like to unlock?');
const [message, setMessage] = useState(initialMessage); const [message, setMessage] = useState(initialMessage);
const amount = Number(unlockTextAmount) || 0;
const defaultValue = previewBalance ? previewBalance * 0.25 : 0;
const keep = const keep =
amount >= 0 amount >= 0
? Boolean(previewBalance) && Number.parseFloat(String(Number(previewBalance) - Number(amount))).toFixed(8) ? Boolean(previewBalance) && Number.parseFloat(String(Number(previewBalance) - amount)).toFixed(8)
: Boolean(previewBalance) && Number.parseFloat(String((Number(previewBalance) / 4) * 3)).toFixed(8); // default unlock 25% : Boolean(previewBalance) && Number.parseFloat(String(defaultValue * 3)).toFixed(8);
const claimId = claim && claim.claim_id; const claimId = claim && claim.claim_id;
const type = claim.value_type; const type = claim.value_type;
useEffect(() => { useEffect(() => {
if (claimId && abandonSupportForClaim) { if (claimId && abandonSupportForClaim) {
abandonSupportForClaim(claimId, type, false, true).then(r => { abandonSupportForClaim(claimId, type, false, true).then((r) => {
setPreviewBalance(r.total_input); setPreviewBalance(r.total_input);
}); });
} }
}, [abandonSupportForClaim, claimId, type, setPreviewBalance]); }, [abandonSupportForClaim, claimId, type, setPreviewBalance]);
function handleSubmit() { function handleSubmit() {
abandonSupportForClaim(claimId, type, keep, false).then(r => { abandonSupportForClaim(claimId, type, keep, false).then((r) => {
if (r) { if (r) {
handleClose(); handleClose();
} }
}); });
} }
function handleChange(a) { const handleRangeChange = useCallback(
if (a === undefined || isNaN(Number(a))) { (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)) {
setMessage(__('Amount must be a number')); setMessage(__('Amount must be a number'));
setError(true); setError(true);
setAmount(0); } else if (unlockAmount > previewBalanceNumber) {
} 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')); setMessage(__('Amount cannot be more than available'));
setError(false);
} else if (Number(a) === Number(previewBalance)) {
setMessage(__(`She's about to close up the library!`));
setAmount(a);
setError(false);
} else if (Number(a) > Number(previewBalance) / 2) {
setMessage(__('Your content will do better with more staked on it'));
setAmount(a);
setError(false);
} else if (Number(a) === 0) {
setMessage(__('Amount cannot be zero'));
setAmount(0);
setError(true); setError(true);
} else if (Number(a) <= Number(previewBalance) / 2) { } else if (Math.abs(unlockAmount - previewBalanceNumber) <= Number.EPSILON) {
setMessage(__(`She's about to close up the library!`));
setError(false);
} else if (unlockAmount > previewBalanceNumber / 2) {
setMessage(__('Your content will do better with more staked on it'));
setError(false);
} else if (unlockAmount === 0) {
setMessage(__('Amount cannot be zero'));
setError(true);
} else if (unlockAmount <= previewBalanceNumber / 2) {
setMessage(__('A prudent choice')); setMessage(__('A prudent choice'));
setAmount(Number(a));
setError(false); setError(false);
} else { } else {
setMessage(initialMessage); setMessage(initialMessage);
setAmount(a);
setError(false); setError(false);
} }
} }, [unlockTextAmount, previewBalance, setMessage, setError]);
return ( return (
<Card <Card
@ -140,8 +173,8 @@ const SupportsLiquidate = (props: Props) => {
min={0} min={0}
step={0.01} step={0.01}
max={previewBalance} max={previewBalance}
value={Number(amount) >= 0 ? amount : previewBalance / 4} // by default, set it to 25% of available value={amount}
onChange={e => handleChange(e.target.value)} onChange={(e) => handleRangeChange(e.target.value)}
/> />
<label className="range__label"> <label className="range__label">
<span>0</span> <span>0</span>
@ -150,9 +183,11 @@ const SupportsLiquidate = (props: Props) => {
</label> </label>
<FormField <FormField
type="text" type="text"
value={amount >= 0 ? amount || '' : previewBalance && previewBalance / 4} value={unlockTextAmount}
helper={message} helper={message}
onChange={e => handleChange(e.target.value)} onFocus={handleUnlockTextFocus}
onChange={(e) => handleChangeUnlockText(e.target.value)}
onBlur={handleUnlockTextBlur}
/> />
</Form> </Form>
)} )}

View file

@ -41,7 +41,7 @@ function SyncToggle(props: Props) {
{!verifiedEmail && ( {!verifiedEmail && (
<div> <div>
<p className="help">{__('An email address is required to sync your account.')}</p> <p className="help">{__('An email address is required to sync your account.')}</p>
<Button requiresAuth button="primary" label={__('Add Email')} /> <Button button="primary" label={__('Add Email')} />
</div> </div>
)} )}
</SettingsRow> </SettingsRow>

View file

@ -103,6 +103,8 @@ function TxoList(props: Props) {
params[TXO.TX_TYPE] = currentUrlParams.type; params[TXO.TX_TYPE] = currentUrlParams.type;
} else if (currentUrlParams.type === TXO.PUBLISH) { } else if (currentUrlParams.type === TXO.PUBLISH) {
params[TXO.TX_TYPE] = TXO.STREAM; params[TXO.TX_TYPE] = TXO.STREAM;
} else if (currentUrlParams.type === TXO.COLLECTION) {
params[TXO.TX_TYPE] = currentUrlParams.type;
} }
} }
if (currentUrlParams.active) { if (currentUrlParams.active) {

View file

@ -1,14 +0,0 @@
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);

View file

@ -1,124 +0,0 @@
// @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;

View file

@ -1,17 +0,0 @@
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);

View file

@ -1,90 +0,0 @@
// @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;

View file

@ -1,26 +0,0 @@
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);

View file

@ -1,164 +0,0 @@
// @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;

View file

@ -43,7 +43,6 @@ type Props = {
toggleVideoTheaterMode: () => void, toggleVideoTheaterMode: () => void,
toggleAutoplayNext: () => void, toggleAutoplayNext: () => void,
setVideoPlaybackRate: (number) => void, setVideoPlaybackRate: (number) => void,
authenticated: boolean,
userId: number, userId: number,
homepageData?: { [string]: HomepageCat }, homepageData?: { [string]: HomepageCat },
shareTelemetry: boolean, shareTelemetry: boolean,

View file

@ -1,27 +0,0 @@
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));

View file

@ -1,707 +0,0 @@
// @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;

View file

@ -1,16 +1,12 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
// import { makeSelectCoverForUri, makeSelectAvatarForUri } from 'redux/selectors/claims';
import Wallpaper from './view'; import Wallpaper from './view';
import * as SETTINGS from 'constants/settings';
import { makeSelectClientSetting } from 'redux/selectors/settings';
/* const select = (state) => ({
const select = (state, props) => { disableBackground: makeSelectClientSetting(SETTINGS.DISABLE_BACKGROUND)(state),
if (props.uri && (props.uri.indexOf('@') !== -1 || props.uri.indexOf('#') !== -1)) { });
return {
cover: makeSelectCoverForUri(props.uri)(state),
avatar: makeSelectAvatarForUri(props.uri)(state),
};
} else return {};
};
*/
export default connect()(Wallpaper); const perform = {};
export default connect(select, perform)(Wallpaper);

View file

@ -8,10 +8,11 @@ type Props = {
// cover: ?string, // cover: ?string,
// avatar: ?string, // avatar: ?string,
reset: ?boolean, reset: ?boolean,
disableBackground: ?boolean,
}; };
const Wallpaper = (props: Props) => { const Wallpaper = (props: Props) => {
// const { cover, avatar } = props; const { disableBackground } = props;
/* /*
if (avatar) { if (avatar) {
@ -227,12 +228,13 @@ const Wallpaper = (props: Props) => {
}} }}
/> />
*/ */
return ( return (
<> <>
<div <div
className={'background-image'} className={'background-image'}
style={{ style={{
backgroundImage: `url(${freeezepeach})`, backgroundImage: disableBackground ? `none` : `url(${freeezepeach})`,
}} }}
/> />
<div className={'theme'} /> <div className={'theme'} />

Some files were not shown because too many files have changed in this diff Show more